rmk-types 0.3.0

Common types in RMK
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! The Rynk command identifier and the command table.
//!
//! [`Cmd`] is the 16-bit identifier carried in the header CMD field. The most
//! significant bit (`0x8000`) acts as a flag to identify "Topics":
//!
//! - `0x0000..=0x7FFF` (Bit 15 = 0): Request/Response pairs.
//! - `0x8000..=0xFFFF` (Bit 15 = 1): Topics (Server -> Host push).
//!

#[cfg(not(feature = "host"))]
use postcard::experimental::max_size::MaxSize;
use serde::Serialize;
use serde::de::DeserializeOwned;

use super::message::{RynkHeader, encode_frame};
use super::{
    BehaviorConfig, DeviceCapabilities, DeviceInfo, GetComboBulkRequest, GetComboBulkResponse, GetEncoderRequest,
    GetKeymapBulkRequest, GetKeymapBulkResponse, GetMacroRequest, GetMorseBulkRequest, GetMorseBulkResponse,
    KeyPosition, LayoutChunk, LockStatus, MacroData, MatrixState, ProtocolVersion, RynkError, SetComboBulkRequest,
    SetComboRequest, SetEncoderRequest, SetForkRequest, SetKeyRequest, SetKeymapBulkRequest, SetMacroRequest,
    SetMorseBulkRequest, SetMorseRequest, StorageResetMode,
};
use crate::action::{EncoderAction, KeyAction};
#[cfg(feature = "_ble")]
use crate::battery::BatteryStatus;
#[cfg(feature = "_ble")]
use crate::ble::BleStatus;
use crate::combo::Combo;
use crate::connection::{ConnectionStatus, ConnectionType};
use crate::fork::Fork;
use crate::led_indicator::LedIndicator;
use crate::morse::Morse;
#[cfg(feature = "split")]
use crate::protocol::rynk::PeripheralStatus;

/// CMD high bit marking a topic (server → host push).
const RYNK_TOPIC_BIT: u16 = 0x8000;

/// A request/response endpoint: its [`Cmd`] plus both payload types — the wire
/// schema and nothing else. Buffer sizing stays out of it: postcard is
/// slice-driven, so `MaxSize` matters only to the no-allocator firmware, which
/// folds it into `MAX_ENDPOINT_PAYLOAD` below.
pub trait Endpoint {
    const CMD: Cmd;
    type Request: Serialize + DeserializeOwned;
    type Response: Serialize + DeserializeOwned;
}

/// The command identifier carried in the header CMD field. The named
/// `Cmd` constants are generated from the `endpoints!`/`topics!` table below.
#[repr(transparent)]
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct Cmd(u16);

impl Cmd {
    /// Build a `Cmd` from its raw wire value.
    pub const fn from_raw(raw: u16) -> Self {
        Self(raw)
    }

    /// Build a `Cmd` from the header's little-endian CMD bytes.
    pub const fn from_le_bytes(bytes: [u8; 2]) -> Self {
        Self(u16::from_le_bytes(bytes))
    }

    /// Return the raw wire value.
    pub const fn raw(self) -> u16 {
        self.0
    }

    /// Return the header's little-endian CMD bytes.
    pub const fn to_le_bytes(self) -> [u8; 2] {
        self.0.to_le_bytes()
    }

    /// Returns `true` for topic / unsolicited push CMDs (high bit set).
    pub const fn is_topic(self) -> bool {
        self.0 & RYNK_TOPIC_BIT != 0
    }
}

impl core::fmt::Debug for Cmd {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(f, "Cmd(0x{:04x})", self.0)
    }
}

#[cfg(feature = "defmt")]
impl defmt::Format for Cmd {
    fn format(&self, fmt: defmt::Formatter) {
        defmt::write!(fmt, "Cmd(0x{=u16:04x})", self.0)
    }
}

/// A command-table row as data for the generated protocol reference. Not
/// feature-gated — the reference lists the whole protocol; a row's gate rides
/// along in [`attrs`](Self::attrs)/[`bulk`](Self::bulk). `#[cfg(test)]`, so
/// these stringified names never reach a firmware binary.
#[cfg(test)]
#[derive(Debug, Clone, Copy)]
pub struct EndpointMeta {
    pub name: &'static str,
    pub cmd: u16,
    pub request: &'static str,
    pub response: &'static str,
    /// Stringified row attributes: doc comments and the `cfg` gate.
    pub attrs: &'static str,
}

/// Push counterpart of [`EndpointMeta`]; test-only, same rules.
#[cfg(test)]
#[derive(Debug, Clone, Copy)]
pub struct TopicMeta {
    pub name: &'static str,
    pub cmd: u16,
    pub payload: &'static str,
    /// Stringified row attributes: doc comments and the `cfg` gate.
    pub attrs: &'static str,
}

/// Compile-time guard: Check whether the command value is unique.
const fn assert_unique(cmds: &[u16]) {
    let mut i = 0;
    while i < cmds.len() {
        let mut j = i + 1;
        while j < cmds.len() {
            core::assert!(cmds[i] != cmds[j], "duplicate CMD value in the command table");
            j += 1;
        }
        i += 1;
    }
}

/// Macro for defining the endpoint (request/response) table.
///
/// Rows are uniform `Name = cmd: Req => Resp;`. Under non-`host` builds the table
/// folds every request and wrapped response into `MAX_ENDPOINT_PAYLOAD`,
/// which the firmware buffer must hold; host builds skip the fold, since bulk
/// payloads are unbounded there and carry no `MaxSize`.
macro_rules! endpoints {
    ($( $(#[$meta:meta])* $name:ident = $cmd:literal : $req:ty => $resp:ty; )*) => {
        #[allow(non_upper_case_globals)]
        impl Cmd {
            $( $(#[$meta])* pub const $name: Self = Cmd::from_raw($cmd); )*
        }
        $(
            $(#[$meta])*
            pub enum $name {}
            $(#[$meta])*
            impl Endpoint for $name {
                const CMD: Cmd = Cmd::$name;
                type Request = $req;
                type Response = $resp;
            }
        )*
        const _: () = {
            $( core::assert!(!Cmd::from_raw($cmd).is_topic(), "request CMD value in the topic range"); )*
            assert_unique(&[$($cmd),*]);
        };
        /// Largest request-or-wrapped-response across the whole endpoint table
        /// (bulk included) — folded firmware-side only, where every payload is a
        /// bounded type with a `MaxSize`.
        #[cfg(not(feature = "host"))]
        #[allow(unused_doc_comments)] // row docs also land on the fold statements
        const MAX_ENDPOINT_PAYLOAD: usize = {
            let mut m = 0;
            $( $(#[$meta])* {
                let req = <$req as MaxSize>::POSTCARD_MAX_SIZE;
                if req > m { m = req; }
                let resp = <Result<$resp, RynkError> as MaxSize>::POSTCARD_MAX_SIZE;
                if resp > m { m = resp; }
            } )*
            m
        };
        /// Endpoint rows as data, in table order — the protocol-reference source
        /// (see [`EndpointMeta`]).
        #[cfg(test)]
        pub const ENDPOINT_META: &[EndpointMeta] = &[
            $( EndpointMeta {
                name: stringify!($name),
                cmd: $cmd,
                request: stringify!($req),
                response: stringify!($resp),
                attrs: stringify!($(#[$meta])*),
            }, )*
        ];
    };
}

/// Macro for defining the topic table. Topics have no marker types: the
/// generated [`TopicEvent`] union is the whole consumer side.
macro_rules! topics {
    ($( $(#[$meta:meta])* $name:ident = $cmd:literal : $payload:ty; )*) => {
        #[allow(non_upper_case_globals)]
        impl Cmd {
            $( $(#[$meta])* pub const $name: Self = Cmd::from_raw($cmd); )*
        }
        const _: () = {
            $( core::assert!(Cmd::from_raw($cmd).is_topic(), "topic CMD value outside the topic range"); )*
            assert_unique(&[$($cmd),*]);
        };
        /// Largest payload across the whole topic table — feeds the firmware
        /// buffer assertion below. Absent on `host` builds, which are alloc
        /// and need no bound.
        #[cfg(not(feature = "host"))]
        #[allow(unused_doc_comments)]
        const MAX_TOPIC_PAYLOAD: usize = {
            let mut m = 0;
            $( $(#[$meta])* {
                let p = <$payload as MaxSize>::POSTCARD_MAX_SIZE;
                if p > m { m = p; }
            } )*
            m
        };
        /// Topic rows as data, in table order (see [`ENDPOINT_META`]).
        #[cfg(test)]
        pub const TOPIC_META: &[TopicMeta] = &[
            $( TopicMeta {
                name: stringify!($name),
                cmd: $cmd,
                payload: stringify!($payload),
                attrs: stringify!($(#[$meta])*),
            }, )*
        ];

        /// A decoded topic push (server → host), one variant per row of the
        /// topic table above — generated from it. `Serialize` lets the host
        /// re-emit a decoded topic as JSON (every payload is already a wire type).
        #[derive(Debug, Clone, serde::Serialize)]
        #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
        #[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
        pub enum TopicEvent {
            $( $(#[$meta])* $name($payload), )*
        }

        impl TopicEvent {
            /// Decode a topic frame's `payload` as the topic named by `cmd`.
            /// `None` for a `cmd` outside the topic table, or a payload that
            /// fails to decode. Trailing bytes are ignored.
            pub fn decode(cmd: Cmd, payload: &[u8]) -> Option<Self> {
                match cmd {
                    $( $(#[$meta])* Cmd::$name => postcard::take_from_bytes::<$payload>(payload)
                        .ok()
                        .map(|(v, _)| TopicEvent::$name(v)), )*
                    _ => None,
                }
            }

            /// Encode this event into `buf` as a topic frame (SEQ = 0).
            /// Returns the framed length; the caller sends `&buf[..len]`.
            pub fn encode(&self, buf: &mut [u8]) -> Result<usize, RynkError> {
                match self {
                    $( $(#[$meta])* TopicEvent::$name(v) =>
                        encode_frame(buf, RynkHeader { cmd: Cmd::$name, seq: 0 }, v), )*
                }
            }
        }
    };
}

// Define endpoints: `Name = value: Request => Response;`
endpoints! {
    // System (0x00xx); 0x0009 reserved for layout.
    GetVersion = 0x0001: () => ProtocolVersion;
    GetCapabilities = 0x0002: () => DeviceCapabilities;
    Reboot = 0x0003: () => ();
    BootloaderJump = 0x0004: () => ();
    StorageReset = 0x0005: StorageResetMode => ();

    // Lock gate. All three stay dispatchable while locked.
    /// Pure read of the current lock state — no side effects.
    GetLockStatus = 0x0006: () => LockStatus;
    /// Arms/refreshes the unlock attempt and samples the held challenge keys.
    UnlockPoll = 0x0007: () => LockStatus;
    /// Relock immediately.
    Lock = 0x0008: () => ();
    /// Get layout blob chunk. `u32` is the byte offset.
    GetLayout = 0x0009: u32 => LayoutChunk;
    /// Identity strings and USB ids; feature gating stays in `GetCapabilities`.
    GetDeviceInfo = 0x000A: () => DeviceInfo;

    // Keymap (0x01xx) — includes encoder.
    GetKeyAction = 0x0101: KeyPosition => KeyAction;
    SetKeyAction = 0x0102: SetKeyRequest => ();
    GetDefaultLayer = 0x0103: () => u8;
    SetDefaultLayer = 0x0104: u8 => ();
    GetEncoderAction = 0x0105: GetEncoderRequest => EncoderAction;
    SetEncoderAction = 0x0106: SetEncoderRequest => ();
    GetKeymapBulk = 0x0107: GetKeymapBulkRequest => GetKeymapBulkResponse;
    SetKeymapBulk = 0x0108: SetKeymapBulkRequest => ();

    // Macro (0x02xx).
    GetMacro = 0x0201: GetMacroRequest => MacroData;
    SetMacro = 0x0202: SetMacroRequest => ();

    // Combo (0x03xx).
    GetCombo = 0x0301: u8 => Combo;
    SetCombo = 0x0302: SetComboRequest => ();
    GetComboBulk = 0x0303: GetComboBulkRequest => GetComboBulkResponse;
    SetComboBulk = 0x0304: SetComboBulkRequest => ();

    // Morse (0x04xx).
    GetMorse = 0x0401: u8 => Morse;
    SetMorse = 0x0402: SetMorseRequest => ();
    GetMorseBulk = 0x0403: GetMorseBulkRequest => GetMorseBulkResponse;
    SetMorseBulk = 0x0404: SetMorseBulkRequest => ();

    // Fork (0x05xx).
    GetFork = 0x0501: u8 => Fork;
    SetFork = 0x0502: SetForkRequest => ();

    // Behavior (0x06xx).
    GetBehaviorConfig = 0x0601: () => BehaviorConfig;
    SetBehaviorConfig = 0x0602: BehaviorConfig => ();

    // Connection (0x07xx).
    GetConnectionType = 0x0701: () => ConnectionType;
    /// Full `ConnectionStatus` snapshot.
    GetConnectionStatus = 0x0702: () => ConnectionStatus;
    #[cfg(feature = "_ble")]
    GetBleStatus = 0x0703: () => BleStatus;
    #[cfg(feature = "_ble")]
    SwitchBleProfile = 0x0704: u8 => ();
    #[cfg(feature = "_ble")]
    ClearBleProfile = 0x0705: u8 => ();

    // Status (0x08xx).
    GetCurrentLayer = 0x0801: () => u8;
    GetMatrixState = 0x0802: () => MatrixState;
    #[cfg(feature = "_ble")]
    GetBatteryStatus = 0x0803: () => BatteryStatus;
    #[cfg(feature = "split")]
    GetPeripheralStatus = 0x0804: u8 => PeripheralStatus;
    /// Latest WPM, sourced from the `WpmUpdate` topic snapshot.
    GetWpm = 0x0805: () => u16;
    /// Latest sleep flag, sourced from the `SleepState` topic snapshot.
    GetSleepState = 0x0806: () => bool;
    /// Latest HID LED bitmap, sourced from the `LedIndicatorChange` topic snapshot.
    GetLedIndicator = 0x0807: () => LedIndicator;

    // 0x09xx is reserved for a relay to answer for itself; nothing needs it yet.
}

// Define topics: `Name = value: Payload;`
topics! {
    // Topics (0x80xx, server → host push).
    LayerChange = 0x8001: u8;
    WpmUpdate = 0x8002: u16;
    ConnectionChange = 0x8003: ConnectionStatus;
    SleepState = 0x8004: bool;
    LedIndicatorChange = 0x8005: LedIndicator;
    #[cfg(feature = "_ble")]
    BatteryStatusChange = 0x8006: BatteryStatus;
}

/// The payload budget advertised to hosts must cover the largest payload
/// either table can produce.
#[cfg(not(feature = "host"))]
const _: () = core::assert!(
    super::message::RYNK_MAX_PAYLOAD_SIZE >= MAX_ENDPOINT_PAYLOAD
        && super::message::RYNK_MAX_PAYLOAD_SIZE >= MAX_TOPIC_PAYLOAD,
    "rynk_buffer_size is too small to hold the largest rynk frame (including bulk and COBS overhead); increase it"
);

#[cfg(test)]
mod tests {
    extern crate alloc;

    use alloc::format;

    use postcard::experimental::max_size::MaxSize;

    use super::*;
    use crate::protocol::rynk::{Deframer, RYNK_HEADER_SIZE, RynkError, RynkHeader};

    #[test]
    fn topic_mask_is_the_high_bit() {
        assert!(Cmd::from_raw(0x8000).is_topic());
        assert!(Cmd::from_raw(0x80ff).is_topic());
        assert!(!Cmd::from_raw(0x0001).is_topic());
        assert!(!Cmd::from_raw(0x7fff).is_topic());
    }

    #[test]
    fn raw_values_round_trip() {
        for cmd in [Cmd::from_raw(0x0001), Cmd::from_raw(0x8001), Cmd::from_raw(0xffff)] {
            assert_eq!(Cmd::from_raw(cmd.raw()), cmd);
            assert_eq!(Cmd::from_le_bytes(cmd.to_le_bytes()), cmd);
        }
    }

    #[test]
    fn debug_is_compact_raw_value() {
        assert_eq!(format!("{:?}", Cmd::from_raw(0x0001)), "Cmd(0x0001)");
        assert_eq!(format!("{:?}", Cmd::from_raw(0x80ff)), "Cmd(0x80ff)");
    }

    #[test]
    fn table_cmds_land_in_their_ranges() {
        assert!(Cmd::LayerChange.is_topic());
        assert!(Cmd::WpmUpdate.is_topic());
        assert!(!Cmd::GetVersion.is_topic());
        assert!(!Cmd::SetKeyAction.is_topic());
    }

    #[test]
    fn topic_event_round_trips_through_the_wire() {
        // The generated enum encodes to a topic frame the host decodes back to
        // the same variant — the producer and consumer halves share one table.
        let mut buf = [0u8; 64];
        let ev = TopicEvent::LayerChange(7);
        let framed_len = ev.encode(&mut buf).unwrap();

        // Decode the COBS frame back to the logical [cmd, seq, payload].
        let mut df = Deframer::new();
        df.commit(framed_len);
        let n = df.next(&mut buf).expect("one whole topic frame");
        let header = RynkHeader::parse(buf[..RYNK_HEADER_SIZE].try_into().unwrap());
        assert_eq!(header.cmd, Cmd::LayerChange);
        assert_eq!(header.seq, 0, "topics push with SEQ 0");
        let decoded = TopicEvent::decode(header.cmd, &buf[RYNK_HEADER_SIZE..n]);
        assert!(matches!(decoded, Some(TopicEvent::LayerChange(7))));
    }

    #[test]
    fn topic_event_decode_rejects_non_topic_and_garbage() {
        // A request-range cmd is not in the topic table.
        assert!(TopicEvent::decode(Cmd::GetVersion, &[]).is_none());
        // A known topic cmd whose payload can't decode (LayerChange needs a byte).
        assert!(TopicEvent::decode(Cmd::LayerChange, &[]).is_none());
    }

    #[test]
    fn response_wrapping_adds_one_byte() {
        // Postcard's Result tag is 1 byte. The wrapped size of any
        // non-trivial T must equal `1 + T::POSTCARD_MAX_SIZE`.
        let bare = <DeviceCapabilities as MaxSize>::POSTCARD_MAX_SIZE;
        let wrapped = <Result<DeviceCapabilities, RynkError> as MaxSize>::POSTCARD_MAX_SIZE;
        assert_eq!(wrapped, bare + 1);
    }
}