diff --git a/docs/docs/main/docs/development/rynk_protocol.md b/docs/docs/main/docs/development/rynk_protocol.md index 4f765947a..3705c8603 100644 --- a/docs/docs/main/docs/development/rynk_protocol.md +++ b/docs/docs/main/docs/development/rynk_protocol.md @@ -89,4 +89,6 @@ Topics are best-effort pushes; the `Get*` endpoints above mirror their payloads - `GetVersion` (`0x0001`) and its `Result` reply are frozen across all versions. - Within a major version, adding a CMD or topic is a `minor` bump: old firmware answers `UnknownCmd`, old hosts ignore unknown topics. +- Appending a `RynkError` variant is also a `minor` bump: an old host fails to decode the new tag and must surface it as a generic failure. - Reshaping an existing request/response — including appending a field — is a `major` bump. +- `0.x` is pre-release and not covered by the rules above: while the protocol is unpublished it stays at `0.1`, whole command segments included. diff --git a/rmk-config/src/default_config/subscriber_default.toml b/rmk-config/src/default_config/subscriber_default.toml index 4c6824616..067051f21 100644 --- a/rmk-config/src/default_config/subscriber_default.toml +++ b/rmk-config/src/default_config/subscriber_default.toml @@ -82,6 +82,15 @@ events = [ { name = "led_indicator" }, ] +# --- Dongle firmware internal subscribers --- + +[[subscriber]] +features = ["dongle"] +events = [ + # dongle/link.rs: LedIndicatorEvent::subscriber() on the one keyboard link. + { name = "led_indicator" }, +] + # --- Split-gated internal subscribers --- [[subscriber]] diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index d858da563..1ede16ffc 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -309,6 +309,10 @@ pub(crate) struct RmkConstantsConfig { /// Default 488 fills exactly two BLE notifications. #[serde_inline_default(488)] pub rynk_buffer_size: usize, + /// Length of one dongle pairing scan in seconds; an unpaired dongle repeats + /// it until a keyboard shows up (dongle firmware only) + #[serde_inline_default(30)] + pub dongle_pairing_window_secs: u32, } fn check_combo_max_num<'de, D>(deserializer: D) -> Result @@ -400,6 +404,7 @@ impl Default for RmkConstantsConfig { protocol_macro_chunk_size: 64, auto_mouse_layer_max_num: None, rynk_buffer_size: 488, + dongle_pairing_window_secs: 30, } } } diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index 7c4ef3f54..d6f8e0c11 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -51,6 +51,7 @@ pub struct BuildConstants { pub auto_mouse_layer_max_num: usize, /// Rynk RX/TX buffer size (bytes). pub rynk_buffer_size: usize, + pub dongle_pairing_window_secs: u32, pub events: Vec, pub passkey: Option, } @@ -199,6 +200,7 @@ impl crate::KeyboardTomlConfig { protocol_macro_chunk_size: rmk.protocol_macro_chunk_size, auto_mouse_layer_max_num, rynk_buffer_size: rmk.rynk_buffer_size, + dongle_pairing_window_secs: rmk.dongle_pairing_window_secs, events, passkey, }) diff --git a/rmk-types/build.rs b/rmk-types/build.rs index 697166c32..11f19c218 100644 --- a/rmk-types/build.rs +++ b/rmk-types/build.rs @@ -146,6 +146,15 @@ fn generate_constants(bc: &BuildConstants, config: &KeyboardTomlConfig) -> Strin lines.push(format!("pub const LAYOUT_BLOB: &[u8] = b\"{}\";", blob.escape_ascii())); } + // How long one dongle pairing scan lasts. It bonds exactly one keyboard, so + // there is nothing else about the relay to size. + if env::var("CARGO_FEATURE_DONGLE").is_ok() { + lines.push(format!( + "pub const DONGLE_PAIRING_WINDOW_SECS: u32 = {};", + bc.dongle_pairing_window_secs + )); + } + // Event channels for ev in &bc.events { let upper = ev.name.to_uppercase(); diff --git a/rmk-types/src/ble.rs b/rmk-types/src/ble.rs index e38658147..455ee0c01 100644 --- a/rmk-types/src/ble.rs +++ b/rmk-types/src/ble.rs @@ -1,8 +1,17 @@ -//! BLE status types. +//! BLE status types and advertising constants. use postcard::experimental::max_size::MaxSize; use serde::{Deserialize, Serialize}; +/// Company identifier in RMK's manufacturer-specific advertising data. +pub const RMK_ADV_COMPANY_ID: u16 = 0x5253; + +/// First manufacturer-specific-data byte of a keyboard's dongle-seeking +/// advertisement, followed by the Rynk protocol major version. Every RMK +/// advertisement kind shares [`RMK_ADV_COMPANY_ID`], so this must not collide +/// with the split-peripheral payload, whose first byte is a small peripheral id. +pub const DONGLE_SEEKING_ADV_KIND: u8 = 0xD0; + /// BLE state (what the BLE subsystem is currently doing). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] diff --git a/rmk-types/src/protocol/rynk/command.rs b/rmk-types/src/protocol/rynk/command.rs index 270ed34aa..533fbce7c 100644 --- a/rmk-types/src/protocol/rynk/command.rs +++ b/rmk-types/src/protocol/rynk/command.rs @@ -337,6 +337,8 @@ endpoints! { 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;` diff --git a/rmk-types/src/protocol/rynk/message.rs b/rmk-types/src/protocol/rynk/message.rs index 2868b5879..ce5d1f406 100644 --- a/rmk-types/src/protocol/rynk/message.rs +++ b/rmk-types/src/protocol/rynk/message.rs @@ -39,6 +39,30 @@ impl RynkHeader { } } + /// Decode the Rynk header out of a COBS-encoded frame, leaving the frame bytes untouched. + /// + /// This is mainly used in the dongle to check the command. + pub fn peek(encoded: &[u8]) -> Option { + let mut out = [0u8; RYNK_HEADER_SIZE]; + let mut n = 0; + let mut state = cobs::DecoderState::Idle; + for &byte in encoded { + match state.feed(byte).ok()? { + cobs::DecodeResult::NoData => {} + // The frame's delimiter arrived before a full header was decoded. + cobs::DecodeResult::DataComplete => return None, + cobs::DecodeResult::DataContinue(b) => { + out[n] = b; + n += 1; + if n == RYNK_HEADER_SIZE { + return Some(Self::parse(&out)); + } + } + } + } + None + } + pub const fn to_bytes(&self) -> [u8; RYNK_HEADER_SIZE] { let cmd_bytes = self.cmd.to_le_bytes(); [cmd_bytes[0], cmd_bytes[1], self.seq] @@ -290,6 +314,56 @@ mod tests { ); } + #[test] + fn decode_header_reads_the_prefix_without_touching_the_frame() { + // Headers with and without interior zeros, with and without payload — + // the header must decode from the COBS prefix and the frame bytes stay put. + for (cmd, seq, payload) in [ + (Cmd::GetVersion, 0x42u8, &[1u8, 2, 3, 4][..]), // cmd_hi = 0x00 + (Cmd::from_raw(0x0901), 0x00, &[][..]), // seq = 0x00, empty payload + (Cmd::from_raw(0x7FFF), 0xFF, &[0u8, 0][..]), // no zeros in the header + ] { + let mut buf = [0u8; 64]; + let n = encode_frame(&mut buf, RynkHeader { cmd, seq }, &payload).unwrap(); + let copy = buf; + let header = RynkHeader::peek(&buf[..n]).expect("header decodes"); + assert_eq!(header.cmd, cmd); + assert_eq!(header.seq, seq); + assert_eq!(buf, copy, "input frame must not be modified"); + // Also without the trailing delimiter, as a raw splitter may hand it over. + let header = RynkHeader::peek(&buf[..n - 1]).unwrap(); + assert_eq!(header.cmd, cmd); + } + } + + #[test] + fn decode_header_survives_a_254_byte_first_group() { + // An all-nonzero frame longer than 254 bytes makes the first COBS group + // 0xFF-coded; the header must not gain a phantom zero at the group edge. + let payload = [0x41u8; 300]; + let mut buf = [0u8; 400]; + let header = RynkHeader { + cmd: Cmd::from_raw(0x0101), + seq: 7, + }; + let n = encode_frame(&mut buf, header, &payload.as_slice()).unwrap(); + let decoded = RynkHeader::peek(&buf[..n]).unwrap(); + assert_eq!(decoded.cmd.raw(), 0x0101); + assert_eq!(decoded.seq, 7); + } + + #[test] + fn decode_header_rejects_truncated_or_empty_input() { + assert!(RynkHeader::peek(&[]).is_none()); + // A bare delimiter or a frame cut before 3 decoded bytes. + assert!(RynkHeader::peek(&[0x00]).is_none()); + assert!(RynkHeader::peek(&[0x02, 0xAA]).is_none()); + assert!( + RynkHeader::peek(&[0x03, 0xAA, 0xBB]).is_none(), + "only 2 decoded bytes + EOF" + ); + } + #[test] fn frame_never_contains_the_delimiter() { // cmd 0x0004 has a zero byte; the encoded frame must not carry a bare 0x00 diff --git a/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap b/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap index ff7ed6bdf..6d3213605 100644 --- a/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap +++ b/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap @@ -4,76 +4,89 @@ # payload, COBS-encoded with a trailing 0x00 delimiter — one per protocol message; the # label names the decoded payload (`()` = empty). A diff means the header, a CMD number, # or a message frame changed. If intentional, bump ProtocolVersion::CURRENT and regenerate: -# UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features rynk wire_frames +# UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features host wire_frames # Format: