Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/main/docs/development/rynk_protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,6 @@ Topics are best-effort pushes; the `Get*` endpoints above mirror their payloads

- `GetVersion` (`0x0001`) and its `Result<ProtocolVersion, RynkError>` 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.
9 changes: 9 additions & 0 deletions rmk-config/src/default_config/subscriber_default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
5 changes: 5 additions & 0 deletions rmk-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize, D::Error>
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions rmk-config/src/resolved/build_constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventChannel>,
pub passkey: Option<Passkey>,
}
Expand Down Expand Up @@ -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,
})
Expand Down
9 changes: 9 additions & 0 deletions rmk-types/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 10 additions & 1 deletion rmk-types/src/ble.rs
Original file line number Diff line number Diff line change
@@ -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))]
Expand Down
2 changes: 2 additions & 0 deletions rmk-types/src/protocol/rynk/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;`
Expand Down
74 changes: 74 additions & 0 deletions rmk-types/src/protocol/rynk/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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]
Expand Down Expand Up @@ -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
Expand Down
Loading