From a54dc3c54371d1cd86fdc1e4bc1630ebd27862be Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 19 Jul 2026 01:19:37 -0700 Subject: [PATCH 01/78] split: add a bounded application-message side channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opaque, bounded application payload that firmware can exchange between the split central and peripheral alongside the normal split traffic, without ever taking priority over key events. * `split_app` module: `SplitAppData` (a small, `MaxSize`, postcard length-prefixed payload) plus four statics — `SPLIT_APP_TX` (central -> peripheral), `SPLIT_APP_PERIPH_TX` (peripheral -> central), the symmetric `SPLIT_APP_RX` inbox, and the `SPLIT_APP_LINK` watch that reports split-link state to the application. * Producers use `try_send` only (bounded, drop-on-full) so the split read/write loops never block or get starved by application traffic; key events are always polled first. * The split driver and peripheral drain the application queues as the lowest-priority arm of their outgoing selects and forward received `SplitMessage::Application` payloads into the inbox. * `SPLIT_APP_LINK` is state-based (a `Watch`), so a late-subscribing application still observes the current link state; the `false -> true` edge is a resync trigger. Link-down edges are emitted from a drop guard so they survive async cancellation of the split session. * On the peripheral the link is raised on the FIRST inbound message from the central rather than on bare connection: over BLE, notifications to a central that has not yet subscribed are silently dropped, so the connection alone is not proof the application channel is usable. Developed for a split keyboard port. --- rmk/src/lib.rs | 3 + rmk/src/split/driver.rs | 34 ++++- rmk/src/split/mod.rs | 6 + rmk/src/split/peripheral.rs | 266 ++++++++++++++++++++++-------------- rmk/src/split_app.rs | 124 +++++++++++++++++ 5 files changed, 328 insertions(+), 105 deletions(-) create mode 100644 rmk/src/split_app.rs diff --git a/rmk/src/lib.rs b/rmk/src/lib.rs index 1be3ed35a..a69f69d8c 100644 --- a/rmk/src/lib.rs +++ b/rmk/src/lib.rs @@ -105,6 +105,9 @@ pub mod matrix; pub mod processor; #[cfg(feature = "split")] pub mod split; +// Bounded application-message hook for the split protocol. +#[cfg(feature = "split")] +pub mod split_app; pub mod state; #[cfg(feature = "storage")] pub mod storage; diff --git a/rmk/src/split/driver.rs b/rmk/src/split/driver.rs index 7fcdb5119..7be44f26a 100644 --- a/rmk/src/split/driver.rs +++ b/rmk/src/split/driver.rs @@ -184,6 +184,24 @@ impl PeripheralManager { #[cfg(feature = "display")] let mut sleep_sub = crate::event::SleepStateEvent::subscriber(); + // Expose the split-link state to the application. This + // manager runs exactly while the peripheral session is up; the + // `false → true` edge is the application's resync trigger. + // + // The link-down edge MUST be sent from a drop guard: on connection + // loss the outer `select3` in `split/ble/central.rs` resolves via its + // connection-monitor arm and this future is *cancelled*, so any + // `send(false)` written on an error path here would never run. + struct LinkDownGuard; + impl Drop for LinkDownGuard { + fn drop(&mut self) { + crate::split_app::SPLIT_APP_LINK.sender().send(false); + } + } + let _link_guard = LinkDownGuard; + let app_link = crate::split_app::SPLIT_APP_LINK.sender(); + app_link.send(true); + // Send the current state once on startup so the peripheral matches us // even when no transition has happened since the central booted. if self @@ -193,7 +211,7 @@ impl PeripheralManager { .await .is_err() { - return; + return; // guard sends the link-down edge } #[cfg(feature = "dfu_split")] @@ -225,6 +243,10 @@ impl PeripheralManager { with_feature("display"): e = wpm_sub.next_event().fuse() => SplitMessage::Wpm(e.0), with_feature("display"): e = modifier_sub.next_event().fuse() => SplitMessage::Modifier(e.modifier.into_bits()), with_feature("display"): e = sleep_sub.next_event().fuse() => SplitMessage::SleepState(e.0), + // Application messages, deliberately the + // last (lowest-priority) outgoing arm; the read arm of the + // outer select still beats all outgoing traffic. + m = crate::split_app::SPLIT_APP_TX.receive().fuse() => SplitMessage::Application(m), } }; @@ -254,7 +276,7 @@ impl PeripheralManager { #[cfg(not(feature = "dfu_split"))] Either::Second(msg) => { if self.send(&msg).await.is_err() { - return; + return; // guard sends the link-down edge } } } @@ -283,6 +305,14 @@ impl PeripheralManager { }, // Non-key events are drop-on-full to keep the split read loop responsive. SplitMessage::Pointing(e) => publish_event(e), + // Forward peripheral → central application + // payloads into the (symmetric) inbox; drop-on-full so a slow + // consumer can never stall the split read loop. + SplitMessage::Application(data) => { + if crate::split_app::SPLIT_APP_RX.try_send(data).is_err() { + warn!("split app message dropped (inbox full)"); + } + } #[cfg(feature = "_ble")] SplitMessage::BatteryStatus(state) => set_peripheral_battery(self.id, state.0), #[cfg(feature = "dfu_split")] diff --git a/rmk/src/split/mod.rs b/rmk/src/split/mod.rs index 9cd58ad22..20a525f0c 100644 --- a/rmk/src/split/mod.rs +++ b/rmk/src/split/mod.rs @@ -102,6 +102,12 @@ pub(crate) enum SplitMessage { /// Peripheral → Central: confirm mark_updated succeeded, about to reset. #[cfg(feature = "dfu_split")] FirmwareUpdateConfirm, + + /// opaque bounded application payload, central → + /// peripheral (see `crate::split_app`). Kept as the LAST variant so + /// the postcard discriminants of all existing messages stay stable across + /// halves flashed at different revisions. + Application(crate::split_app::SplitAppData), } // ----------------------------------------------------------------------- diff --git a/rmk/src/split/peripheral.rs b/rmk/src/split/peripheral.rs index 15446c1de..6f465929c 100644 --- a/rmk/src/split/peripheral.rs +++ b/rmk/src/split/peripheral.rs @@ -83,6 +83,40 @@ impl SplitPeripheral { /// The peripheral uses the general matrix, does scanning and sends key events through `SplitWriter`. /// It also receives split messages from the central through `SplitReader`. pub(crate) async fn run(&mut self) { + // Expose the split-link state to the application. + // `run` executes exactly while a central session is up (for BLE it is + // invoked per connection and returns on disconnect). + // + // As on the central side (split/driver.rs), the link-down edge MUST + // come from a drop guard: this future can be cancelled from outside + // when the session dies, so no in-line `send(false)` is guaranteed to + // run. Without the false edge, reconnects look like true->true and + // link-up-triggered behavior (e.g. the version announcement) never + // re-arms. + // + // The link-UP edge is deliberately NOT sent here at session start: + // for BLE the connection is up, but the central has not yet + // subscribed to the peripheral's notify characteristic (CCCD write), + // and trouble-host silently drops notifications to an unsubscribed + // peer — anything the application sent in that window would vanish. + // Instead, link-up is declared on the FIRST message received from + // the central: the central's `PeripheralManager` sends its + // `ConnectionStatus` snapshot immediately after subscribing, and ATT + // bearer ordering guarantees the CCCD write was processed before + // that message, so peripheral → central traffic is deliverable from + // this point on. (Serial split has no subscription step and reaches + // the same first message; the edge is simply "the session is + // bidirectionally live".) + struct LinkDownGuard; + impl Drop for LinkDownGuard { + fn drop(&mut self) { + crate::split_app::SPLIT_APP_LINK.sender().send(false); + } + } + let _link_guard = LinkDownGuard; + let app_link = crate::split_app::SPLIT_APP_LINK.sender(); + let mut link_up_sent = false; + // Proactively announce our firmware hash so the central can detect // us even when it booted first and already gave up waiting for a query response. #[cfg(feature = "dfu_split")] @@ -113,127 +147,150 @@ impl SplitPeripheral { }, e = pointing_sub.next_message_pure().fuse() => SplitMessage::Pointing(e), with_feature("_ble"): e = battery_sub.next_event().fuse() => SplitMessage::BatteryStatus(e), + // Peripheral → central application + // messages, deliberately the last (lowest-priority) + // outgoing arm behind key events. + m = crate::split_app::SPLIT_APP_PERIPH_TX.receive().fuse() => SplitMessage::Application(m), } }; match select(self.split_driver.read(), read_message_to_send).await { Either::First(m) => match m { // Process split messages from the central - Ok(split_message) => match split_message { - SplitMessage::ConnectionStatus(status) => { - trace!("Received central connection status: {:?}", status); - update_status(|c| *c = status); - } - #[cfg(all(feature = "_ble", feature = "storage"))] - SplitMessage::ClearPeer => { - // Clear the peer address - FLASH_CHANNEL - .send(crate::storage::FlashOperationMessage::PeerAddress(PeerAddress::new( - 0, false, [0; 6], - ))) - .await; - } - SplitMessage::KeyboardIndicator(indicator) => { - // Publish KeyboardIndicator event - publish_event(LedIndicatorEvent::new( - rmk_types::led_indicator::LedIndicator::from_bits(indicator), - )); - } - SplitMessage::Layer(layer) => { - // Publish Layer event - publish_event(LayerChangeEvent::new(layer)); - } - #[cfg(feature = "display")] - SplitMessage::Wpm(wpm) => publish_event(WpmUpdateEvent::new(wpm)), - #[cfg(feature = "display")] - SplitMessage::Modifier(bits) => { - publish_event(ModifierEvent { - modifier: rmk_types::modifier::ModifierCombination::from_bits(bits), - }); - } - #[cfg(feature = "display")] - SplitMessage::SleepState(sleeping) => { - publish_event(SleepStateEvent::new(sleeping)); + Ok(split_message) => { + // First traffic from the central ⇒ the + // session is bidirectionally live (see the LinkDownGuard + // comment above for why link-up is not sent at start). + if !link_up_sent { + app_link.send(true); + link_up_sent = true; } - // --- dfu_split: firmware update handlers --- - #[cfg(feature = "dfu_split")] - SplitMessage::FirmwareHashQuery => { - let hash = crate::dfu::read_embedded_firmware_hash(); - info!("dfu_split: hash query, responding with {:#x}", hash); - self.split_driver - .write(&SplitMessage::FirmwareHashResponse(hash)) - .await - .ok(); - } - #[cfg(feature = "dfu_split")] - SplitMessage::FirmwareChunk { offset, len, data } => { - if self.dfu_handler.is_none() { - self.dfu_handler = crate::dfu::SplitDfuHandler::new(); + match split_message { + SplitMessage::ConnectionStatus(status) => { + trace!("Received central connection status: {:?}", status); + update_status(|c| *c = status); + } + #[cfg(all(feature = "_ble", feature = "storage"))] + SplitMessage::ClearPeer => { + // Clear the peer address + FLASH_CHANNEL + .send(crate::storage::FlashOperationMessage::PeerAddress(PeerAddress::new( + 0, false, [0; 6], + ))) + .await; + } + SplitMessage::KeyboardIndicator(indicator) => { + // Publish KeyboardIndicator event + publish_event(LedIndicatorEvent::new( + rmk_types::led_indicator::LedIndicator::from_bits(indicator), + )); + } + SplitMessage::Layer(layer) => { + // Publish Layer event + publish_event(LayerChangeEvent::new(layer)); + } + #[cfg(feature = "display")] + SplitMessage::Wpm(wpm) => publish_event(WpmUpdateEvent::new(wpm)), + #[cfg(feature = "display")] + SplitMessage::Modifier(bits) => { + publish_event(ModifierEvent { + modifier: rmk_types::modifier::ModifierCombination::from_bits(bits), + }); + } + #[cfg(feature = "display")] + SplitMessage::SleepState(sleeping) => { + publish_event(SleepStateEvent::new(sleeping)); + } + // --- dfu_split: firmware update handlers --- + #[cfg(feature = "dfu_split")] + SplitMessage::FirmwareHashQuery => { + let hash = crate::dfu::read_embedded_firmware_hash(); + info!("dfu_split: hash query, responding with {:#x}", hash); + self.split_driver + .write(&SplitMessage::FirmwareHashResponse(hash)) + .await + .ok(); + } + #[cfg(feature = "dfu_split")] + SplitMessage::FirmwareChunk { offset, len, data } => { if self.dfu_handler.is_none() { - error!("dfu_split: FlashManager not initialized, skipping chunk"); - continue; + self.dfu_handler = crate::dfu::SplitDfuHandler::new(); + if self.dfu_handler.is_none() { + error!("dfu_split: FlashManager not initialized, skipping chunk"); + continue; + } } - } - let handler = self.dfu_handler.as_mut().unwrap(); - let actual_len = len as usize; - let chunk_data = &data.0[..actual_len]; - match handler.write_chunk(offset as u32, chunk_data) { - Ok(()) => { - debug!("dfu_split: wrote {} bytes at offset {}", actual_len, offset); - let ack = SplitMessage::FirmwareChunkAck { - offset, - crc: crate::crc32::crc32(chunk_data), - }; - self.split_driver.write(&ack).await.ok(); + let handler = self.dfu_handler.as_mut().unwrap(); + let actual_len = len as usize; + let chunk_data = &data.0[..actual_len]; + match handler.write_chunk(offset as u32, chunk_data) { + Ok(()) => { + debug!("dfu_split: wrote {} bytes at offset {}", actual_len, offset); + let ack = SplitMessage::FirmwareChunkAck { + offset, + crc: crate::crc32::crc32(chunk_data), + }; + self.split_driver.write(&ack).await.ok(); + } + Err(()) => error!("dfu_split: write error at offset {}", offset), } - Err(()) => error!("dfu_split: write error at offset {}", offset), } - } - #[cfg(feature = "dfu_split")] - SplitMessage::FirmwareUpdateComplete => { - if let Some(ref mut handler) = self.dfu_handler { - let dfu_crc = handler.compute_dfu_crc(); - info!("dfu_split: DFU partition CRC: {:#010x}", dfu_crc); - let crc_msg = SplitMessage::FirmwareCrcReport(dfu_crc); - self.split_driver.write(&crc_msg).await.ok(); - info!("dfu_split: CRC report sent"); + #[cfg(feature = "dfu_split")] + SplitMessage::FirmwareUpdateComplete => { + if let Some(ref mut handler) = self.dfu_handler { + let dfu_crc = handler.compute_dfu_crc(); + info!("dfu_split: DFU partition CRC: {:#010x}", dfu_crc); + let crc_msg = SplitMessage::FirmwareCrcReport(dfu_crc); + self.split_driver.write(&crc_msg).await.ok(); + info!("dfu_split: CRC report sent"); - let deadline = embassy_time::Instant::now() + embassy_time::Duration::from_secs(5); - let ok = loop { - match select(self.split_driver.read(), embassy_time::Timer::at(deadline)).await { - Either::First(Ok(SplitMessage::FirmwareCrcOk)) => { - info!("dfu_split: central confirmed CRC, resetting"); - break true; - } - Either::First(Ok(SplitMessage::FirmwareCrcFail)) => { - warn!("dfu_split: central rejected CRC, stopping update"); - break false; - } - Either::First(Ok(_)) => {} - Either::First(Err(e)) => { - error!("read error: {:?}", e); - break false; - } - Either::Second(_) => { - error!("timeout"); - break false; + let deadline = embassy_time::Instant::now() + embassy_time::Duration::from_secs(5); + let ok = loop { + match select(self.split_driver.read(), embassy_time::Timer::at(deadline)).await + { + Either::First(Ok(SplitMessage::FirmwareCrcOk)) => { + info!("dfu_split: central confirmed CRC, resetting"); + break true; + } + Either::First(Ok(SplitMessage::FirmwareCrcFail)) => { + warn!("dfu_split: central rejected CRC, stopping update"); + break false; + } + Either::First(Ok(_)) => {} + Either::First(Err(e)) => { + error!("read error: {:?}", e); + break false; + } + Either::Second(_) => { + error!("timeout"); + break false; + } } - } - }; + }; - if ok { - self.split_driver.write(&SplitMessage::FirmwareUpdateConfirm).await.ok(); - embassy_time::Timer::after_millis(50).await; - handler.mark_updated_and_reset().ok(); + if ok { + self.split_driver.write(&SplitMessage::FirmwareUpdateConfirm).await.ok(); + embassy_time::Timer::after_millis(50).await; + handler.mark_updated_and_reset().ok(); + } else { + self.dfu_handler = None; + } } else { - self.dfu_handler = None; + error!("dfu_split: no active DFU session"); + } + } + // Forward application payloads; + // drop-on-full so a slow consumer can never stall the + // split read loop (the application resyncs on + // reconnect and must tolerate loss). + SplitMessage::Application(data) => { + if crate::split_app::SPLIT_APP_RX.try_send(data).is_err() { + warn!("split app message dropped (inbox full)"); } - } else { - error!("dfu_split: no active DFU session"); } + _ => (), } - _ => (), - }, + } Err(e) => { error!("Split message read error: {:?}", e); if let crate::split::driver::SplitDriverError::Disconnected = e { @@ -247,5 +304,8 @@ impl SplitPeripheral { } } } + + // The loop only exits on disconnect. + app_link.send(false); } } diff --git a/rmk/src/split_app.rs b/rmk/src/split_app.rs new file mode 100644 index 000000000..7dc37f3dd --- /dev/null +++ b/rmk/src/split_app.rs @@ -0,0 +1,124 @@ +//! Bounded application-message hook for the split protocol. +//! +//! A small, opaque, bounded payload that an application can send between the +//! split central and its peripheral alongside — but never in front of — the +//! normal split traffic. RMK itself attaches no meaning to the bytes; a +//! firmware built on RMK can use it for application-level side-band state +//! (for example, propagating lighting or UI state across the split link). +//! +//! Flow: +//! +//! - Central: the application queues [`SplitAppData`] into [`SPLIT_APP_TX`] +//! (bounded, `try_send` only — the queue must never be awaited full). +//! `PeripheralManager` drains it as one more (lowest-priority) arm of its +//! outgoing-message select and wraps each payload in +//! `SplitMessage::Application`. Peripheral→central key events always win: +//! they arrive on the read arm, which is polled first. +//! - Peripheral: `SplitPeripheral` forwards received `Application` messages +//! into [`SPLIT_APP_RX`] with `try_send` (drop-on-full keeps the split read +//! loop responsive; the application is expected to tolerate loss, e.g. by +//! resyncing on reconnect). +//! - Peripheral → central: the peripheral application queues +//! [`SplitAppData`] into [`SPLIT_APP_PERIPH_TX`] (bounded, `try_send` +//! only); `SplitPeripheral` drains it as one more outgoing arm of its +//! select, behind key events. The central's `PeripheralManager` forwards +//! received `Application` messages into [`SPLIT_APP_RX`] the same way the +//! peripheral does — the inbox is symmetric ("this side's received +//! application messages"), only the senders differ. +//! - Both sides: [`SPLIT_APP_LINK`] carries the split-link state (central: +//! "peripheral link up"; peripheral: "central link up"), set by the split +//! driver. The central raises it at session start; the peripheral raises +//! it on the FIRST message received from the central — for BLE the bare +//! connection is not enough, since notifications to a central that has +//! not yet subscribed are silently dropped (see `split/peripheral.rs`). +//! Both lower it at session end. Applications use the `false → true` edge +//! to trigger an idempotent resync. +//! +//! Note: the queues assume a single split peripheral. Extending this hook to +//! multiple peripherals would key them by peripheral id. + +use embassy_sync::channel::Channel; +use embassy_sync::watch::Watch; +use postcard::experimental::max_size::MaxSize; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::RawMutex; + +/// Maximum payload of one application split message. Deliberately small: +/// every split BLE transfer is `SPLIT_MESSAGE_MAX_SIZE` bytes on the wire, +/// so this bound also taxes key-event messages. Hard cap: the trouble +/// `gatt_service` macro initializes its characteristic arrays via +/// `Default`, which arrays only implement up to 32 elements — so +/// `SPLIT_MESSAGE_MAX_SIZE` (this + 1-byte length prefix + 1-byte enum +/// discriminant + 4 bytes margin) must stay ≤ 32. +pub const SPLIT_APP_MSG_MAX: usize = 26; + +/// One opaque application payload. Only `data[..len]` is meaningful. +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub struct SplitAppData { + pub len: u8, + pub data: [u8; SPLIT_APP_MSG_MAX], +} + +impl SplitAppData { + /// Wrap `payload`; `None` if it exceeds [`SPLIT_APP_MSG_MAX`]. + pub fn new(payload: &[u8]) -> Option { + if payload.len() > SPLIT_APP_MSG_MAX { + return None; + } + let mut data = [0u8; SPLIT_APP_MSG_MAX]; + data[..payload.len()].copy_from_slice(payload); + Some(Self { + len: payload.len() as u8, + data, + }) + } + + pub fn payload(&self) -> &[u8] { + &self.data[..(self.len as usize).min(SPLIT_APP_MSG_MAX)] + } +} + +// Postcard stores the payload as `&[u8]` (varint length prefix + bytes), so +// only the used bytes travel inside the (fixed-size) split transfer buffer. +impl Serialize for SplitAppData { + fn serialize(&self, serializer: S) -> Result { + self.payload().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SplitAppData { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let buf: &[u8] = Deserialize::deserialize(deserializer)?; + SplitAppData::new(buf).ok_or_else(|| D::Error::custom("split app message too long")) + } +} + +impl MaxSize for SplitAppData { + // 1-byte varint length prefix + payload. + const POSTCARD_MAX_SIZE: usize = SPLIT_APP_MSG_MAX + 1; +} + +/// Central → peripheral queue, drained by `PeripheralManager` while the link +/// is up. Producers MUST use `try_send` (bounded, never block); capacity is +/// sized so one full application resync burst fits with headroom. +pub static SPLIT_APP_TX: Channel = Channel::new(); + +/// This side's inbox of received application messages (peripheral: from the +/// central's `SPLIT_APP_TX`; central: from the peripheral's +/// `SPLIT_APP_PERIPH_TX`). Filled with `try_send` (drop-on-full) by the +/// split read loops. +pub static SPLIT_APP_RX: Channel = Channel::new(); + +/// Peripheral → central queue, drained by `SplitPeripheral` while the link +/// is up. Producers MUST use `try_send`. Small: the application announces +/// tiny, rare state (e.g. its build identity once per link-up). +pub static SPLIT_APP_PERIPH_TX: Channel = Channel::new(); + +/// Split-link state for the application: on the central, "peripheral link +/// up"; on the peripheral, "central link up". Written by the split driver at +/// session start/end; state-based (a late receiver still observes the latest +/// value), so edges cannot be lost the way pub/sub events can. +pub static SPLIT_APP_LINK: Watch = Watch::new(); From a1ce157734066e9873b86cb929c110ca0cfa89c7 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 20 Jul 2026 10:25:25 -0700 Subject: [PATCH 02/78] feat(lighting): add topology-aware lighting system --- .../src/default_config/event_default.toml | 6 + .../default_config/subscriber_default.toml | 13 + rmk-config/src/layout.rs | 101 ++ rmk-config/src/lib.rs | 143 +++ rmk-config/src/resolved/build_constants.rs | 13 +- rmk-config/src/resolved/layout.rs | 57 +- rmk-config/src/resolved/lighting.rs | 632 ++++++++++ rmk-config/src/resolved/mod.rs | 4 +- rmk-macro/src/codegen/lighting.rs | 405 +++++++ rmk-macro/src/codegen/mod.rs | 1 + rmk-macro/src/codegen/orchestrator.rs | 101 +- rmk-types/Cargo.toml | 3 +- rmk/Cargo.toml | 6 +- rmk/src/ble/battery_service.rs | 5 +- rmk/src/ble/sleep.rs | 29 +- rmk/src/display/mod.rs | 15 + rmk/src/event/mod.rs | 2 +- rmk/src/event/state.rs | 15 + rmk/src/hid.rs | 4 +- rmk/src/keyboard.rs | 13 + rmk/src/lib.rs | 3 + rmk/src/lighting/color.rs | 26 + rmk/src/lighting/compositor.rs | 309 +++++ rmk/src/lighting/context.rs | 74 ++ rmk/src/lighting/effect.rs | 247 ++++ rmk/src/lighting/mod.rs | 59 + rmk/src/lighting/output.rs | 583 +++++++++ rmk/src/lighting/processor.rs | 358 ++++++ rmk/src/lighting/rmk_state.rs | 67 ++ rmk/src/lighting/selector.rs | 216 ++++ rmk/src/lighting/service.rs | 1045 +++++++++++++++++ rmk/src/lighting/source.rs | 527 +++++++++ rmk/src/lighting/standard.rs | 975 +++++++++++++++ rmk/src/lighting/topology.rs | 668 +++++++++++ rmk/src/physical_layout.rs | 135 +++ rmk/src/split/driver.rs | 4 +- rmk/src/split/mod.rs | 2 +- rmk/src/split/peripheral.rs | 12 +- rmk/src/state.rs | 38 +- 39 files changed, 6840 insertions(+), 76 deletions(-) create mode 100644 rmk-config/src/resolved/lighting.rs create mode 100644 rmk-macro/src/codegen/lighting.rs create mode 100644 rmk/src/lighting/color.rs create mode 100644 rmk/src/lighting/compositor.rs create mode 100644 rmk/src/lighting/context.rs create mode 100644 rmk/src/lighting/effect.rs create mode 100644 rmk/src/lighting/mod.rs create mode 100644 rmk/src/lighting/output.rs create mode 100644 rmk/src/lighting/processor.rs create mode 100644 rmk/src/lighting/rmk_state.rs create mode 100644 rmk/src/lighting/selector.rs create mode 100644 rmk/src/lighting/service.rs create mode 100644 rmk/src/lighting/source.rs create mode 100644 rmk/src/lighting/standard.rs create mode 100644 rmk/src/lighting/topology.rs create mode 100644 rmk/src/physical_layout.rs diff --git a/rmk-config/src/default_config/event_default.toml b/rmk-config/src/default_config/event_default.toml index e6d054cfb..0c81357a3 100644 --- a/rmk-config/src/default_config/event_default.toml +++ b/rmk-config/src/default_config/event_default.toml @@ -39,6 +39,12 @@ channel_size = 1 pubs = 1 subs = 1 +# Unit invalidation published after authoritative lighting state changes. +[event.lighting_changed] +channel_size = 1 +pubs = 1 +subs = 1 + # Power events [event.battery_status] channel_size = 1 diff --git a/rmk-config/src/default_config/subscriber_default.toml b/rmk-config/src/default_config/subscriber_default.toml index 4c6824616..e9182c264 100644 --- a/rmk-config/src/default_config/subscriber_default.toml +++ b/rmk-config/src/default_config/subscriber_default.toml @@ -68,6 +68,12 @@ events = [ { name = "led_indicator" }, ] +[[subscriber]] +features = ["rynk", "lighting"] +events = [ + { name = "lighting_changed" }, +] + [[subscriber]] features = ["rynk", "_ble"] events = [ @@ -82,6 +88,13 @@ events = [ { name = "led_indicator" }, ] +[[subscriber]] +features = ["rynk", "lighting", "_ble"] +events = [ + # Second Rynk session slot on dual-transport boards. + { name = "lighting_changed" }, +] + # --- Split-gated internal subscribers --- [[subscriber]] diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index 275b2aaf8..cbcd79d6a 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -113,6 +113,12 @@ impl LayoutInfo { } } +pub(crate) struct ResolvedLayout { + pub blob: Vec, + pub keys: Vec<[u8; 2]>, + pub physical: crate::resolved::PhysicalLayout, +} + /// A resolved shape: every default applied. `rect2` is the L-key's second /// rectangle stored as center-relative offsets — its `x`/`y` are offsets from /// the primary center, not absolute positions (the walk resolves them). @@ -668,6 +674,101 @@ pub(crate) fn build_layout_blob( Ok(miniz_oxide::deflate::compress_to_vec(&bytes, 10)) } +/// Resolve both host-facing KLE data and the compact firmware geometry from +/// one parse/walk. This keeps `[layout].map` the only board-layout authority. +pub(crate) fn build_resolved_layout( + layout: &LayoutTomlConfig, + expected_encoders: Option, +) -> Result { + let Some(info) = build_layout_info(layout, expected_encoders)? else { + return Ok(ResolvedLayout { + blob: Vec::new(), + keys: Vec::new(), + physical: crate::resolved::PhysicalLayout::default(), + }); + }; + + let bytes = + postcard::to_allocvec(&info).map_err(|e| format!("keyboard.toml: layout blob serialize failed: {e}"))?; + let blob = miniz_oxide::deflate::compress_to_vec(&bytes, 10); + let keys = parse_map(layout.map.as_deref().unwrap_or_default(), layout.rows, layout.cols)? + .into_iter() + .filter_map(|token| match token { + MapToken::Key { row, col, .. } => Some([row, col]), + _ => None, + }) + .collect(); + let variant = &info.variants[info.default_variant as usize]; + let physical = crate::resolved::PhysicalLayout { + keys: variant + .keys + .iter() + .map(|key| { + let center = fixed_point(key.rect.x, key.rect.y) + .map_err(|reason| format!("layout.map key ({},{}) center {reason}", key.row, key.col))?; + let size = fixed_size(key.rect.w, key.rect.h) + .map_err(|reason| format!("layout.map key ({},{}) size {reason}", key.row, key.col))?; + let rotation_centidegrees = centidegrees(key.r) + .map_err(|reason| format!("layout.map key ({},{}) rotation {reason}", key.row, key.col))?; + Ok(crate::resolved::PhysicalKey { + matrix: [key.row, key.col], + center, + size, + rotation_centidegrees, + }) + }) + .collect::, String>>()?, + }; + + Ok(ResolvedLayout { blob, keys, physical }) +} + +fn fixed_point(x: f32, y: f32) -> Result { + fn axis(value: f32) -> Result { + if !value.is_finite() { + return Err("must be finite"); + } + let raw = (value * 256.0).round(); + if raw < i16::MIN as f32 || raw > i16::MAX as f32 { + return Err("does not fit signed Q8.8 key-pitch units"); + } + Ok(raw as i16) + } + Ok(crate::resolved::FixedPoint3 { + x: axis(x)?, + y: axis(y)?, + z: 0, + }) +} + +fn fixed_size(width: f32, height: f32) -> Result { + fn axis(value: f32) -> Result { + if !value.is_finite() || value <= 0.0 { + return Err("must be finite and positive"); + } + let raw = (value * 256.0).round(); + if raw < 1.0 || raw > u16::MAX as f32 { + return Err("does not fit unsigned Q8.8 key-pitch units"); + } + Ok(raw as u16) + } + Ok(crate::resolved::FixedSize2 { + width: axis(width)?, + height: axis(height)?, + }) +} + +fn centidegrees(degrees: f32) -> Result { + if !degrees.is_finite() { + return Err("must be finite"); + } + let raw = (degrees * 100.0).round(); + if raw < i16::MIN as f32 || raw > i16::MAX as f32 { + return Err("does not fit signed centidegrees"); + } + Ok(raw as i16) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index d858da563..882e34790 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -89,6 +89,9 @@ pub struct KeyboardTomlConfig { /// Layout config: the physical key arrangement (`map`) plus the rendered layout. /// For split keyboards, the total row/col is defined in this section. layout: Option, + /// Topology-aware lighting. Key geometry is always derived from + /// `[layout].map`; emitters add semantic identity and electrical routing. + lighting: Option, /// Behavior config behavior: Option, /// Light config @@ -466,6 +469,7 @@ define_event_config!( wpm_update, led_indicator, sleep_state, + lighting_changed, // Power events battery_status, battery_adc, @@ -526,6 +530,141 @@ pub(crate) struct VariantToml { pub hidden: Option>, } +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingTomlConfig { + #[serde(default = "default_topology_revision")] + pub topology_revision: u32, + #[serde(default, rename = "zone")] + pub zones: Vec, + #[serde(default, rename = "output")] + pub outputs: Vec, + #[serde(default, rename = "emitter")] + pub emitters: Vec, + #[serde(default, rename = "layer_scene")] + pub layer_scenes: Vec, + pub background: Option, +} + +fn default_topology_revision() -> u32 { + 1 +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingZoneTomlConfig { + pub id: u8, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingOutputTomlConfig { + pub node: u8, + pub id: u8, + pub pixel_count: u16, + pub capabilities: Vec, + #[serde(default)] + pub sparse: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingEmitterTomlConfig { + pub id: u16, + pub key: Option<[u8; 2]>, + pub position: Option<[f32; 3]>, + #[serde(default)] + pub zones: Vec, + pub node: u8, + pub output: u8, + pub physical_index: u16, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingLayerSceneTomlConfig { + pub layer: u8, + #[serde(default, rename = "cell")] + pub cells: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingSceneCellTomlConfig { + pub target: LightingTargetTomlConfig, + pub effect: LightingEffectTomlConfig, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum LightingTargetTomlConfig { + Led { led: u16 }, + Key { key: [u8; 2] }, + Zone { zone: u8 }, + All { all: bool }, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum LightingEffectTomlConfig { + Solid { + color: [u8; 3], + }, + Blink { + color: [u8; 3], + period_ms: u32, + #[serde(default)] + phase_ms: u32, + duty_percent: u8, + }, + Breathe { + color: [u8; 3], + period_ms: u32, + #[serde(default)] + phase_ms: u32, + #[serde(default = "default_breathe_step_ms")] + step_ms: u16, + }, +} + +fn default_breathe_step_ms() -> u16 { + 16 +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingBackgroundTomlConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub hue: u8, + #[serde(default)] + pub saturation: u8, + #[serde(default = "default_background_value")] + pub value: u8, + #[serde(default = "default_background_speed")] + pub speed: u8, + #[serde(default)] + pub mode: LightingBackgroundModeToml, +} + +fn default_background_value() -> u8 { + 32 +} + +fn default_background_speed() -> u8 { + 128 +} + +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LightingBackgroundModeToml { + #[default] + Solid, + Breathe, +} + /// The `[keymap]` section: layer count plus the per-layer key actions. #[derive(Clone, Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] @@ -1434,6 +1573,10 @@ mod tests { assert_eq!(config.led_indicator.pubs, 2); assert_eq!(config.led_indicator.subs, 3); + assert_eq!(config.lighting_changed.channel_size, 1); + assert_eq!(config.lighting_changed.pubs, 1); + assert_eq!(config.lighting_changed.subs, 1); + assert_eq!(config.pointing.channel_size, 8); assert_eq!(config.pointing.subs, 2); diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index 7c4ef3f54..3274333b9 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -105,6 +105,7 @@ impl crate::KeyboardTomlConfig { wpm_update, led_indicator, sleep_state, + lighting_changed, battery_status, battery_adc, charging_state, @@ -267,7 +268,9 @@ mod tests { #[test] fn reserves_led_subscribers_for_display_split_and_dual_rynk_sessions() { let config: KeyboardTomlConfig = toml::from_str("").unwrap(); - let constants = config.build_constants(&["display", "split", "rynk", "_ble"]).unwrap(); + let constants = config + .build_constants(&["display", "split", "rynk", "lighting", "_ble"]) + .unwrap(); let led_indicator = constants .events .iter() @@ -276,6 +279,14 @@ mod tests { // Three indicator processors, the display, two split peripherals, and USB/BLE Rynk sessions. assert_eq!(led_indicator.subs, 8); + + let lighting_changed = constants + .events + .iter() + .find(|event| event.name == "lighting_changed") + .unwrap(); + // One public subscriber plus USB and BLE Rynk sessions. + assert_eq!(lighting_changed.subs, 3); } #[test] diff --git a/rmk-config/src/resolved/layout.rs b/rmk-config/src/resolved/layout.rs index dbdb32416..0f860cfd4 100644 --- a/rmk-config/src/resolved/layout.rs +++ b/rmk-config/src/resolved/layout.rs @@ -1,16 +1,61 @@ -/// Resolved physical layout: the compressed, opaque blob the firmware streams -/// verbatim over `GetLayout`. Empty when there's no `[layout].map`. +/// Signed Q8.8 board-space point in key-pitch units. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FixedPoint3 { + pub x: i16, + pub y: i16, + pub z: i16, +} + +/// Unsigned Q8.8 key size in key-pitch units. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FixedSize2 { + pub width: u16, + pub height: u16, +} + +/// Fixed-point geometry for one key in the selected/default KLE variant. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhysicalKey { + pub matrix: [u8; 2], + pub center: FixedPoint3, + pub size: FixedSize2, + /// Clockwise rotation in hundredths of one degree. + pub rotation_centidegrees: i16, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PhysicalLayout { + pub keys: Vec, +} + +/// Resolved physical layout. `blob` preserves the complete variant-aware KLE +/// representation streamed over `GetLayout`; `physical` is the allocation-free +/// firmware geometry derived from its selected/default variant. `keys` is the +/// variant-independent set of logical matrix positions from `[layout].map`. pub struct Layout { pub blob: Vec, + pub rows: u8, + pub cols: u8, + pub keys: Vec<[u8; 2]>, + pub physical: PhysicalLayout, } impl crate::KeyboardTomlConfig { /// Resolve the physical layout blob from the `[layout]` section. pub fn layout(&self) -> Result { - let blob = match &self.layout { - Some(l) => crate::layout::build_layout_blob(l, Some(self.total_encoders()))?, - None => Vec::new(), + let (blob, keys, physical, rows, cols) = match &self.layout { + Some(l) => { + let resolved = crate::layout::build_resolved_layout(l, Some(self.total_encoders()))?; + (resolved.blob, resolved.keys, resolved.physical, l.rows, l.cols) + } + None => (Vec::new(), Vec::new(), PhysicalLayout::default(), 0, 0), }; - Ok(Layout { blob }) + Ok(Layout { + blob, + rows, + cols, + keys, + physical, + }) } } diff --git a/rmk-config/src/resolved/lighting.rs b/rmk-config/src/resolved/lighting.rs new file mode 100644 index 000000000..30f9a9eab --- /dev/null +++ b/rmk-config/src/resolved/lighting.rs @@ -0,0 +1,632 @@ +use std::collections::{HashMap, HashSet}; + +use crate::{LightingBackgroundModeToml, LightingEffectTomlConfig, LightingTargetTomlConfig}; + +use super::Keymap; +use super::layout::{FixedPoint3, Layout}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LightingKey { + pub matrix: [u8; 2], +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LightingZone { + pub id: u8, + pub name: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingEmitter { + pub id: u16, + pub key: Option<[u8; 2]>, + pub position: Option, + pub zone_start: u16, + pub zone_len: u8, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingOutput { + pub node: u8, + pub id: u8, + pub pixel_count: u16, + pub capabilities: u8, + pub sparse: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingRoute { + pub slot: u16, + pub node: u8, + pub output: u8, + pub physical_index: u16, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LightingEffect { + Solid { + color: [u8; 3], + }, + Blink { + color: [u8; 3], + period_ms: u32, + phase_ms: u32, + duty_percent: u8, + }, + Breathe { + color: [u8; 3], + period_ms: u32, + phase_ms: u32, + step_ms: u16, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingSceneCell { + pub slot: u16, + pub effect: LightingEffect, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LightingLayerScene { + pub layer: u8, + pub cells: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LightingBackgroundMode { + Solid, + Breathe, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingBackground { + pub enabled: bool, + pub hue: u8, + pub saturation: u8, + pub value: u8, + pub speed: u8, + pub mode: LightingBackgroundMode, +} + +impl Default for LightingBackground { + fn default() -> Self { + Self { + enabled: true, + hue: 0, + saturation: 0, + value: 32, + speed: 128, + mode: LightingBackgroundMode::Solid, + } + } +} + +/// Fully validated build-time lighting data. Key identities and fallback key +/// geometry come from the already-resolved `[layout].map`; emitters and routes +/// add semantic and electrical topology without redefining the board layout. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Lighting { + pub topology_revision: u32, + pub matrix: [u8; 2], + pub keys: Vec, + pub zones: Vec, + pub emitters: Vec, + pub zone_memberships: Vec, + pub outputs: Vec, + pub routes: Vec, + pub layer_scenes: Vec, + pub background: LightingBackground, +} + +impl crate::KeyboardTomlConfig { + pub fn lighting(&self, layout: &Layout, keymap: &Keymap) -> Result, String> { + let Some(config) = &self.lighting else { + return Ok(None); + }; + if layout.keys.is_empty() { + return Err("[lighting] requires `[layout].map` as the canonical logical key layout".into()); + } + if config.emitters.is_empty() { + return Err("[lighting] must define at least one [[lighting.emitter]]".into()); + } + if config.emitters.len() > u16::MAX as usize { + return Err("[lighting] emitter count exceeds LedSlot u16 capacity".into()); + } + + let zones = resolve_zones(&config.zones)?; + let zone_ids: HashSet = zones.iter().map(|zone| zone.id).collect(); + let mut emitter_ids = HashSet::new(); + let mut zone_memberships = Vec::new(); + let mut emitters = Vec::with_capacity(config.emitters.len()); + let mut routes = Vec::with_capacity(config.emitters.len()); + for (slot, emitter) in config.emitters.iter().enumerate() { + if !emitter_ids.insert(emitter.id) { + return Err(format!("duplicate lighting emitter id {}", emitter.id)); + } + if let Some(key) = emitter.key + && !layout.keys.contains(&key) + { + return Err(format!( + "lighting emitter {} key [{}, {}] is not a logical key in layout.map", + emitter.id, key[0], key[1] + )); + } + let mut local_zones = HashSet::new(); + for zone in &emitter.zones { + if !zone_ids.contains(zone) { + return Err(format!( + "lighting emitter {} references unknown zone {zone}", + emitter.id + )); + } + if !local_zones.insert(*zone) { + return Err(format!("lighting emitter {} repeats zone {zone}", emitter.id)); + } + } + if emitter.zones.len() > u8::MAX as usize + || zone_memberships.len() + emitter.zones.len() > u16::MAX as usize + { + return Err("lighting zone membership table exceeds bounded representation".into()); + } + let zone_start = zone_memberships.len() as u16; + zone_memberships.extend_from_slice(&emitter.zones); + emitters.push(LightingEmitter { + id: emitter.id, + key: emitter.key, + position: emitter + .position + .map(to_fixed_point) + .transpose() + .map_err(str::to_owned)?, + zone_start, + zone_len: emitter.zones.len() as u8, + }); + routes.push(LightingRoute { + slot: slot as u16, + node: emitter.node, + output: emitter.output, + physical_index: emitter.physical_index, + }); + } + + let outputs = resolve_outputs(&config.outputs)?; + validate_routes(&outputs, &routes)?; + let layer_scenes = resolve_layer_scenes( + keymap.layers, + &config.layer_scenes, + &emitters, + &zone_memberships, + &zone_ids, + )?; + let background = config + .background + .as_ref() + .map(|background| LightingBackground { + enabled: background.enabled, + hue: background.hue, + saturation: background.saturation, + value: background.value, + speed: background.speed, + mode: match background.mode { + LightingBackgroundModeToml::Solid => LightingBackgroundMode::Solid, + LightingBackgroundModeToml::Breathe => LightingBackgroundMode::Breathe, + }, + }) + .unwrap_or_default(); + + Ok(Some(Lighting { + topology_revision: config.topology_revision, + matrix: [layout.rows, layout.cols], + keys: layout + .keys + .iter() + .copied() + .map(|matrix| LightingKey { matrix }) + .collect(), + zones, + emitters, + zone_memberships, + outputs, + routes, + layer_scenes, + background, + })) + } +} + +fn to_fixed_point(point: [f32; 3]) -> Result { + fn axis(value: f32) -> Result { + if !value.is_finite() { + return Err("must contain finite coordinates"); + } + let raw = (value * 256.0).round(); + if raw < i16::MIN as f32 || raw > i16::MAX as f32 { + return Err("does not fit signed Q8.8 key-pitch units"); + } + Ok(raw as i16) + } + Ok(FixedPoint3 { + x: axis(point[0])?, + y: axis(point[1])?, + z: axis(point[2])?, + }) +} + +fn resolve_zones(config: &[crate::LightingZoneTomlConfig]) -> Result, String> { + let mut ids = HashSet::new(); + let mut names = HashSet::new(); + config + .iter() + .map(|zone| { + if !ids.insert(zone.id) { + return Err(format!("duplicate lighting zone id {}", zone.id)); + } + if zone.name.is_empty() || !names.insert(zone.name.clone()) { + return Err(format!("duplicate or empty lighting zone name {:?}", zone.name)); + } + Ok(LightingZone { + id: zone.id, + name: zone.name.clone(), + }) + }) + .collect() +} + +fn resolve_outputs(config: &[crate::LightingOutputTomlConfig]) -> Result, String> { + let mut ids = HashSet::new(); + config + .iter() + .map(|output| { + if !ids.insert((output.node, output.id)) { + return Err(format!( + "duplicate lighting output node {} id {}", + output.node, output.id + )); + } + if output.pixel_count == 0 { + return Err(format!( + "lighting output node {} id {} has zero pixels", + output.node, output.id + )); + } + let mut capabilities = 0u8; + for capability in &output.capabilities { + let bit = match capability.as_str() { + "binary" => 1 << 0, + "intensity" => 1 << 1, + "rgb" => 1 << 2, + "white" => 1 << 3, + "rgbw" => (1 << 2) | (1 << 3), + "addressable" => 1 << 4, + other => return Err(format!("unknown lighting output capability {other:?}")), + }; + if capabilities & bit != 0 { + return Err(format!( + "lighting output node {} id {} repeats capability {capability:?}", + output.node, output.id + )); + } + capabilities |= bit; + } + if capabilities & 0b1111 == 0 { + return Err(format!( + "lighting output node {} id {} has no color capability", + output.node, output.id + )); + } + Ok(LightingOutput { + node: output.node, + id: output.id, + pixel_count: output.pixel_count, + capabilities, + sparse: output.sparse, + }) + }) + .collect() +} + +fn validate_routes(outputs: &[LightingOutput], routes: &[LightingRoute]) -> Result<(), String> { + let output_map: HashMap<(u8, u8), &LightingOutput> = outputs + .iter() + .map(|output| ((output.node, output.id), output)) + .collect(); + let mut addresses = HashSet::new(); + for route in routes { + let Some(output) = output_map.get(&(route.node, route.output)) else { + return Err(format!( + "lighting slot {} routes to unknown node {} output {}", + route.slot, route.node, route.output + )); + }; + if route.physical_index >= output.pixel_count { + return Err(format!( + "lighting slot {} physical index {} is outside node {} output {} length {}", + route.slot, route.physical_index, route.node, route.output, output.pixel_count + )); + } + if !addresses.insert((route.node, route.output, route.physical_index)) { + return Err(format!( + "duplicate lighting physical route node {} output {} index {}", + route.node, route.output, route.physical_index + )); + } + } + for output in outputs.iter().filter(|output| !output.sparse) { + for physical_index in 0..output.pixel_count { + if !addresses.contains(&(output.node, output.id, physical_index)) { + return Err(format!( + "complete lighting output node {} id {} has hole at index {}", + output.node, output.id, physical_index + )); + } + } + } + Ok(()) +} + +fn resolve_layer_scenes( + layer_count: u8, + config: &[crate::LightingLayerSceneTomlConfig], + emitters: &[LightingEmitter], + zone_memberships: &[u8], + zone_ids: &HashSet, +) -> Result, String> { + let id_to_slot: HashMap = emitters + .iter() + .enumerate() + .map(|(slot, emitter)| (emitter.id, slot as u16)) + .collect(); + let mut scenes = Vec::with_capacity(config.len()); + for scene in config { + if scene.layer >= layer_count { + return Err(format!( + "lighting layer scene {} is outside configured layer count {}", + scene.layer, layer_count + )); + } + if scene.cells.is_empty() { + return Err(format!("lighting layer scene {} has no cells", scene.layer)); + } + let mut cells = Vec::new(); + for cell in &scene.cells { + let slots: Vec = match cell.target { + LightingTargetTomlConfig::Led { led } => vec![ + *id_to_slot + .get(&led) + .ok_or_else(|| format!("lighting scene references unknown emitter id {led}"))?, + ], + LightingTargetTomlConfig::Key { key } => emitters + .iter() + .enumerate() + .filter(|(_, emitter)| emitter.key == Some(key)) + .map(|(slot, _)| slot as u16) + .collect(), + LightingTargetTomlConfig::Zone { zone } => { + if !zone_ids.contains(&zone) { + return Err(format!("lighting scene references unknown zone {zone}")); + } + emitters + .iter() + .enumerate() + .filter(|(_, emitter)| { + let start = emitter.zone_start as usize; + let end = start + emitter.zone_len as usize; + zone_memberships[start..end].contains(&zone) + }) + .map(|(slot, _)| slot as u16) + .collect() + } + LightingTargetTomlConfig::All { all: true } => (0..emitters.len() as u16).collect(), + LightingTargetTomlConfig::All { all: false } => { + return Err("lighting target `{ all = false }` is invalid".into()); + } + }; + if slots.is_empty() { + return Err(format!( + "lighting layer scene {} target resolves to no emitters", + scene.layer + )); + } + let effect = resolve_effect(&cell.effect)?; + cells.extend(slots.into_iter().map(|slot| LightingSceneCell { slot, effect })); + } + scenes.push(LightingLayerScene { + layer: scene.layer, + cells, + }); + } + Ok(scenes) +} + +fn resolve_effect(config: &LightingEffectTomlConfig) -> Result { + Ok(match *config { + LightingEffectTomlConfig::Solid { color } => LightingEffect::Solid { color }, + LightingEffectTomlConfig::Blink { + color, + period_ms, + phase_ms, + duty_percent, + } => { + if period_ms == 0 { + return Err("blink period_ms must be greater than zero".into()); + } + if duty_percent > 100 { + return Err(format!("blink duty_percent {duty_percent} exceeds 100")); + } + LightingEffect::Blink { + color, + period_ms, + phase_ms, + duty_percent, + } + } + LightingEffectTomlConfig::Breathe { + color, + period_ms, + phase_ms, + step_ms, + } => { + if period_ms < 2 { + return Err("breathe period_ms must be at least two".into()); + } + if step_ms == 0 || u32::from(step_ms) >= period_ms { + return Err(format!( + "breathe step_ms {step_ms} must be greater than zero and less than period_ms {period_ms}" + )); + } + LightingEffect::Breathe { + color, + period_ms, + phase_ms, + step_ms, + } + } + }) +} + +#[cfg(test)] +mod tests { + fn parse(config: &str) -> crate::KeyboardTomlConfig { + toml::from_str(config).unwrap() + } + + const BASE: &str = r#" +[matrix] +row_pins = ["r0"] +col_pins = ["c0", "c1"] + +[layout] +rows = 1 +cols = 2 +map = "(0,0,@wide) (0,1)" + +[layout.shapes] +wide = { w = 1.5, r = -7.5 } + +[keymap] +layers = 2 +[[keymap.layer]] +keys = "A B" +[[keymap.layer]] +keys = "A B" + +[lighting] +topology_revision = 7 +[[lighting.zone]] +id = 1 +name = "keys" +[[lighting.output]] +node = 0 +id = 0 +pixel_count = 2 +capabilities = ["rgb", "addressable"] +[[lighting.emitter]] +id = 10 +key = [0, 0] +zones = [1] +node = 0 +output = 0 +physical_index = 1 +[[lighting.emitter]] +id = 20 +key = [0, 1] +position = [1.0, 0.0, 0.25] +zones = [1] +node = 0 +output = 0 +physical_index = 0 +[[lighting.layer_scene]] +layer = 1 +[[lighting.layer_scene.cell]] +target = { zone = 1 } +effect = { kind = "solid", color = [1, 2, 3] } +"#; + + #[test] + fn derives_geometry_and_logical_keys_from_layout_map() { + let config = parse(BASE); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + assert_eq!(layout.keys, vec![[0, 0], [0, 1]]); + assert_eq!(layout.physical.keys[0].size.width, 384); + assert_eq!(layout.physical.keys[0].rotation_centidegrees, -750); + let lighting = config.lighting(&layout, &keymap).unwrap().unwrap(); + assert_eq!(lighting.keys.len(), 2); + assert_eq!(lighting.emitters.len(), 2); + assert_eq!(lighting.routes[0].physical_index, 1); + assert_eq!(lighting.layer_scenes[0].cells.len(), 2); + } + + #[test] + fn rejects_emitter_key_that_is_only_inside_matrix_bounds() { + let hole = BASE + .replace("col_pins = [\"c0\", \"c1\"]", "col_pins = [\"c0\", \"c1\", \"c2\"]") + .replace("cols = 2", "cols = 3") + .replace("(0,1)", "(0,2)"); + let config = parse(&hole); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + let error = config.lighting(&layout, &keymap).unwrap_err(); + assert!(error.contains("not a logical key in layout.map"), "{error}"); + } + + #[test] + fn hidden_default_variant_key_remains_a_logical_emitter_key_without_geometry() { + let source = BASE.replace( + "map = \"(0,0,@wide) (0,1)\"", + r#"map = "(0,0,@wide) (0,1)" +default_variant = "compact" +[[layout.variant]] +name = "full" +[[layout.variant]] +name = "compact" +hidden = ["(0,1)"]"#, + ); + let config = parse(&source); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + + assert!(layout.keys.contains(&[0, 1]), "logical identity is variant-independent"); + assert!( + layout.physical.keys.iter().all(|key| key.matrix != [0, 1]), + "the selected/default variant has no fallback center for its hidden key" + ); + let lighting = config.lighting(&layout, &keymap).unwrap().unwrap(); + assert_eq!(lighting.emitters[1].key, Some([0, 1])); + assert_eq!(lighting.emitters[1].position.unwrap().z, 64); + } + + #[test] + fn rejects_degenerate_animated_effects() { + for (source, expected) in [ + ( + BASE.replace( + "effect = { kind = \"solid\", color = [1, 2, 3] }", + "effect = { kind = \"blink\", color = [1, 2, 3], period_ms = 0, duty_percent = 50 }", + ), + "blink period_ms", + ), + ( + BASE.replace( + "effect = { kind = \"solid\", color = [1, 2, 3] }", + "effect = { kind = \"breathe\", color = [1, 2, 3], period_ms = 1, step_ms = 1 }", + ), + "breathe period_ms", + ), + ( + BASE.replace( + "effect = { kind = \"solid\", color = [1, 2, 3] }", + "effect = { kind = \"breathe\", color = [1, 2, 3], period_ms = 100, step_ms = 100 }", + ), + "breathe step_ms", + ), + ] { + let config = parse(&source); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + let error = config.lighting(&layout, &keymap).unwrap_err(); + assert!(error.contains(expected), "{error}"); + } + } +} diff --git a/rmk-config/src/resolved/mod.rs b/rmk-config/src/resolved/mod.rs index e5957ded6..6fa30a97a 100644 --- a/rmk-config/src/resolved/mod.rs +++ b/rmk-config/src/resolved/mod.rs @@ -32,6 +32,7 @@ pub mod host; pub mod identity; pub mod keymap; pub mod layout; +pub mod lighting; pub use behavior::Behavior; pub use build_constants::BuildConstants; @@ -39,7 +40,8 @@ pub use hardware::Hardware; pub use host::Host; pub use identity::Identity; pub use keymap::Keymap; -pub use layout::Layout; +pub use layout::{FixedPoint3, FixedSize2, Layout, PhysicalKey, PhysicalLayout}; +pub use lighting::Lighting; // Re-export constants used by codegen pub use crate::keycode_alias::KEYCODE_ALIAS; diff --git a/rmk-macro/src/codegen/lighting.rs b/rmk-macro/src/codegen/lighting.rs new file mode 100644 index 000000000..74763cee2 --- /dev/null +++ b/rmk-macro/src/codegen/lighting.rs @@ -0,0 +1,405 @@ +//! Generate flash-resident shared geometry and semantic lighting topology. + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use rmk_config::resolved::lighting::{ + Lighting, LightingBackgroundMode, LightingEffect, LightingSceneCell, +}; +use rmk_config::resolved::{FixedPoint3, PhysicalLayout}; + +pub(crate) fn expand_physical_layout(layout: &PhysicalLayout) -> TokenStream2 { + let keys = layout.keys.iter().map(|key| { + let [row, col] = key.matrix; + let center = expand_point(key.center); + let width = key.size.width; + let height = key.size.height; + let rotation = key.rotation_centidegrees; + quote! { + ::rmk::physical_layout::PhysicalKey { + matrix: ::rmk::physical_layout::KeyPosition::new(#row, #col), + center: #center, + size: ::rmk::physical_layout::KeySize::new( + ::rmk::physical_layout::Extent::from_raw(#width), + ::rmk::physical_layout::Extent::from_raw(#height), + ), + rotation: ::rmk::physical_layout::Rotation::from_centidegrees(#rotation), + } + } + }); + let len = layout.keys.len(); + + quote! { + pub static PHYSICAL_KEYS: [::rmk::physical_layout::PhysicalKey; #len] = [#(#keys),*]; + pub const PHYSICAL_LAYOUT: ::rmk::physical_layout::PhysicalLayout<'static> = + ::rmk::physical_layout::PhysicalLayout::new(&PHYSICAL_KEYS); + } +} + +/// Generate the flash-resident topology, routing, and built-in semantic +/// lighting configuration for a resolved `[lighting]` section. +pub(crate) fn expand_lighting_topology(lighting: Option<&Lighting>) -> TokenStream2 { + let Some(lighting) = lighting else { + return TokenStream2::new(); + }; + let revision = lighting.topology_revision; + let [rows, cols] = lighting.matrix; + let led_count = lighting.emitters.len(); + + let keys = lighting.keys.iter().map(|key| { + let [row, col] = key.matrix; + quote! { ::rmk::lighting::topology::MatrixPosition::new(#row, #col) } + }); + let key_count = lighting.keys.len(); + let zones = lighting.zones.iter().map(|zone| { + let id = zone.id; + let name = &zone.name; + quote! { + ::rmk::lighting::topology::ZoneMetadata { + id: ::rmk::lighting::topology::ZoneId(#id), + name: #name, + } + } + }); + let zone_count = lighting.zones.len(); + let emitters = lighting.emitters.iter().map(|emitter| { + let id = emitter.id; + let key = match emitter.key { + Some([row, col]) => quote! { + ::core::option::Option::Some(::rmk::lighting::topology::MatrixPosition::new(#row, #col)) + }, + None => quote! { ::core::option::Option::None }, + }; + let position = match emitter.position { + Some(point) => { + let point = expand_point(point); + quote! { ::core::option::Option::Some(#point) } + } + None => quote! { ::core::option::Option::None }, + }; + let zone_start = emitter.zone_start; + let zone_len = emitter.zone_len; + quote! { + ::rmk::lighting::topology::LedMetadata { + id: ::rmk::lighting::topology::LedId(#id), + key: #key, + position: #position, + zones: ::rmk::lighting::topology::ZoneSpan::new(#zone_start, #zone_len), + } + } + }); + let memberships = lighting.zone_memberships.iter().map(|id| { + quote! { ::rmk::lighting::topology::ZoneId(#id) } + }); + let membership_count = lighting.zone_memberships.len(); + let outputs = lighting.outputs.iter().map(|output| { + let node = output.node; + let id = output.id; + let pixel_count = output.pixel_count; + let capabilities = output.capabilities; + let coverage = if output.sparse { + quote! { ::rmk::lighting::topology::OutputCoverage::Sparse } + } else { + quote! { ::rmk::lighting::topology::OutputCoverage::Complete } + }; + quote! { + ::rmk::lighting::topology::OutputMetadata { + node: ::rmk::lighting::topology::LightingNodeId(#node), + id: ::rmk::lighting::topology::OutputId(#id), + pixel_count: #pixel_count, + capabilities: ::rmk::lighting::topology::OutputCapabilities::from_bits(#capabilities) + .expect("rmk-config emitted validated output capabilities"), + coverage: #coverage, + } + } + }); + let output_count = lighting.outputs.len(); + let routes = lighting.routes.iter().map(|route| { + let slot = route.slot; + let node = route.node; + let output = route.output; + let physical_index = route.physical_index; + quote! { + ::rmk::lighting::topology::PhysicalRoute { + slot: ::rmk::lighting::topology::LedSlot(#slot), + node: ::rmk::lighting::topology::LightingNodeId(#node), + output: ::rmk::lighting::topology::OutputId(#output), + physical_index: #physical_index, + } + } + }); + let route_count = lighting.routes.len(); + let layer_scene_cells = lighting.layer_scenes.iter().enumerate().map(|(index, scene)| { + let name = quote::format_ident!("LIGHTING_LAYER_SCENE_{index}_CELLS"); + let cells = scene.cells.iter().map(expand_scene_cell); + let len = scene.cells.len(); + quote! { + pub static #name: [::rmk::lighting::SceneCell<::rmk::lighting::BuiltinEffect>; #len] = + [#(#cells),*]; + } + }); + let layer_scene_table = lighting + .layer_scenes + .iter() + .enumerate() + .map(|(index, scene)| { + let name = quote::format_ident!("LIGHTING_LAYER_SCENE_{index}_CELLS"); + let layer = scene.layer; + quote! { + ::rmk::lighting::LayerScene { + layer: #layer, + cells: &#name, + } + } + }); + let layer_scene_count = lighting.layer_scenes.len(); + let background = &lighting.background; + let background_enabled = background.enabled; + let background_hue = background.hue; + let background_saturation = background.saturation; + let background_value = background.value; + let background_speed = background.speed; + let background_mode = match background.mode { + LightingBackgroundMode::Solid => quote! { ::rmk::lighting::BackgroundMode::Solid }, + LightingBackgroundMode::Breathe => quote! { ::rmk::lighting::BackgroundMode::Breathe }, + }; + + quote! { + pub const LIGHTING_TOPOLOGY_REVISION: u32 = #revision; + pub const LIGHTING_LED_COUNT: usize = #led_count; + pub static LIGHTING_KEYS: [::rmk::lighting::topology::MatrixPosition; #key_count] = [#(#keys),*]; + pub static LIGHTING_ZONES: [::rmk::lighting::topology::ZoneMetadata<'static>; #zone_count] = [#(#zones),*]; + pub static LIGHTING_EMITTERS: [::rmk::lighting::topology::LedMetadata; #led_count] = [#(#emitters),*]; + pub static LIGHTING_ZONE_MEMBERSHIPS: [::rmk::lighting::topology::ZoneId; #membership_count] = [#(#memberships),*]; + pub static LIGHTING_OUTPUTS: [::rmk::lighting::topology::OutputMetadata; #output_count] = [#(#outputs),*]; + pub static LIGHTING_ROUTES: [::rmk::lighting::topology::PhysicalRoute; #route_count] = [#(#routes),*]; + pub const LIGHTING_TOPOLOGY: ::rmk::lighting::topology::LightingTopology<'static> = + ::rmk::lighting::topology::LightingTopology { + matrix: ::rmk::lighting::topology::MatrixSize::new(#rows, #cols), + keys: &LIGHTING_KEYS, + physical_layout: PHYSICAL_LAYOUT, + leds: &LIGHTING_EMITTERS, + zones: &LIGHTING_ZONES, + zone_memberships: &LIGHTING_ZONE_MEMBERSHIPS, + }; + pub const LIGHTING_ROUTING: ::rmk::lighting::topology::LightingRouting<'static> = + ::rmk::lighting::topology::LightingRouting { + outputs: &LIGHTING_OUTPUTS, + routes: &LIGHTING_ROUTES, + }; + + #(#layer_scene_cells)* + pub static LIGHTING_LAYER_SCENE_TABLE: + [::rmk::lighting::LayerScene<'static, ::rmk::lighting::BuiltinEffect>; #layer_scene_count] = + [#(#layer_scene_table),*]; + pub const LIGHTING_LAYER_SCENES: + ::rmk::lighting::LayerScenes<'static, ::rmk::lighting::BuiltinEffect> = + ::rmk::lighting::LayerScenes { + scenes: &LIGHTING_LAYER_SCENE_TABLE, + policy: ::rmk::lighting::LayerPolicy::ActiveStack, + }; + pub const LIGHTING_BACKGROUND: ::rmk::lighting::BackgroundState = + ::rmk::lighting::BackgroundState { + enabled: #background_enabled, + hue: #background_hue, + saturation: #background_saturation, + value: #background_value, + speed: #background_speed, + mode: #background_mode, + }; + } +} + +fn expand_scene_cell(cell: &LightingSceneCell) -> TokenStream2 { + let slot = cell.slot; + let effect = expand_effect(cell.effect); + quote! { + ::rmk::lighting::SceneCell { + slot: ::rmk::lighting::topology::LedSlot(#slot), + effect: #effect, + } + } +} + +fn expand_effect(effect: LightingEffect) -> TokenStream2 { + match effect { + LightingEffect::Solid { color } => { + let [r, g, b] = color; + quote! { + ::rmk::lighting::BuiltinEffect::Solid { + color: ::rmk::lighting::Rgb8::new(#r, #g, #b), + } + } + } + LightingEffect::Blink { + color, + period_ms, + phase_ms, + duty_percent, + } => { + let [r, g, b] = color; + quote! { + ::rmk::lighting::BuiltinEffect::Blink { + color: ::rmk::lighting::Rgb8::new(#r, #g, #b), + period_ms: #period_ms, + phase_ms: #phase_ms, + duty: #duty_percent, + } + } + } + LightingEffect::Breathe { + color, + period_ms, + phase_ms, + step_ms, + } => { + let [r, g, b] = color; + quote! { + ::rmk::lighting::BuiltinEffect::Breathe { + color: ::rmk::lighting::Rgb8::new(#r, #g, #b), + period_ms: #period_ms, + phase_ms: #phase_ms, + step_ms: #step_ms, + } + } + } + } +} + +fn expand_point(point: FixedPoint3) -> TokenStream2 { + let x = point.x; + let y = point.y; + let z = point.z; + quote! { + ::rmk::physical_layout::Point3::new( + ::rmk::physical_layout::Coordinate::from_raw(#x), + ::rmk::physical_layout::Coordinate::from_raw(#y), + ::rmk::physical_layout::Coordinate::from_raw(#z), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rmk_config::resolved::lighting::{ + LightingBackground, LightingEmitter, LightingKey, LightingLayerScene, LightingOutput, + LightingRoute, LightingSceneCell, LightingZone, + }; + use rmk_config::resolved::{FixedSize2, PhysicalKey}; + + #[test] + fn emits_shared_geometry_and_topology_without_electrical_order_leaking_into_ids() { + let physical = PhysicalLayout { + keys: vec![PhysicalKey { + matrix: [0, 0], + center: FixedPoint3 { + x: -128, + y: 256, + z: 0, + }, + size: FixedSize2 { + width: 384, + height: 256, + }, + rotation_centidegrees: -750, + }], + }; + let geometry = expand_physical_layout(&physical).to_string(); + assert!(geometry.contains("PHYSICAL_LAYOUT")); + assert!(geometry.contains("from_raw (- 128i16)")); + + let lighting = Lighting { + topology_revision: 4, + matrix: [1, 1], + keys: vec![LightingKey { matrix: [0, 0] }], + zones: vec![LightingZone { + id: 1, + name: "keys".into(), + }], + emitters: vec![LightingEmitter { + id: 42, + key: Some([0, 0]), + position: None, + zone_start: 0, + zone_len: 1, + }], + zone_memberships: vec![1], + outputs: vec![LightingOutput { + node: 2, + id: 3, + pixel_count: 2, + capabilities: 0b10100, + sparse: true, + }], + routes: vec![LightingRoute { + slot: 0, + node: 2, + output: 3, + physical_index: 1, + }], + layer_scenes: vec![ + LightingLayerScene { + layer: 0, + cells: vec![LightingSceneCell { + slot: 0, + effect: LightingEffect::Solid { color: [1, 2, 3] }, + }], + }, + LightingLayerScene { + layer: 1, + cells: vec![ + LightingSceneCell { + slot: 0, + effect: LightingEffect::Blink { + color: [4, 5, 6], + period_ms: 1000, + phase_ms: 250, + duty_percent: 40, + }, + }, + LightingSceneCell { + slot: 0, + effect: LightingEffect::Breathe { + color: [7, 8, 9], + period_ms: 2000, + phase_ms: 500, + step_ms: 20, + }, + }, + ], + }, + ], + background: LightingBackground { + enabled: false, + hue: 11, + saturation: 22, + value: 33, + speed: 44, + mode: LightingBackgroundMode::Breathe, + }, + }; + let topology = expand_lighting_topology(Some(&lighting)).to_string(); + assert!(topology.contains("LedId (42u16)")); + assert!(topology.contains("physical_index : 1u16")); + assert!(topology.contains("physical_layout : PHYSICAL_LAYOUT")); + assert!(topology.contains("LIGHTING_LAYER_SCENE_0_CELLS")); + assert!(topology.contains("LIGHTING_LAYER_SCENE_1_CELLS")); + assert!(topology.contains("LIGHTING_LAYER_SCENE_TABLE")); + assert!(topology.contains("LIGHTING_LAYER_SCENES")); + assert!(topology.contains("LayerPolicy :: ActiveStack")); + assert!(topology.contains("BuiltinEffect :: Solid")); + assert!(topology.contains("BuiltinEffect :: Blink")); + assert!(topology.contains("duty : 40u8")); + assert!(topology.contains("BuiltinEffect :: Breathe")); + assert!(topology.contains("step_ms : 20u16")); + assert!(topology.contains("LIGHTING_BACKGROUND")); + assert!(topology.contains("enabled : false")); + assert!(topology.contains("hue : 11u8")); + assert!(topology.contains("mode : :: rmk :: lighting :: BackgroundMode :: Breathe")); + } + + #[test] + fn omits_all_lighting_symbols_without_resolved_lighting() { + assert!(expand_lighting_topology(None).is_empty()); + } +} diff --git a/rmk-macro/src/codegen/mod.rs b/rmk-macro/src/codegen/mod.rs index 0908c6755..935d4cbcd 100644 --- a/rmk-macro/src/codegen/mod.rs +++ b/rmk-macro/src/codegen/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod import; pub(crate) mod input_device; pub(crate) mod keyboard_config; pub(crate) mod keymap; +pub(crate) mod lighting; pub(crate) mod matrix; pub(crate) mod orchestrator; pub(crate) mod override_helper; diff --git a/rmk-macro/src/codegen/orchestrator.rs b/rmk-macro/src/codegen/orchestrator.rs index 3583a6722..75928523e 100644 --- a/rmk-macro/src/codegen/orchestrator.rs +++ b/rmk-macro/src/codegen/orchestrator.rs @@ -22,6 +22,7 @@ use super::keyboard_config::{ expand_keyboard_info, expand_lock_config, expand_vial_config, read_keyboard_toml_config, }; use super::keymap::expand_default_keymap; +use super::lighting::{expand_lighting_topology, expand_physical_layout}; use super::matrix::{expand_bootmagic_check, expand_matrix_config}; use super::registered_processor::expand_registered_processor_init; use super::split::central::expand_split_central_config; @@ -56,18 +57,29 @@ pub(crate) fn parse_keyboard_mod(item_mod: syn::ItemMod) -> TokenStream2 { let layout = keyboard_config .layout() .expect("failed to resolve layout config"); + let lighting = keyboard_config + .lighting(&layout, &keymap) + .expect("failed to resolve lighting config"); validate_feature_config_parity( &rmk_features, hardware.storage.is_some(), host.vial_enabled, host.rynk_enabled, + lighting.is_some(), ) .unwrap_or_else(|err| panic!("{err}")); // Generate imports and statics - let imports_and_statics = - expand_imports_and_constants(&identity, &host, &hardware, &behavior, &keymap); + let imports_and_statics = expand_imports_and_constants( + &identity, + &host, + &hardware, + &behavior, + &keymap, + &layout, + lighting.as_ref(), + ); // Generate main function body let main_function = expand_main( @@ -92,6 +104,7 @@ fn validate_feature_config_parity( storage_in_config: bool, vial_in_config: bool, rynk_in_config: bool, + lighting_in_config: bool, ) -> Result<(), String> { // A feature enabled in keyboard.toml must have its rmk Cargo feature enabled, and vice versa. // (Cargo feature, keyboard.toml field, enabled in keyboard.toml?) @@ -120,6 +133,17 @@ fn validate_feature_config_parity( ); } + // Unlike the host/storage integrations, lighting also has a public Rust + // construction path, so enabling the feature without TOML is valid. A + // TOML section does require the feature because codegen emits lighting + // runtime types. + if lighting_in_config && !is_feature_enabled(rmk_features, "lighting") { + return Err( + "A `[lighting]` section in keyboard.toml requires enabling the \"lighting\" Cargo feature for rmk." + .to_string(), + ); + } + Ok(()) } @@ -129,6 +153,8 @@ pub(crate) fn expand_imports_and_constants( hardware: &Hardware, behavior: &Behavior, keymap: &Keymap, + layout: &Layout, + lighting: Option<&rmk_config::resolved::Lighting>, ) -> TokenStream2 { // Generate keyboard info and number of rows/cols/layers let keyboard_info_static_var = expand_keyboard_info(identity, keymap); @@ -138,6 +164,8 @@ pub(crate) fn expand_imports_and_constants( let vial_static_var = expand_vial_config(host); // Generate rynk lock-gate config let lock_static_var = expand_lock_config(host); + let physical_layout = expand_physical_layout(&layout.physical); + let lighting_topology = expand_lighting_topology(lighting); // Generate extra imports, panic handler and logger let imports = match hardware.chip.series { @@ -178,6 +206,8 @@ pub(crate) fn expand_imports_and_constants( #vial_static_var #lock_static_var #default_keymap + #physical_layout + #lighting_topology } } @@ -193,22 +223,45 @@ mod tests { #[test] fn accepts_matching_storage_vial_rynk_feature_states() { assert!( - validate_feature_config_parity(&features(&["storage", "vial"]), true, true, false) + validate_feature_config_parity( + &features(&["storage", "vial"]), + true, + true, + false, + false + ) + .is_ok() + ); + assert!(validate_feature_config_parity(&features(&[]), false, false, false, false).is_ok()); + assert!( + validate_feature_config_parity(&features(&["storage"]), true, false, false, false) .is_ok() ); - assert!(validate_feature_config_parity(&features(&[]), false, false, false).is_ok()); assert!( - validate_feature_config_parity(&features(&["storage"]), true, false, false).is_ok() + validate_feature_config_parity( + &features(&["storage", "rynk"]), + true, + false, + true, + false + ) + .is_ok() + ); + assert!( + validate_feature_config_parity(&features(&["lighting"]), false, false, false, false) + .is_ok(), + "the lighting feature supports public Rust construction without TOML" ); assert!( - validate_feature_config_parity(&features(&["storage", "rynk"]), true, false, true) + validate_feature_config_parity(&features(&["lighting"]), false, false, false, true) .is_ok() ); } #[test] fn rejects_storage_enabled_in_config_without_feature() { - let err = validate_feature_config_parity(&features(&[]), true, false, false).unwrap_err(); + let err = + validate_feature_config_parity(&features(&[]), true, false, false, false).unwrap_err(); assert_eq!( err, "If the \"storage\" Cargo feature is disabled, `storage.enabled` must be set to false in keyboard.toml." @@ -217,8 +270,9 @@ mod tests { #[test] fn rejects_storage_feature_without_config() { - let err = validate_feature_config_parity(&features(&["storage"]), false, false, false) - .unwrap_err(); + let err = + validate_feature_config_parity(&features(&["storage"]), false, false, false, false) + .unwrap_err(); assert_eq!( err, "`storage.enabled = false` in keyboard.toml requires disabling the \"storage\" Cargo feature for rmk in Cargo.toml (for example with `default-features = false` and explicitly re-enabling the features you need)." @@ -227,7 +281,8 @@ mod tests { #[test] fn rejects_vial_enabled_in_config_without_feature() { - let err = validate_feature_config_parity(&features(&[]), false, true, false).unwrap_err(); + let err = + validate_feature_config_parity(&features(&[]), false, true, false, false).unwrap_err(); assert_eq!( err, "If the \"vial\" Cargo feature is disabled, `host.vial_enabled` must be set to false in keyboard.toml." @@ -236,8 +291,8 @@ mod tests { #[test] fn rejects_vial_feature_without_config() { - let err = - validate_feature_config_parity(&features(&["vial"]), false, false, false).unwrap_err(); + let err = validate_feature_config_parity(&features(&["vial"]), false, false, false, false) + .unwrap_err(); assert_eq!( err, "`host.vial_enabled = false` in keyboard.toml requires disabling the \"vial\" Cargo feature for rmk in Cargo.toml (for example with `default-features = false` and explicitly re-enabling the features you need)." @@ -246,7 +301,8 @@ mod tests { #[test] fn rejects_rynk_enabled_in_config_without_feature() { - let err = validate_feature_config_parity(&features(&[]), false, false, true).unwrap_err(); + let err = + validate_feature_config_parity(&features(&[]), false, false, true, false).unwrap_err(); assert_eq!( err, "If the \"rynk\" Cargo feature is disabled, `host.rynk_enabled` must be set to false in keyboard.toml." @@ -255,8 +311,8 @@ mod tests { #[test] fn rejects_rynk_feature_without_config() { - let err = - validate_feature_config_parity(&features(&["rynk"]), false, false, false).unwrap_err(); + let err = validate_feature_config_parity(&features(&["rynk"]), false, false, false, false) + .unwrap_err(); assert_eq!( err, "`host.rynk_enabled = false` in keyboard.toml requires disabling the \"rynk\" Cargo feature for rmk in Cargo.toml (for example with `default-features = false` and explicitly re-enabling the features you need)." @@ -265,13 +321,24 @@ mod tests { #[test] fn rejects_vial_and_rynk_both_enabled() { - let err = validate_feature_config_parity(&features(&["vial", "rynk"]), false, true, true) - .unwrap_err(); + let err = + validate_feature_config_parity(&features(&["vial", "rynk"]), false, true, true, false) + .unwrap_err(); assert_eq!( err, "`host.vial_enabled` and `host.rynk_enabled` are mutually exclusive — set exactly one to true (the underlying Cargo features for rmk also conflict)." ); } + + #[test] + fn rejects_lighting_config_without_feature() { + let err = + validate_feature_config_parity(&features(&[]), false, false, false, true).unwrap_err(); + assert_eq!( + err, + "A `[lighting]` section in keyboard.toml requires enabling the \"lighting\" Cargo feature for rmk." + ); + } } fn expand_main( diff --git a/rmk-types/Cargo.toml b/rmk-types/Cargo.toml index f26d49d27..1b604bfff 100644 --- a/rmk-types/Cargo.toml +++ b/rmk-types/Cargo.toml @@ -42,7 +42,7 @@ _codegen = [] # Enable RMK's Rynk protocol rynk = ["dep:cobs"] # Host tool -host = ["rynk", "_ble", "split", "steno", "serde/alloc"] +host = ["rynk", "_ble", "split", "steno", "lighting", "serde/alloc"] # TypeScript type + wasm ABI export for the web client. Enabled by a codegen/wasm # build, never by firmware. wasm = ["dep:tsify", "dep:wasm-bindgen", "dep:serde-wasm-bindgen", "host"] @@ -50,6 +50,7 @@ wasm = ["dep:tsify", "dep:wasm-bindgen", "dep:serde-wasm-bindgen", "host"] _ble = [] split = [] display = [] +lighting = [] passkey_entry = [] # DFU firmware update support dfu = [] diff --git a/rmk/Cargo.toml b/rmk/Cargo.toml index 4917d530d..406ab6caa 100644 --- a/rmk/Cargo.toml +++ b/rmk/Cargo.toml @@ -179,7 +179,11 @@ std = [ ] ## Enable display support (traits + processor) -display = ["dep:embedded-graphics", "rmk-types/display"] +display = ["dep:embedded-graphics", "rmk-types/display", "_render_state"] +## Enable topology-aware composable lighting support. +lighting = ["rmk-types/lighting", "_render_state"] +## Shared authoritative state needed by display and lighting renderers. +_render_state = [] ## Enable SSD1306 OLED driver ssd1306 = ["display", "dep:ssd1306", "dep:display-interface", "dep:display-interface-i2c"] ## Enable OLED drivers via oled_async (SH1106, SH1107, SH1108, SSD1309) diff --git a/rmk/src/ble/battery_service.rs b/rmk/src/ble/battery_service.rs index 5461b0b0c..0c1811e87 100644 --- a/rmk/src/ble/battery_service.rs +++ b/rmk/src/ble/battery_service.rs @@ -1,5 +1,3 @@ -use core::sync::atomic::Ordering; - use embassy_futures::join::join; use embassy_futures::select::{Either, select}; use embassy_sync::pubsub::Subscriber; @@ -8,7 +6,6 @@ use rmk_types::battery::BatteryStatus; use trouble_host::prelude::*; use super::ble_server::Server; -use crate::ble::sleep::SLEEPING_STATE; use crate::core_traits::Runnable; use crate::event::{BatteryStatusEvent, SubscribableEvent}; use crate::keyboard::LAST_KEY_TIMESTAMP; @@ -109,7 +106,7 @@ impl BleBatteryServer<'_, '_, '_, P> { loop { embassy_time::Timer::after_secs(1800).await; // 30 minutes passed and the keyboard isn't in sleep mode: timeout - if !SLEEPING_STATE.load(Ordering::Acquire) { + if !crate::state::current_sleeping() { break; } } diff --git a/rmk/src/ble/sleep.rs b/rmk/src/ble/sleep.rs index 0c7cb8a6a..bcc3aa264 100644 --- a/rmk/src/ble/sleep.rs +++ b/rmk/src/ble/sleep.rs @@ -1,26 +1,19 @@ //! Keyboard-wide sleep management. //! -//! One manager owns the keyboard's sleep state: it watches [`SLEEP_INPUT`], -//! latches each decision in [`SLEEPING_STATE`] for pollers like the battery -//! service, and publishes it as [`SleepStateEvent`] for everything else — the -//! display, and on split centrals the per-link connection-parameter followers -//! in `split::ble::central`. - -use core::sync::atomic::{AtomicBool, Ordering}; +//! One manager owns the keyboard's sleep state: it watches [`SLEEP_INPUT`] and +//! latches each decision through [`crate::state::set_sleeping`], which both +//! stores the value for pollers like the battery service and publishes it as +//! [`crate::event::SleepStateEvent`] for everything else — the display, the +//! lighting engine, host readback, and on split centrals the per-link +//! connection-parameter followers in `split::ble::central`. use embassy_futures::select::{Either, select}; use embassy_sync::signal::Signal; use embassy_time::{Duration, Timer}; use crate::SPLIT_CENTRAL_SLEEP_TIMEOUT_SECONDS; -use crate::event::{SleepStateEvent, publish_event}; - -/// The latched sleep state. -/// - `true`: the keyboard is idle and sleeping -/// - `false`: the keyboard is awake -pub(crate) static SLEEPING_STATE: AtomicBool = AtomicBool::new(false); -/// Input to [`run_sleep_manager`], same encoding as [`SLEEPING_STATE`]: +/// Input to [`run_sleep_manager`], same encoding as the latched sleep state: /// - `true`: sleep now, without waiting out the idle timeout /// - `false`: activity — wake up, or restart the idle timeout static SLEEP_INPUT: Signal = Signal::new(); @@ -71,15 +64,13 @@ async fn manage_sleep_state(idle_timeout: Duration) -> ! { } } info!("Entering sleep mode"); - SLEEPING_STATE.store(true, Ordering::Release); - publish_event(SleepStateEvent::new(true)); + crate::state::set_sleeping(true); // Asleep: only activity wakes us; further sleep requests change nothing. while SLEEP_INPUT.wait().await {} info!("Waking up from sleep mode due to activity"); - SLEEPING_STATE.store(false, Ordering::Release); - publish_event(SleepStateEvent::new(false)); + crate::state::set_sleeping(false); } } @@ -96,7 +87,7 @@ mod tests { } fn sleeping() -> bool { - SLEEPING_STATE.load(Ordering::Acquire) + crate::state::current_sleeping() } #[test] diff --git a/rmk/src/display/mod.rs b/rmk/src/display/mod.rs index a9481faba..242da1a50 100644 --- a/rmk/src/display/mod.rs +++ b/rmk/src/display/mod.rs @@ -100,6 +100,9 @@ use crate::processor::Processor; /// corresponding features in their `Cargo.toml` dependency on `rmk`, /// and guard access with matching `#[cfg]` attributes. pub struct RenderContext { + /// Board-global key geometry generated from the same physical-layout + /// source used by lighting and host layout readback. + pub physical_layout: crate::physical_layout::PhysicalLayout<'static>, /// Current active layer index. pub layer: u8, /// Current words-per-minute estimate. @@ -138,6 +141,7 @@ pub struct RenderContext { impl Default for RenderContext { fn default() -> Self { Self { + physical_layout: crate::physical_layout::PhysicalLayout::default(), layer: 0, wpm: 0, caps_lock: false, @@ -291,6 +295,13 @@ where self } + /// Provide the keyboard's generated board-global key geometry to custom + /// renderers. This does not force built-in renderers to draw a keyboard. + pub fn with_physical_layout(mut self, physical_layout: crate::physical_layout::PhysicalLayout<'static>) -> Self { + self.ctx.physical_layout = physical_layout; + self + } + /// Set the minimum time between event-driven renders. /// /// When events arrive faster than this interval, redraws are coalesced @@ -420,6 +431,10 @@ where // Prime from current state after subscribing, so the first render // reflects state that was set before the processor started. + self.ctx.sleeping = crate::state::current_sleeping(); + let indicators = crate::keyboard::current_led_indicator(); + self.ctx.caps_lock = indicators.caps_lock(); + self.ctx.num_lock = indicators.num_lock(); #[cfg(feature = "_ble")] { self.ctx.ble_status = crate::state::current_ble_status(); diff --git a/rmk/src/event/mod.rs b/rmk/src/event/mod.rs index 52017a5ee..01023971f 100644 --- a/rmk/src/event/mod.rs +++ b/rmk/src/event/mod.rs @@ -67,7 +67,7 @@ pub use input::{ pub use split::ClearPeerEvent; #[cfg(feature = "split")] pub use split::{CentralConnectedEvent, PeripheralBatteryEvent, PeripheralConnectedEvent}; -pub use state::{LayerChangeEvent, LedIndicatorEvent, SleepStateEvent, WpmUpdateEvent}; +pub use state::{LayerChangeEvent, LedIndicatorEvent, LightingChangedEvent, SleepStateEvent, WpmUpdateEvent}; /// Trait for event publishers pub trait EventPublisher { diff --git a/rmk/src/event/state.rs b/rmk/src/event/state.rs index eb38b7213..8bf34389e 100644 --- a/rmk/src/event/state.rs +++ b/rmk/src/event/state.rs @@ -58,3 +58,18 @@ impl SleepStateEvent { } impl_payload_wrapper!(SleepStateEvent, bool); + +/// Authoritative lighting state changed. +/// +/// This is deliberately a unit invalidation: consumers read a fresh state +/// snapshot from the lighting controller instead of trusting an event mirror. +#[event(channel_size = crate::LIGHTING_CHANGED_EVENT_CHANNEL_SIZE, pubs = crate::LIGHTING_CHANGED_EVENT_PUB_SIZE, subs = crate::LIGHTING_CHANGED_EVENT_SUB_SIZE)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub struct LightingChangedEvent; + +impl LightingChangedEvent { + pub const fn new() -> Self { + Self + } +} diff --git a/rmk/src/hid.rs b/rmk/src/hid.rs index b21747793..45d144d61 100644 --- a/rmk/src/hid.rs +++ b/rmk/src/hid.rs @@ -1,6 +1,5 @@ /// Traits and types for HID message reporting and listening. use core::future::Future; -use core::sync::atomic::Ordering; use embassy_usb::class::hid::ReadError; use embassy_usb::driver::EndpointError; @@ -13,7 +12,6 @@ use usbd_hid::descriptor::generator_prelude::*; use usbd_hid::descriptor::{AsInputReport, MediaKeyboardReport, MouseReport, SystemControlReport}; use crate::event::{LedIndicatorEvent, publish_event}; -use crate::keyboard::LOCK_LED_STATES; /// KeyboardReport describes a report and its companion descriptor that can be /// used to send keyboard button presses to a host and receive the status of the @@ -403,7 +401,7 @@ pub(crate) async fn run_led_reader> Ok(led_indicator) => { info!("Got led indicator"); if crate::state::active_transport() == Some(kind) { - LOCK_LED_STATES.store(led_indicator.into_bits(), Ordering::Relaxed); + crate::keyboard::set_current_led_indicator(led_indicator); publish_event(LedIndicatorEvent::new(led_indicator)); } } diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index beb231428..814357097 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -64,6 +64,13 @@ pub(crate) fn current_led_indicator() -> LedIndicator { LedIndicator::from_bits(LOCK_LED_STATES.load(core::sync::atomic::Ordering::Relaxed)) } +/// Update the authoritative host-driven lock LED state before publishing its +/// invalidation event. This also covers split peripherals, which do not have a +/// local HID reader. +pub(crate) fn set_current_led_indicator(indicator: LedIndicator) { + LOCK_LED_STATES.store(indicator.into_bits(), core::sync::atomic::Ordering::Relaxed); +} + /// State machine for Caps Word #[derive(Debug, Default)] enum CapsWordState { @@ -1221,6 +1228,12 @@ impl<'a> Keyboard<'a> { } async fn process_key_action_normal(&mut self, action: Action, event: KeyboardEvent) { + #[cfg(feature = "lighting")] + if event.pressed + && let Action::Light(light_action) = action + { + crate::lighting::send_light_action(light_action).await; + } publish_event_async(ActionEvent { action, keyboard_event: event, diff --git a/rmk/src/lib.rs b/rmk/src/lib.rs index a69f69d8c..71437ebe1 100644 --- a/rmk/src/lib.rs +++ b/rmk/src/lib.rs @@ -101,7 +101,10 @@ pub mod keyboard_macros; pub mod keymap; pub mod layout_macro; pub mod light; +#[cfg(feature = "lighting")] +pub mod lighting; pub mod matrix; +pub mod physical_layout; pub mod processor; #[cfg(feature = "split")] pub mod split; diff --git a/rmk/src/lighting/color.rs b/rmk/src/lighting/color.rs new file mode 100644 index 000000000..c3c33d680 --- /dev/null +++ b/rmk/src/lighting/color.rs @@ -0,0 +1,26 @@ +/// Device-independent, linear RGB sample used by RMK's standard compositor. +/// +/// A driver or output transform is responsible for channel order, gamma, +/// RGBW/mono conversion, brightness, and electrical safety limits. +#[derive(Copy, Clone, Default, Debug, Eq, PartialEq)] +#[repr(C)] +pub struct Rgb8 { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Rgb8 { + pub const BLACK: Self = Self::new(0, 0, 0); + + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + pub const fn scale(self, level: u8) -> Self { + const fn channel(value: u8, level: u8) -> u8 { + ((value as u16 * level as u16) / 255) as u8 + } + Self::new(channel(self.r, level), channel(self.g, level), channel(self.b, level)) + } +} diff --git a/rmk/src/lighting/compositor.rs b/rmk/src/lighting/compositor.rs new file mode 100644 index 000000000..6ee8fa2b7 --- /dev/null +++ b/rmk/src/lighting/compositor.rs @@ -0,0 +1,309 @@ +use super::effect::EffectSample; +use super::topology::LedSlot; + +/// One source contribution. Transparent samples may carry a deadline because +/// a currently invisible effect can become opaque later. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Contribution { + Transparent { next_change_ms: Option }, + Opaque(EffectSample), +} + +/// Inputs common to all sources in one render transaction. +#[derive(Copy, Clone, Debug)] +pub struct RenderInput<'a, Context> { + pub now_ms: u64, + pub context: &'a Context, +} + +/// Allocation-free pull interface for sparse or dense sources. +/// +/// Targets are exposed separately so the transaction can validate the whole +/// source before changing the frame. That makes an invalid target atomic. +pub trait LightingSource { + fn len(&self, input: &RenderInput<'_, Context>) -> usize; + fn slot(&self, index: usize, input: &RenderInput<'_, Context>) -> LedSlot; + /// Sample one previously validated target. + /// + /// `len` and `slot` must be pure for the duration of this call. Sampling + /// is mutable so cached, RNG-backed, and otherwise stateful effects do not + /// require interior mutability. + fn contribution(&mut self, index: usize, input: &RenderInput<'_, Context>) -> Contribution; + + fn is_empty(&self, input: &RenderInput<'_, Context>) -> bool { + self.len(input) == 0 + } +} + +/// User-level transform applied after composition but before changed +/// detection. Hard electrical limits remain the output driver's job. +pub trait OutputTransform { + fn transform(&mut self, slot: LedSlot, color: C) -> C; + + fn next_change_ms(&self, _slot: LedSlot, _before: C, _after: C, source_next_change_ms: Option) -> Option { + source_next_change_ms + } +} + +#[derive(Copy, Clone, Debug, Default)] +pub struct IdentityTransform; + +impl OutputTransform for IdentityTransform { + fn transform(&mut self, _slot: LedSlot, color: C) -> C { + color + } +} + +/// Standard dense logical frame. Its slot order has semantic meaning only in +/// conjunction with a validated topology; it is never physical chain order. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LogicalFrame { + pixels: [C; N], +} + +impl LogicalFrame { + pub const fn new(fill: C) -> Self { + Self { pixels: [fill; N] } + } + + pub const fn as_array(&self) -> &[C; N] { + &self.pixels + } + + pub fn as_mut_array(&mut self) -> &mut [C; N] { + &mut self.pixels + } + + pub fn as_slice(&self) -> &[C] { + &self.pixels + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum RenderError { + PriorityRegression { previous: u8, attempted: u8 }, + SlotOutOfRange { slot: LedSlot, frame_len: usize }, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct RenderResult { + pub changed: bool, + /// Absolute deadline. `None` means no timer should be armed. + pub next_wake_ms: Option, +} + +/// Keeps the last frame successfully presented by the output. +/// +/// [`Compositor::commit`] is deliberately separate from rendering. A service +/// commits only after a successful driver write, so failures remain dirty and +/// can be retried without waiting for an unrelated event. +pub struct Compositor { + committed: LogicalFrame, + has_committed: bool, +} + +impl Compositor { + pub const fn new(fill: C) -> Self { + Self { + committed: LogicalFrame::new(fill), + has_committed: false, + } + } + + pub fn begin<'a, 'context, Context>( + &'a self, + now_ms: u64, + context: &'context Context, + fill: C, + frame: &'a mut LogicalFrame, + ) -> RenderTransaction<'a, 'context, C, Context, N> { + frame.pixels.fill(fill); + RenderTransaction { + compositor: self, + frame, + deadlines: [None; N], + input: RenderInput { now_ms, context }, + last_priority: None, + } + } + + pub fn commit(&mut self, frame: &LogicalFrame) { + self.committed.pixels.copy_from_slice(&frame.pixels); + self.has_committed = true; + } + + pub fn has_committed(&self) -> bool { + self.has_committed + } +} + +pub struct RenderTransaction<'a, 'context, C, Context, const N: usize> { + compositor: &'a Compositor, + frame: &'a mut LogicalFrame, + deadlines: [Option; N], + input: RenderInput<'context, Context>, + last_priority: Option, +} + +impl RenderTransaction<'_, '_, C, Context, N> { + /// Apply a source. Priorities must be nondecreasing; equal priorities use + /// stable call order, with the later opaque contribution winning. + pub fn apply(&mut self, priority: u8, source: &mut impl LightingSource) -> Result<(), RenderError> { + if let Some(previous) = self.last_priority + && priority < previous + { + return Err(RenderError::PriorityRegression { + previous, + attempted: priority, + }); + } + + // Validate every target before mutating anything. + for index in 0..source.len(&self.input) { + let slot = source.slot(index, &self.input); + if slot.index() >= N { + return Err(RenderError::SlotOutOfRange { slot, frame_len: N }); + } + } + + for index in 0..source.len(&self.input) { + let slot = source.slot(index, &self.input).index(); + match source.contribution(index, &self.input) { + Contribution::Transparent { next_change_ms } => { + self.deadlines[slot] = + earliest(self.deadlines[slot], future_deadline(self.input.now_ms, next_change_ms)); + } + Contribution::Opaque(sample) => { + self.frame.pixels[slot] = sample.color; + // Opaque replacement intentionally erases all deadlines + // belonging to sources hidden below this winner. + self.deadlines[slot] = future_deadline(self.input.now_ms, sample.next_change_ms); + } + } + } + self.last_priority = Some(priority); + Ok(()) + } + + pub fn finish(self) -> RenderResult { + self.finish_with(&mut IdentityTransform) + } + + pub fn finish_with(self, transform: &mut impl OutputTransform) -> RenderResult { + let now_ms = self.input.now_ms; + let mut next_wake_ms = None; + for slot_index in 0..N { + let slot = LedSlot::from_index(slot_index); + let before = self.frame.pixels[slot_index]; + let after = transform.transform(slot, before); + self.frame.pixels[slot_index] = after; + let transformed_deadline = transform.next_change_ms(slot, before, after, self.deadlines[slot_index]); + next_wake_ms = earliest(next_wake_ms, future_deadline(now_ms, transformed_deadline)); + } + + RenderResult { + changed: !self.compositor.has_committed || self.frame.pixels != self.compositor.committed.pixels, + next_wake_ms, + } + } +} + +fn future_deadline(now_ms: u64, deadline: Option) -> Option { + match deadline { + Some(deadline) if deadline > now_ms => Some(deadline), + Some(_) => now_ms.checked_add(1), + None => None, + } +} + +fn earliest(left: Option, right: Option) -> Option { + match (left, right) { + (Some(left), Some(right)) => Some(left.min(right)), + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + } +} + +#[cfg(test)] +mod tests { + use super::super::Rgb8; + use super::*; + + struct Source([(LedSlot, Contribution); M]); + + impl LightingSource for Source { + fn len(&self, _: &RenderInput<'_, Context>) -> usize { + M + } + fn slot(&self, index: usize, _: &RenderInput<'_, Context>) -> LedSlot { + self.0[index].0 + } + fn contribution(&mut self, index: usize, _: &RenderInput<'_, Context>) -> Contribution { + self.0[index].1 + } + } + + fn opaque(slot: usize, color: Rgb8, next: Option) -> (LedSlot, Contribution) { + ( + LedSlot::from_index(slot), + Contribution::Opaque(EffectSample { + color, + next_change_ms: next, + }), + ) + } + + #[test] + fn transparency_ties_and_occluded_deadlines_are_deterministic() { + let mut compositor = Compositor::::new(Rgb8::BLACK); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + let context = (); + let mut low = Source([opaque(0, Rgb8::new(1, 0, 0), Some(10))]); + let mut transparent = Source([( + LedSlot(0), + Contribution::Transparent { + next_change_ms: Some(20), + }, + )]); + let mut high = Source([opaque(0, Rgb8::new(0, 1, 0), None)]); + let mut tx = compositor.begin(0, &context, Rgb8::BLACK, &mut frame); + tx.apply(1, &mut low).unwrap(); + tx.apply(1, &mut transparent).unwrap(); + tx.apply(2, &mut high).unwrap(); + let result = tx.finish(); + assert_eq!(frame.as_slice(), &[Rgb8::new(0, 1, 0), Rgb8::BLACK]); + assert_eq!(result.next_wake_ms, None); + assert!(result.changed); + + compositor.commit(&frame); + } + + #[test] + fn failed_apply_is_atomic_and_priority_regression_does_not_advance_order() { + let compositor = Compositor::::new(Rgb8::BLACK); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + let mut good = Source([opaque(0, Rgb8::new(3, 0, 0), None)]); + let mut bad = Source([opaque(0, Rgb8::new(9, 0, 0), None), opaque(2, Rgb8::new(8, 0, 0), None)]); + let mut tx = compositor.begin(0, &(), Rgb8::BLACK, &mut frame); + assert!(matches!(tx.apply(5, &mut bad), Err(RenderError::SlotOutOfRange { .. }))); + tx.apply(4, &mut good).unwrap(); + assert_eq!(tx.finish().next_wake_ms, None); + assert_eq!(frame.as_slice(), &[Rgb8::new(3, 0, 0)]); + } + + #[test] + fn uncommitted_output_remains_changed_for_driver_retry() { + let mut compositor = Compositor::::new(Rgb8::BLACK); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + let mut source = Source([opaque(0, Rgb8::new(1, 2, 3), None)]); + for _ in 0..2 { + let mut tx = compositor.begin(0, &(), Rgb8::BLACK, &mut frame); + tx.apply(0, &mut source).unwrap(); + assert!(tx.finish().changed); + } + compositor.commit(&frame); + let mut tx = compositor.begin(0, &(), Rgb8::BLACK, &mut frame); + tx.apply(0, &mut source).unwrap(); + assert!(!tx.finish().changed); + } +} diff --git a/rmk/src/lighting/context.rs b/rmk/src/lighting/context.rs new file mode 100644 index 000000000..594ac8f12 --- /dev/null +++ b/rmk/src/lighting/context.rs @@ -0,0 +1,74 @@ +/// Bounded snapshot of RMK's layer state. +/// +/// The complete active set is retained because the effective layer alone is +/// insufficient for transparent fallthrough and held-layer indicators. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct LayerState { + pub effective: u8, + pub default: u8, + active: u64, +} + +impl LayerState { + pub const CAPACITY: u8 = 64; + + pub const fn new(effective: u8, default: u8, active: u64) -> Self { + Self { + effective, + default, + active, + } + } + + pub const fn active_bits(self) -> u64 { + self.active + } + + pub const fn is_active(self, layer: u8) -> bool { + layer < Self::CAPACITY && self.active & (1_u64 << layer) != 0 + } +} + +impl Default for LayerState { + fn default() -> Self { + Self::new(0, 0, 1) + } +} + +/// Host-controlled keyboard indicators available to lighting sources. +/// +/// This deliberately mirrors the semantic HID state rather than exposing its +/// wire bitfield. Sources can therefore use the same context on USB, BLE, and +/// split peripherals. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct IndicatorState { + pub num_lock: bool, + pub caps_lock: bool, + pub scroll_lock: bool, + pub compose: bool, + pub kana: bool, +} + +/// State RMK makes available to standard and external lighting sources. +/// Additional firmware-specific state can be carried in a source of the +/// board's own type; it does not belong in the core compositor. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct LightingContext { + pub layers: LayerState, + pub indicators: IndicatorState, +} + +/// Access to RMK's standard lighting state from a board-extended snapshot. +/// +/// A board may add battery, connection, or sensor fields to its snapshot and +/// still reuse built-in layer and indicator sources by implementing this +/// trait. The compositor itself remains generic over the complete context. +pub trait LightingContextProvider { + fn lighting_context(&self) -> &LightingContext; +} + +impl LightingContextProvider for LightingContext { + fn lighting_context(&self) -> &LightingContext { + self + } +} diff --git a/rmk/src/lighting/effect.rs b/rmk/src/lighting/effect.rs new file mode 100644 index 000000000..3dbc25fee --- /dev/null +++ b/rmk/src/lighting/effect.rs @@ -0,0 +1,247 @@ +use super::Rgb8; + +/// One opaque effect sample and the next instant its visible value can +/// change. `None` means the sample is static until an external event. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct EffectSample { + pub color: C, + pub next_change_ms: Option, +} + +/// Extensibility boundary for effects supplied by board or third-party +/// crates. The compositor owns ordering and scheduling; the effect owns only +/// its waveform. +pub trait LightingEffect { + fn sample(&self, now_ms: u64) -> EffectSample; +} + +/// Small built-in set sufficient for static scenes and common indicators. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum BuiltinEffect { + Solid { + color: Rgb8, + }, + Blink { + color: Rgb8, + period_ms: u32, + phase_ms: u32, + duty: u8, + }, + Breathe { + color: Rgb8, + period_ms: u32, + phase_ms: u32, + step_ms: u16, + }, +} + +impl Default for BuiltinEffect { + fn default() -> Self { + Self::solid(Rgb8::BLACK) + } +} + +impl BuiltinEffect { + pub const fn solid(color: Rgb8) -> Self { + Self::Solid { color } + } +} + +impl LightingEffect for BuiltinEffect { + fn sample(&self, now_ms: u64) -> EffectSample { + match *self { + Self::Solid { color } => EffectSample { + color, + next_change_ms: None, + }, + Self::Blink { + color, + period_ms, + phase_ms, + duty, + } => { + if color == Rgb8::BLACK { + return EffectSample { + color, + next_change_ms: None, + }; + } + if period_ms == 0 || duty >= 100 { + return EffectSample { + color, + next_change_ms: None, + }; + } + if duty == 0 { + return EffectSample { + color: Rgb8::BLACK, + next_change_ms: None, + }; + } + let local = phase_local(now_ms, period_ms, phase_ms); + let on_ms = ((period_ms as u64 * duty as u64) / 100) as u32; + if on_ms == 0 { + return EffectSample { + color: Rgb8::BLACK, + next_change_ms: None, + }; + } + let (shown, delta) = if local < on_ms { + (color, on_ms - local) + } else { + (Rgb8::BLACK, period_ms - local) + }; + EffectSample { + color: shown, + next_change_ms: now_ms.checked_add(delta as u64), + } + } + Self::Breathe { + color, + period_ms, + phase_ms, + step_ms, + } => { + if period_ms < 2 || step_ms == 0 || color == Rgb8::BLACK { + return EffectSample { + color, + next_change_ms: None, + }; + } + let local = phase_local(now_ms, period_ms, phase_ms) as u64; + let period = period_ms as u64; + let step = step_ms as u64; + if step >= period { + return EffectSample { + color: Rgb8::BLACK, + next_change_ms: None, + }; + } + // Quantize both sampling and scheduling to phase-local step + // boundaries. An unrelated event between boundaries can + // therefore re-render without changing the advertised value. + let sampled_local = local / step * step; + let half = period / 2; + let level = if sampled_local < half { + sampled_local * 255 / half + } else { + (period - sampled_local) * 255 / (period - half) + } as u8; + let delta = (step - local % step).min(period - local); + EffectSample { + color: color.scale(level), + next_change_ms: now_ms.checked_add(delta), + } + } + } + } +} + +fn phase_local(now_ms: u64, period_ms: u32, phase_ms: u32) -> u32 { + let period = period_ms as u64; + ((now_ms % period + phase_ms as u64 % period) % period) as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blink_wakes_on_exact_edges_and_dark_is_opaque() { + let effect = BuiltinEffect::Blink { + color: Rgb8::new(10, 20, 30), + period_ms: 100, + phase_ms: 0, + duty: 25, + }; + assert_eq!(effect.sample(24).next_change_ms, Some(25)); + assert_eq!(effect.sample(25).color, Rgb8::BLACK); + assert_eq!(effect.sample(25).next_change_ms, Some(100)); + } + + #[test] + fn phase_math_does_not_overflow_at_u64_max() { + let effect = BuiltinEffect::Blink { + color: Rgb8::new(1, 2, 3), + period_ms: 100, + phase_ms: 7, + duty: 50, + }; + assert_eq!(effect.sample(u64::MAX).next_change_ms, None); + } + + #[test] + fn breathe_is_constant_between_its_reported_phase_local_boundaries() { + let effect = BuiltinEffect::Breathe { + color: Rgb8::new(200, 100, 50), + period_ms: 100, + phase_ms: 7, + step_ms: 10, + }; + let first = effect.sample(3); // phase-local 10: exactly on a boundary + assert_eq!(first.next_change_ms, Some(13)); + for now in 4..13 { + assert_eq!(effect.sample(now).color, first.color); + assert_eq!(effect.sample(now).next_change_ms, Some(13)); + } + assert_ne!(effect.sample(13).color, first.color); + } + + #[test] + fn degenerate_effects_are_static() { + let color = Rgb8::new(1, 2, 3); + let effects = [ + BuiltinEffect::Blink { + color, + period_ms: 0, + phase_ms: 0, + duty: 50, + }, + BuiltinEffect::Blink { + color, + period_ms: 10, + phase_ms: 0, + duty: 0, + }, + BuiltinEffect::Blink { + color, + period_ms: 10, + phase_ms: 0, + duty: 1, + }, + BuiltinEffect::Blink { + color, + period_ms: 10, + phase_ms: 0, + duty: 100, + }, + BuiltinEffect::Breathe { + color, + period_ms: 1, + phase_ms: 0, + step_ms: 16, + }, + BuiltinEffect::Breathe { + color, + period_ms: 10, + phase_ms: 0, + step_ms: 0, + }, + BuiltinEffect::Blink { + color: Rgb8::BLACK, + period_ms: 10, + phase_ms: 0, + duty: 50, + }, + BuiltinEffect::Breathe { + color: Rgb8::BLACK, + period_ms: 10, + phase_ms: 0, + step_ms: 1, + }, + ]; + for effect in effects { + assert_eq!(effect.sample(5).next_change_ms, None); + } + } +} diff --git a/rmk/src/lighting/mod.rs b/rmk/src/lighting/mod.rs new file mode 100644 index 000000000..79b4f5449 --- /dev/null +++ b/rmk/src/lighting/mod.rs @@ -0,0 +1,59 @@ +//! Hardware- and protocol-independent lighting primitives. +//! +//! The module separates stable semantic LED identity, local frame slots, and +//! physical routing. Rendering uses caller-provided fixed storage and performs +//! no allocation, I/O, sleeping, or protocol handling. + +use embassy_sync::channel::Channel; +use rmk_types::action::LightAction; + +use crate::RawMutex; + +pub mod color; +pub mod compositor; +pub mod context; +pub mod effect; +pub mod output; +pub mod processor; +pub mod rmk_state; +pub mod selector; +pub mod service; +pub mod source; +pub mod standard; +pub mod topology; + +pub use color::Rgb8; +pub use compositor::{Compositor, LogicalFrame, RenderError, RenderResult, RenderTransaction}; +pub use context::{IndicatorState, LayerState, LightingContext, LightingContextProvider}; +pub use effect::{BuiltinEffect, EffectSample, LightingEffect}; +pub use output::{ + BrightnessTransform, OutputSelection, RouteError, RoutedFrameSink, RoutedPixel, ValidatedRouting, VisitSummary, +}; +pub use processor::{LightingMailbox, LightingProcessor}; +pub use rmk_state::{KeymapLightingState, TooManyLayers}; +pub use selector::{LedSelector, ResolveError, ResolvedTargets}; +pub use service::{ + CommandResult, Invalidation, LightingEngine, LightingOutput, LightingService, OutputCompletion, OutputOperation, + OutputState, PowerState, RenderOutcome, ServiceAction, SnapshotProvider, +}; +pub use source::{ + DenseSource, Indicator, IndicatorScene, IndicatorScenes, LayerPolicy, LayerScene, LayerScenes, OverlayError, + OverlayUpdate, SceneCell, SparseScene, TtlOverlay, +}; +pub use standard::{ + BackgroundMode, BackgroundPatch, BackgroundState, EmptySource, OverlayBatch, OverlayCell, StandardCommand, + StandardError, StandardInput, StandardLightingEngine, StandardMutableState, StandardState, UniformBackground, +}; +pub use topology::*; + +/// Dedicated lossless path for edge-sensitive lighting key actions. State +/// notifications may be coalesced, but brightness/toggle/mode presses may not. +static LIGHT_ACTIONS: Channel = Channel::new(); + +pub(crate) async fn send_light_action(action: LightAction) { + LIGHT_ACTIONS.send(action).await; +} + +async fn next_light_action() -> LightAction { + LIGHT_ACTIONS.receive().await +} diff --git a/rmk/src/lighting/output.rs b/rmk/src/lighting/output.rs new file mode 100644 index 000000000..e3fe95f78 --- /dev/null +++ b/rmk/src/lighting/output.rs @@ -0,0 +1,583 @@ +//! Allocation-free routing from semantic lighting frames to physical outputs. +//! +//! [`LogicalFrame`] order is the dense [`LedSlot`] order defined by a +//! [`LightingTopology`]. It is deliberately unrelated to electrical chain +//! order. This module joins a logical frame with validated [`LightingRouting`] +//! and visits pixels in physical output order without depending on a HAL or +//! concrete driver. + +use super::color::Rgb8; +use super::compositor::{LogicalFrame, OutputTransform}; +use super::topology::{ + LedId, LedSlot, LightingNodeId, LightingRouting, LightingTopology, OutputCapabilities, OutputId, OutputMetadata, + ValidationError, validate, +}; + +/// A topology and routing pair whose complete structural contract has been +/// validated. +/// +/// Construction checks that every semantic slot has exactly one route, every +/// physical address is unique and in bounds, and complete outputs have no +/// holes. Keeping this proof object separate prevents frame delivery from +/// repeating quadratic validation on every write. +#[derive(Clone, Copy, Debug)] +pub struct ValidatedRouting<'a> { + topology: LightingTopology<'a>, + routing: LightingRouting<'a>, +} + +impl<'a> ValidatedRouting<'a> { + pub fn new(topology: LightingTopology<'a>, routing: LightingRouting<'a>) -> Result { + validate(&topology, &routing)?; + Ok(Self { topology, routing }) + } + + pub const fn topology(&self) -> &LightingTopology<'a> { + &self.topology + } + + pub const fn routing(&self) -> &LightingRouting<'a> { + &self.routing + } + + /// Visit a standard logical frame in deterministic physical order. + pub fn visit_frame>( + &self, + frame: &LogicalFrame, + selection: OutputSelection, + sink: &mut S, + ) -> Result> { + self.visit_slice(frame.as_slice(), selection, sink) + } + + /// Visit any logical slice whose indices use this topology's slot order. + /// + /// Outputs are visited in `LightingRouting::outputs` order and pixels in + /// ascending physical index. Route table order and semantic slot order do + /// not affect delivery. Sparse-output holes simply produce no pixel call. + /// The frame length is checked before the first sink callback. + pub fn visit_slice>( + &self, + frame: &[C], + selection: OutputSelection, + sink: &mut S, + ) -> Result> { + let expected = self.topology.len(); + if frame.len() != expected { + return Err(RouteError::FrameLength { + expected, + actual: frame.len(), + }); + } + + let mut summary = VisitSummary::default(); + for output in self.routing.outputs { + if !selection.matches(output.capabilities) { + continue; + } + + sink.begin_output(*output).map_err(RouteError::Sink)?; + summary.outputs += 1; + + for physical_index in 0..output.pixel_count { + let Some(route) = self.routing.routes.iter().find(|route| { + route.node == output.node && route.output == output.id && route.physical_index == physical_index + }) else { + // Validated sparse outputs may intentionally contain holes. + continue; + }; + let slot_index = route.slot.index(); + let led = self + .topology + .led(route.slot) + .expect("validated routes always reference a topology slot"); + sink.write_pixel(RoutedPixel { + slot: route.slot, + led_id: led.id, + node: output.node, + output: output.id, + physical_index, + capabilities: output.capabilities, + value: frame[slot_index], + }) + .map_err(RouteError::Sink)?; + summary.pixels += 1; + } + + sink.end_output(*output).map_err(RouteError::Sink)?; + } + + Ok(summary) + } +} + +/// Capability filter applied before an output is presented to a sink. +/// +/// `required` bits must all be present. When `any` is non-empty, at least one +/// of those bits must also be present. This supports, for example, selecting +/// addressable RGB/RGBW outputs separately from binary or intensity outputs +/// while retaining one heterogeneous route table. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OutputSelection { + required: OutputCapabilities, + any: OutputCapabilities, +} + +impl OutputSelection { + pub const ALL: Self = Self { + required: OutputCapabilities::NONE, + any: OutputCapabilities::NONE, + }; + + pub const fn requiring(required: OutputCapabilities) -> Self { + Self { + required, + any: OutputCapabilities::NONE, + } + } + + pub const fn any_of(any: OutputCapabilities) -> Self { + Self { + required: OutputCapabilities::NONE, + any, + } + } + + pub const fn requiring_any(required: OutputCapabilities, any: OutputCapabilities) -> Self { + Self { required, any } + } + + pub const fn required(self) -> OutputCapabilities { + self.required + } + + pub const fn any(self) -> OutputCapabilities { + self.any + } + + pub const fn matches(self, capabilities: OutputCapabilities) -> bool { + capabilities.contains(self.required) && (self.any.bits() == 0 || capabilities.intersects(self.any)) + } +} + +impl Default for OutputSelection { + fn default() -> Self { + Self::ALL + } +} + +/// One logical value annotated with both semantic and physical identity. +/// +/// A heterogeneous sink can choose RGB, RGBW, intensity, or binary conversion +/// from `capabilities` and store the result in board-owned output buffers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RoutedPixel { + pub slot: LedSlot, + pub led_id: LedId, + pub node: LightingNodeId, + pub output: OutputId, + pub physical_index: u16, + pub capabilities: OutputCapabilities, + pub value: C, +} + +/// Hardware-independent consumer of physically addressed frame values. +/// +/// Implementations normally fill caller-owned fixed arrays or forward each +/// output to a board-specific adapter. The core does not prescribe a common +/// physical pixel type: the output metadata and every pixel carry capability +/// information so the sink owns conversion policy. +pub trait RoutedFrameSink { + type Error; + + fn begin_output(&mut self, _output: OutputMetadata) -> Result<(), Self::Error> { + Ok(()) + } + + fn write_pixel(&mut self, pixel: RoutedPixel) -> Result<(), Self::Error>; + + fn end_output(&mut self, _output: OutputMetadata) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct VisitSummary { + pub outputs: usize, + pub pixels: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RouteError { + FrameLength { expected: usize, actual: usize }, + Sink(E), +} + +/// User brightness applied after composition and before changed detection. +/// +/// This is intentionally not a hardware safety limit. Drivers must still +/// enforce their immutable channel/current policy after routing. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrightnessTransform { + level: u8, +} + +impl BrightnessTransform { + pub const OFF: Self = Self::new(0); + pub const FULL: Self = Self::new(u8::MAX); + + pub const fn new(level: u8) -> Self { + Self { level } + } + + pub const fn level(self) -> u8 { + self.level + } + + pub fn set_level(&mut self, level: u8) { + self.level = level; + } +} + +impl Default for BrightnessTransform { + fn default() -> Self { + Self::FULL + } +} + +impl OutputTransform for BrightnessTransform { + fn transform(&mut self, _slot: LedSlot, color: Rgb8) -> Rgb8 { + color.scale(self.level) + } + + fn next_change_ms( + &self, + _slot: LedSlot, + _before: Rgb8, + _after: Rgb8, + source_next_change_ms: Option, + ) -> Option { + // Zero brightness makes every possible source value visibly black, + // so source animation cannot change the transformed frame. + (self.level != 0).then_some(source_next_change_ms).flatten() + } +} + +#[cfg(test)] +mod tests { + use super::super::compositor::{Compositor, Contribution, LightingSource, RenderInput}; + use super::super::effect::EffectSample; + use super::super::topology::{LedMetadata, MatrixSize, OutputCoverage, PhysicalLayout, PhysicalRoute, ZoneSpan}; + use super::*; + + const RGB_ADDRESSABLE: OutputCapabilities = OutputCapabilities::RGB.union(OutputCapabilities::ADDRESSABLE); + + static LEDS: [LedMetadata; 4] = [ + LedMetadata { + id: LedId(100), + key: None, + position: None, + zones: ZoneSpan::EMPTY, + }, + LedMetadata { + id: LedId(200), + key: None, + position: None, + zones: ZoneSpan::EMPTY, + }, + LedMetadata { + id: LedId(300), + key: None, + position: None, + zones: ZoneSpan::EMPTY, + }, + LedMetadata { + id: LedId(400), + key: None, + position: None, + zones: ZoneSpan::EMPTY, + }, + ]; + + static OUTPUTS: [OutputMetadata; 3] = [ + OutputMetadata { + node: LightingNodeId(0), + id: OutputId(0), + pixel_count: 2, + capabilities: RGB_ADDRESSABLE, + coverage: OutputCoverage::Complete, + }, + OutputMetadata { + node: LightingNodeId(0), + id: OutputId(1), + pixel_count: 1, + capabilities: OutputCapabilities::BINARY, + coverage: OutputCoverage::Complete, + }, + OutputMetadata { + node: LightingNodeId(1), + id: OutputId(0), + pixel_count: 1, + capabilities: OutputCapabilities::INTENSITY, + coverage: OutputCoverage::Complete, + }, + ]; + + // Deliberately neither semantic-slot nor physical-output order. + static ROUTES: [PhysicalRoute; 4] = [ + PhysicalRoute { + slot: LedSlot(0), + node: LightingNodeId(1), + output: OutputId(0), + physical_index: 0, + }, + PhysicalRoute { + slot: LedSlot(2), + node: LightingNodeId(0), + output: OutputId(1), + physical_index: 0, + }, + PhysicalRoute { + slot: LedSlot(1), + node: LightingNodeId(0), + output: OutputId(0), + physical_index: 1, + }, + PhysicalRoute { + slot: LedSlot(3), + node: LightingNodeId(0), + output: OutputId(0), + physical_index: 0, + }, + ]; + + fn topology() -> LightingTopology<'static> { + LightingTopology { + matrix: MatrixSize::new(0, 0), + keys: &[], + physical_layout: PhysicalLayout::EMPTY, + leds: &LEDS, + zones: &[], + zone_memberships: &[], + } + } + + fn validated() -> ValidatedRouting<'static> { + ValidatedRouting::new( + topology(), + LightingRouting { + outputs: &OUTPUTS, + routes: &ROUTES, + }, + ) + .unwrap() + } + + #[derive(Default)] + struct RecordingSink { + begun: std::vec::Vec<(LightingNodeId, OutputId)>, + pixels: std::vec::Vec>, + ended: std::vec::Vec<(LightingNodeId, OutputId)>, + } + + impl RoutedFrameSink for RecordingSink { + type Error = core::convert::Infallible; + + fn begin_output(&mut self, output: OutputMetadata) -> Result<(), Self::Error> { + self.begun.push((output.node, output.id)); + Ok(()) + } + + fn write_pixel(&mut self, pixel: RoutedPixel) -> Result<(), Self::Error> { + self.pixels.push(pixel); + Ok(()) + } + + fn end_output(&mut self, output: OutputMetadata) -> Result<(), Self::Error> { + self.ended.push((output.node, output.id)); + Ok(()) + } + } + + #[test] + fn physical_visit_order_is_independent_of_semantic_and_route_order() { + let frame = LogicalFrame::new(0u16); + let mut values = frame; + *values.as_mut_array() = [10, 20, 30, 40]; + let mut sink = RecordingSink::default(); + + let summary = validated() + .visit_frame(&values, OutputSelection::ALL, &mut sink) + .unwrap(); + + assert_eq!(summary, VisitSummary { outputs: 3, pixels: 4 }); + assert_eq!( + sink.begun, + [ + (LightingNodeId(0), OutputId(0)), + (LightingNodeId(0), OutputId(1)), + (LightingNodeId(1), OutputId(0)) + ] + ); + assert_eq!( + sink.pixels + .iter() + .map(|pixel| (pixel.slot, pixel.led_id, pixel.physical_index, pixel.value)) + .collect::>(), + [ + (LedSlot(3), LedId(400), 0, 40), + (LedSlot(1), LedId(200), 1, 20), + (LedSlot(2), LedId(300), 0, 30), + (LedSlot(0), LedId(100), 0, 10), + ] + ); + assert_eq!(sink.ended, sink.begun); + } + + #[test] + fn capability_selection_handles_heterogeneous_outputs() { + let frame = [10u8, 20, 30, 40]; + + let mut rgb = RecordingSink::default(); + let summary = validated() + .visit_slice(&frame, OutputSelection::requiring(OutputCapabilities::RGB), &mut rgb) + .unwrap(); + assert_eq!(summary, VisitSummary { outputs: 1, pixels: 2 }); + assert!( + rgb.pixels + .iter() + .all(|pixel| pixel.capabilities.contains(OutputCapabilities::RGB)) + ); + + let mut mono = RecordingSink::default(); + let summary = validated() + .visit_slice( + &frame, + OutputSelection::any_of(OutputCapabilities::BINARY.union(OutputCapabilities::INTENSITY)), + &mut mono, + ) + .unwrap(); + assert_eq!(summary, VisitSummary { outputs: 2, pixels: 2 }); + assert_eq!( + mono.pixels + .iter() + .map(|pixel| pixel.value) + .collect::>(), + [30, 10] + ); + } + + #[test] + fn frame_length_is_rejected_before_sink_observes_output() { + let mut sink = RecordingSink::default(); + assert_eq!( + validated().visit_slice(&[1u8, 2, 3], OutputSelection::ALL, &mut sink), + Err(RouteError::FrameLength { expected: 4, actual: 3 }) + ); + assert!(sink.begun.is_empty()); + assert!(sink.pixels.is_empty()); + } + + #[test] + fn invalid_routing_cannot_construct_proof_object() { + let duplicate = [ROUTES[0], ROUTES[0], ROUTES[2], ROUTES[3]]; + assert!(matches!( + ValidatedRouting::new( + topology(), + LightingRouting { + outputs: &OUTPUTS, + routes: &duplicate, + }, + ), + Err(ValidationError::DuplicateRouteForSlot { .. }) + )); + } + + #[test] + fn sparse_physical_holes_are_skipped_without_placeholder_values() { + let leds = [LEDS[0]]; + let outputs = [OutputMetadata { + node: LightingNodeId(9), + id: OutputId(4), + pixel_count: 3, + capabilities: OutputCapabilities::INTENSITY, + coverage: OutputCoverage::Sparse, + }]; + let routes = [PhysicalRoute { + slot: LedSlot(0), + node: LightingNodeId(9), + output: OutputId(4), + physical_index: 2, + }]; + let routing = ValidatedRouting::new( + LightingTopology { + matrix: MatrixSize::new(0, 0), + keys: &[], + physical_layout: PhysicalLayout::EMPTY, + leds: &leds, + zones: &[], + zone_memberships: &[], + }, + LightingRouting { + outputs: &outputs, + routes: &routes, + }, + ) + .unwrap(); + let mut sink = RecordingSink::default(); + assert_eq!( + routing.visit_slice(&[77u8], OutputSelection::ALL, &mut sink).unwrap(), + VisitSummary { outputs: 1, pixels: 1 } + ); + assert_eq!(sink.pixels[0].physical_index, 2); + } + + struct AnimatedSource; + + impl LightingSource for AnimatedSource { + fn len(&self, _: &RenderInput<'_, Context>) -> usize { + 1 + } + + fn slot(&self, _: usize, _: &RenderInput<'_, Context>) -> LedSlot { + LedSlot(0) + } + + fn contribution(&mut self, _: usize, _: &RenderInput<'_, Context>) -> Contribution { + Contribution::Opaque(EffectSample { + color: Rgb8::new(255, 128, 64), + next_change_ms: Some(25), + }) + } + } + + #[test] + fn brightness_is_applied_before_diffing_and_zero_suppresses_deadlines() { + let compositor = Compositor::::new(Rgb8::BLACK); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + let mut half = BrightnessTransform::new(128); + let mut source = AnimatedSource; + let mut tx = compositor.begin(0, &(), Rgb8::BLACK, &mut frame); + tx.apply(0, &mut source).unwrap(); + let result = tx.finish_with(&mut half); + assert_eq!(frame.as_slice(), &[Rgb8::new(128, 64, 32)]); + assert_eq!(result.next_wake_ms, Some(25)); + + let mut off = BrightnessTransform::OFF; + let mut tx = compositor.begin(0, &(), Rgb8::BLACK, &mut frame); + tx.apply(0, &mut source).unwrap(); + let result = tx.finish_with(&mut off); + assert_eq!(frame.as_slice(), &[Rgb8::BLACK]); + assert_eq!(result.next_wake_ms, None); + } + + #[test] + fn brightness_level_is_runtime_mutable() { + let color = Rgb8::new(255, 100, 1); + let mut brightness = BrightnessTransform::OFF; + assert_eq!(brightness.transform(LedSlot(0), color), Rgb8::BLACK); + brightness.set_level(u8::MAX); + assert_eq!(brightness.level(), u8::MAX); + assert_eq!(brightness.transform(LedSlot(0), color), color); + } +} diff --git a/rmk/src/lighting/processor.rs b/rmk/src/lighting/processor.rs new file mode 100644 index 000000000..a176ea667 --- /dev/null +++ b/rmk/src/lighting/processor.rs @@ -0,0 +1,358 @@ +//! RMK event, command, deadline, and output integration for lighting. + +use core::cell::Cell; +use core::num::NonZeroU32; + +use embassy_futures::select::{Either, Either3, Either4, select, select3, select4}; +use embassy_sync::blocking_mutex::Mutex as BlockingMutex; +use embassy_sync::channel::Channel; +use embassy_sync::mutex::Mutex; +use embassy_sync::signal::Signal; +use embassy_time::{Instant, Timer}; +use rmk_types::action::LightAction; + +use super::service::{ + LightingEngine, LightingOutput, LightingService, OutputCompletion, OutputOperation, PowerState, ServiceAction, + ServiceError, SnapshotProvider, +}; +use crate::RawMutex; +use crate::core_traits::Runnable; +use crate::event::{ + EventSubscriber, LayerChangeEvent, LedIndicatorEvent, LightingChangedEvent, SleepStateEvent, publish_event, +}; +use crate::processor::Processor; + +/// Reliable single-owner request/reply path used by Rynk, Vial, or a board +/// API. Concurrent callers are serialized; commands are never coalesced. +pub struct LightingMailbox { + requests: Channel, CAPACITY>, + response: Signal>, + retry_output: Signal, + caller: Mutex, + next_id: BlockingMutex>, +} + +struct MailboxRequest { + id: u32, + command: Command, +} + +struct MailboxResponse { + id: u32, + result: Result, +} + +impl LightingMailbox { + pub const fn new() -> Self { + Self { + requests: Channel::new(), + response: Signal::new(), + retry_output: Signal::new(), + caller: Mutex::new(()), + next_id: BlockingMutex::new(Cell::new(0)), + } + } + + /// Submit one command and wait for authoritative service readback. + pub async fn request(&self, command: Command) -> Result { + let _caller = self.caller.lock().await; + let id = self.next_id.lock(|next| { + let id = next.get(); + next.set(id.wrapping_add(1)); + id + }); + self.requests.send(MailboxRequest { id, command }).await; + loop { + let response = self.response.wait().await; + if response.id == id { + return response.result; + } + } + } + + /// Wake a processor whose output policy blocked automatic retries. + pub fn retry_output(&self) { + self.retry_output.signal(()); + } + + async fn receive(&self) -> MailboxRequest { + self.requests.receive().await + } + + fn reply(&self, id: u32, result: Result) { + self.response.signal(MailboxResponse { id, result }); + } +} + +impl Default for LightingMailbox { + fn default() -> Self { + Self::new() + } +} + +/// Sole mutable runtime owner for one lighting engine and output. +/// +/// State notifications invalidate a fresh authoritative snapshot. LightAction +/// edges and mailbox commands remain reliable and ordered. The loop arms only +/// the deadline returned by the service; static output has no timer. +#[::rmk::macros::processor(subscribe = [LayerChangeEvent, LedIndicatorEvent, SleepStateEvent])] +#[::rmk::macros::runnable_generated] +pub struct LightingProcessor<'mailbox, P, E, O, const COMMAND_CAPACITY: usize> +where + P: SnapshotProvider, + E: LightingEngine, + E::Input: From, + E::Command: Send, + E::Reply: Send, + E::Error: Send, + O: LightingOutput, +{ + service: LightingService, + output: O, + mailbox: &'mailbox LightingMailbox, + engine_retry: NonZeroU32, +} + +impl<'mailbox, P, E, O, const COMMAND_CAPACITY: usize> LightingProcessor<'mailbox, P, E, O, COMMAND_CAPACITY> +where + P: SnapshotProvider, + E: LightingEngine, + E::Input: From, + E::Command: Send, + E::Reply: Send, + E::Error: Send, + O: LightingOutput, +{ + pub fn new( + service: LightingService, + output: O, + mailbox: &'mailbox LightingMailbox, + ) -> Self { + Self { + service, + output, + mailbox, + engine_retry: NonZeroU32::new(10).unwrap(), + } + } + + pub fn with_engine_retry(mut self, delay: NonZeroU32) -> Self { + self.engine_retry = delay; + self + } + + pub const fn service(&self) -> &LightingService { + &self.service + } + + pub fn service_mut(&mut self) -> &mut LightingService { + &mut self.service + } + + fn publish_pending_lighting_change(&mut self) { + if self.service.take_lighting_change() { + publish_event(LightingChangedEvent::new()); + } + } + + /// Drive immediate lifecycle/render/present work and return the next + /// absolute millisecond deadline. + async fn drive_until_wait(&mut self) -> Option { + loop { + let now_ms = Instant::now().as_millis(); + let action = match self.service.next_action(now_ms) { + Ok(action) => action, + Err(ServiceError::Engine(_)) => { + return now_ms.checked_add(self.engine_retry.get() as u64); + } + Err(ServiceError::OperationInFlight(_)) => { + // Only this method begins and completes operations, so an + // in-flight action here is an internal invariant failure. + return now_ms.checked_add(self.engine_retry.get() as u64); + } + }; + + match action { + ServiceAction::Wait { next_wake_ms } => { + self.publish_pending_lighting_change(); + return next_wake_ms; + } + ServiceAction::Initialize => { + let completion = match self.output.initialize().await { + Ok(()) => OutputCompletion::Succeeded, + Err(error) => OutputCompletion::Failed { + retry_in_ms: self.output.retry_after(OutputOperation::Initialize, &error), + }, + }; + let _ = self.service.complete_output(Instant::now().as_millis(), completion); + } + ServiceAction::Present(frame) => { + let completion = match self.output.present(frame).await { + Ok(()) => OutputCompletion::Succeeded, + Err(error) => OutputCompletion::Failed { + retry_in_ms: self.output.retry_after(OutputOperation::Present, &error), + }, + }; + let _ = self.service.complete_output(Instant::now().as_millis(), completion); + } + ServiceAction::Suspend => { + let completion = match self.output.suspend().await { + Ok(()) => OutputCompletion::Succeeded, + Err(error) => OutputCompletion::Failed { + retry_in_ms: self.output.retry_after(OutputOperation::Suspend, &error), + }, + }; + let _ = self.service.complete_output(Instant::now().as_millis(), completion); + } + ServiceAction::Resume => { + let completion = match self.output.resume().await { + Ok(()) => OutputCompletion::Succeeded, + Err(error) => OutputCompletion::Failed { + retry_in_ms: self.output.retry_after(OutputOperation::Resume, &error), + }, + }; + let _ = self.service.complete_output(Instant::now().as_millis(), completion); + } + } + self.publish_pending_lighting_change(); + } + } + + async fn handle_mailbox_command(&mut self, request: MailboxRequest) { + let response = self.service.handle_command(Instant::now().as_millis(), request.command); + self.publish_pending_lighting_change(); + self.mailbox.reply(request.id, response); + } + + async fn on_layer_change_event(&mut self, _event: LayerChangeEvent) { + self.service.request_render(); + } + + async fn on_led_indicator_event(&mut self, _event: LedIndicatorEvent) { + self.service.request_render(); + } + + async fn on_sleep_state_event(&mut self, event: SleepStateEvent) { + let target = if event.0 { + PowerState::Suspended + } else { + PowerState::Active + }; + let _ = self.service.set_power(target); + } +} + +impl Runnable for LightingProcessor<'_, P, E, O, COMMAND_CAPACITY> +where + P: SnapshotProvider, + E: LightingEngine, + E::Input: From, + E::Command: Send, + E::Reply: Send, + E::Error: Send, + O: LightingOutput, +{ + async fn run(&mut self) -> ! { + let mut subscriber = ::subscriber(); + if crate::state::current_sleeping() { + let _ = self.service.set_power(PowerState::Suspended); + } + + loop { + let deadline = self.drive_until_wait().await; + match deadline { + Some(deadline) => { + match select4( + Timer::at(Instant::from_millis(deadline)), + subscriber.next_event(), + self.mailbox.receive(), + select(self.mailbox.retry_output.wait(), super::next_light_action()), + ) + .await + { + Either4::First(_) => {} + Either4::Second(event) => self.process(event).await, + Either4::Third(request) => self.handle_mailbox_command(request).await, + Either4::Fourth(Either::First(())) => { + let _ = self.service.retry_output_now(); + } + Either4::Fourth(Either::Second(action)) => { + let _ = self.service.on_input(E::Input::from(action)); + self.publish_pending_lighting_change(); + } + } + } + None => { + match select3( + subscriber.next_event(), + self.mailbox.receive(), + select(self.mailbox.retry_output.wait(), super::next_light_action()), + ) + .await + { + Either3::First(event) => self.process(event).await, + Either3::Second(request) => self.handle_mailbox_command(request).await, + Either3::Third(Either::First(())) => { + let _ = self.service.retry_output_now(); + } + Either3::Third(Either::Second(action)) => { + let _ = self.service.on_input(E::Input::from(action)); + self.publish_pending_lighting_change(); + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use embassy_futures::join::join; + + use super::LightingMailbox; + use crate::test_support::test_block_on as block_on; + + #[test] + fn mailbox_delivers_ordered_request_and_authoritative_reply() { + let mailbox = LightingMailbox::::new(); + let (reply, ()) = block_on(join(mailbox.request(7), async { + let request = mailbox.receive().await; + assert_eq!(request.command, 7); + mailbox.reply(request.id, Ok(42)); + })); + assert_eq!(reply, Ok(42)); + } + + #[test] + fn mailbox_propagates_command_errors() { + let mailbox = LightingMailbox::<(), (), u8, 1>::new(); + let (reply, ()) = block_on(join(mailbox.request(()), async { + let request = mailbox.receive().await; + mailbox.reply(request.id, Err(9)); + })); + assert_eq!(reply, Err(9)); + } + + #[test] + fn cancelled_request_reply_cannot_poison_the_next_caller() { + use embassy_futures::select::{Either, select}; + use embassy_futures::yield_now; + + let mailbox = LightingMailbox::::new(); + let cancelled = block_on(select(mailbox.request(1), async { + yield_now().await; + })); + assert!(matches!(cancelled, Either::Second(()))); + + let (reply, ()) = block_on(join(mailbox.request(2), async { + let abandoned = mailbox.receive().await; + assert_eq!(abandoned.command, 1); + mailbox.reply(abandoned.id, Ok(11)); + let current = mailbox.receive().await; + assert_eq!(current.command, 2); + mailbox.reply(current.id, Ok(22)); + })); + assert_eq!(reply, Ok(22)); + } +} diff --git a/rmk/src/lighting/rmk_state.rs b/rmk/src/lighting/rmk_state.rs new file mode 100644 index 000000000..48dd83769 --- /dev/null +++ b/rmk/src/lighting/rmk_state.rs @@ -0,0 +1,67 @@ +//! Adapters from authoritative RMK state to lighting snapshots. + +use super::{IndicatorState, LayerState, LightingContext, SnapshotProvider}; +use crate::keymap::KeyMap; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TooManyLayers { + pub configured: usize, + pub supported: usize, +} + +/// Authoritative layer snapshot provider backed by the live RMK keymap. +/// +/// Layer-change events are only wakeups. Every render and command reads this +/// provider, so startup and coalesced events cannot leave lighting with a +/// reconstructed or stale active-layer set. +#[derive(Clone, Copy)] +pub struct KeymapLightingState<'keymap, 'data> { + keymap: &'keymap KeyMap<'data>, +} + +impl<'keymap, 'data> KeymapLightingState<'keymap, 'data> { + pub fn new(keymap: &'keymap KeyMap<'data>) -> Result { + let configured = keymap.num_layer(); + if configured > LayerState::CAPACITY as usize { + return Err(TooManyLayers { + configured, + supported: LayerState::CAPACITY as usize, + }); + } + Ok(Self { keymap }) + } + + pub const fn keymap(&self) -> &'keymap KeyMap<'data> { + self.keymap + } +} + +impl SnapshotProvider for KeymapLightingState<'_, '_> { + type Snapshot = LightingContext; + + fn snapshot(&self) -> Self::Snapshot { + let effective = self.keymap.get_activated_layer(); + let default = self.keymap.get_default_layer(); + let mut active = 1_u64 << default; + for layer in 0..self.keymap.num_layer() as u8 { + if self.keymap.is_layer_active(layer) { + active |= 1_u64 << layer; + } + } + LightingContext { + layers: LayerState::new(effective, default, active), + indicators: indicator_state(), + } + } +} + +fn indicator_state() -> IndicatorState { + let indicator = crate::keyboard::current_led_indicator(); + IndicatorState { + num_lock: indicator.num_lock(), + caps_lock: indicator.caps_lock(), + scroll_lock: indicator.scroll_lock(), + compose: indicator.compose(), + kana: indicator.kana(), + } +} diff --git a/rmk/src/lighting/selector.rs b/rmk/src/lighting/selector.rs new file mode 100644 index 000000000..8f09e0526 --- /dev/null +++ b/rmk/src/lighting/selector.rs @@ -0,0 +1,216 @@ +use super::topology::{LedId, LedSlot, LightingTopology, MatrixPosition, ZoneId}; + +/// Stable configuration-level selector. None of these variants exposes a +/// local frame slot or electrical chain index. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum LedSelector { + Led(LedId), + Key(MatrixPosition), + Zone(ZoneId), + All, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ResolveError { + EmptySelection, + NoMatches(LedSelector), + UnknownLed(LedId), + UnknownKey(MatrixPosition), + UnknownZone(ZoneId), + CapacityExceeded { capacity: usize }, +} + +/// Deduplicated, fixed-capacity target set resolved once at configuration or +/// source installation time. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct ResolvedTargets { + slots: [LedSlot; CAP], + len: usize, +} + +impl ResolvedTargets { + pub const fn new() -> Self { + Self { + slots: [LedSlot(0); CAP], + len: 0, + } + } + + pub fn resolve(topology: &LightingTopology<'_>, selectors: &[LedSelector]) -> Result { + if selectors.is_empty() { + return Err(ResolveError::EmptySelection); + } + let mut resolved = Self::new(); + for selector in selectors { + let mut matched = false; + match *selector { + LedSelector::Led(id) => { + let slot = topology.slot(id).ok_or(ResolveError::UnknownLed(id))?; + matched = true; + resolved.insert(slot)?; + } + LedSelector::Key(key) => { + if !topology.has_key(key) { + return Err(ResolveError::UnknownKey(key)); + } + for (slot, _) in topology.leds_for_key(key) { + matched = true; + resolved.insert(slot)?; + } + } + LedSelector::Zone(zone) => { + if !topology.zones.iter().any(|metadata| metadata.id == zone) { + return Err(ResolveError::UnknownZone(zone)); + } + for index in 0..topology.len() { + let slot = LedSlot::from_index(index); + if topology.has_zone(slot, zone) { + matched = true; + resolved.insert(slot)?; + } + } + } + LedSelector::All => { + for index in 0..topology.len() { + matched = true; + resolved.insert(LedSlot::from_index(index))?; + } + } + } + if !matched { + return Err(ResolveError::NoMatches(*selector)); + } + } + Ok(resolved) + } + + pub const fn len(&self) -> usize { + self.len + } + + pub const fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn as_slice(&self) -> &[LedSlot] { + &self.slots[..self.len] + } + + fn insert(&mut self, slot: LedSlot) -> Result<(), ResolveError> { + if self.as_slice().contains(&slot) { + return Ok(()); + } + if self.len == CAP { + return Err(ResolveError::CapacityExceeded { capacity: CAP }); + } + self.slots[self.len] = slot; + self.len += 1; + Ok(()) + } +} + +impl Default for ResolvedTargets { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::super::topology::{LedMetadata, LightingTopology, MatrixSize, PhysicalLayout, ZoneMetadata, ZoneSpan}; + use super::*; + + const KEY: MatrixPosition = MatrixPosition::new(0, 0); + static KEYS: [MatrixPosition; 1] = [KEY]; + static ZONES: [ZoneMetadata<'static>; 1] = [ZoneMetadata { + id: ZoneId(7), + name: "thumb", + }]; + static MEMBERSHIPS: [ZoneId; 2] = [ZoneId(7), ZoneId(7)]; + static LEDS: [LedMetadata; 3] = [ + LedMetadata { + id: LedId(10), + key: Some(KEY), + position: None, + zones: ZoneSpan::new(0, 1), + }, + LedMetadata { + id: LedId(20), + key: Some(KEY), + position: None, + zones: ZoneSpan::new(1, 1), + }, + LedMetadata { + id: LedId(99), + key: None, + position: None, + zones: ZoneSpan::EMPTY, + }, + ]; + + fn topology() -> LightingTopology<'static> { + LightingTopology { + matrix: MatrixSize::new(1, 1), + keys: &KEYS, + physical_layout: PhysicalLayout::EMPTY, + leds: &LEDS, + zones: &ZONES, + zone_memberships: &MEMBERSHIPS, + } + } + + #[test] + fn resolves_keys_to_multiple_leds_and_deduplicates_selectors() { + let targets = ResolvedTargets::<3>::resolve( + &topology(), + &[ + LedSelector::Led(LedId(10)), + LedSelector::Key(KEY), + LedSelector::Zone(ZoneId(7)), + ], + ) + .unwrap(); + assert_eq!(targets.as_slice(), &[LedSlot(0), LedSlot(1)]); + } + + #[test] + fn resolution_failure_is_atomic() { + assert_eq!( + ResolvedTargets::<1>::resolve(&topology(), &[LedSelector::All]), + Err(ResolveError::CapacityExceeded { capacity: 1 }) + ); + assert_eq!( + ResolvedTargets::<3>::resolve(&topology(), &[LedSelector::Led(LedId(404))]), + Err(ResolveError::UnknownLed(LedId(404))) + ); + assert_eq!( + ResolvedTargets::<3>::resolve(&topology(), &[]), + Err(ResolveError::EmptySelection) + ); + } + + #[test] + fn known_but_empty_key_or_zone_is_rejected() { + let empty_keys = [KEY]; + let empty_zone = [ZoneMetadata { + id: ZoneId(8), + name: "empty", + }]; + let empty = LightingTopology { + matrix: MatrixSize::new(1, 1), + keys: &empty_keys, + physical_layout: PhysicalLayout::EMPTY, + leds: &[], + zones: &empty_zone, + zone_memberships: &[], + }; + assert_eq!( + ResolvedTargets::<1>::resolve(&empty, &[LedSelector::Key(KEY)]), + Err(ResolveError::NoMatches(LedSelector::Key(KEY))) + ); + assert_eq!( + ResolvedTargets::<1>::resolve(&empty, &[LedSelector::Zone(ZoneId(8))]), + Err(ResolveError::NoMatches(LedSelector::Zone(ZoneId(8)))) + ); + } +} diff --git a/rmk/src/lighting/service.rs b/rmk/src/lighting/service.rs new file mode 100644 index 000000000..447607719 --- /dev/null +++ b/rmk/src/lighting/service.rs @@ -0,0 +1,1045 @@ +//! Executor-independent lighting service ownership and scheduling. +//! +//! The service in this module deliberately knows nothing about RMK events, +//! Embassy, pixels, or physical LED protocols. An adapter translates its event +//! types into [`LightingEngine::Input`], polls [`LightingService::next_action`], +//! performs the requested asynchronous output operation, and reports the +//! result with [`LightingService::complete_output`]. Keeping that loop outside +//! the core makes command ordering, deadlines, and retry behavior deterministic +//! in host tests. + +use core::num::NonZeroU32; + +/// Read-only access to the current authoritative keyboard state. +/// +/// Notifications are only invalidations: a consumer must take a fresh +/// snapshot instead of reconstructing state from event history. This lets +/// lossy or coalesced state notifications still converge after startup and +/// bursts of changes. Edge-sensitive operations belong in +/// [`LightingEngine::Input`] or [`LightingEngine::Command`]. +pub trait SnapshotProvider { + type Snapshot; + + fn snapshot(&self) -> Self::Snapshot; +} + +/// Authoritative state-change and render invalidation produced by an engine. +/// +/// `Render` means engine-owned state changed and another render is required. +/// Application-owned state such as layers requests a render directly through +/// [`LightingService::request_render`] and therefore does not emit a lighting +/// state-change notification. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Invalidation { + #[default] + None, + Render, + /// Engine-owned state changed without changing the visible frame. + StateChanged, +} + +impl Invalidation { + pub const fn requires_render(self) -> bool { + matches!(self, Self::Render) + } + + pub const fn state_changed(self) -> bool { + !matches!(self, Self::None) + } +} + +/// Result of a serialized mutation request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CommandResult { + pub reply: R, + pub invalidation: Invalidation, +} + +impl CommandResult { + pub const fn new(reply: R, invalidation: Invalidation) -> Self { + Self { reply, invalidation } + } + + pub const fn unchanged(reply: R) -> Self { + Self::new(reply, Invalidation::None) + } + + pub const fn render(reply: R) -> Self { + Self::new(reply, Invalidation::Render) + } + + pub const fn changed(reply: R) -> Self { + Self::new(reply, Invalidation::StateChanged) + } +} + +/// Inputs to one pure render pass. +#[derive(Clone, Copy, Debug)] +pub struct RenderInput<'a, S> { + pub now_ms: u64, + pub snapshot: &'a S, +} + +/// Result of one render pass. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RenderOutcome { + /// The completed frame differs from the last successfully presented frame. + pub changed: bool, + /// Rendering itself changed authoritative engine state, for example by + /// pruning an expired overlay. The service coalesces this into one unit + /// invalidation for consumers to refresh their snapshot. + pub state_changed: bool, + /// Positive relative delay until visible output can next differ. + /// + /// `None` means static output and therefore no render timer. Relative time + /// keeps the engine independent of an executor's clock representation. + pub next_wake_in_ms: Option, +} + +impl RenderOutcome { + pub const fn static_frame(changed: bool) -> Self { + Self { + changed, + state_changed: false, + next_wake_in_ms: None, + } + } +} + +/// Stateful rendering policy owned exclusively by a [`LightingService`]. +/// +/// `Frame` is the *whole* logical output shape selected by the board. It may +/// be a conventional dense RGB frame, but it can equally be a struct combining +/// RGB chains, PWM channels, and indicator bits. This boundary intentionally +/// makes no homogeneous-slice assumption. +pub trait LightingEngine { + type Frame; + type Input; + type Command; + type Reply; + type Error; + + /// Consume an edge-sensitive or transient input. + fn on_input(&mut self, input: Self::Input, snapshot: &S) -> Result; + + /// Apply one serialized mutation and return protocol-independent readback. + fn handle_command( + &mut self, + now_ms: u64, + command: Self::Command, + snapshot: &S, + ) -> Result, Self::Error>; + + /// Render the complete logical frame from current authoritative state. + fn render(&mut self, input: RenderInput<'_, S>, frame: &mut Self::Frame) -> Result; + + /// Commit renderer history after, and only after, successful presentation. + /// + /// A compositor commonly uses this hook to update the frame against which + /// its next `changed` result is calculated. Failed writes must not call it. + fn on_presented(&mut self, _frame: &Self::Frame) {} +} + +/// Hardware-facing lifecycle and presentation contract. +/// +/// The pure [`LightingService`] does not run these futures. An RMK or other +/// executor adapter performs the operation requested by [`ServiceAction`] and +/// reports its result. Implementations retain electrical ordering, encoding, +/// power sequencing, and hard safety limits. +#[allow(async_fn_in_trait)] +pub trait LightingOutput { + type Error; + + async fn initialize(&mut self) -> Result<(), Self::Error>; + async fn present(&mut self, frame: &F) -> Result<(), Self::Error>; + async fn suspend(&mut self) -> Result<(), Self::Error>; + async fn resume(&mut self) -> Result<(), Self::Error>; + + /// Select an automatic retry delay for a failed operation. + /// + /// Returning `None` blocks that operation until the adapter explicitly + /// calls [`LightingService::retry_output_now`]. + fn retry_after(&self, _operation: OutputOperation, _error: &Self::Error) -> Option { + None + } +} + +/// Desired logical power state. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum PowerState { + #[default] + Active, + Suspended, +} + +/// Last successfully completed output lifecycle state. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OutputState { + #[default] + Uninitialized, + Active, + Suspended, +} + +/// One operation performed by the executor-facing adapter. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OutputOperation { + Initialize, + Present, + Suspend, + Resume, +} + +/// The next externally performed service action. +#[derive(Debug, Eq, PartialEq)] +pub enum ServiceAction<'a, F> { + /// No immediate work. `next_wake_ms` is the earliest absolute instant at + /// which the adapter should poll again, or `None` when only an external + /// input/command can make progress. + Wait { + next_wake_ms: Option, + }, + Initialize, + Present(&'a F), + Suspend, + Resume, +} + +impl ServiceAction<'_, F> { + pub const fn operation(&self) -> Option { + match self { + Self::Wait { .. } => None, + Self::Initialize => Some(OutputOperation::Initialize), + Self::Present(_) => Some(OutputOperation::Present), + Self::Suspend => Some(OutputOperation::Suspend), + Self::Resume => Some(OutputOperation::Resume), + } + } +} + +/// Result reported after an output operation completes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OutputCompletion { + Succeeded, + Failed { + /// Positive relative retry delay selected by + /// [`LightingOutput::retry_after`]. `None` requires explicit retry. + retry_in_ms: Option, + }, +} + +/// Failure while calculating the next service action. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceError { + Engine(E), + OperationInFlight(OutputOperation), +} + +/// Invalid completion/retry call made by an adapter. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CompletionError { + NoOperationInFlight, + OperationInFlight(OutputOperation), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct RetryState { + operation: OutputOperation, + at_ms: Option, +} + +/// Synchronous ownership, scheduling, and output-retry state machine. +/// +/// The service owns the snapshot provider, engine, and complete logical frame. +/// It intentionally does not own an async output object: this keeps the state +/// transitions directly testable and lets RMK, another executor, or a blocking +/// host harness drive the exact same logic. +pub struct LightingService +where + P: SnapshotProvider, + E: LightingEngine, +{ + provider: P, + engine: E, + frame: E::Frame, + desired_power: PowerState, + output_state: OutputState, + dirty: bool, + present_required: bool, + next_render_ms: Option, + retry: Option, + in_flight: Option, + lighting_change_pending: bool, +} + +impl LightingService +where + P: SnapshotProvider, + E: LightingEngine, +{ + /// Construct a service with explicit board-selected frame storage. + /// + /// The first active poll initializes the output, renders authoritative + /// state, and presents once even if the engine reports `changed == false`. + pub const fn new(provider: P, engine: E, frame: E::Frame) -> Self { + Self { + provider, + engine, + frame, + desired_power: PowerState::Active, + output_state: OutputState::Uninitialized, + dirty: true, + present_required: true, + next_render_ms: None, + retry: None, + in_flight: None, + lighting_change_pending: false, + } + } + + pub const fn desired_power(&self) -> PowerState { + self.desired_power + } + + pub const fn output_state(&self) -> OutputState { + self.output_state + } + + pub const fn is_dirty(&self) -> bool { + self.dirty + } + + pub const fn presentation_pending(&self) -> bool { + self.present_required + } + + pub const fn next_render_ms(&self) -> Option { + self.next_render_ms + } + + pub const fn in_flight(&self) -> Option { + self.in_flight + } + + pub const fn lighting_change_pending(&self) -> bool { + self.lighting_change_pending + } + + /// Take one coalesced authoritative-lighting-state invalidation. + pub fn take_lighting_change(&mut self) -> bool { + core::mem::take(&mut self.lighting_change_pending) + } + + pub const fn frame(&self) -> &E::Frame { + &self.frame + } + + pub const fn engine(&self) -> &E { + &self.engine + } + + pub fn engine_mut(&mut self) -> &mut E { + &mut self.engine + } + + pub const fn snapshot_provider(&self) -> &P { + &self.provider + } + + pub fn snapshot_provider_mut(&mut self) -> &mut P { + &mut self.provider + } + + /// Request active or suspended hardware state. + /// + /// Power transitions supersede a blocked retry of a now-irrelevant output + /// operation. Rendering deadlines are disarmed while suspended; current + /// state is rendered and force-presented after a successful resume. + pub fn set_power(&mut self, power: PowerState) -> Result<(), CompletionError> { + if let Some(operation) = self.in_flight { + return Err(CompletionError::OperationInFlight(operation)); + } + if self.desired_power != power { + self.desired_power = power; + self.retry = None; + if power == PowerState::Suspended { + self.next_render_ms = None; + } + } + Ok(()) + } + + /// Deliver one edge-sensitive input using a fresh authoritative snapshot. + pub fn on_input(&mut self, input: E::Input) -> Result<(), E::Error> { + let snapshot = self.provider.snapshot(); + let invalidation = self.engine.on_input(input, &snapshot)?; + self.invalidate(invalidation); + Ok(()) + } + + /// Apply one mutation synchronously through the sole mutable owner. + pub fn handle_command(&mut self, now_ms: u64, command: E::Command) -> Result { + let snapshot = self.provider.snapshot(); + let result = self.engine.handle_command(now_ms, command, &snapshot)?; + self.invalidate(result.invalidation); + Ok(result.reply) + } + + /// Explicitly request a render after application-owned state changes. + pub fn request_render(&mut self) { + self.dirty = true; + } + + /// Unblock the last failed operation without waiting for another event. + pub fn retry_output_now(&mut self) -> Result<(), CompletionError> { + if let Some(operation) = self.in_flight { + return Err(CompletionError::OperationInFlight(operation)); + } + self.retry = None; + Ok(()) + } + + /// Produce the next action and mark output operations as in flight. + /// + /// Callers must report every non-`Wait` action through + /// [`complete_output`](Self::complete_output) before polling again. + pub fn next_action(&mut self, now_ms: u64) -> Result, ServiceError> { + if let Some(operation) = self.in_flight { + return Err(ServiceError::OperationInFlight(operation)); + } + + let lifecycle_operation = match (self.output_state, self.desired_power) { + (OutputState::Uninitialized, _) => Some(OutputOperation::Initialize), + (OutputState::Active, PowerState::Suspended) => Some(OutputOperation::Suspend), + (OutputState::Suspended, PowerState::Active) => Some(OutputOperation::Resume), + _ => None, + }; + if let Some(operation) = lifecycle_operation { + if self.operation_ready(operation, now_ms) { + return Ok(self.begin_operation(operation)); + } + return Ok(ServiceAction::Wait { + next_wake_ms: self.wait_deadline(), + }); + } + + if self.output_state == OutputState::Suspended { + return Ok(ServiceAction::Wait { next_wake_ms: None }); + } + + if self.next_render_ms.is_some_and(|deadline| deadline <= now_ms) { + self.next_render_ms = None; + self.dirty = true; + } + + if self.dirty { + let snapshot = self.provider.snapshot(); + let outcome = self + .engine + .render( + RenderInput { + now_ms, + snapshot: &snapshot, + }, + &mut self.frame, + ) + .map_err(ServiceError::Engine)?; + self.dirty = false; + self.next_render_ms = outcome + .next_wake_in_ms + .and_then(|delay| now_ms.checked_add(delay.get() as u64)); + self.present_required |= outcome.changed; + self.lighting_change_pending |= outcome.state_changed; + } + + if self.present_required { + if self.operation_ready(OutputOperation::Present, now_ms) { + self.in_flight = Some(OutputOperation::Present); + return Ok(ServiceAction::Present(&self.frame)); + } + return Ok(ServiceAction::Wait { + next_wake_ms: self.wait_deadline(), + }); + } + + // A successful render/present makes any stale presentation retry moot. + if self + .retry + .is_some_and(|retry| retry.operation == OutputOperation::Present) + { + self.retry = None; + } + Ok(ServiceAction::Wait { + next_wake_ms: self.wait_deadline(), + }) + } + + /// Complete the operation most recently returned by [`next_action`]. + pub fn complete_output(&mut self, now_ms: u64, completion: OutputCompletion) -> Result<(), CompletionError> { + let operation = self.in_flight.take().ok_or(CompletionError::NoOperationInFlight)?; + + match completion { + OutputCompletion::Succeeded => { + self.retry = None; + match operation { + OutputOperation::Initialize => { + self.output_state = OutputState::Active; + } + OutputOperation::Present => { + self.engine.on_presented(&self.frame); + self.present_required = false; + } + OutputOperation::Suspend => { + self.output_state = OutputState::Suspended; + self.next_render_ms = None; + } + OutputOperation::Resume => { + self.output_state = OutputState::Active; + self.dirty = true; + self.present_required = true; + } + } + } + OutputCompletion::Failed { retry_in_ms } => { + self.retry = Some(RetryState { + operation, + at_ms: retry_in_ms.and_then(|delay| now_ms.checked_add(delay.get() as u64)), + }); + // Lifecycle state and successfully presented renderer history + // intentionally remain unchanged. + if operation == OutputOperation::Present { + self.present_required = true; + } + } + } + Ok(()) + } + + fn invalidate(&mut self, invalidation: Invalidation) { + self.lighting_change_pending |= invalidation.state_changed(); + if invalidation.requires_render() { + self.dirty = true; + } + } + + fn operation_ready(&mut self, operation: OutputOperation, now_ms: u64) -> bool { + let Some(retry) = self.retry else { + return true; + }; + if retry.operation != operation { + self.retry = None; + return true; + } + match retry.at_ms { + Some(deadline) if deadline <= now_ms => { + self.retry = None; + true + } + Some(_) | None => false, + } + } + + fn begin_operation(&mut self, operation: OutputOperation) -> ServiceAction<'_, E::Frame> { + self.in_flight = Some(operation); + match operation { + OutputOperation::Initialize => ServiceAction::Initialize, + OutputOperation::Present => ServiceAction::Present(&self.frame), + OutputOperation::Suspend => ServiceAction::Suspend, + OutputOperation::Resume => ServiceAction::Resume, + } + } + + fn wait_deadline(&self) -> Option { + earliest(self.next_render_ms, self.retry.and_then(|retry| retry.at_ms)) + } +} + +const fn earliest(left: Option, right: Option) -> Option { + match (left, right) { + (Some(left), Some(right)) => Some(if left < right { left } else { right }), + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] + struct Snapshot { + value: u8, + indicator: bool, + } + + #[derive(Debug)] + struct Provider { + current: Snapshot, + } + + impl Provider { + fn new(value: u8) -> Self { + Self { + current: Snapshot { + value, + indicator: false, + }, + } + } + } + + impl SnapshotProvider for Provider { + type Snapshot = Snapshot; + + fn snapshot(&self) -> Self::Snapshot { + // The production trait deliberately takes `&self`; use interior + // mutability only when read accounting matters. Most tests infer + // reads through the engine's recorded snapshots instead. + self.current + } + } + + #[derive(Clone, Debug, Default, Eq, PartialEq)] + struct BoardFrame { + rgb: [u8; 2], + indicator: bool, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Input { + Add(u8), + Ignore, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Command { + SetOffset(u8), + Read, + Fail, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum EngineError { + Requested, + Render, + } + + struct Engine { + offset: u8, + committed: Option, + render_calls: usize, + presented_calls: usize, + snapshots: [Option; 8], + snapshot_count: usize, + next_wake: Option, + fail_render_once: bool, + state_change_on_render: bool, + } + + impl Engine { + fn new() -> Self { + Self { + offset: 0, + committed: None, + render_calls: 0, + presented_calls: 0, + snapshots: [None; 8], + snapshot_count: 0, + next_wake: None, + fail_render_once: false, + state_change_on_render: false, + } + } + + fn record(&mut self, snapshot: Snapshot) { + if let Some(slot) = self.snapshots.get_mut(self.snapshot_count) { + *slot = Some(snapshot); + } + self.snapshot_count += 1; + } + } + + impl LightingEngine for Engine { + type Frame = BoardFrame; + type Input = Input; + type Command = Command; + type Reply = u8; + type Error = EngineError; + + fn on_input(&mut self, input: Self::Input, snapshot: &Snapshot) -> Result { + self.record(*snapshot); + match input { + Input::Add(value) => { + self.offset = self.offset.wrapping_add(value); + Ok(Invalidation::Render) + } + Input::Ignore => Ok(Invalidation::None), + } + } + + fn handle_command( + &mut self, + _now_ms: u64, + command: Self::Command, + snapshot: &Snapshot, + ) -> Result, Self::Error> { + self.record(*snapshot); + match command { + Command::SetOffset(value) => { + let previous = self.offset; + self.offset = value; + Ok(CommandResult::render(previous)) + } + Command::Read => Ok(CommandResult::unchanged(self.offset)), + Command::Fail => Err(EngineError::Requested), + } + } + + fn render( + &mut self, + input: RenderInput<'_, Snapshot>, + frame: &mut Self::Frame, + ) -> Result { + self.record(*input.snapshot); + self.render_calls += 1; + if core::mem::take(&mut self.fail_render_once) { + return Err(EngineError::Render); + } + let value = input.snapshot.value.wrapping_add(self.offset); + frame.rgb = [value, value.wrapping_add(1)]; + frame.indicator = input.snapshot.indicator; + Ok(RenderOutcome { + changed: self.committed.as_ref() != Some(frame), + state_changed: core::mem::take(&mut self.state_change_on_render), + next_wake_in_ms: self.next_wake, + }) + } + + fn on_presented(&mut self, frame: &Self::Frame) { + self.committed = Some(frame.clone()); + self.presented_calls += 1; + } + } + + type Service = LightingService; + + fn service(value: u8) -> Service { + LightingService::new(Provider::new(value), Engine::new(), BoardFrame::default()) + } + + fn complete(service: &mut Service, now_ms: u64) { + service.complete_output(now_ms, OutputCompletion::Succeeded).unwrap(); + } + + fn initialize(service: &mut Service, now_ms: u64) { + assert_eq!(service.next_action(now_ms), Ok(ServiceAction::Initialize)); + complete(service, now_ms); + } + + fn initial_present(service: &mut Service, now_ms: u64) { + initialize(service, now_ms); + let expected = service.provider.current.value; + match service.next_action(now_ms).unwrap() { + ServiceAction::Present(frame) => { + assert_eq!(frame.rgb, [expected, expected + 1]); + } + other => panic!("expected presentation, got {other:?}"), + } + complete(service, now_ms); + } + + #[test] + fn initial_render_uses_latest_authoritative_snapshot_and_whole_frame() { + let mut service = service(1); + assert_eq!(service.next_action(0), Ok(ServiceAction::Initialize)); + complete(&mut service, 0); + + // No state event is required: the render queries authoritative state. + service.provider.current = Snapshot { + value: 7, + indicator: true, + }; + match service.next_action(1).unwrap() { + ServiceAction::Present(frame) => { + assert_eq!(frame.rgb, [7, 8]); + assert!(frame.indicator); + } + other => panic!("expected presentation, got {other:?}"), + } + complete(&mut service, 1); + assert_eq!(service.engine.presented_calls, 1); + assert_eq!(service.engine.committed, Some(service.frame.clone())); + } + + #[test] + fn successful_static_frame_is_not_rendered_or_presented_twice() { + let mut service = service(3); + initial_present(&mut service, 0); + + assert_eq!(service.next_action(100), Ok(ServiceAction::Wait { next_wake_ms: None })); + assert_eq!(service.engine.render_calls, 1); + assert_eq!(service.engine.presented_calls, 1); + + service.on_input(Input::Ignore).unwrap(); + assert!(!service.is_dirty()); + assert_eq!(service.next_action(101), Ok(ServiceAction::Wait { next_wake_ms: None })); + } + + #[test] + fn input_and_command_use_fresh_snapshots_and_serialize_mutation() { + let mut service = service(2); + service.provider.current.value = 4; + service.on_input(Input::Add(1)).unwrap(); + service.provider.current.value = 6; + assert_eq!(service.handle_command(10, Command::SetOffset(9)), Ok(1)); + assert_eq!(service.handle_command(11, Command::Read), Ok(9)); + assert_eq!(service.handle_command(12, Command::Fail), Err(EngineError::Requested)); + + assert_eq!(service.engine.snapshots[0].unwrap().value, 4); + assert_eq!(service.engine.snapshots[1].unwrap().value, 6); + assert_eq!(service.engine.snapshots[2].unwrap().value, 6); + assert_eq!(service.engine.snapshots[3].unwrap().value, 6); + assert!(service.is_dirty()); + } + + #[test] + fn authoritative_change_is_coalesced_and_render_requests_are_not_changes() { + let mut service = service(2); + assert!(!service.lighting_change_pending()); + + service.request_render(); + assert!(!service.lighting_change_pending()); + service.on_input(Input::Ignore).unwrap(); + assert!(!service.lighting_change_pending()); + + service.on_input(Input::Add(1)).unwrap(); + assert!(service.lighting_change_pending()); + assert_eq!(service.handle_command(1, Command::SetOffset(9)), Ok(1)); + assert!(service.take_lighting_change()); + assert!(!service.take_lighting_change()); + + assert_eq!(service.handle_command(2, Command::Read), Ok(9)); + assert_eq!(service.handle_command(3, Command::Fail), Err(EngineError::Requested)); + assert!(!service.lighting_change_pending()); + } + + #[test] + fn render_owned_state_change_is_reported_once() { + let mut service = service(2); + initialize(&mut service, 0); + service.engine.state_change_on_render = true; + assert!(matches!(service.next_action(0), Ok(ServiceAction::Present(_)))); + assert!(service.take_lighting_change()); + assert!(!service.take_lighting_change()); + } + + #[test] + fn failed_present_retries_at_exact_deadline_without_recommitting_or_rerendering() { + let mut service = service(5); + initialize(&mut service, 0); + assert!(matches!(service.next_action(0), Ok(ServiceAction::Present(_)))); + service + .complete_output( + 0, + OutputCompletion::Failed { + retry_in_ms: NonZeroU32::new(10), + }, + ) + .unwrap(); + + assert_eq!(service.engine.presented_calls, 0); + assert_eq!(service.engine.render_calls, 1); + assert_eq!( + service.next_action(9), + Ok(ServiceAction::Wait { next_wake_ms: Some(10) }) + ); + assert!(matches!(service.next_action(10), Ok(ServiceAction::Present(_)))); + assert_eq!(service.engine.render_calls, 1); + complete(&mut service, 10); + assert_eq!(service.engine.presented_calls, 1); + } + + #[test] + fn unscheduled_failure_stays_blocked_until_explicit_retry_but_can_rerender_latest_state() { + let mut service = service(1); + initialize(&mut service, 0); + assert!(matches!(service.next_action(0), Ok(ServiceAction::Present(_)))); + service + .complete_output(0, OutputCompletion::Failed { retry_in_ms: None }) + .unwrap(); + + service.provider.current.value = 8; + service.request_render(); + assert_eq!(service.next_action(50), Ok(ServiceAction::Wait { next_wake_ms: None })); + assert_eq!(service.frame.rgb, [8, 9]); + assert_eq!(service.engine.render_calls, 2); + + service.retry_output_now().unwrap(); + match service.next_action(50).unwrap() { + ServiceAction::Present(frame) => assert_eq!(frame.rgb, [8, 9]), + other => panic!("expected presentation, got {other:?}"), + } + } + + #[test] + fn engine_deadline_renders_exactly_when_due_and_reschedules_relatively() { + let mut service = service(1); + service.engine.next_wake = NonZeroU32::new(25); + initial_present(&mut service, 100); + assert_eq!(service.next_render_ms(), Some(125)); + assert_eq!( + service.next_action(124), + Ok(ServiceAction::Wait { + next_wake_ms: Some(125) + }) + ); + + // The frame is unchanged at the deadline, so there is no write, but + // the next visible-change deadline is still advanced. + assert_eq!( + service.next_action(125), + Ok(ServiceAction::Wait { + next_wake_ms: Some(150) + }) + ); + assert_eq!(service.engine.render_calls, 2); + } + + #[test] + fn a_new_event_before_the_old_deadline_replaces_the_schedule() { + let mut service = service(1); + service.engine.next_wake = NonZeroU32::new(20); + initial_present(&mut service, 10); + assert_eq!(service.next_render_ms(), Some(30)); + + service.engine.next_wake = NonZeroU32::new(50); + service.on_input(Input::Add(1)).unwrap(); + assert!(matches!(service.next_action(15), Ok(ServiceAction::Present(_)))); + assert_eq!(service.next_render_ms(), Some(65)); + } + + #[test] + fn suspend_disarms_deadline_and_resume_forces_fresh_render_and_present() { + let mut service = service(4); + service.engine.next_wake = NonZeroU32::new(10); + initial_present(&mut service, 0); + + service.set_power(PowerState::Suspended).unwrap(); + assert_eq!(service.next_render_ms(), None); + assert_eq!(service.next_action(1), Ok(ServiceAction::Suspend)); + complete(&mut service, 1); + assert_eq!(service.output_state(), OutputState::Suspended); + + service.provider.current.value = 9; + service.on_input(Input::Add(0)).unwrap(); + assert_eq!(service.next_action(100), Ok(ServiceAction::Wait { next_wake_ms: None })); + + service.set_power(PowerState::Active).unwrap(); + assert_eq!(service.next_action(101), Ok(ServiceAction::Resume)); + complete(&mut service, 101); + match service.next_action(101).unwrap() { + ServiceAction::Present(frame) => assert_eq!(frame.rgb, [9, 10]), + other => panic!("expected presentation, got {other:?}"), + } + } + + #[test] + fn lifecycle_failure_retries_without_changing_successful_state() { + let mut service = service(0); + assert_eq!(service.next_action(0), Ok(ServiceAction::Initialize)); + service + .complete_output( + 0, + OutputCompletion::Failed { + retry_in_ms: NonZeroU32::new(5), + }, + ) + .unwrap(); + assert_eq!(service.output_state(), OutputState::Uninitialized); + assert_eq!( + service.next_action(4), + Ok(ServiceAction::Wait { next_wake_ms: Some(5) }) + ); + assert_eq!(service.next_action(5), Ok(ServiceAction::Initialize)); + complete(&mut service, 5); + assert_eq!(service.output_state(), OutputState::Active); + } + + #[test] + fn output_operation_must_complete_before_any_other_mutating_transition() { + let mut service = service(0); + assert_eq!(service.next_action(0), Ok(ServiceAction::Initialize)); + assert_eq!( + service.next_action(0), + Err(ServiceError::OperationInFlight(OutputOperation::Initialize)) + ); + assert_eq!( + service.set_power(PowerState::Suspended), + Err(CompletionError::OperationInFlight(OutputOperation::Initialize)) + ); + assert_eq!( + service.retry_output_now(), + Err(CompletionError::OperationInFlight(OutputOperation::Initialize)) + ); + complete(&mut service, 0); + assert_eq!( + service.complete_output(0, OutputCompletion::Succeeded), + Err(CompletionError::NoOperationInFlight) + ); + } + + #[test] + fn render_failure_leaves_service_dirty_for_a_deterministic_retry() { + let mut service = service(2); + initialize(&mut service, 0); + service.engine.fail_render_once = true; + assert_eq!(service.next_action(0), Err(ServiceError::Engine(EngineError::Render))); + assert!(service.is_dirty()); + assert!(matches!(service.next_action(0), Ok(ServiceAction::Present(_)))); + assert_eq!(service.engine.render_calls, 2); + } + + #[test] + fn deadline_overflow_does_not_create_a_busy_loop() { + let mut service = service(0); + service.engine.next_wake = NonZeroU32::new(2); + initialize(&mut service, u64::MAX - 1); + assert!(matches!( + service.next_action(u64::MAX - 1), + Ok(ServiceAction::Present(_)) + )); + assert_eq!(service.next_render_ms(), None); + } + + #[test] + fn retry_deadline_overflow_requires_explicit_retry() { + let mut service = service(0); + assert_eq!(service.next_action(u64::MAX), Ok(ServiceAction::Initialize)); + service + .complete_output( + u64::MAX, + OutputCompletion::Failed { + retry_in_ms: NonZeroU32::new(1), + }, + ) + .unwrap(); + assert_eq!( + service.next_action(u64::MAX), + Ok(ServiceAction::Wait { next_wake_ms: None }) + ); + service.retry_output_now().unwrap(); + assert_eq!(service.next_action(u64::MAX), Ok(ServiceAction::Initialize)); + } + + #[test] + fn power_change_supersedes_a_blocked_presentation_retry() { + let mut service = service(0); + initialize(&mut service, 0); + assert!(matches!(service.next_action(0), Ok(ServiceAction::Present(_)))); + service + .complete_output(0, OutputCompletion::Failed { retry_in_ms: None }) + .unwrap(); + service.set_power(PowerState::Suspended).unwrap(); + assert_eq!(service.next_action(1), Ok(ServiceAction::Suspend)); + } +} diff --git a/rmk/src/lighting/source.rs b/rmk/src/lighting/source.rs new file mode 100644 index 000000000..1a61d16dc --- /dev/null +++ b/rmk/src/lighting/source.rs @@ -0,0 +1,527 @@ +use super::compositor::{Contribution, LightingSource, RenderInput}; +use super::context::{LightingContext, LightingContextProvider}; +use super::effect::{EffectSample, LightingEffect}; +use super::topology::LedSlot; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct SceneCell { + pub slot: LedSlot, + pub effect: E, +} + +/// A pre-resolved sparse scene. Key, stable-ID, and zone selectors are +/// resolved to local slots when configuration is installed, not while +/// rendering. +#[derive(Copy, Clone, Debug)] +pub struct SparseScene<'a, E> { + pub cells: &'a [SceneCell], +} + +impl LightingSource for SparseScene<'_, E> +where + E: LightingEffect, +{ + fn len(&self, _: &RenderInput<'_, Context>) -> usize { + self.cells.len() + } + + fn slot(&self, index: usize, _: &RenderInput<'_, Context>) -> LedSlot { + self.cells[index].slot + } + + fn contribution(&mut self, index: usize, input: &RenderInput<'_, Context>) -> Contribution { + Contribution::Opaque(self.cells[index].effect.sample(input.now_ms)) + } +} + +#[derive(Copy, Clone, Debug)] +pub struct LayerScene<'a, E> { + pub layer: u8, + pub cells: &'a [SceneCell], +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum LayerPolicy { + /// Only the effective layer contributes. + EffectiveOnly, + /// Default first, then the complete active set in ascending RMK layer + /// precedence, with the effective layer last. Sparse cells fall through. + ActiveStack, +} + +/// RMK's built-in, layer-aware sparse source. +#[derive(Copy, Clone, Debug)] +pub struct LayerScenes<'a, E> { + pub scenes: &'a [LayerScene<'a, E>], + pub policy: LayerPolicy, +} + +impl<'a, E> LayerScenes<'a, E> { + fn cell_for_layer(&self, layer: u8, wanted: &mut usize) -> Option<&SceneCell> { + for scene in self.scenes.iter().filter(|scene| scene.layer == layer) { + if *wanted < scene.cells.len() { + return Some(&scene.cells[*wanted]); + } + *wanted -= scene.cells.len(); + } + None + } + + fn cell_at(&self, context: &LightingContext, mut wanted: usize) -> &SceneCell { + let effective = context.layers.effective; + match self.policy { + LayerPolicy::EffectiveOnly => self.cell_for_layer(effective, &mut wanted), + LayerPolicy::ActiveStack => { + let default = context.layers.default; + if let Some(cell) = self.cell_for_layer(default, &mut wanted) { + return cell; + } + for layer in 0..super::context::LayerState::CAPACITY { + if layer != default + && layer != effective + && context.layers.is_active(layer) + && let Some(cell) = self.cell_for_layer(layer, &mut wanted) + { + return cell; + } + } + if effective != default { + self.cell_for_layer(effective, &mut wanted) + } else { + None + } + } + } + .expect("LightingSource index must be below len") + } + + fn included_len(&self, context: &LightingContext) -> usize { + let effective = context.layers.effective; + self.scenes + .iter() + .filter(|scene| match self.policy { + LayerPolicy::EffectiveOnly => scene.layer == effective, + LayerPolicy::ActiveStack => { + scene.layer == context.layers.default + || scene.layer == effective + || context.layers.is_active(scene.layer) + } + }) + .map(|scene| scene.cells.len()) + .sum() + } +} + +impl LightingSource for LayerScenes<'_, E> +where + E: LightingEffect, + Context: LightingContextProvider, +{ + fn len(&self, input: &RenderInput<'_, Context>) -> usize { + self.included_len(input.context.lighting_context()) + } + + fn slot(&self, index: usize, input: &RenderInput<'_, Context>) -> LedSlot { + self.cell_at(input.context.lighting_context(), index).slot + } + + fn contribution(&mut self, index: usize, input: &RenderInput<'_, Context>) -> Contribution { + Contribution::Opaque( + self.cell_at(input.context.lighting_context(), index) + .effect + .sample(input.now_ms), + ) + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Indicator { + NumLock, + CapsLock, + ScrollLock, + Compose, + Kana, +} + +#[derive(Copy, Clone, Debug)] +pub struct IndicatorScene<'a, E> { + pub indicator: Indicator, + pub active: bool, + pub cells: &'a [SceneCell], +} + +/// Sparse status scenes driven from the authoritative indicator snapshot. +/// Multiple matching scenes compose in declaration order within the source. +#[derive(Copy, Clone, Debug)] +pub struct IndicatorScenes<'a, E> { + pub scenes: &'a [IndicatorScene<'a, E>], +} + +impl IndicatorScenes<'_, E> { + fn scene_active(scene: &IndicatorScene<'_, E>, context: &Context) -> bool { + let indicators = context.lighting_context().indicators; + let actual = match scene.indicator { + Indicator::NumLock => indicators.num_lock, + Indicator::CapsLock => indicators.caps_lock, + Indicator::ScrollLock => indicators.scroll_lock, + Indicator::Compose => indicators.compose, + Indicator::Kana => indicators.kana, + }; + actual == scene.active + } + + fn cell_at(&self, context: &Context, mut wanted: usize) -> &SceneCell { + for scene in self.scenes.iter().filter(|scene| Self::scene_active(scene, context)) { + if wanted < scene.cells.len() { + return &scene.cells[wanted]; + } + wanted -= scene.cells.len(); + } + panic!("LightingSource index must be below len") + } +} + +impl LightingSource for IndicatorScenes<'_, E> +where + Context: LightingContextProvider, + E: LightingEffect, +{ + fn len(&self, input: &RenderInput<'_, Context>) -> usize { + self.scenes + .iter() + .filter(|scene| Self::scene_active(scene, input.context)) + .map(|scene| scene.cells.len()) + .sum() + } + + fn slot(&self, index: usize, input: &RenderInput<'_, Context>) -> LedSlot { + self.cell_at(input.context, index).slot + } + + fn contribution(&mut self, index: usize, input: &RenderInput<'_, Context>) -> Contribution { + Contribution::Opaque(self.cell_at(input.context, index).effect.sample(input.now_ms)) + } +} + +/// Adapter for a caller-owned dense effect buffer. +#[derive(Copy, Clone, Debug)] +pub struct DenseSource<'a, C> { + pub pixels: &'a [C], + pub next_change_ms: Option, +} + +impl LightingSource for DenseSource<'_, C> { + fn len(&self, _: &RenderInput<'_, Context>) -> usize { + self.pixels.len() + } + + fn slot(&self, index: usize, _: &RenderInput<'_, Context>) -> LedSlot { + LedSlot::from_index(index) + } + + fn contribution(&mut self, index: usize, _: &RenderInput<'_, Context>) -> Contribution { + Contribution::Opaque(EffectSample { + color: self.pixels[index], + next_change_ms: self.next_change_ms, + }) + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct OverlayUpdate { + pub slot: LedSlot, + pub effect: E, + pub expires_ms: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct OverlayEntry { + active: bool, + update: OverlayUpdate, +} + +impl OverlayEntry { + fn empty() -> Self { + Self { + active: false, + update: OverlayUpdate { + slot: LedSlot(0), + effect: E::default(), + expires_ms: None, + }, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum OverlayError { + Full, + TooManyEntries { supplied: usize, capacity: usize }, + DuplicateSlot { slot: LedSlot }, +} + +/// Fixed-capacity host/transient overlay. It is an ordinary source rather +/// than a privileged side channel, so priority and occlusion rules stay +/// uniform. +pub struct TtlOverlay { + entries: [OverlayEntry; CAP], +} + +impl TtlOverlay { + pub fn new() -> Self { + Self { + entries: [OverlayEntry::empty(); CAP], + } + } + + pub fn set(&mut self, now_ms: u64, update: OverlayUpdate) -> Result<(), OverlayError> { + self.prune_expired(now_ms); + if let Some(entry) = self + .entries + .iter_mut() + .find(|entry| entry.active && entry.update.slot == update.slot) + { + entry.update = update; + return Ok(()); + } + let Some(entry) = self.entries.iter_mut().find(|entry| !entry.active) else { + return Err(OverlayError::Full); + }; + *entry = OverlayEntry { active: true, update }; + Ok(()) + } + + pub fn unset(&mut self, slot: LedSlot) -> bool { + if let Some(entry) = self + .entries + .iter_mut() + .find(|entry| entry.active && entry.update.slot == slot) + { + entry.active = false; + true + } else { + false + } + } + + pub fn clear(&mut self) { + for entry in &mut self.entries { + entry.active = false; + } + } + + /// Atomically replace the overlay; validation happens before mutation. + pub fn replace(&mut self, now_ms: u64, updates: &[OverlayUpdate]) -> Result<(), OverlayError> { + if updates.len() > CAP { + return Err(OverlayError::TooManyEntries { + supplied: updates.len(), + capacity: CAP, + }); + } + for (index, update) in updates.iter().enumerate() { + if updates[..index].iter().any(|previous| previous.slot == update.slot) { + return Err(OverlayError::DuplicateSlot { slot: update.slot }); + } + } + self.clear(); + for (entry, update) in self.entries.iter_mut().zip(updates.iter().copied()) { + if !update.expires_ms.is_some_and(|expires| expires <= now_ms) { + *entry = OverlayEntry { active: true, update }; + } + } + Ok(()) + } + + pub fn prune_expired(&mut self, now_ms: u64) { + for entry in &mut self.entries { + if entry.active && entry.update.expires_ms.is_some_and(|expires| expires <= now_ms) { + entry.active = false; + } + } + } + + pub fn active_len(&self) -> usize { + self.entries.iter().filter(|entry| entry.active).count() + } + + fn active_entry(&self, wanted: usize) -> &OverlayEntry { + self.entries + .iter() + .filter(|entry| entry.active) + .nth(wanted) + .expect("LightingSource index must be below len") + } +} + +impl Default for TtlOverlay { + fn default() -> Self { + Self::new() + } +} + +impl LightingSource for TtlOverlay +where + E: Copy + Default + LightingEffect, +{ + fn len(&self, _: &RenderInput<'_, Context>) -> usize { + self.active_len() + } + + fn slot(&self, index: usize, _: &RenderInput<'_, Context>) -> LedSlot { + self.active_entry(index).update.slot + } + + fn contribution(&mut self, index: usize, input: &RenderInput<'_, Context>) -> Contribution { + let update = self.active_entry(index).update; + if update.expires_ms.is_some_and(|expires| expires <= input.now_ms) { + Contribution::Transparent { next_change_ms: None } + } else { + let mut sample = update.effect.sample(input.now_ms); + sample.next_change_ms = earliest(sample.next_change_ms, update.expires_ms); + Contribution::Opaque(sample) + } + } +} + +fn earliest(left: Option, right: Option) -> Option { + match (left, right) { + (Some(left), Some(right)) => Some(left.min(right)), + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + } +} + +#[cfg(test)] +mod tests { + use super::super::{BuiltinEffect, Compositor, LayerState, LogicalFrame, Rgb8}; + use super::*; + + const RED: Rgb8 = Rgb8::new(10, 0, 0); + const GREEN: Rgb8 = Rgb8::new(0, 10, 0); + const BLUE: Rgb8 = Rgb8::new(0, 0, 10); + + #[test] + fn layer_source_has_sparse_active_stack_fallthrough() { + let base = [SceneCell { + slot: LedSlot(0), + effect: BuiltinEffect::solid(RED), + }]; + let held = [SceneCell { + slot: LedSlot(1), + effect: BuiltinEffect::solid(GREEN), + }]; + let effective = [SceneCell { + slot: LedSlot(0), + effect: BuiltinEffect::solid(BLUE), + }]; + let scenes = [ + LayerScene { layer: 0, cells: &base }, + LayerScene { layer: 2, cells: &held }, + LayerScene { + layer: 3, + cells: &effective, + }, + ]; + let mut source = LayerScenes { + scenes: &scenes, + policy: LayerPolicy::ActiveStack, + }; + let context = LightingContext { + layers: LayerState::new(3, 0, 0b1101), + indicators: Default::default(), + }; + let compositor = Compositor::::new(Rgb8::BLACK); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + let mut tx = compositor.begin(0, &context, Rgb8::BLACK, &mut frame); + tx.apply(10, &mut source).unwrap(); + tx.finish(); + assert_eq!(frame.as_slice(), &[BLUE, GREEN]); + } + + #[test] + fn ttl_overlay_expires_exactly_and_replace_is_atomic() { + let mut overlay = TtlOverlay::::new(); + overlay + .set( + 0, + OverlayUpdate { + slot: LedSlot(0), + effect: BuiltinEffect::solid(RED), + expires_ms: Some(10), + }, + ) + .unwrap(); + overlay + .set( + 0, + OverlayUpdate { + slot: LedSlot(1), + effect: BuiltinEffect::solid(GREEN), + expires_ms: None, + }, + ) + .unwrap(); + let before = overlay.active_len(); + assert_eq!( + overlay.replace( + 0, + &[ + OverlayUpdate { + slot: LedSlot(0), + effect: BuiltinEffect::solid(BLUE), + expires_ms: None + }, + OverlayUpdate { + slot: LedSlot(0), + effect: BuiltinEffect::solid(GREEN), + expires_ms: None + }, + ] + ), + Err(OverlayError::DuplicateSlot { slot: LedSlot(0) }) + ); + assert_eq!(overlay.active_len(), before); + + let compositor = Compositor::::new(Rgb8::BLACK); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + let mut tx = compositor.begin(9, &(), Rgb8::BLACK, &mut frame); + tx.apply(0, &mut overlay).unwrap(); + assert_eq!(tx.finish().next_wake_ms, Some(10)); + let mut tx = compositor.begin(10, &(), Rgb8::BLACK, &mut frame); + tx.apply(0, &mut overlay).unwrap(); + tx.finish(); + assert_eq!(frame.as_slice(), &[Rgb8::BLACK, GREEN]); + } + + #[test] + fn indicator_scenes_use_extended_context_provider() { + #[derive(Default)] + struct Extended { + lighting: LightingContext, + _battery_percent: u8, + } + impl LightingContextProvider for Extended { + fn lighting_context(&self) -> &LightingContext { + &self.lighting + } + } + let cells = [SceneCell { + slot: LedSlot(0), + effect: BuiltinEffect::solid(RED), + }]; + let scenes = [IndicatorScene { + indicator: Indicator::CapsLock, + active: true, + cells: &cells, + }]; + let mut source = IndicatorScenes { scenes: &scenes }; + let mut context = Extended::default(); + context.lighting.indicators.caps_lock = true; + let compositor = Compositor::::new(Rgb8::BLACK); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + let mut tx = compositor.begin(0, &context, Rgb8::BLACK, &mut frame); + tx.apply(0, &mut source).unwrap(); + tx.finish(); + assert_eq!(frame.as_slice(), &[RED]); + } +} diff --git a/rmk/src/lighting/standard.rs b/rmk/src/lighting/standard.rs new file mode 100644 index 000000000..1e11a5552 --- /dev/null +++ b/rmk/src/lighting/standard.rs @@ -0,0 +1,975 @@ +//! Ready-to-use compositor engine for ordinary RMK lighting. +//! +//! Boards provide static layer scenes plus optional extension and status +//! sources. The engine supplies a controllable uniform background, TTL host +//! overlay, deterministic priority bands, output brightness, frame history, +//! `LightAction` handling, and protocol-independent commands/readback. + +use core::num::NonZeroU32; + +use rmk_types::action::LightAction; + +use super::Rgb8; +use super::compositor::{ + Compositor, Contribution, LightingSource, LogicalFrame, RenderError, RenderInput as SourceRenderInput, +}; +use super::context::LightingContextProvider; +use super::effect::{BuiltinEffect, LightingEffect}; +use super::output::BrightnessTransform; +use super::service::{CommandResult, Invalidation, LightingEngine, RenderInput, RenderOutcome}; +use super::source::{LayerScenes, OverlayError, OverlayUpdate, TtlOverlay}; +use super::topology::LedSlot; + +/// Stable default priority bands. Equal-priority call order remains stable. +pub mod priority { + pub const BACKGROUND: u8 = 0; + pub const EXTENSION: u8 = 32; + pub const LAYER: u8 = 64; + pub const HOST_OVERLAY: u8 = 128; + pub const STATUS: u8 = 192; +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum BackgroundMode { + Solid, + Breathe, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct BackgroundState { + pub enabled: bool, + pub hue: u8, + pub saturation: u8, + pub value: u8, + pub speed: u8, + pub mode: BackgroundMode, +} + +impl Default for BackgroundState { + fn default() -> Self { + Self { + enabled: true, + hue: 0, + saturation: 0, + value: 32, + speed: 128, + mode: BackgroundMode::Solid, + } + } +} + +/// Atomic partial update of the designated background. +/// +/// Protocol adapters use this instead of a `ReadState` followed by +/// `SetBackground`: the lighting engine remains the sole mutable owner and +/// concurrent callers cannot overwrite fields changed between two mailbox +/// requests. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct BackgroundPatch { + pub enabled: Option, + pub hue: Option, + pub saturation: Option, + pub value: Option, + pub speed: Option, + pub mode: Option, +} + +impl BackgroundPatch { + pub const fn apply_to(self, state: &mut BackgroundState) { + if let Some(enabled) = self.enabled { + state.enabled = enabled; + } + if let Some(hue) = self.hue { + state.hue = hue; + } + if let Some(saturation) = self.saturation { + state.saturation = saturation; + } + if let Some(value) = self.value { + state.value = value; + } + if let Some(speed) = self.speed { + state.speed = speed; + } + if let Some(mode) = self.mode { + state.mode = mode; + } + } +} + +/// Built-in designated background controlled by RGB/Vial-compatible fields. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct UniformBackground { + state: BackgroundState, +} + +impl UniformBackground { + pub const fn new(state: BackgroundState) -> Self { + Self { state } + } + + pub const fn state(&self) -> BackgroundState { + self.state + } + + pub fn set_state(&mut self, state: BackgroundState) { + self.state = state; + } + + fn effect(&self) -> BuiltinEffect { + let color = if self.state.enabled { + hsv(self.state.hue, self.state.saturation, self.state.value) + } else { + Rgb8::BLACK + }; + match self.state.mode { + BackgroundMode::Solid => BuiltinEffect::Solid { color }, + BackgroundMode::Breathe => BuiltinEffect::Breathe { + color, + period_ms: 250 + ((u8::MAX - self.state.speed) as u32 * 3_750 / 255), + phase_ms: 0, + step_ms: 16, + }, + } + } +} + +impl LightingSource for UniformBackground { + fn len(&self, _: &SourceRenderInput<'_, Context>) -> usize { + N + } + + fn slot(&self, index: usize, _: &SourceRenderInput<'_, Context>) -> LedSlot { + LedSlot::from_index(index) + } + + fn contribution(&mut self, _: usize, input: &SourceRenderInput<'_, Context>) -> Contribution { + Contribution::Opaque(self.effect().sample(input.now_ms)) + } +} + +/// Zero-sized source used when a board does not need an extension band. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct EmptySource; + +impl LightingSource for EmptySource { + fn len(&self, _: &SourceRenderInput<'_, Context>) -> usize { + 0 + } + + fn slot(&self, _: usize, _: &SourceRenderInput<'_, Context>) -> LedSlot { + unreachable!("EmptySource has no targets") + } + + fn contribution(&mut self, _: usize, _: &SourceRenderInput<'_, Context>) -> Contribution { + unreachable!("EmptySource has no samples") + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct OverlayCell { + pub slot: LedSlot, + pub effect: BuiltinEffect, + /// Relative lifetime from command application. `None` persists until an + /// explicit unset/clear or reboot. + pub ttl_ms: Option, +} + +const EMPTY_OVERLAY_CELL: OverlayCell = OverlayCell { + slot: LedSlot(0), + effect: BuiltinEffect::Solid { color: Rgb8::BLACK }, + ttl_ms: None, +}; + +/// Fixed-capacity, owned batch suitable for a bounded async mailbox. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct OverlayBatch { + cells: [OverlayCell; CAP], + len: usize, +} + +impl OverlayBatch { + pub const fn new() -> Self { + Self { + cells: [EMPTY_OVERLAY_CELL; CAP], + len: 0, + } + } + + pub fn push(&mut self, cell: OverlayCell) -> Result<(), OverlayError> { + if self.len == CAP { + return Err(OverlayError::TooManyEntries { + supplied: self.len + 1, + capacity: CAP, + }); + } + self.cells[self.len] = cell; + self.len += 1; + Ok(()) + } + + pub fn as_slice(&self) -> &[OverlayCell] { + &self.cells[..self.len] + } +} + +impl Default for OverlayBatch { + fn default() -> Self { + Self::new() + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum StandardCommand { + SetOutputEnabled(bool), + SetOutputBrightness(u8), + SetBackground(BackgroundState), + PatchBackground(BackgroundPatch), + SetStateIfRevision { + expected_revision: u32, + state: StandardMutableState, + }, + SetOverlay(OverlayCell), + SetOverlayIfRevision { + expected_revision: u32, + cell: OverlayCell, + }, + UnsetOverlay(LedSlot), + UnsetOverlayIfRevision { + expected_revision: u32, + slot: LedSlot, + }, + ClearOverlay, + ClearOverlayIfRevision { + expected_revision: u32, + }, + ReplaceOverlay(OverlayBatch), + ReplaceOverlayIfRevision { + expected_revision: u32, + batch: OverlayBatch, + }, + ReadState, +} + +/// Mutable standard state excluding the transient overlay contents and the +/// engine-owned optimistic-concurrency revision. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct StandardMutableState { + pub output_enabled: bool, + pub output_brightness: u8, + pub background: BackgroundState, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct StandardState { + pub revision: u32, + pub output_enabled: bool, + pub output_brightness: u8, + pub background: BackgroundState, + pub overlay_len: usize, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum StandardError { + Render(RenderError), + Overlay(OverlayError), + DeadlineOverflow, + RevisionConflict { expected: u32, current: u32 }, +} + +impl From for StandardError { + fn from(value: RenderError) -> Self { + Self::Render(value) + } +} + +impl From for StandardError { + fn from(value: OverlayError) -> Self { + Self::Overlay(value) + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct StandardInput(pub LightAction); + +impl From for StandardInput { + fn from(value: LightAction) -> Self { + Self(value) + } +} + +/// End-to-end standard engine. `Extension` composes above the designated +/// background and below layers; `Status` composes last. Either can be an +/// external stateful source, or [`EmptySource`]. +pub struct StandardLightingEngine<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize> { + compositor: Compositor, + background: UniformBackground, + extension: Extension, + layers: LayerScenes<'scenes, BuiltinEffect>, + overlay: TtlOverlay, + status: Status, + revision: u32, + output_enabled: bool, + output_brightness: u8, +} + +impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize> + StandardLightingEngine<'scenes, Extension, Status, N, OVERLAY_CAP> +{ + pub fn new( + background: BackgroundState, + layers: LayerScenes<'scenes, BuiltinEffect>, + extension: Extension, + status: Status, + ) -> Self { + Self { + compositor: Compositor::new(Rgb8::BLACK), + background: UniformBackground::new(background), + extension, + layers, + overlay: TtlOverlay::new(), + status, + revision: 0, + output_enabled: true, + output_brightness: u8::MAX, + } + } + + pub fn state(&self) -> StandardState { + StandardState { + revision: self.revision, + output_enabled: self.output_enabled, + output_brightness: self.output_brightness, + background: self.background.state(), + overlay_len: self.overlay.active_len(), + } + } + + pub const fn extension(&self) -> &Extension { + &self.extension + } + + pub fn extension_mut(&mut self) -> &mut Extension { + &mut self.extension + } + + pub const fn status(&self) -> &Status { + &self.status + } + + pub fn status_mut(&mut self) -> &mut Status { + &mut self.status + } + + fn apply_light_action(&mut self, action: LightAction) -> bool { + const STEP: u8 = 17; + let background = &mut self.background.state; + match action { + LightAction::BacklightOn => self.output_enabled = true, + LightAction::BacklightOff => self.output_enabled = false, + LightAction::BacklightToggle => self.output_enabled = !self.output_enabled, + LightAction::BacklightDown => self.output_brightness = self.output_brightness.saturating_sub(STEP), + LightAction::BacklightUp => self.output_brightness = self.output_brightness.saturating_add(STEP), + LightAction::BacklightStep => self.output_brightness = self.output_brightness.wrapping_add(STEP), + LightAction::BacklightToggleBreathing => { + background.mode = match background.mode { + BackgroundMode::Solid => BackgroundMode::Breathe, + BackgroundMode::Breathe => BackgroundMode::Solid, + } + } + LightAction::RgbTog => background.enabled = !background.enabled, + LightAction::RgbModeForward | LightAction::RgbModeReverse => { + background.mode = match background.mode { + BackgroundMode::Solid => BackgroundMode::Breathe, + BackgroundMode::Breathe => BackgroundMode::Solid, + } + } + LightAction::RgbHui => background.hue = background.hue.wrapping_add(STEP), + LightAction::RgbHud => background.hue = background.hue.wrapping_sub(STEP), + LightAction::RgbSai => background.saturation = background.saturation.saturating_add(STEP), + LightAction::RgbSad => background.saturation = background.saturation.saturating_sub(STEP), + LightAction::RgbVai => background.value = background.value.saturating_add(STEP), + LightAction::RgbVad => background.value = background.value.saturating_sub(STEP), + LightAction::RgbSpi => background.speed = background.speed.saturating_add(STEP), + LightAction::RgbSpd => background.speed = background.speed.saturating_sub(STEP), + LightAction::RgbModePlain => background.mode = BackgroundMode::Solid, + LightAction::RgbModeBreathe => background.mode = BackgroundMode::Breathe, + // The standard engine advertises only Solid and Breathe. Other + // named modes remain available to an extension source/command. + _ => return false, + } + true + } + + fn check_revision(&self, expected: u32) -> Result<(), StandardError> { + if expected == self.revision { + Ok(()) + } else { + Err(StandardError::RevisionConflict { + expected, + current: self.revision, + }) + } + } + + fn advance_revision(&mut self) { + self.revision = self.revision.wrapping_add(1); + } + + fn set_mutable_state(&mut self, state: StandardMutableState) { + self.output_enabled = state.output_enabled; + self.output_brightness = state.output_brightness; + self.background.set_state(state.background); + } + + fn expires_at(now_ms: u64, ttl_ms: Option) -> Result, StandardError> { + ttl_ms + .map(|ttl| { + now_ms + .checked_add(ttl.get() as u64) + .ok_or(StandardError::DeadlineOverflow) + }) + .transpose() + } + + fn overlay_update(now_ms: u64, cell: OverlayCell) -> Result, StandardError> { + Ok(OverlayUpdate { + slot: cell.slot, + effect: cell.effect, + expires_ms: Self::expires_at(now_ms, cell.ttl_ms)?, + }) + } +} + +impl<'scenes, Context, Extension, Status, const N: usize, const OVERLAY_CAP: usize> LightingEngine + for StandardLightingEngine<'scenes, Extension, Status, N, OVERLAY_CAP> +where + Context: LightingContextProvider, + Extension: LightingSource, + Status: LightingSource, +{ + type Frame = LogicalFrame; + type Input = StandardInput; + type Command = StandardCommand; + type Reply = StandardState; + type Error = StandardError; + + fn on_input(&mut self, input: Self::Input, _snapshot: &Context) -> Result { + if self.apply_light_action(input.0) { + self.advance_revision(); + Ok(Invalidation::Render) + } else { + Ok(Invalidation::None) + } + } + + fn handle_command( + &mut self, + now_ms: u64, + command: Self::Command, + _snapshot: &Context, + ) -> Result, Self::Error> { + let (mut invalidation, advances_revision) = match command { + StandardCommand::SetOutputEnabled(enabled) => { + self.output_enabled = enabled; + (Invalidation::Render, true) + } + StandardCommand::SetOutputBrightness(level) => { + self.output_brightness = level; + (Invalidation::Render, true) + } + StandardCommand::SetBackground(state) => { + self.background.set_state(state); + (Invalidation::Render, true) + } + StandardCommand::PatchBackground(patch) => { + patch.apply_to(&mut self.background.state); + (Invalidation::Render, true) + } + StandardCommand::SetStateIfRevision { + expected_revision, + state, + } => { + self.check_revision(expected_revision)?; + self.set_mutable_state(state); + (Invalidation::Render, true) + } + StandardCommand::SetOverlay(cell) => { + let update = Self::overlay_update(now_ms, cell)?; + self.overlay.set(now_ms, update)?; + (Invalidation::Render, true) + } + StandardCommand::SetOverlayIfRevision { + expected_revision, + cell, + } => { + self.check_revision(expected_revision)?; + let update = Self::overlay_update(now_ms, cell)?; + self.overlay.set(now_ms, update)?; + (Invalidation::Render, true) + } + StandardCommand::UnsetOverlay(slot) => { + let changed = self.overlay.unset(slot); + ( + if changed { + Invalidation::Render + } else { + Invalidation::None + }, + true, + ) + } + StandardCommand::UnsetOverlayIfRevision { + expected_revision, + slot, + } => { + self.check_revision(expected_revision)?; + let changed = self.overlay.unset(slot); + ( + if changed { + Invalidation::Render + } else { + Invalidation::None + }, + true, + ) + } + StandardCommand::ClearOverlay => { + let changed = self.overlay.active_len() != 0; + self.overlay.clear(); + ( + if changed { + Invalidation::Render + } else { + Invalidation::None + }, + true, + ) + } + StandardCommand::ClearOverlayIfRevision { expected_revision } => { + self.check_revision(expected_revision)?; + let changed = self.overlay.active_len() != 0; + self.overlay.clear(); + ( + if changed { + Invalidation::Render + } else { + Invalidation::None + }, + true, + ) + } + StandardCommand::ReplaceOverlay(batch) => { + let mut updates = [OverlayUpdate { + slot: LedSlot(0), + effect: BuiltinEffect::Solid { color: Rgb8::BLACK }, + expires_ms: None, + }; OVERLAY_CAP]; + for (target, cell) in updates.iter_mut().zip(batch.as_slice().iter().copied()) { + *target = Self::overlay_update(now_ms, cell)?; + } + self.overlay.replace(now_ms, &updates[..batch.as_slice().len()])?; + (Invalidation::Render, true) + } + StandardCommand::ReplaceOverlayIfRevision { + expected_revision, + batch, + } => { + self.check_revision(expected_revision)?; + let mut updates = [OverlayUpdate { + slot: LedSlot(0), + effect: BuiltinEffect::Solid { color: Rgb8::BLACK }, + expires_ms: None, + }; OVERLAY_CAP]; + for (target, cell) in updates.iter_mut().zip(batch.as_slice().iter().copied()) { + *target = Self::overlay_update(now_ms, cell)?; + } + self.overlay.replace(now_ms, &updates[..batch.as_slice().len()])?; + (Invalidation::Render, true) + } + StandardCommand::ReadState => (Invalidation::None, false), + }; + if advances_revision { + self.advance_revision(); + if invalidation == Invalidation::None { + invalidation = Invalidation::StateChanged; + } + } + Ok(CommandResult::new(self.state(), invalidation)) + } + + fn render( + &mut self, + input: RenderInput<'_, Context>, + frame: &mut Self::Frame, + ) -> Result { + let overlay_len = self.overlay.active_len(); + self.overlay.prune_expired(input.now_ms); + let state_changed = self.overlay.active_len() != overlay_len; + if state_changed { + self.advance_revision(); + } + let Self { + compositor, + background, + extension, + layers, + overlay, + status, + output_enabled, + output_brightness, + revision: _, + } = self; + let mut transaction = compositor.begin(input.now_ms, input.snapshot, Rgb8::BLACK, frame); + transaction.apply(priority::BACKGROUND, background)?; + transaction.apply(priority::EXTENSION, extension)?; + transaction.apply(priority::LAYER, layers)?; + transaction.apply(priority::HOST_OVERLAY, overlay)?; + transaction.apply(priority::STATUS, status)?; + let mut transform = BrightnessTransform::new(if *output_enabled { *output_brightness } else { 0 }); + let result = transaction.finish_with(&mut transform); + let next_wake_in_ms = result.next_wake_ms.map(|deadline| { + let delay = deadline.saturating_sub(input.now_ms).clamp(1, u32::MAX as u64); + NonZeroU32::new(delay as u32).expect("clamped delay is nonzero") + }); + Ok(RenderOutcome { + changed: result.changed, + state_changed, + next_wake_in_ms, + }) + } + + fn on_presented(&mut self, frame: &Self::Frame) { + self.compositor.commit(frame); + } +} + +fn hsv(hue: u8, saturation: u8, value: u8) -> Rgb8 { + if saturation == 0 { + return Rgb8::new(value, value, value); + } + let region = hue / 43; + let remainder = (hue - region * 43) as u16 * 6; + let p = (value as u16 * (255 - saturation as u16) / 255) as u8; + let q = (value as u16 * (255 - (saturation as u16 * remainder / 255)) / 255) as u8; + let t = (value as u16 * (255 - (saturation as u16 * (255 - remainder) / 255)) / 255) as u8; + match region { + 0 => Rgb8::new(value, t, p), + 1 => Rgb8::new(q, value, p), + 2 => Rgb8::new(p, value, t), + 3 => Rgb8::new(p, q, value), + 4 => Rgb8::new(t, p, value), + _ => Rgb8::new(value, p, q), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lighting::source::SparseScene; + use crate::lighting::{LayerPolicy, LayerScene, LayerState, LightingContext, SceneCell}; + + const RED: Rgb8 = Rgb8::new(200, 0, 0); + const GREEN: Rgb8 = Rgb8::new(0, 200, 0); + + type Engine = StandardLightingEngine<'static, EmptySource, EmptySource, 2, 2>; + + static LAYER_CELLS: [SceneCell; 1] = [SceneCell { + slot: LedSlot(0), + effect: BuiltinEffect::Solid { color: RED }, + }]; + static LAYERS: [LayerScene<'static, BuiltinEffect>; 1] = [LayerScene { + layer: 1, + cells: &LAYER_CELLS, + }]; + static STATUS_CELLS: [SceneCell; 1] = [SceneCell { + slot: LedSlot(1), + effect: BuiltinEffect::Solid { color: GREEN }, + }]; + + fn engine() -> Engine { + StandardLightingEngine::new( + BackgroundState { + value: 10, + ..BackgroundState::default() + }, + LayerScenes { + scenes: &LAYERS, + policy: LayerPolicy::ActiveStack, + }, + EmptySource, + EmptySource, + ) + } + + fn context(layer: u8) -> LightingContext { + LightingContext { + layers: LayerState::new(layer, 0, 1 | (1 << layer)), + indicators: Default::default(), + } + } + + #[test] + fn background_patch_preserves_unmentioned_fields() { + let mut engine = engine(); + let snapshot = context(0); + let before = engine.state(); + let reply = engine + .handle_command( + 0, + StandardCommand::PatchBackground(BackgroundPatch { + value: Some(77), + ..BackgroundPatch::default() + }), + &snapshot, + ) + .unwrap() + .reply; + + assert_eq!(reply.background.value, 77); + assert_eq!(reply.background.enabled, before.background.enabled); + assert_eq!(reply.background.hue, before.background.hue); + assert_eq!(reply.background.saturation, before.background.saturation); + assert_eq!(reply.background.speed, before.background.speed); + assert_eq!(reply.background.mode, before.background.mode); + assert_eq!(reply.output_enabled, before.output_enabled); + assert_eq!(reply.output_brightness, before.output_brightness); + assert_eq!(reply.revision, before.revision + 1); + } + + #[test] + fn revision_checks_are_atomic_and_cover_inputs_and_expiry() { + let mut engine = engine(); + let snapshot = context(0); + assert_eq!(engine.state().revision, 0); + + engine + .handle_command( + 10, + StandardCommand::PatchBackground(BackgroundPatch { + value: Some(77), + ..BackgroundPatch::default() + }), + &snapshot, + ) + .unwrap(); + assert_eq!(engine.state().revision, 1); + + let desired = StandardMutableState { + output_enabled: false, + output_brightness: 12, + background: BackgroundState { + value: 99, + ..BackgroundState::default() + }, + }; + assert_eq!( + engine.handle_command( + 10, + StandardCommand::SetStateIfRevision { + expected_revision: 0, + state: desired, + }, + &snapshot, + ), + Err(StandardError::RevisionConflict { + expected: 0, + current: 1, + }) + ); + assert!(engine.state().output_enabled, "a stale update is all-or-nothing"); + + let reply = engine + .handle_command( + 10, + StandardCommand::SetStateIfRevision { + expected_revision: 1, + state: desired, + }, + &snapshot, + ) + .unwrap() + .reply; + assert_eq!(reply.revision, 2); + assert!(!reply.output_enabled); + + engine + .on_input(StandardInput(LightAction::BacklightOn), &snapshot) + .unwrap(); + assert_eq!(engine.state().revision, 3); + assert_eq!( + engine.on_input(StandardInput(LightAction::RgbModeRainbow), &snapshot), + Ok(Invalidation::None) + ); + assert_eq!(engine.state().revision, 3); + + engine + .handle_command( + 10, + StandardCommand::SetOverlayIfRevision { + expected_revision: 3, + cell: OverlayCell { + slot: LedSlot(0), + effect: BuiltinEffect::Solid { color: GREEN }, + ttl_ms: NonZeroU32::new(1), + }, + }, + &snapshot, + ) + .unwrap(); + assert_eq!(engine.state().revision, 4); + + let mut frame = LogicalFrame::new(Rgb8::BLACK); + let first_expiry = engine + .render( + RenderInput { + now_ms: 11, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + assert!(first_expiry.state_changed); + assert_eq!(engine.state().revision, 5, "TTL expiry is authoritative state"); + let second_expiry = engine + .render( + RenderInput { + now_ms: 12, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + assert!(!second_expiry.state_changed); + assert_eq!(engine.state().revision, 5, "expiry advances exactly once"); + } + + #[test] + fn disabling_background_does_not_disable_layer_or_status_sources() { + type StatusEngine = StandardLightingEngine<'static, EmptySource, SparseScene<'static, BuiltinEffect>, 2, 2>; + let mut engine = StatusEngine::new( + BackgroundState { + value: 10, + ..BackgroundState::default() + }, + LayerScenes { + scenes: &LAYERS, + policy: LayerPolicy::ActiveStack, + }, + EmptySource, + SparseScene { cells: &STATUS_CELLS }, + ); + let snapshot = context(1); + let before = engine.state(); + engine + .handle_command( + 0, + StandardCommand::PatchBackground(BackgroundPatch { + enabled: Some(false), + ..BackgroundPatch::default() + }), + &snapshot, + ) + .unwrap(); + + assert!(engine.state().output_enabled); + assert_eq!(engine.state().output_brightness, before.output_brightness); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + engine + .render( + RenderInput { + now_ms: 0, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + assert_eq!(frame.as_slice(), &[RED, GREEN]); + } + + #[test] + fn standard_engine_composes_background_layer_and_expiring_overlay() { + let mut engine = engine(); + let snapshot = context(1); + engine + .handle_command( + 100, + StandardCommand::SetOverlay(OverlayCell { + slot: LedSlot(1), + effect: BuiltinEffect::Solid { color: GREEN }, + ttl_ms: NonZeroU32::new(10), + }), + &snapshot, + ) + .unwrap(); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + let outcome = engine + .render( + RenderInput { + now_ms: 100, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + assert_eq!(frame.as_slice(), &[RED, GREEN]); + assert_eq!(outcome.next_wake_in_ms, NonZeroU32::new(10)); + >::on_presented(&mut engine, &frame); + + engine + .render( + RenderInput { + now_ms: 110, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + assert_eq!(frame.as_slice(), &[RED, Rgb8::new(10, 10, 10)]); + assert_eq!(engine.state().overlay_len, 0); + } + + #[test] + fn expired_overlay_capacity_is_reclaimed_and_light_actions_are_scoped() { + let mut engine = engine(); + let snapshot = context(0); + for slot in [LedSlot(0), LedSlot(1)] { + engine + .handle_command( + 0, + StandardCommand::SetOverlay(OverlayCell { + slot, + effect: BuiltinEffect::Solid { color: GREEN }, + ttl_ms: NonZeroU32::new(1), + }), + &snapshot, + ) + .unwrap(); + } + assert!( + engine + .handle_command( + 1, + StandardCommand::SetOverlay(OverlayCell { + slot: LedSlot(0), + effect: BuiltinEffect::Solid { color: RED }, + ttl_ms: None, + }), + &snapshot, + ) + .is_ok() + ); + + let before = engine.state(); + assert_eq!( + engine.on_input(StandardInput(LightAction::RgbModeRainbow), &snapshot), + Ok(Invalidation::None) + ); + assert_eq!(engine.state(), before); + assert_eq!( + engine.on_input(StandardInput(LightAction::BacklightOff), &snapshot), + Ok(Invalidation::Render) + ); + assert!(!engine.state().output_enabled); + } +} diff --git a/rmk/src/lighting/topology.rs b/rmk/src/lighting/topology.rs new file mode 100644 index 000000000..35b8be357 --- /dev/null +++ b/rmk/src/lighting/topology.rs @@ -0,0 +1,668 @@ +//! Allocation-free descriptions of logical lights and physical routing. + +use core::ops::{BitOr, BitOrAssign}; + +pub use crate::physical_layout::{Coordinate as Coord, KeyPosition as MatrixPosition, PhysicalLayout, Point3}; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct LedId(pub u16); + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct LedSlot(pub u16); + +impl LedSlot { + pub const fn from_index(index: usize) -> Self { + assert!(index <= u16::MAX as usize, "LED slot exceeds u16 capacity"); + Self(index as u16) + } + + pub const fn index(self) -> usize { + self.0 as usize + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct ZoneId(pub u8); + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct LightingNodeId(pub u8); + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct OutputId(pub u8); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MatrixSize { + pub rows: u8, + pub cols: u8, +} + +impl MatrixSize { + pub const fn new(rows: u8, cols: u8) -> Self { + Self { rows, cols } + } + + pub const fn contains(self, position: MatrixPosition) -> bool { + position.row < self.rows && position.col < self.cols + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ZoneSpan { + pub start: u16, + pub len: u8, +} + +impl ZoneSpan { + pub const EMPTY: Self = Self { start: 0, len: 0 }; + + pub const fn new(start: u16, len: u8) -> Self { + Self { start, len } + } + + fn range(self, total: usize) -> Option> { + let start = self.start as usize; + let end = start.checked_add(self.len as usize)?; + (end <= total).then_some(start..end) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ZoneMetadata<'a> { + pub id: ZoneId, + pub name: &'a str, +} + +/// A semantic emitter. `key` records a real logical relationship; an explicit +/// position overrides the associated key center for spatial effects. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LedMetadata { + pub id: LedId, + pub key: Option, + pub position: Option, + pub zones: ZoneSpan, +} + +#[derive(Clone, Copy, Debug)] +pub struct LightingTopology<'a> { + pub matrix: MatrixSize, + pub keys: &'a [MatrixPosition], + pub physical_layout: PhysicalLayout<'a>, + pub leds: &'a [LedMetadata], + pub zones: &'a [ZoneMetadata<'a>], + pub zone_memberships: &'a [ZoneId], +} + +impl<'a> LightingTopology<'a> { + pub const fn len(&self) -> usize { + self.leds.len() + } + + pub const fn is_empty(&self) -> bool { + self.leds.is_empty() + } + + pub fn slot(&self, id: LedId) -> Option { + self.leds + .iter() + .position(|led| led.id == id) + .and_then(|index| u16::try_from(index).ok()) + .map(LedSlot) + } + + pub fn led(&self, slot: LedSlot) -> Option<&'a LedMetadata> { + self.leds.get(slot.index()) + } + + pub fn led_by_id(&self, id: LedId) -> Option<(LedSlot, &'a LedMetadata)> { + let slot = self.slot(id)?; + Some((slot, self.led(slot)?)) + } + + pub fn has_key(&self, matrix: MatrixPosition) -> bool { + self.keys.contains(&matrix) + } + + pub fn effective_position(&self, slot: LedSlot) -> Option { + let led = self.led(slot)?; + led.position.or_else(|| { + led.key + .and_then(|key| self.physical_layout.key(key)) + .map(|key| key.center) + }) + } + + pub fn zones_for(&self, slot: LedSlot) -> Option<&'a [ZoneId]> { + let range = self.led(slot)?.zones.range(self.zone_memberships.len())?; + Some(&self.zone_memberships[range]) + } + + pub fn has_zone(&self, slot: LedSlot, zone: ZoneId) -> bool { + self.zones_for(slot).is_some_and(|zones| zones.contains(&zone)) + } + + pub fn leds_for_key(&'a self, key: MatrixPosition) -> impl Iterator + 'a { + self.leds.iter().enumerate().filter_map(move |(index, led)| { + if led.key != Some(key) { + return None; + } + Some((LedSlot(u16::try_from(index).ok()?), led)) + }) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[repr(transparent)] +pub struct OutputCapabilities(u8); + +impl OutputCapabilities { + pub const NONE: Self = Self(0); + pub const BINARY: Self = Self(1 << 0); + pub const INTENSITY: Self = Self(1 << 1); + pub const RGB: Self = Self(1 << 2); + pub const WHITE: Self = Self(1 << 3); + pub const ADDRESSABLE: Self = Self(1 << 4); + pub const RGBW: Self = Self(Self::RGB.0 | Self::WHITE.0); + const KNOWN_BITS: u8 = Self::BINARY.0 | Self::INTENSITY.0 | Self::RGB.0 | Self::WHITE.0 | Self::ADDRESSABLE.0; + const COLOR_BITS: u8 = Self::BINARY.0 | Self::INTENSITY.0 | Self::RGB.0 | Self::WHITE.0; + + pub const fn from_bits(bits: u8) -> Option { + if bits & !Self::KNOWN_BITS == 0 { + Some(Self(bits)) + } else { + None + } + } + + pub const fn bits(self) -> u8 { + self.0 + } + + pub const fn contains(self, required: Self) -> bool { + self.0 & required.0 == required.0 + } + + pub const fn intersects(self, other: Self) -> bool { + self.0 & other.0 != 0 + } + + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + pub const fn has_color_capability(self) -> bool { + self.0 & Self::COLOR_BITS != 0 + } +} + +impl BitOr for OutputCapabilities { + type Output = Self; + + fn bitor(self, rhs: Self) -> Self::Output { + self.union(rhs) + } +} + +impl BitOrAssign for OutputCapabilities { + fn bitor_assign(&mut self, rhs: Self) { + *self = self.union(rhs); + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OutputCoverage { + Complete, + Sparse, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OutputMetadata { + pub node: LightingNodeId, + pub id: OutputId, + pub pixel_count: u16, + pub capabilities: OutputCapabilities, + pub coverage: OutputCoverage, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PhysicalRoute { + pub slot: LedSlot, + pub node: LightingNodeId, + pub output: OutputId, + pub physical_index: u16, +} + +#[derive(Clone, Copy, Debug)] +pub struct LightingRouting<'a> { + pub outputs: &'a [OutputMetadata], + pub routes: &'a [PhysicalRoute], +} + +impl<'a> LightingRouting<'a> { + pub fn output(&self, node: LightingNodeId, id: OutputId) -> Option<&'a OutputMetadata> { + self.outputs + .iter() + .find(|output| output.node == node && output.id == id) + } + + pub fn route(&self, slot: LedSlot) -> Option<&'a PhysicalRoute> { + self.routes.iter().find(|route| route.slot == slot) + } + + pub fn capabilities_for(&self, slot: LedSlot) -> Option { + let route = self.route(slot)?; + Some(self.output(route.node, route.output)?.capabilities) + } +} + +/// A precise topology or routing validation failure. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ValidationError { + TooManyLeds { + count: usize, + }, + TooManyZoneMemberships { + count: usize, + }, + InvalidMatrixSize { + size: MatrixSize, + }, + KeyOutOfBounds { + key_index: usize, + matrix: MatrixPosition, + }, + DuplicateKey { + first_index: usize, + second_index: usize, + matrix: MatrixPosition, + }, + UnknownPhysicalKey { + key_index: usize, + matrix: MatrixPosition, + }, + DuplicatePhysicalKey { + first_index: usize, + second_index: usize, + matrix: MatrixPosition, + }, + DuplicateLedId { + first_slot: LedSlot, + second_slot: LedSlot, + id: LedId, + }, + UnknownKey { + slot: LedSlot, + matrix: MatrixPosition, + }, + InvalidZoneSpan { + slot: LedSlot, + span: ZoneSpan, + }, + EmptyZoneName { + zone_index: usize, + id: ZoneId, + }, + DuplicateZoneId { + first_index: usize, + second_index: usize, + id: ZoneId, + }, + DuplicateZoneName { + first_index: usize, + second_index: usize, + }, + UnknownZone { + slot: LedSlot, + membership_index: usize, + id: ZoneId, + }, + DuplicateLedZone { + slot: LedSlot, + id: ZoneId, + }, + DuplicateOutputId { + first_index: usize, + second_index: usize, + node: LightingNodeId, + output: OutputId, + }, + EmptyOutput { + output_index: usize, + }, + OutputHasNoColorCapability { + output_index: usize, + }, + RouteSlotOutOfBounds { + route_index: usize, + slot: LedSlot, + }, + DuplicateRouteForSlot { + first_index: usize, + second_index: usize, + slot: LedSlot, + }, + MissingRouteForSlot { + slot: LedSlot, + }, + UnknownOutput { + route_index: usize, + node: LightingNodeId, + output: OutputId, + }, + PhysicalIndexOutOfBounds { + route_index: usize, + physical_index: u16, + pixel_count: u16, + }, + DuplicatePhysicalAddress { + first_index: usize, + second_index: usize, + }, + MissingPhysicalRoute { + output_index: usize, + physical_index: u16, + }, +} + +/// Validate semantic identity, key/geometry references, zones, split/output +/// ownership, and the logical-to-physical routing bijection. +/// +/// This function allocates nothing. Its quadratic checks are deliberate: the +/// topology is small static board data and validation normally runs at build +/// time or once during initialization. +pub fn validate(topology: &LightingTopology<'_>, routing: &LightingRouting<'_>) -> Result<(), ValidationError> { + if topology.leds.len() > u16::MAX as usize + 1 { + return Err(ValidationError::TooManyLeds { + count: topology.leds.len(), + }); + } + if topology.zone_memberships.len() > u16::MAX as usize + 1 { + return Err(ValidationError::TooManyZoneMemberships { + count: topology.zone_memberships.len(), + }); + } + if (topology.matrix.rows == 0) != (topology.matrix.cols == 0) { + return Err(ValidationError::InvalidMatrixSize { size: topology.matrix }); + } + + for (index, key) in topology.keys.iter().copied().enumerate() { + if !topology.matrix.contains(key) { + return Err(ValidationError::KeyOutOfBounds { + key_index: index, + matrix: key, + }); + } + if let Some((first_index, _)) = topology.keys[..index] + .iter() + .enumerate() + .find(|(_, previous)| **previous == key) + { + return Err(ValidationError::DuplicateKey { + first_index, + second_index: index, + matrix: key, + }); + } + } + + for (index, key) in topology.physical_layout.keys.iter().enumerate() { + let matrix = key.matrix; + if !topology.matrix.contains(matrix) { + return Err(ValidationError::KeyOutOfBounds { + key_index: index, + matrix, + }); + } + if !topology.has_key(matrix) { + return Err(ValidationError::UnknownPhysicalKey { + key_index: index, + matrix, + }); + } + if let Some((first_index, _)) = topology.physical_layout.keys[..index] + .iter() + .enumerate() + .find(|(_, previous)| previous.matrix == matrix) + { + return Err(ValidationError::DuplicatePhysicalKey { + first_index, + second_index: index, + matrix, + }); + } + } + + for (index, zone) in topology.zones.iter().enumerate() { + if zone.name.is_empty() { + return Err(ValidationError::EmptyZoneName { + zone_index: index, + id: zone.id, + }); + } + for (first_index, previous) in topology.zones[..index].iter().enumerate() { + if previous.id == zone.id { + return Err(ValidationError::DuplicateZoneId { + first_index, + second_index: index, + id: zone.id, + }); + } + if previous.name == zone.name { + return Err(ValidationError::DuplicateZoneName { + first_index, + second_index: index, + }); + } + } + } + + for (index, led) in topology.leds.iter().enumerate() { + let slot = LedSlot(index as u16); + if let Some((first, _)) = topology.leds[..index] + .iter() + .enumerate() + .find(|(_, previous)| previous.id == led.id) + { + return Err(ValidationError::DuplicateLedId { + first_slot: LedSlot(first as u16), + second_slot: slot, + id: led.id, + }); + } + if let Some(matrix) = led.key + && !topology.has_key(matrix) + { + return Err(ValidationError::UnknownKey { slot, matrix }); + } + let Some(range) = led.zones.range(topology.zone_memberships.len()) else { + return Err(ValidationError::InvalidZoneSpan { slot, span: led.zones }); + }; + for membership_index in range.clone() { + let id = topology.zone_memberships[membership_index]; + if !topology.zones.iter().any(|zone| zone.id == id) { + return Err(ValidationError::UnknownZone { + slot, + membership_index, + id, + }); + } + if topology.zone_memberships[range.start..membership_index].contains(&id) { + return Err(ValidationError::DuplicateLedZone { slot, id }); + } + } + } + + for (index, output) in routing.outputs.iter().enumerate() { + if let Some((first_index, _)) = routing.outputs[..index] + .iter() + .enumerate() + .find(|(_, previous)| previous.node == output.node && previous.id == output.id) + { + return Err(ValidationError::DuplicateOutputId { + first_index, + second_index: index, + node: output.node, + output: output.id, + }); + } + if output.pixel_count == 0 { + return Err(ValidationError::EmptyOutput { output_index: index }); + } + if !output.capabilities.has_color_capability() { + return Err(ValidationError::OutputHasNoColorCapability { output_index: index }); + } + } + + for (index, route) in routing.routes.iter().enumerate() { + if route.slot.0 as usize >= topology.leds.len() { + return Err(ValidationError::RouteSlotOutOfBounds { + route_index: index, + slot: route.slot, + }); + } + if let Some((first_index, _)) = routing.routes[..index] + .iter() + .enumerate() + .find(|(_, previous)| previous.slot == route.slot) + { + return Err(ValidationError::DuplicateRouteForSlot { + first_index, + second_index: index, + slot: route.slot, + }); + } + let Some(output) = routing.output(route.node, route.output) else { + return Err(ValidationError::UnknownOutput { + route_index: index, + node: route.node, + output: route.output, + }); + }; + if route.physical_index >= output.pixel_count { + return Err(ValidationError::PhysicalIndexOutOfBounds { + route_index: index, + physical_index: route.physical_index, + pixel_count: output.pixel_count, + }); + } + if let Some((first_index, _)) = routing.routes[..index].iter().enumerate().find(|(_, previous)| { + previous.node == route.node + && previous.output == route.output + && previous.physical_index == route.physical_index + }) { + return Err(ValidationError::DuplicatePhysicalAddress { + first_index, + second_index: index, + }); + } + } + + for slot in 0..topology.leds.len() { + let slot = LedSlot(slot as u16); + if routing.route(slot).is_none() { + return Err(ValidationError::MissingRouteForSlot { slot }); + } + } + + for (output_index, output) in routing.outputs.iter().enumerate() { + if output.coverage == OutputCoverage::Sparse { + continue; + } + for physical_index in 0..output.pixel_count { + if !routing.routes.iter().any(|route| { + route.node == output.node && route.output == output.id && route.physical_index == physical_index + }) { + return Err(ValidationError::MissingPhysicalRoute { + output_index, + physical_index, + }); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::physical_layout::*; + + #[test] + fn keyed_emitter_falls_back_to_shared_key_center() { + let keys = [PhysicalKey { + matrix: KeyPosition::new(0, 0), + center: Point3::new(Coordinate::ONE, Coordinate::ZERO, Coordinate::ZERO), + size: KeySize::ONE, + rotation: Rotation::ZERO, + }]; + let leds = [LedMetadata { + id: LedId(7), + key: Some(KeyPosition::new(0, 0)), + position: None, + zones: ZoneSpan::new(0, 0), + }]; + let topology = LightingTopology { + matrix: MatrixSize::new(1, 1), + keys: &[KeyPosition::new(0, 0)], + physical_layout: PhysicalLayout::new(&keys), + leds: &leds, + zones: &[], + zone_memberships: &[], + }; + assert_eq!(topology.effective_position(LedSlot(0)), Some(keys[0].center)); + } + + #[test] + fn validation_accepts_canonical_layout_and_semantic_route() { + let keys = [PhysicalKey { + matrix: KeyPosition::new(0, 0), + center: Point3::new(Coordinate::ONE, Coordinate::ZERO, Coordinate::ZERO), + size: KeySize::ONE, + rotation: Rotation::ZERO, + }]; + let leds = [LedMetadata { + id: LedId(7), + key: Some(KeyPosition::new(0, 0)), + position: None, + zones: ZoneSpan::EMPTY, + }]; + let topology = LightingTopology { + matrix: MatrixSize::new(1, 1), + keys: &[KeyPosition::new(0, 0)], + physical_layout: PhysicalLayout::new(&keys), + leds: &leds, + zones: &[], + zone_memberships: &[], + }; + let outputs = [OutputMetadata { + node: LightingNodeId(0), + id: OutputId(0), + pixel_count: 1, + capabilities: OutputCapabilities::RGB.union(OutputCapabilities::ADDRESSABLE), + coverage: OutputCoverage::Complete, + }]; + let routes = [PhysicalRoute { + slot: LedSlot(0), + node: LightingNodeId(0), + output: OutputId(0), + physical_index: 0, + }]; + + assert_eq!( + validate( + &topology, + &LightingRouting { + outputs: &outputs, + routes: &routes, + } + ), + Ok(()) + ); + } +} diff --git a/rmk/src/physical_layout.rs b/rmk/src/physical_layout.rs new file mode 100644 index 000000000..3e3852ec7 --- /dev/null +++ b/rmk/src/physical_layout.rs @@ -0,0 +1,135 @@ +//! Allocation-free physical key geometry shared by lighting, display, and +//! host-facing layout consumers. + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct KeyPosition { + pub row: u8, + pub col: u8, +} + +impl KeyPosition { + pub const fn new(row: u8, col: u8) -> Self { + Self { row, col } + } +} + +/// Signed Q8.8 coordinate measured in key-pitch units. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct Coordinate(i16); + +impl Coordinate { + pub const ZERO: Self = Self(0); + pub const ONE: Self = Self(256); + + pub const fn from_raw(raw: i16) -> Self { + Self(raw) + } + + pub const fn raw(self) -> i16 { + self.0 + } +} + +/// Positive Q8.8 extent measured in key-pitch units. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct Extent(u16); + +impl Extent { + pub const ONE: Self = Self(256); + + pub const fn from_raw(raw: u16) -> Self { + Self(raw) + } + + pub const fn raw(self) -> u16 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct Rotation(i16); + +impl Rotation { + pub const ZERO: Self = Self(0); + + pub const fn from_centidegrees(value: i16) -> Self { + Self(value) + } + + pub const fn centidegrees(self) -> i16 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub struct Point3 { + pub x: Coordinate, + pub y: Coordinate, + pub z: Coordinate, +} + +impl Point3 { + pub const fn new(x: Coordinate, y: Coordinate, z: Coordinate) -> Self { + Self { x, y, z } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct KeySize { + pub width: Extent, + pub height: Extent, +} + +impl KeySize { + pub const ONE: Self = Self::new(Extent::ONE, Extent::ONE); + + pub const fn new(width: Extent, height: Extent) -> Self { + Self { width, height } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PhysicalKey { + pub matrix: KeyPosition, + pub center: Point3, + pub size: KeySize, + pub rotation: Rotation, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct PhysicalLayout<'a> { + pub keys: &'a [PhysicalKey], +} + +impl<'a> PhysicalLayout<'a> { + pub const EMPTY: Self = Self { keys: &[] }; + + pub const fn new(keys: &'a [PhysicalKey]) -> Self { + Self { keys } + } + + pub fn key(&self, matrix: KeyPosition) -> Option<&'a PhysicalKey> { + self.keys.iter().find(|key| key.matrix == matrix) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_geometry_is_lossless() { + let key = PhysicalKey { + matrix: KeyPosition::new(1, 2), + center: Point3::new(Coordinate::from_raw(-128), Coordinate::ONE, Coordinate::ZERO), + size: KeySize::ONE, + rotation: Rotation::from_centidegrees(-750), + }; + let layout = PhysicalLayout::new(core::slice::from_ref(&key)); + assert_eq!(layout.key(KeyPosition::new(1, 2)).unwrap(), &key); + assert_eq!(key.center.x.raw(), -128); + } +} diff --git a/rmk/src/split/driver.rs b/rmk/src/split/driver.rs index 7be44f26a..45622ec14 100644 --- a/rmk/src/split/driver.rs +++ b/rmk/src/split/driver.rs @@ -181,7 +181,7 @@ impl PeripheralManager { let mut wpm_sub = crate::event::WpmUpdateEvent::subscriber(); #[cfg(feature = "display")] let mut modifier_sub = crate::event::ModifierEvent::subscriber(); - #[cfg(feature = "display")] + #[cfg(feature = "_render_state")] let mut sleep_sub = crate::event::SleepStateEvent::subscriber(); // Expose the split-link state to the application. This @@ -242,7 +242,7 @@ impl PeripheralManager { }, with_feature("display"): e = wpm_sub.next_event().fuse() => SplitMessage::Wpm(e.0), with_feature("display"): e = modifier_sub.next_event().fuse() => SplitMessage::Modifier(e.modifier.into_bits()), - with_feature("display"): e = sleep_sub.next_event().fuse() => SplitMessage::SleepState(e.0), + with_feature("_render_state"): e = sleep_sub.next_event().fuse() => SplitMessage::SleepState(e.0), // Application messages, deliberately the // last (lowest-priority) outgoing arm; the read arm of the // outer select still beats all outgoing traffic. diff --git a/rmk/src/split/mod.rs b/rmk/src/split/mod.rs index 20a525f0c..6c44bd781 100644 --- a/rmk/src/split/mod.rs +++ b/rmk/src/split/mod.rs @@ -61,7 +61,7 @@ pub(crate) enum SplitMessage { #[cfg(feature = "display")] Modifier(u8), /// Sleep state from central to peripheral - #[cfg(feature = "display")] + #[cfg(feature = "_render_state")] SleepState(bool), /// Battery status, from peripheral to central #[cfg(feature = "_ble")] diff --git a/rmk/src/split/peripheral.rs b/rmk/src/split/peripheral.rs index 6f465929c..947462faf 100644 --- a/rmk/src/split/peripheral.rs +++ b/rmk/src/split/peripheral.rs @@ -19,7 +19,7 @@ use crate::event::{ KeyboardEvent, LayerChangeEvent, LedIndicatorEvent, PointingEvent, SubscribableEvent, publish_event, }; #[cfg(feature = "display")] -use crate::event::{ModifierEvent, SleepStateEvent, WpmUpdateEvent}; +use crate::event::{ModifierEvent, WpmUpdateEvent}; #[cfg(not(feature = "_ble"))] use crate::split::serial::SerialSplitDriver; use crate::state::update_status; @@ -181,9 +181,9 @@ impl SplitPeripheral { } SplitMessage::KeyboardIndicator(indicator) => { // Publish KeyboardIndicator event - publish_event(LedIndicatorEvent::new( - rmk_types::led_indicator::LedIndicator::from_bits(indicator), - )); + let indicator = rmk_types::led_indicator::LedIndicator::from_bits(indicator); + crate::keyboard::set_current_led_indicator(indicator); + publish_event(LedIndicatorEvent::new(indicator)); } SplitMessage::Layer(layer) => { // Publish Layer event @@ -197,9 +197,9 @@ impl SplitPeripheral { modifier: rmk_types::modifier::ModifierCombination::from_bits(bits), }); } - #[cfg(feature = "display")] + #[cfg(feature = "_render_state")] SplitMessage::SleepState(sleeping) => { - publish_event(SleepStateEvent::new(sleeping)); + crate::state::set_sleeping(sleeping); } // --- dfu_split: firmware update handlers --- #[cfg(feature = "dfu_split")] diff --git a/rmk/src/state.rs b/rmk/src/state.rs index c25340aea..02cb64af0 100644 --- a/rmk/src/state.rs +++ b/rmk/src/state.rs @@ -9,6 +9,31 @@ use rmk_types::connection::{ConnectionStatus, ConnectionType, UsbState}; use crate::RawMutex; use crate::event::{ConnectionStatusChangeEvent, publish_event}; +/// Authoritative device sleep state shared by display, lighting, host +/// readback, battery management, and split forwarding. Events invalidate this +/// value; they are not a second owner of it. +static SLEEPING: Mutex> = Mutex::new(Cell::new(false)); + +pub(crate) fn current_sleeping() -> bool { + SLEEPING.lock(Cell::get) +} + +/// Compatibility spelling used by the existing Rynk status handler. +pub(crate) fn current_sleep_state() -> bool { + current_sleeping() +} + +pub(crate) fn set_sleeping(sleeping: bool) { + let changed = SLEEPING.lock(|state| { + let changed = state.get() != sleeping; + state.set(sleeping); + changed + }); + if changed { + publish_event(crate::event::SleepStateEvent::new(sleeping)); + } +} + /// Single source of truth for transport state and routing. All writes go /// through the mutator helpers below so the active-output cascade runs and /// change events fire on every transition. @@ -27,19 +52,6 @@ pub(crate) fn current_usb_state() -> UsbState { CONNECTION_STATUS.lock(|c| c.get().usb) } -/// Current central sleep state for host polling. Sourced from the BLE sleep -/// manager's `SLEEPING_STATE`; always `false` in builds without BLE. -pub(crate) fn current_sleep_state() -> bool { - #[cfg(feature = "_ble")] - { - crate::ble::sleep::SLEEPING_STATE.load(core::sync::atomic::Ordering::Acquire) - } - #[cfg(not(feature = "_ble"))] - { - false - } -} - #[cfg(feature = "_ble")] pub(crate) fn current_ble_status() -> BleStatus { CONNECTION_STATUS.lock(|c| c.get().ble) From 6dd5b85d0b858a57eafbf362f994b105e7a6e1cd Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 20 Jul 2026 10:26:08 -0700 Subject: [PATCH 03/78] feat(rynk): add native lighting control --- .../main/docs/development/rynk_protocol.md | 129 +- rmk-types/src/protocol/rynk/command.rs | 49 + .../src/protocol/rynk/payload/lighting.rs | 600 +++++++++ rmk-types/src/protocol/rynk/payload/mod.rs | 4 + rmk-types/src/protocol/rynk/payload/system.rs | 4 +- .../rynk/snapshots/lighting_wire_frames.snap | 45 + .../protocol/rynk/snapshots/wire_values.snap | 2 +- rmk-types/src/protocol/rynk/tests.rs | 425 +++++++ rmk/src/host/mod.rs | 5 + rmk/src/host/rynk/handlers/lighting.rs | 1100 +++++++++++++++++ rmk/src/host/rynk/handlers/mod.rs | 2 + rmk/src/host/rynk/handlers/system.rs | 5 +- rmk/src/host/rynk/lighting.rs | 568 +++++++++ rmk/src/host/rynk/mod.rs | 85 +- rmk/src/host/rynk/topics.rs | 7 + rynk/Cargo.toml | 2 +- rynk/rynk-wasm/src/client.rs | 29 +- rynk/src/api.rs | 155 ++- rynk/src/driver.rs | 7 +- 19 files changed, 3152 insertions(+), 71 deletions(-) create mode 100644 rmk-types/src/protocol/rynk/payload/lighting.rs create mode 100644 rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap create mode 100644 rmk/src/host/rynk/handlers/lighting.rs create mode 100644 rmk/src/host/rynk/lighting.rs diff --git a/docs/docs/main/docs/development/rynk_protocol.md b/docs/docs/main/docs/development/rynk_protocol.md index 4f765947a..d799c2ddd 100644 --- a/docs/docs/main/docs/development/rynk_protocol.md +++ b/docs/docs/main/docs/development/rynk_protocol.md @@ -4,7 +4,7 @@ # Rynk Protocol Reference -Current protocol version: **0.1**. +Current protocol version: **0.2**. Every transport (USB CDC, BLE GATT, BLE HID) carries the same frame — a 3-byte header plus a [postcard](https://docs.rs/postcard)-encoded payload: @@ -19,71 +19,90 @@ Every transport (USB CDC, BLE GATT, BLE HID) carries the same frame — a 3-byte On the wire the whole frame is COBS-encoded and terminated by a single `0x00` delimiter, so the byte stream is self-synchronizing. - **Requests** use CMD `0x0000..=0x7FFF`. The response echoes CMD and SEQ and wraps its payload in postcard `Result` (`T = ()` for `Set*`). +- **Lighting responses** use a `Lighting*Result` as `T`, preserving domain-specific `LightingError` detail inside the outer Rynk result. - **Topics** use CMD `0x8000..=0xFFFF` (server → host push, SEQ `0`, bare payload). Which commands a firmware answers depends on the RMK Cargo features it was built with: a row with no **Feature** is present once `rynk` is on, and the rest need their feature (`_ble`, `split`, …) compiled in. A command the firmware wasn't built with answers `UnknownCmd`. ## Endpoints -| CMD | Name | Request | Response | Feature | Notes | -| -------- | --------------------- | ---------------------- | ----------------------- | ------- | ---------------------------------------------------------------------------- | -| `0x0001` | `GetVersion` | `()` | `ProtocolVersion` | | | -| `0x0002` | `GetCapabilities` | `()` | `DeviceCapabilities` | | | -| `0x0003` | `Reboot` | `()` | `()` | | | -| `0x0004` | `BootloaderJump` | `()` | `()` | | | -| `0x0005` | `StorageReset` | `StorageResetMode` | `()` | | | -| `0x0006` | `GetLockStatus` | `()` | `LockStatus` | | Pure read of the current lock state — no side effects. | -| `0x0007` | `UnlockPoll` | `()` | `LockStatus` | | Arms/refreshes the unlock attempt and samples the held challenge keys. | -| `0x0008` | `Lock` | `()` | `()` | | Relock immediately. | -| `0x0009` | `GetLayout` | `u32` | `LayoutChunk` | | Get layout blob chunk. `u32` is the byte offset. | -| `0x000A` | `GetDeviceInfo` | `()` | `DeviceInfo` | | Identity strings and USB ids; feature gating stays in `GetCapabilities`. | -| `0x0101` | `GetKeyAction` | `KeyPosition` | `KeyAction` | | | -| `0x0102` | `SetKeyAction` | `SetKeyRequest` | `()` | | | -| `0x0103` | `GetDefaultLayer` | `()` | `u8` | | | -| `0x0104` | `SetDefaultLayer` | `u8` | `()` | | | -| `0x0105` | `GetEncoderAction` | `GetEncoderRequest` | `EncoderAction` | | | -| `0x0106` | `SetEncoderAction` | `SetEncoderRequest` | `()` | | | -| `0x0107` | `GetKeymapBulk` | `GetKeymapBulkRequest` | `GetKeymapBulkResponse` | | | -| `0x0108` | `SetKeymapBulk` | `SetKeymapBulkRequest` | `()` | | | -| `0x0201` | `GetMacro` | `GetMacroRequest` | `MacroData` | | | -| `0x0202` | `SetMacro` | `SetMacroRequest` | `()` | | | -| `0x0301` | `GetCombo` | `u8` | `Combo` | | | -| `0x0302` | `SetCombo` | `SetComboRequest` | `()` | | | -| `0x0303` | `GetComboBulk` | `GetComboBulkRequest` | `GetComboBulkResponse` | | | -| `0x0304` | `SetComboBulk` | `SetComboBulkRequest` | `()` | | | -| `0x0401` | `GetMorse` | `u8` | `Morse` | | | -| `0x0402` | `SetMorse` | `SetMorseRequest` | `()` | | | -| `0x0403` | `GetMorseBulk` | `GetMorseBulkRequest` | `GetMorseBulkResponse` | | | -| `0x0404` | `SetMorseBulk` | `SetMorseBulkRequest` | `()` | | | -| `0x0501` | `GetFork` | `u8` | `Fork` | | | -| `0x0502` | `SetFork` | `SetForkRequest` | `()` | | | -| `0x0601` | `GetBehaviorConfig` | `()` | `BehaviorConfig` | | | -| `0x0602` | `SetBehaviorConfig` | `BehaviorConfig` | `()` | | | -| `0x0701` | `GetConnectionType` | `()` | `ConnectionType` | | | -| `0x0702` | `GetConnectionStatus` | `()` | `ConnectionStatus` | | Full `ConnectionStatus` snapshot. | -| `0x0703` | `GetBleStatus` | `()` | `BleStatus` | `_ble` | | -| `0x0704` | `SwitchBleProfile` | `u8` | `()` | `_ble` | | -| `0x0705` | `ClearBleProfile` | `u8` | `()` | `_ble` | | -| `0x0801` | `GetCurrentLayer` | `()` | `u8` | | | -| `0x0802` | `GetMatrixState` | `()` | `MatrixState` | | | -| `0x0803` | `GetBatteryStatus` | `()` | `BatteryStatus` | `_ble` | | -| `0x0804` | `GetPeripheralStatus` | `u8` | `PeripheralStatus` | `split` | | -| `0x0805` | `GetWpm` | `()` | `u16` | | Latest WPM, sourced from the `WpmUpdate` topic snapshot. | -| `0x0806` | `GetSleepState` | `()` | `bool` | | Latest sleep flag, sourced from the `SleepState` topic snapshot. | -| `0x0807` | `GetLedIndicator` | `()` | `LedIndicator` | | Latest HID LED bitmap, sourced from the `LedIndicatorChange` topic snapshot. | +| CMD | Name | Request | Response | Feature | Notes | +| -------- | ------------------------------ | ------------------------------------- | ----------------------------------- | ---------- | ---------------------------------------------------------------------------- | +| `0x0001` | `GetVersion` | `()` | `ProtocolVersion` | | | +| `0x0002` | `GetCapabilities` | `()` | `DeviceCapabilities` | | | +| `0x0003` | `Reboot` | `()` | `()` | | | +| `0x0004` | `BootloaderJump` | `()` | `()` | | | +| `0x0005` | `StorageReset` | `StorageResetMode` | `()` | | | +| `0x0006` | `GetLockStatus` | `()` | `LockStatus` | | Pure read of the current lock state — no side effects. | +| `0x0007` | `UnlockPoll` | `()` | `LockStatus` | | Arms/refreshes the unlock attempt and samples the held challenge keys. | +| `0x0008` | `Lock` | `()` | `()` | | Relock immediately. | +| `0x0009` | `GetLayout` | `u32` | `LayoutChunk` | | Get layout blob chunk. `u32` is the byte offset. | +| `0x000A` | `GetDeviceInfo` | `()` | `DeviceInfo` | | Identity strings and USB ids; feature gating stays in `GetCapabilities`. | +| `0x0101` | `GetKeyAction` | `KeyPosition` | `KeyAction` | | | +| `0x0102` | `SetKeyAction` | `SetKeyRequest` | `()` | | | +| `0x0103` | `GetDefaultLayer` | `()` | `u8` | | | +| `0x0104` | `SetDefaultLayer` | `u8` | `()` | | | +| `0x0105` | `GetEncoderAction` | `GetEncoderRequest` | `EncoderAction` | | | +| `0x0106` | `SetEncoderAction` | `SetEncoderRequest` | `()` | | | +| `0x0107` | `GetKeymapBulk` | `GetKeymapBulkRequest` | `GetKeymapBulkResponse` | | | +| `0x0108` | `SetKeymapBulk` | `SetKeymapBulkRequest` | `()` | | | +| `0x0201` | `GetMacro` | `GetMacroRequest` | `MacroData` | | | +| `0x0202` | `SetMacro` | `SetMacroRequest` | `()` | | | +| `0x0301` | `GetCombo` | `u8` | `Combo` | | | +| `0x0302` | `SetCombo` | `SetComboRequest` | `()` | | | +| `0x0303` | `GetComboBulk` | `GetComboBulkRequest` | `GetComboBulkResponse` | | | +| `0x0304` | `SetComboBulk` | `SetComboBulkRequest` | `()` | | | +| `0x0401` | `GetMorse` | `u8` | `Morse` | | | +| `0x0402` | `SetMorse` | `SetMorseRequest` | `()` | | | +| `0x0403` | `GetMorseBulk` | `GetMorseBulkRequest` | `GetMorseBulkResponse` | | | +| `0x0404` | `SetMorseBulk` | `SetMorseBulkRequest` | `()` | | | +| `0x0501` | `GetFork` | `u8` | `Fork` | | | +| `0x0502` | `SetFork` | `SetForkRequest` | `()` | | | +| `0x0601` | `GetBehaviorConfig` | `()` | `BehaviorConfig` | | | +| `0x0602` | `SetBehaviorConfig` | `BehaviorConfig` | `()` | | | +| `0x0701` | `GetConnectionType` | `()` | `ConnectionType` | | | +| `0x0702` | `GetConnectionStatus` | `()` | `ConnectionStatus` | | Full `ConnectionStatus` snapshot. | +| `0x0703` | `GetBleStatus` | `()` | `BleStatus` | `_ble` | | +| `0x0704` | `SwitchBleProfile` | `u8` | `()` | `_ble` | | +| `0x0705` | `ClearBleProfile` | `u8` | `()` | `_ble` | | +| `0x0801` | `GetCurrentLayer` | `()` | `u8` | | | +| `0x0802` | `GetMatrixState` | `()` | `MatrixState` | | | +| `0x0803` | `GetBatteryStatus` | `()` | `BatteryStatus` | `_ble` | | +| `0x0804` | `GetPeripheralStatus` | `u8` | `PeripheralStatus` | `split` | | +| `0x0805` | `GetWpm` | `()` | `u16` | | Latest WPM, sourced from the `WpmUpdate` topic snapshot. | +| `0x0806` | `GetSleepState` | `()` | `bool` | | Latest sleep flag, sourced from the `SleepState` topic snapshot. | +| `0x0807` | `GetLedIndicator` | `()` | `LedIndicator` | | Latest HID LED bitmap, sourced from the `LedIndicatorChange` topic snapshot. | +| `0x0901` | `GetLightingCapabilities` | `()` | `LightingCapabilitiesResult` | `lighting` | | +| `0x0902` | `GetLightingState` | `()` | `LightingStateResult` | `lighting` | | +| `0x0903` | `SetLightingState` | `SetLightingStateRequest` | `LightingStateResult` | `lighting` | | +| `0x0904` | `GetLightingPhysicalKeys` | `LightingPageRequest` | `LightingPhysicalKeysPageResult` | `lighting` | | +| `0x0905` | `GetLightingLeds` | `LightingPageRequest` | `LightingLedsPageResult` | `lighting` | | +| `0x0906` | `GetLightingZones` | `LightingPageRequest` | `LightingZonesPageResult` | `lighting` | | +| `0x0907` | `GetLightingZoneMemberships` | `LightingPageRequest` | `LightingZoneMembershipsPageResult` | `lighting` | | +| `0x0908` | `GetLightingOutputs` | `LightingPageRequest` | `LightingOutputsPageResult` | `lighting` | | +| `0x0909` | `GetLightingRoutes` | `LightingPageRequest` | `LightingRoutesPageResult` | `lighting` | | +| `0x090A` | `SetLightingOverlay` | `SetLightingOverlayRequest` | `LightingStateResult` | `lighting` | | +| `0x090B` | `UnsetLightingOverlay` | `UnsetLightingOverlayRequest` | `LightingStateResult` | `lighting` | | +| `0x090C` | `ClearLightingOverlay` | `ClearLightingOverlayRequest` | `LightingStateResult` | `lighting` | | +| `0x090D` | `BeginLightingOverlayReplace` | `BeginLightingOverlayReplaceRequest` | `LightingOverlayTransactionResult` | `lighting` | | +| `0x090E` | `PutLightingOverlayChunk` | `PutLightingOverlayChunkRequest` | `LightingUnitResult` | `lighting` | | +| `0x090F` | `CommitLightingOverlayReplace` | `CommitLightingOverlayReplaceRequest` | `LightingStateResult` | `lighting` | | +| `0x0910` | `AbortLightingOverlayReplace` | `AbortLightingOverlayReplaceRequest` | `LightingUnitResult` | `lighting` | | +| `0x0911` | `GetLightingKeys` | `LightingPageRequest` | `LightingKeysPageResult` | `lighting` | Logical matrix keys are distinct from optional physical geometry. | ## Topics Topics are best-effort pushes; the `Get*` endpoints above mirror their payloads so a host can recover a missed push. -| CMD | Name | Payload | Feature | Notes | -| -------- | --------------------- | ------------------ | ------- | ----- | -| `0x8001` | `LayerChange` | `u8` | | | -| `0x8002` | `WpmUpdate` | `u16` | | | -| `0x8003` | `ConnectionChange` | `ConnectionStatus` | | | -| `0x8004` | `SleepState` | `bool` | | | -| `0x8005` | `LedIndicatorChange` | `LedIndicator` | | | -| `0x8006` | `BatteryStatusChange` | `BatteryStatus` | `_ble` | | +| CMD | Name | Payload | Feature | Notes | +| -------- | --------------------- | ------------------ | ---------- | ----- | +| `0x8001` | `LayerChange` | `u8` | | | +| `0x8002` | `WpmUpdate` | `u16` | | | +| `0x8003` | `ConnectionChange` | `ConnectionStatus` | | | +| `0x8004` | `SleepState` | `bool` | | | +| `0x8005` | `LedIndicatorChange` | `LedIndicator` | | | +| `0x8006` | `BatteryStatusChange` | `BatteryStatus` | `_ble` | | +| `0x8007` | `LightingChange` | `LightingChanged` | `lighting` | | ## Compatibility diff --git a/rmk-types/src/protocol/rynk/command.rs b/rmk-types/src/protocol/rynk/command.rs index 270ed34aa..d2fd47095 100644 --- a/rmk-types/src/protocol/rynk/command.rs +++ b/rmk-types/src/protocol/rynk/command.rs @@ -32,6 +32,15 @@ use crate::led_indicator::LedIndicator; use crate::morse::Morse; #[cfg(feature = "split")] use crate::protocol::rynk::PeripheralStatus; +#[cfg(feature = "lighting")] +use crate::protocol::rynk::{ + AbortLightingOverlayReplaceRequest, BeginLightingOverlayReplaceRequest, ClearLightingOverlayRequest, + CommitLightingOverlayReplaceRequest, LightingCapabilitiesResult, LightingChanged, LightingKeysPageResult, + LightingLedsPageResult, LightingOutputsPageResult, LightingOverlayTransactionResult, LightingPageRequest, + LightingPhysicalKeysPageResult, LightingRoutesPageResult, LightingStateResult, LightingUnitResult, + LightingZoneMembershipsPageResult, LightingZonesPageResult, PutLightingOverlayChunkRequest, + SetLightingOverlayRequest, SetLightingStateRequest, UnsetLightingOverlayRequest, +}; /// CMD high bit marking a topic (server → host push). const RYNK_TOPIC_BIT: u16 = 0x8000; @@ -337,6 +346,44 @@ endpoints! { GetSleepState = 0x0806: () => bool; /// Latest HID LED bitmap, sourced from the `LedIndicatorChange` topic snapshot. GetLedIndicator = 0x0807: () => LedIndicator; + + // Lighting (0x09xx). Lighting-domain errors are nested inside Rynk's + // outer protocol result so hosts retain precise rejection reasons. + #[cfg(feature = "lighting")] + GetLightingCapabilities = 0x0901: () => LightingCapabilitiesResult; + #[cfg(feature = "lighting")] + GetLightingState = 0x0902: () => LightingStateResult; + #[cfg(feature = "lighting")] + SetLightingState = 0x0903: SetLightingStateRequest => LightingStateResult; + #[cfg(feature = "lighting")] + GetLightingPhysicalKeys = 0x0904: LightingPageRequest => LightingPhysicalKeysPageResult; + #[cfg(feature = "lighting")] + GetLightingLeds = 0x0905: LightingPageRequest => LightingLedsPageResult; + #[cfg(feature = "lighting")] + GetLightingZones = 0x0906: LightingPageRequest => LightingZonesPageResult; + #[cfg(feature = "lighting")] + GetLightingZoneMemberships = 0x0907: LightingPageRequest => LightingZoneMembershipsPageResult; + #[cfg(feature = "lighting")] + GetLightingOutputs = 0x0908: LightingPageRequest => LightingOutputsPageResult; + #[cfg(feature = "lighting")] + GetLightingRoutes = 0x0909: LightingPageRequest => LightingRoutesPageResult; + #[cfg(feature = "lighting")] + SetLightingOverlay = 0x090A: SetLightingOverlayRequest => LightingStateResult; + #[cfg(feature = "lighting")] + UnsetLightingOverlay = 0x090B: UnsetLightingOverlayRequest => LightingStateResult; + #[cfg(feature = "lighting")] + ClearLightingOverlay = 0x090C: ClearLightingOverlayRequest => LightingStateResult; + #[cfg(feature = "lighting")] + BeginLightingOverlayReplace = 0x090D: BeginLightingOverlayReplaceRequest => LightingOverlayTransactionResult; + #[cfg(feature = "lighting")] + PutLightingOverlayChunk = 0x090E: PutLightingOverlayChunkRequest => LightingUnitResult; + #[cfg(feature = "lighting")] + CommitLightingOverlayReplace = 0x090F: CommitLightingOverlayReplaceRequest => LightingStateResult; + #[cfg(feature = "lighting")] + AbortLightingOverlayReplace = 0x0910: AbortLightingOverlayReplaceRequest => LightingUnitResult; + /// Logical matrix keys are distinct from optional physical geometry. + #[cfg(feature = "lighting")] + GetLightingKeys = 0x0911: LightingPageRequest => LightingKeysPageResult; } // Define topics: `Name = value: Payload;` @@ -349,6 +396,8 @@ topics! { LedIndicatorChange = 0x8005: LedIndicator; #[cfg(feature = "_ble")] BatteryStatusChange = 0x8006: BatteryStatus; + #[cfg(feature = "lighting")] + LightingChange = 0x8007: LightingChanged; } /// The payload budget advertised to hosts must cover the largest payload diff --git a/rmk-types/src/protocol/rynk/payload/lighting.rs b/rmk-types/src/protocol/rynk/payload/lighting.rs new file mode 100644 index 000000000..8dd116638 --- /dev/null +++ b/rmk-types/src/protocol/rynk/payload/lighting.rs @@ -0,0 +1,600 @@ +//! Lighting protocol types. +//! +//! The wire model deliberately separates stable, board-visible identities +//! from dense compositor slots and electrical chain order. Hosts address +//! lights by [`LightingLedId`]; topology and routing readback explain key +//! association, geometry, zones, split-node ownership, and physical outputs. + +use heapless::{String, Vec}; +use postcard::experimental::max_size::MaxSize; +use serde::{Deserialize, Serialize}; + +/// Maximum postcard payload admitted by this first lighting ICD. +pub const LIGHTING_PAYLOAD_SIZE: usize = 256; +/// Number of metadata records in one topology page. +pub const LIGHTING_PAGE_SIZE: usize = 8; +/// Number of overlay cells in one replacement chunk. +pub const LIGHTING_OVERLAY_CHUNK_SIZE: usize = 8; +/// Maximum UTF-8 byte length of a zone name. +pub const LIGHTING_ZONE_NAME_SIZE: usize = 24; + +macro_rules! wire_type { + ($item:item) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)] + #[cfg_attr(feature = "defmt", derive(defmt::Format))] + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] + #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] + $item + }; +} + +wire_type! { + /// Stable, board-wide identity of one independently controllable light. + #[repr(transparent)] + pub struct LightingLedId(pub u16); +} + +wire_type! { + /// Stable identity of one semantic lighting zone. + #[repr(transparent)] + pub struct LightingZoneId(pub u8); +} + +wire_type! { + /// Identity of a lighting processor, such as one half of a split keyboard. + #[repr(transparent)] + pub struct LightingNodeId(pub u8); +} + +wire_type! { + /// Identity of one physical output owned by a lighting node. + #[repr(transparent)] + pub struct LightingOutputId(pub u8); +} + +wire_type! { + /// One real key in RMK's logical matrix. Matrix holes have no record. + pub struct LightingMatrixPosition { + pub row: u8, + pub col: u8, + } +} + +wire_type! { + /// Board-global Q8.8 point in key-pitch units. + pub struct LightingPoint3 { + pub x: i16, + pub y: i16, + pub z: i16, + } +} + +wire_type! { + /// Positive Q8.8 key dimensions in key-pitch units. + pub struct LightingKeySize { + pub width: u16, + pub height: u16, + } +} + +wire_type! { + /// Shared physical-key geometry consumed by lighting, displays, and hosts. + pub struct LightingPhysicalKey { + pub matrix: LightingMatrixPosition, + pub center: LightingPoint3, + pub size: LightingKeySize, + /// Clockwise rotation in hundredths of one degree. + pub rotation: i16, + } +} + +wire_type! { + /// One semantic light. It may have key association, explicit geometry, + /// both, or neither. + pub struct LightingLed { + pub id: LightingLedId, + pub key: Option, + pub position: Option, + /// Span into the flat zone-membership table. + pub zone_start: u16, + pub zone_len: u8, + } +} + +/// One named semantic zone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct LightingZone { + pub id: LightingZoneId, + #[cfg_attr(feature = "wasm", tsify(type = "string"))] + pub name: String, +} + +impl MaxSize for LightingZone { + const POSTCARD_MAX_SIZE: usize = + LightingZoneId::POSTCARD_MAX_SIZE + crate::heapless_vec_max_size::(); +} + +wire_type! { + /// Color and addressability capabilities of a physical output. + #[repr(transparent)] + pub struct LightingOutputCapabilities(pub u8); +} + +impl LightingOutputCapabilities { + pub const BINARY: u8 = 1 << 0; + pub const INTENSITY: u8 = 1 << 1; + pub const RGB: u8 = 1 << 2; + pub const WHITE: u8 = 1 << 3; + pub const ADDRESSABLE: u8 = 1 << 4; + + pub const fn contains(self, bits: u8) -> bool { + self.0 & bits == bits + } +} + +wire_type! { + /// Whether all physical pixels of an output must have a logical route. + pub enum LightingOutputCoverage { + Complete, + Sparse, + } +} + +wire_type! { + /// One concrete output on one lighting node. + pub struct LightingOutput { + pub node: LightingNodeId, + pub id: LightingOutputId, + pub pixel_count: u16, + pub capabilities: LightingOutputCapabilities, + pub coverage: LightingOutputCoverage, + } +} + +wire_type! { + /// Stable-light to physical-address mapping. Dense compositor slots are + /// intentionally not part of the public protocol. + pub struct LightingRoute { + pub led_id: LightingLedId, + pub node: LightingNodeId, + pub output: LightingOutputId, + pub physical_index: u16, + } +} + +wire_type! { + /// Optional capabilities beyond the mandatory state/topology surface. + #[repr(transparent)] + pub struct LightingFeatureFlags(pub u16); +} + +impl LightingFeatureFlags { + pub const PHYSICAL_GEOMETRY: u16 = 1 << 0; + pub const ZONES: u16 = 1 << 1; + pub const ROUTING: u16 = 1 << 2; + pub const OVERLAY_TTL: u16 = 1 << 3; + pub const ATOMIC_OVERLAY_REPLACE: u16 = 1 << 4; + pub const LAYER_AWARE: u16 = 1 << 5; + + pub const fn contains(self, bits: u16) -> bool { + self.0 & bits == bits + } +} + +wire_type! { + /// Built-in effects accepted by this firmware. + #[repr(transparent)] + pub struct LightingEffectFlags(pub u8); +} + +impl LightingEffectFlags { + pub const SOLID: u8 = 1 << 0; + pub const BLINK: u8 = 1 << 1; + pub const BREATHE: u8 = 1 << 2; + + pub const fn contains(self, bits: u8) -> bool { + self.0 & bits == bits + } +} + +wire_type! { + /// Static limits and topology identity for a lighting-enabled device. + pub struct LightingCapabilities { + pub topology_revision: u32, + /// Real logical matrix keys, including keys without measured geometry. + pub logical_key_count: u16, + pub physical_key_count: u16, + pub led_count: u16, + pub zone_count: u16, + pub zone_membership_count: u16, + pub output_count: u16, + pub route_count: u16, + pub overlay_capacity: u16, + pub page_capacity: u8, + pub overlay_chunk_capacity: u8, + pub features: LightingFeatureFlags, + pub effects: LightingEffectFlags, + } +} + +wire_type! { + /// Revision-pinned request for one metadata page. + pub struct LightingPageRequest { + pub topology_revision: u32, + pub offset: u16, + } +} + +macro_rules! page_type { + ($name:ident, $item:ty, $ts:literal) => { + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[cfg_attr(feature = "defmt", derive(defmt::Format))] + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] + #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] + pub struct $name { + pub topology_revision: u32, + pub total_count: u16, + #[cfg_attr(feature = "wasm", tsify(type = $ts))] + pub items: Vec<$item, LIGHTING_PAGE_SIZE>, + } + + impl MaxSize for $name { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::<$item, LIGHTING_PAGE_SIZE>(); + } + }; +} + +page_type!(LightingKeysPage, LightingMatrixPosition, "LightingMatrixPosition[]"); +page_type!(LightingPhysicalKeysPage, LightingPhysicalKey, "LightingPhysicalKey[]"); +page_type!(LightingLedsPage, LightingLed, "LightingLed[]"); +page_type!(LightingZonesPage, LightingZone, "LightingZone[]"); +page_type!(LightingZoneMembershipsPage, LightingZoneId, "LightingZoneId[]"); +page_type!(LightingOutputsPage, LightingOutput, "LightingOutput[]"); +page_type!(LightingRoutesPage, LightingRoute, "LightingRoute[]"); + +wire_type! { + /// Device-independent linear RGB sample. + pub struct LightingRgb8 { + pub r: u8, + pub g: u8, + pub b: u8, + } +} + +wire_type! { + /// Bounded set of standard effects understood by the RMK lighting engine. + pub enum LightingEffect { + Solid { + color: LightingRgb8, + }, + Blink { + color: LightingRgb8, + period_ms: u32, + phase_ms: u32, + duty: u8, + }, + Breathe { + color: LightingRgb8, + period_ms: u32, + phase_ms: u32, + step_ms: u16, + }, + } +} + +impl LightingEffect { + /// Validate parameters before adapting this wire value to the standard + /// engine. Invalid effects never partially mutate live lighting state. + pub const fn validate(&self) -> LightingResult<()> { + match *self { + Self::Solid { .. } => Ok(()), + Self::Blink { period_ms, duty, .. } if period_ms != 0 && duty <= 100 => Ok(()), + Self::Breathe { period_ms, step_ms, .. } + if period_ms >= 2 && step_ms != 0 && (step_ms as u32) < period_ms => + { + Ok(()) + } + _ => Err(LightingError::InvalidEffect), + } + } +} + +wire_type! { + pub enum LightingBackgroundMode { + Solid, + Breathe, + } +} + +wire_type! { + /// VIA-compatible designated background. It is only the lowest standard + /// source; disabling it does not disable layers, overlays, or status. + pub struct LightingBackgroundState { + pub enabled: bool, + pub hue: u8, + pub saturation: u8, + pub value: u8, + pub speed: u8, + pub mode: LightingBackgroundMode, + } +} + +wire_type! { + pub struct LightingMutableState { + pub output_enabled: bool, + pub output_brightness: u8, + pub background: LightingBackgroundState, + } +} + +wire_type! { + /// Authoritative mutable state and optimistic-concurrency revision. + pub struct LightingState { + pub revision: u32, + pub output_enabled: bool, + pub output_brightness: u8, + pub background: LightingBackgroundState, + pub overlay_len: u16, + } +} + +wire_type! { + pub struct SetLightingStateRequest { + pub expected_revision: u32, + pub state: LightingMutableState, + } +} + +wire_type! { + /// One transient overlay cell addressed by stable LED identity. + pub struct LightingOverlayCell { + pub led_id: LightingLedId, + pub effect: LightingEffect, + /// Relative lifetime. `None` lasts until unset, clear, or reboot; + /// `Some(0)` is invalid. + pub ttl_ms: Option, + } +} + +impl LightingOverlayCell { + /// Validate the effect and relative lifetime. `None` is persistent and a + /// positive TTL expires in firmware time; zero is never ambiguous. + pub const fn validate(&self) -> LightingResult<()> { + if matches!(self.ttl_ms, Some(0)) { + return Err(LightingError::InvalidTtl); + } + self.effect.validate() + } +} + +wire_type! { + pub struct SetLightingOverlayRequest { + pub expected_revision: u32, + pub cell: LightingOverlayCell, + } +} + +wire_type! { + pub struct UnsetLightingOverlayRequest { + pub expected_revision: u32, + pub led_id: LightingLedId, + } +} + +wire_type! { + pub struct ClearLightingOverlayRequest { + pub expected_revision: u32, + } +} + +wire_type! { + /// Begin an atomic, multi-packet overlay replacement. + pub struct BeginLightingOverlayReplaceRequest { + pub expected_revision: u32, + pub cell_count: u16, + } +} + +wire_type! { + /// Opaque transaction token allocated by the firmware. + pub struct LightingOverlayTransaction { + pub id: u32, + pub cell_count: u16, + } +} + +/// One ordered transaction chunk. Chunks are applied only by commit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct PutLightingOverlayChunkRequest { + pub transaction_id: u32, + pub offset: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingOverlayCell[]"))] + pub cells: Vec, +} + +impl MaxSize for PutLightingOverlayChunkRequest { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +wire_type! { + pub struct CommitLightingOverlayReplaceRequest { + pub transaction_id: u32, + } +} + +wire_type! { + pub struct AbortLightingOverlayReplaceRequest { + pub transaction_id: u32, + } +} + +wire_type! { + /// Lighting-domain rejection carried inside Rynk's outer protocol result. + pub enum LightingError { + Unsupported, + InvalidRequest, + InvalidEffect, + InvalidTtl, + TopologyRevisionConflict { expected: u32, current: u32 }, + StateRevisionConflict { expected: u32, current: u32 }, + UnknownLed { led_id: LightingLedId }, + OverlayFull { capacity: u16 }, + TransactionBusy, + InvalidTransaction, + TransactionExpired, + TransactionIncomplete { expected: u16, received: u16 }, + } +} + +/// Detailed lighting result nested inside Rynk's transport/protocol result. +pub type LightingResult = Result; +pub type LightingCapabilitiesResult = LightingResult; +pub type LightingStateResult = LightingResult; +pub type LightingKeysPageResult = LightingResult; +pub type LightingPhysicalKeysPageResult = LightingResult; +pub type LightingLedsPageResult = LightingResult; +pub type LightingZonesPageResult = LightingResult; +pub type LightingZoneMembershipsPageResult = LightingResult; +pub type LightingOutputsPageResult = LightingResult; +pub type LightingRoutesPageResult = LightingResult; +pub type LightingOverlayTransactionResult = LightingResult; +pub type LightingUnitResult = LightingResult<()>; + +wire_type! { + /// Best-effort invalidation marker. Hosts recover current authoritative + /// state with `GetLightingState`; events never carry a second state copy. + pub struct LightingChanged; +} + +const _: () = { + use crate::protocol::rynk::RynkError; + + macro_rules! assert_endpoint_fits { + ($req:ty, $resp:ty) => { + core::assert!(<$req as MaxSize>::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + core::assert!( as MaxSize>::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + }; + } + + assert_endpoint_fits!((), LightingCapabilitiesResult); + assert_endpoint_fits!((), LightingStateResult); + assert_endpoint_fits!(SetLightingStateRequest, LightingStateResult); + assert_endpoint_fits!(LightingPageRequest, LightingKeysPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingPhysicalKeysPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingLedsPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingZonesPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingZoneMembershipsPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingOutputsPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingRoutesPageResult); + assert_endpoint_fits!(SetLightingOverlayRequest, LightingStateResult); + assert_endpoint_fits!(UnsetLightingOverlayRequest, LightingStateResult); + assert_endpoint_fits!(ClearLightingOverlayRequest, LightingStateResult); + assert_endpoint_fits!(BeginLightingOverlayReplaceRequest, LightingOverlayTransactionResult); + assert_endpoint_fits!(PutLightingOverlayChunkRequest, LightingUnitResult); + assert_endpoint_fits!(CommitLightingOverlayReplaceRequest, LightingStateResult); + assert_endpoint_fits!(AbortLightingOverlayReplaceRequest, LightingUnitResult); + core::assert!(LightingChanged::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); +}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::rynk::tests::{assert_max_size_bound, round_trip}; + + fn cell(id: u16) -> LightingOverlayCell { + LightingOverlayCell { + led_id: LightingLedId(id), + effect: LightingEffect::Blink { + color: LightingRgb8 { r: 1, g: 2, b: 3 }, + period_ms: u32::MAX, + phase_ms: u32::MAX, + duty: 100, + }, + ttl_ms: Some(u32::MAX), + } + } + + #[test] + fn geometry_and_key_association_round_trip() { + round_trip(&LightingLed { + id: LightingLedId(42), + key: Some(LightingMatrixPosition { row: 3, col: 7 }), + position: Some(LightingPoint3 { x: -128, y: 256, z: 64 }), + zone_start: 2, + zone_len: 3, + }); + round_trip(&LightingLed { + id: LightingLedId(1000), + key: None, + position: None, + zone_start: 0, + zone_len: 0, + }); + } + + #[test] + fn maximum_overlay_chunk_respects_bound() { + let mut cells = Vec::new(); + for id in 0..LIGHTING_OVERLAY_CHUNK_SIZE as u16 { + cells.push(cell(id)).unwrap(); + } + let request = PutLightingOverlayChunkRequest { + transaction_id: u32::MAX, + offset: u16::MAX, + cells, + }; + round_trip(&request); + assert_max_size_bound(&request); + assert!(PutLightingOverlayChunkRequest::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + } + + #[test] + fn maximum_zone_page_respects_bound() { + let mut items = Vec::new(); + for id in 0..LIGHTING_PAGE_SIZE as u8 { + let mut name = String::new(); + for _ in 0..LIGHTING_ZONE_NAME_SIZE { + name.push('x').unwrap(); + } + items + .push(LightingZone { + id: LightingZoneId(id), + name, + }) + .unwrap(); + } + let page = LightingZonesPage { + topology_revision: u32::MAX, + total_count: u16::MAX, + items, + }; + round_trip(&page); + assert_max_size_bound(&page); + } + + #[test] + fn effect_and_ttl_validation_is_explicit() { + let mut valid = cell(1); + assert_eq!(valid.validate(), Ok(())); + valid.ttl_ms = Some(0); + assert_eq!(valid.validate(), Err(LightingError::InvalidTtl)); + valid.ttl_ms = None; + valid.effect = LightingEffect::Breathe { + color: LightingRgb8 { r: 1, g: 2, b: 3 }, + period_ms: 100, + phase_ms: 0, + step_ms: 100, + }; + assert_eq!(valid.validate(), Err(LightingError::InvalidEffect)); + } +} diff --git a/rmk-types/src/protocol/rynk/payload/mod.rs b/rmk-types/src/protocol/rynk/payload/mod.rs index f2f8ec5d0..283b01863 100644 --- a/rmk-types/src/protocol/rynk/payload/mod.rs +++ b/rmk-types/src/protocol/rynk/payload/mod.rs @@ -6,6 +6,8 @@ mod encoder; mod fork; mod keymap; mod layout; +#[cfg(feature = "lighting")] +mod lighting; mod macro_data; mod morse; mod status; @@ -17,6 +19,8 @@ pub use self::encoder::*; pub use self::fork::*; pub use self::keymap::*; pub use self::layout::*; +#[cfg(feature = "lighting")] +pub use self::lighting::*; pub use self::macro_data::*; pub use self::morse::*; pub use self::status::*; diff --git a/rmk-types/src/protocol/rynk/payload/system.rs b/rmk-types/src/protocol/rynk/payload/system.rs index 112dbe664..9a37c2556 100644 --- a/rmk-types/src/protocol/rynk/payload/system.rs +++ b/rmk-types/src/protocol/rynk/payload/system.rs @@ -20,8 +20,8 @@ pub struct ProtocolVersion { impl ProtocolVersion { /// Current protocol version for this firmware release. - /// Now the protocol is still being developed, so the version is v0.1 - pub const CURRENT: Self = Self { major: 0, minor: 1 }; + /// The protocol is still under development; lighting endpoints were added in v0.2. + pub const CURRENT: Self = Self { major: 0, minor: 2 }; } /// Device capabilities discovered during the connection handshake. diff --git a/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap b/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap new file mode 100644 index 000000000..ef4d3c989 --- /dev/null +++ b/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap @@ -0,0 +1,45 @@ +# Lighting wire-format FRAME snapshot — DO NOT edit by hand. +# File: snapshots/lighting_wire_frames.snap +# Each entry is one complete feature-gated lighting Rynk frame. The nested +# Ok/Err exemplars pin the outer Rynk result and inner lighting result. +# UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features rynk --features lighting lighting_wire_frames +# Format: