From 547e1102303d78486bedf76cd973aa569f4f30d3 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 20 Jul 2026 21:19:43 -0700 Subject: [PATCH 1/3] feat(rynk): define the lighting scene wire surface Add the protocol types for runtime-configurable per-layer scenes: LightingSceneCell (layer + stable LED id + effect), LightingLayerPolicy, a revision-pinned scenes page, single-cell set/unset requests, an atomic Begin/Put/Commit/Abort replacement transaction, and SetLightingLayerPolicy. Discovery deliberately leaves LightingCapabilities and LightingState byte-identical: postcard is positional, so appending fields would break new-host/old-firmware decode. Scenes are advertised through a new LAYER_SCENES bit in the existing LightingFeatureFlags plus a dedicated GetLightingSceneStatus endpoint carrying capacity, occupancy, and policy. New LightingError variants (UnknownLayer, SceneFull) are appended, so existing encodings are unchanged. Protocol version bumps to v0.3. Co-Authored-By: Claude Fable 5 --- .../main/docs/development/rynk_protocol.md | 137 +++++----- rmk-types/src/protocol/rynk/command.rs | 32 ++- .../src/protocol/rynk/payload/lighting.rs | 235 ++++++++++++++++++ rmk-types/src/protocol/rynk/payload/system.rs | 5 +- .../rynk/snapshots/lighting_wire_frames.snap | 20 ++ .../protocol/rynk/snapshots/wire_frames.snap | 2 +- .../protocol/rynk/snapshots/wire_values.snap | 2 +- rmk-types/src/protocol/rynk/tests.rs | 176 +++++++++++++ 8 files changed, 537 insertions(+), 72 deletions(-) diff --git a/docs/docs/main/docs/development/rynk_protocol.md b/docs/docs/main/docs/development/rynk_protocol.md index 458e93288..2a1d9f30e 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.2**. +Current protocol version: **0.3**. Every transport (USB CDC, BLE GATT, BLE HID) carries the same frame — a 5-byte header plus a [postcard](https://docs.rs/postcard)-encoded payload: @@ -24,69 +24,78 @@ Which commands a firmware answers depends on the RMK Cargo features it was built ## 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. | -| `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. | +| 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. | +| `0x0912` | `GetLightingSceneStatus` | `()` | `LightingSceneStatusResult` | `lighting` | Scene discovery lives outside `LightingCapabilities`/`LightingState` so their postcard layout stays stable for existing hosts. | +| `0x0913` | `GetLightingScenes` | `LightingScenePageRequest` | `LightingScenesPageResult` | `lighting` | Scene pages are pinned to `LightingState.revision` for consistency. | +| `0x0914` | `SetLightingSceneCell` | `SetLightingSceneCellRequest` | `LightingStateResult` | `lighting` | | +| `0x0915` | `UnsetLightingSceneCell` | `UnsetLightingSceneCellRequest` | `LightingStateResult` | `lighting` | | +| `0x0916` | `BeginLightingSceneReplace` | `BeginLightingSceneReplaceRequest` | `LightingSceneTransactionResult` | `lighting` | | +| `0x0917` | `PutLightingSceneChunk` | `PutLightingSceneChunkRequest` | `LightingUnitResult` | `lighting` | | +| `0x0918` | `CommitLightingSceneReplace` | `CommitLightingSceneReplaceRequest` | `LightingStateResult` | `lighting` | | +| `0x0919` | `AbortLightingSceneReplace` | `AbortLightingSceneReplaceRequest` | `LightingUnitResult` | `lighting` | | +| `0x091A` | `SetLightingLayerPolicy` | `SetLightingLayerPolicyRequest` | `LightingStateResult` | `lighting` | | ## Topics diff --git a/rmk-types/src/protocol/rynk/command.rs b/rmk-types/src/protocol/rynk/command.rs index 2788e7fe4..50e69f68b 100644 --- a/rmk-types/src/protocol/rynk/command.rs +++ b/rmk-types/src/protocol/rynk/command.rs @@ -33,12 +33,15 @@ use crate::morse::Morse; use crate::protocol::rynk::PeripheralStatus; #[cfg(feature = "lighting")] use crate::protocol::rynk::{ - AbortLightingOverlayReplaceRequest, BeginLightingOverlayReplaceRequest, ClearLightingOverlayRequest, - CommitLightingOverlayReplaceRequest, LightingCapabilitiesResult, LightingChanged, LightingKeysPageResult, + AbortLightingOverlayReplaceRequest, AbortLightingSceneReplaceRequest, BeginLightingOverlayReplaceRequest, + BeginLightingSceneReplaceRequest, ClearLightingOverlayRequest, CommitLightingOverlayReplaceRequest, + CommitLightingSceneReplaceRequest, LightingCapabilitiesResult, LightingChanged, LightingKeysPageResult, LightingLedsPageResult, LightingOutputsPageResult, LightingOverlayTransactionResult, LightingPageRequest, - LightingPhysicalKeysPageResult, LightingRoutesPageResult, LightingStateResult, LightingUnitResult, + LightingPhysicalKeysPageResult, LightingRoutesPageResult, LightingScenePageRequest, LightingSceneStatusResult, + LightingSceneTransactionResult, LightingScenesPageResult, LightingStateResult, LightingUnitResult, LightingZoneMembershipsPageResult, LightingZonesPageResult, PutLightingOverlayChunkRequest, - SetLightingOverlayRequest, SetLightingStateRequest, UnsetLightingOverlayRequest, + PutLightingSceneChunkRequest, SetLightingLayerPolicyRequest, SetLightingOverlayRequest, + SetLightingSceneCellRequest, SetLightingStateRequest, UnsetLightingOverlayRequest, UnsetLightingSceneCellRequest, }; /// `const fn` max/min used by the firmware payload-size fold and the bulk @@ -395,6 +398,27 @@ endpoints! { /// Logical matrix keys are distinct from optional physical geometry. #[cfg(feature = "lighting")] GetLightingKeys = 0x0911: LightingPageRequest => LightingKeysPageResult; + /// Scene discovery lives outside `LightingCapabilities`/`LightingState` + /// so their postcard layout stays stable for existing hosts. + #[cfg(feature = "lighting")] + GetLightingSceneStatus = 0x0912: () => LightingSceneStatusResult; + /// Scene pages are pinned to `LightingState.revision` for consistency. + #[cfg(feature = "lighting")] + GetLightingScenes = 0x0913: LightingScenePageRequest => LightingScenesPageResult; + #[cfg(feature = "lighting")] + SetLightingSceneCell = 0x0914: SetLightingSceneCellRequest => LightingStateResult; + #[cfg(feature = "lighting")] + UnsetLightingSceneCell = 0x0915: UnsetLightingSceneCellRequest => LightingStateResult; + #[cfg(feature = "lighting")] + BeginLightingSceneReplace = 0x0916: BeginLightingSceneReplaceRequest => LightingSceneTransactionResult; + #[cfg(feature = "lighting")] + PutLightingSceneChunk = 0x0917: PutLightingSceneChunkRequest => LightingUnitResult; + #[cfg(feature = "lighting")] + CommitLightingSceneReplace = 0x0918: CommitLightingSceneReplaceRequest => LightingStateResult; + #[cfg(feature = "lighting")] + AbortLightingSceneReplace = 0x0919: AbortLightingSceneReplaceRequest => LightingUnitResult; + #[cfg(feature = "lighting")] + SetLightingLayerPolicy = 0x091A: SetLightingLayerPolicyRequest => LightingStateResult; } // Define topics: `Name = value: Payload;` diff --git a/rmk-types/src/protocol/rynk/payload/lighting.rs b/rmk-types/src/protocol/rynk/payload/lighting.rs index 8dd116638..ff3821a3c 100644 --- a/rmk-types/src/protocol/rynk/payload/lighting.rs +++ b/rmk-types/src/protocol/rynk/payload/lighting.rs @@ -15,6 +15,8 @@ pub const LIGHTING_PAYLOAD_SIZE: usize = 256; pub const LIGHTING_PAGE_SIZE: usize = 8; /// Number of overlay cells in one replacement chunk. pub const LIGHTING_OVERLAY_CHUNK_SIZE: usize = 8; +/// Number of scene cells in one scene page or replacement chunk. +pub const LIGHTING_SCENE_CHUNK_SIZE: usize = 8; /// Maximum UTF-8 byte length of a zone name. pub const LIGHTING_ZONE_NAME_SIZE: usize = 24; @@ -178,6 +180,8 @@ impl LightingFeatureFlags { pub const OVERLAY_TTL: u16 = 1 << 3; pub const ATOMIC_OVERLAY_REPLACE: u16 = 1 << 4; pub const LAYER_AWARE: u16 = 1 << 5; + /// Runtime-configurable per-layer scenes stored on the device. + pub const LAYER_SCENES: u16 = 1 << 6; pub const fn contains(self, bits: u16) -> bool { self.0 & bits == bits @@ -438,6 +442,147 @@ wire_type! { } } +wire_type! { + /// Wire mirror of the engine's layer composition policy. + pub enum LightingLayerPolicy { + /// Only the effective layer contributes scene cells. + EffectiveOnly, + /// Default first, then the active set in ascending precedence, with + /// the effective layer last. Sparse cells fall through. + ActiveStack, + } +} + +wire_type! { + /// One durable scene cell: an effect bound to a stable LED on one layer. + pub struct LightingSceneCell { + pub layer: u8, + pub led_id: LightingLedId, + pub effect: LightingEffect, + } +} + +impl LightingSceneCell { + /// Validate the effect. Layer and LED bounds are checked against the + /// live keymap and topology by the firmware service. + pub const fn validate(&self) -> LightingResult<()> { + self.effect.validate() + } +} + +wire_type! { + /// Scene limits and current occupancy. Kept out of + /// [`LightingCapabilities`]/[`LightingState`] so their postcard layout is + /// unchanged for existing hosts; discovery uses + /// [`LightingFeatureFlags::LAYER_SCENES`] plus this endpoint. + pub struct LightingSceneStatus { + /// Current [`LightingState::revision`]; scene mutations advance it. + pub revision: u32, + /// Maximum stored scene cells. `0` means scenes are absent. + pub capacity: u16, + pub scene_len: u16, + pub policy: LightingLayerPolicy, + /// Cells per `GetLightingScenes` page and per replacement chunk. + pub chunk_capacity: u8, + } +} + +wire_type! { + /// Revision-pinned request for one scene page. `revision` is the expected + /// [`LightingState::revision`]; a stale read is rejected so multi-page + /// reads stay self-consistent. + pub struct LightingScenePageRequest { + pub revision: u32, + pub offset: u16, + } +} + +/// One page of stored scene cells, echoing the pinned state revision. +#[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 LightingScenesPage { + pub revision: u32, + pub total_count: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingSceneCell[]"))] + pub items: Vec, +} + +impl MaxSize for LightingScenesPage { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +wire_type! { + pub struct SetLightingSceneCellRequest { + pub expected_revision: u32, + pub cell: LightingSceneCell, + } +} + +wire_type! { + pub struct UnsetLightingSceneCellRequest { + pub expected_revision: u32, + pub layer: u8, + pub led_id: LightingLedId, + } +} + +wire_type! { + pub struct SetLightingLayerPolicyRequest { + pub expected_revision: u32, + pub policy: LightingLayerPolicy, + } +} + +wire_type! { + /// Begin an atomic, multi-packet scene-table replacement. + pub struct BeginLightingSceneReplaceRequest { + pub expected_revision: u32, + pub cell_count: u16, + } +} + +wire_type! { + /// Opaque scene transaction token allocated by the firmware. + pub struct LightingSceneTransaction { + pub id: u32, + pub cell_count: u16, + } +} + +/// One ordered scene 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 PutLightingSceneChunkRequest { + pub transaction_id: u32, + pub offset: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingSceneCell[]"))] + pub cells: Vec, +} + +impl MaxSize for PutLightingSceneChunkRequest { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +wire_type! { + pub struct CommitLightingSceneReplaceRequest { + pub transaction_id: u32, + } +} + +wire_type! { + pub struct AbortLightingSceneReplaceRequest { + pub transaction_id: u32, + } +} + wire_type! { /// Lighting-domain rejection carried inside Rynk's outer protocol result. pub enum LightingError { @@ -453,6 +598,10 @@ wire_type! { InvalidTransaction, TransactionExpired, TransactionIncomplete { expected: u16, received: u16 }, + // Appended after the first lighting ICD; new variants only surface + // from the new scene endpoints, so older hosts never decode them. + UnknownLayer { layer: u8 }, + SceneFull { capacity: u16 }, } } @@ -468,6 +617,9 @@ pub type LightingZoneMembershipsPageResult = LightingResult; pub type LightingRoutesPageResult = LightingResult; pub type LightingOverlayTransactionResult = LightingResult; +pub type LightingSceneStatusResult = LightingResult; +pub type LightingScenesPageResult = LightingResult; +pub type LightingSceneTransactionResult = LightingResult; pub type LightingUnitResult = LightingResult<()>; wire_type! { @@ -503,6 +655,15 @@ const _: () = { assert_endpoint_fits!(PutLightingOverlayChunkRequest, LightingUnitResult); assert_endpoint_fits!(CommitLightingOverlayReplaceRequest, LightingStateResult); assert_endpoint_fits!(AbortLightingOverlayReplaceRequest, LightingUnitResult); + assert_endpoint_fits!((), LightingSceneStatusResult); + assert_endpoint_fits!(LightingScenePageRequest, LightingScenesPageResult); + assert_endpoint_fits!(SetLightingSceneCellRequest, LightingStateResult); + assert_endpoint_fits!(UnsetLightingSceneCellRequest, LightingStateResult); + assert_endpoint_fits!(SetLightingLayerPolicyRequest, LightingStateResult); + assert_endpoint_fits!(BeginLightingSceneReplaceRequest, LightingSceneTransactionResult); + assert_endpoint_fits!(PutLightingSceneChunkRequest, LightingUnitResult); + assert_endpoint_fits!(CommitLightingSceneReplaceRequest, LightingStateResult); + assert_endpoint_fits!(AbortLightingSceneReplaceRequest, LightingUnitResult); core::assert!(LightingChanged::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); }; @@ -582,6 +743,80 @@ mod tests { assert_max_size_bound(&page); } + fn scene_cell(layer: u8, id: u16) -> LightingSceneCell { + LightingSceneCell { + layer, + led_id: LightingLedId(id), + effect: LightingEffect::Breathe { + color: LightingRgb8 { r: 4, g: 5, b: 6 }, + period_ms: u32::MAX, + phase_ms: u32::MAX, + step_ms: u16::MAX - 1, + }, + } + } + + #[test] + fn scene_types_round_trip() { + round_trip(&LightingLayerPolicy::EffectiveOnly); + round_trip(&LightingLayerPolicy::ActiveStack); + round_trip(&scene_cell(3, 42)); + round_trip(&LightingSceneStatus { + revision: u32::MAX, + capacity: 256, + scene_len: 12, + policy: LightingLayerPolicy::ActiveStack, + chunk_capacity: LIGHTING_SCENE_CHUNK_SIZE as u8, + }); + round_trip(&LightingSceneTransaction { + id: u32::MAX, + cell_count: u16::MAX, + }); + round_trip(&LightingError::UnknownLayer { layer: 9 }); + round_trip(&LightingError::SceneFull { capacity: 256 }); + } + + #[test] + fn maximum_scene_chunk_and_page_respect_bounds() { + let mut cells = Vec::new(); + for id in 0..LIGHTING_SCENE_CHUNK_SIZE as u16 { + cells.push(scene_cell(u8::MAX, id)).unwrap(); + } + let request = PutLightingSceneChunkRequest { + transaction_id: u32::MAX, + offset: u16::MAX, + cells: cells.clone(), + }; + round_trip(&request); + assert_max_size_bound(&request); + assert!(PutLightingSceneChunkRequest::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + + let page = LightingScenesPage { + revision: u32::MAX, + total_count: u16::MAX, + items: cells, + }; + round_trip(&page); + assert_max_size_bound(&page); + assert!(LightingScenesPage::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + } + + #[test] + fn scene_cell_validation_is_effect_validation() { + assert_eq!(scene_cell(0, 1).validate(), Ok(())); + let invalid = LightingSceneCell { + layer: 0, + led_id: LightingLedId(1), + effect: LightingEffect::Blink { + color: LightingRgb8 { r: 1, g: 2, b: 3 }, + period_ms: 0, + phase_ms: 0, + duty: 50, + }, + }; + assert_eq!(invalid.validate(), Err(LightingError::InvalidEffect)); + } + #[test] fn effect_and_ttl_validation_is_explicit() { let mut valid = cell(1); diff --git a/rmk-types/src/protocol/rynk/payload/system.rs b/rmk-types/src/protocol/rynk/payload/system.rs index 5ce6ef1c1..1a035e097 100644 --- a/rmk-types/src/protocol/rynk/payload/system.rs +++ b/rmk-types/src/protocol/rynk/payload/system.rs @@ -20,8 +20,9 @@ pub struct ProtocolVersion { impl ProtocolVersion { /// Current protocol version for this firmware release. - /// The protocol is still under development; lighting endpoints were added in v0.2. - pub const CURRENT: Self = Self { major: 0, minor: 2 }; + /// The protocol is still under development; lighting endpoints were added + /// in v0.2 and lighting scene endpoints in v0.3. + pub const CURRENT: Self = Self { major: 0, minor: 3 }; } /// 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 index ef4d3c989..f0fd64d8f 100644 --- a/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap +++ b/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap @@ -7,12 +7,18 @@ AbortLightingOverlayReplace reply 10 09 01 02 00 00 00 AbortLightingOverlayReplace request 10 09 01 01 00 0d +AbortLightingSceneReplace reply 19 09 01 02 00 00 00 +AbortLightingSceneReplace request 19 09 01 01 00 15 BeginLightingOverlayReplace reply 0d 09 01 04 00 00 00 0d 01 BeginLightingOverlayReplace request 0d 09 01 02 00 0c 01 +BeginLightingSceneReplace reply 16 09 01 04 00 00 00 15 01 +BeginLightingSceneReplace request 16 09 01 02 00 0c 01 ClearLightingOverlay reply 0c 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 ClearLightingOverlay request 0c 09 01 01 00 0b CommitLightingOverlayReplace reply 0f 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 CommitLightingOverlayReplace request 0f 09 01 01 00 0d +CommitLightingSceneReplace reply 18 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 +CommitLightingSceneReplace request 18 09 01 01 00 15 GetLightingCapabilities reply 01 09 01 0f 00 00 00 01 02 03 04 05 06 07 08 09 08 08 3f 07 GetLightingCapabilities request 01 09 01 00 00 GetLightingKeys reply 11 09 01 07 00 00 00 01 01 01 01 02 @@ -25,6 +31,10 @@ GetLightingPhysicalKeys reply 04 09 01 13 00 00 00 01 0 GetLightingPhysicalKeys request 04 09 01 02 00 01 00 GetLightingRoutes reply 09 09 01 09 00 00 00 01 01 01 2a 01 02 07 GetLightingRoutes request 09 09 01 02 00 01 00 +GetLightingSceneStatus reply 12 09 01 08 00 00 00 09 80 02 01 01 08 +GetLightingSceneStatus request 12 09 01 00 00 +GetLightingScenes reply 13 09 01 0b 00 00 00 09 01 01 02 2a 00 07 08 09 +GetLightingScenes request 13 09 01 02 00 09 00 GetLightingState reply 02 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 GetLightingState request 02 09 01 00 00 GetLightingZoneMemberships reply 07 09 01 06 00 00 00 01 01 01 04 @@ -35,11 +45,21 @@ LightingChange topic 07 80 00 00 00 PutLightingOverlayChunk inner Err(TransactionExpired) 0e 09 01 03 00 00 01 0a PutLightingOverlayChunk reply 0e 09 01 02 00 00 00 PutLightingOverlayChunk request 0e 09 01 10 00 0d 00 01 2a 01 01 02 03 e8 07 fa 01 32 01 88 27 +PutLightingSceneChunk reply 17 09 01 02 00 00 00 +PutLightingSceneChunk request 17 09 01 09 00 15 00 01 02 2a 00 07 08 09 +SetLightingLayerPolicy reply 1a 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 +SetLightingLayerPolicy request 1a 09 01 02 00 0b 00 SetLightingOverlay inner Err(UnknownLed) 0a 09 01 05 00 00 01 06 e7 07 SetLightingOverlay outer Err(Locked) 0a 09 01 02 00 01 07 SetLightingOverlay reply 0a 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 SetLightingOverlay request 0a 09 01 0e 00 09 2a 01 01 02 03 e8 07 fa 01 32 01 88 27 +SetLightingSceneCell inner Err(SceneFull) 14 09 01 05 00 00 01 0d 80 02 +SetLightingSceneCell inner Err(UnknownLayer) 14 09 01 04 00 00 01 0c c8 +SetLightingSceneCell reply 14 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 +SetLightingSceneCell request 14 09 01 07 00 09 02 2a 00 07 08 09 SetLightingState reply 03 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 SetLightingState request 03 09 01 09 00 08 01 c8 01 0a 14 1e 28 01 UnsetLightingOverlay reply 0b 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 UnsetLightingOverlay request 0b 09 01 02 00 0a 2a +UnsetLightingSceneCell reply 15 09 01 0c 00 00 00 09 01 c8 01 0a 14 1e 28 01 01 +UnsetLightingSceneCell request 15 09 01 03 00 0a 02 2a diff --git a/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap b/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap index 18eb2da25..68850facf 100644 --- a/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap +++ b/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap @@ -44,7 +44,7 @@ GetMorse reply Ok(Morse{TAP->Key(A)}) 01 04 01 GetMorse request 0 01 04 01 01 00 00 GetSleepState reply Ok(true) 06 08 01 02 00 00 01 GetSleepState request () 06 08 01 00 00 -GetVersion reply Ok(CURRENT) 01 00 01 03 00 00 00 02 +GetVersion reply Ok(CURRENT) 01 00 01 03 00 00 00 03 GetVersion request () 01 00 01 00 00 GetWpm reply Ok(42) 05 08 01 02 00 00 2a GetWpm request () 05 08 01 00 00 diff --git a/rmk-types/src/protocol/rynk/snapshots/wire_values.snap b/rmk-types/src/protocol/rynk/snapshots/wire_values.snap index 257fc628e..b482397f3 100644 --- a/rmk-types/src/protocol/rynk/snapshots/wire_values.snap +++ b/rmk-types/src/protocol/rynk/snapshots/wire_values.snap @@ -64,7 +64,7 @@ ModifierCombination(LCtrl|RGui) 81 MorseProfile(Normal,200,150) c8 81 b0 89 0c Morse{TAP->Key(A)} 00 01 02 01 00 04 MouseButtons(B1|B8) 81 -ProtocolVersion::CURRENT 00 02 +ProtocolVersion::CURRENT 00 03 ProtocolVersion{1,0} 01 00 Result<(),RynkError>::Err(StorageFault) 01 02 Result<(),RynkError>::Ok 00 diff --git a/rmk-types/src/protocol/rynk/tests.rs b/rmk-types/src/protocol/rynk/tests.rs index 0fccc36de..0f56fc0c4 100644 --- a/rmk-types/src/protocol/rynk/tests.rs +++ b/rmk-types/src/protocol/rynk/tests.rs @@ -1114,6 +1114,58 @@ fn lighting_wire_frames_locked() { let abort = AbortLightingOverlayReplaceRequest { transaction_id: transaction.id, }; + let scene_cell = LightingSceneCell { + layer: 2, + led_id: led.id, + effect: LightingEffect::Solid { + color: LightingRgb8 { r: 7, g: 8, b: 9 }, + }, + }; + let scene_status = LightingSceneStatus { + revision: state.revision, + capacity: 256, + scene_len: 1, + policy: LightingLayerPolicy::ActiveStack, + chunk_capacity: LIGHTING_SCENE_CHUNK_SIZE as u8, + }; + let scene_page_request = LightingScenePageRequest { + revision: state.revision, + offset: 0, + }; + let scenes_page = LightingScenesPage { + revision: state.revision, + total_count: 1, + items: one(scene_cell), + }; + let set_scene_cell = SetLightingSceneCellRequest { + expected_revision: 9, + cell: scene_cell, + }; + let unset_scene_cell = UnsetLightingSceneCellRequest { + expected_revision: 10, + layer: scene_cell.layer, + led_id: led.id, + }; + let set_layer_policy = SetLightingLayerPolicyRequest { + expected_revision: 11, + policy: LightingLayerPolicy::EffectiveOnly, + }; + let scene_begin = BeginLightingSceneReplaceRequest { + expected_revision: 12, + cell_count: 1, + }; + let scene_transaction = LightingSceneTransaction { id: 21, cell_count: 1 }; + let scene_put = PutLightingSceneChunkRequest { + transaction_id: scene_transaction.id, + offset: 0, + cells: one(scene_cell), + }; + let scene_commit = CommitLightingSceneReplaceRequest { + transaction_id: scene_transaction.id, + }; + let scene_abort = AbortLightingSceneReplaceRequest { + transaction_id: scene_transaction.id, + }; let entries: alloc::vec::Vec<(&str, alloc::vec::Vec)> = alloc::vec![ ( @@ -1346,6 +1398,130 @@ fn lighting_wire_frames_locked() { &Ok::(Err(LightingError::TransactionExpired)) ) ), + ( + "GetLightingSceneStatus request", + encode_frame(Cmd::GetLightingSceneStatus, SEQ, &()) + ), + ( + "GetLightingSceneStatus reply", + encode_frame( + Cmd::GetLightingSceneStatus, + SEQ, + &Ok::(Ok(scene_status)) + ) + ), + ( + "GetLightingScenes request", + encode_frame(Cmd::GetLightingScenes, SEQ, &scene_page_request) + ), + ( + "GetLightingScenes reply", + encode_frame( + Cmd::GetLightingScenes, + SEQ, + &Ok::(Ok(scenes_page)) + ) + ), + ( + "SetLightingSceneCell request", + encode_frame(Cmd::SetLightingSceneCell, SEQ, &set_scene_cell) + ), + ( + "SetLightingSceneCell reply", + encode_frame( + Cmd::SetLightingSceneCell, + SEQ, + &Ok::(Ok(state)) + ) + ), + ( + "UnsetLightingSceneCell request", + encode_frame(Cmd::UnsetLightingSceneCell, SEQ, &unset_scene_cell) + ), + ( + "UnsetLightingSceneCell reply", + encode_frame( + Cmd::UnsetLightingSceneCell, + SEQ, + &Ok::(Ok(state)) + ) + ), + ( + "SetLightingLayerPolicy request", + encode_frame(Cmd::SetLightingLayerPolicy, SEQ, &set_layer_policy) + ), + ( + "SetLightingLayerPolicy reply", + encode_frame( + Cmd::SetLightingLayerPolicy, + SEQ, + &Ok::(Ok(state)) + ) + ), + ( + "BeginLightingSceneReplace request", + encode_frame(Cmd::BeginLightingSceneReplace, SEQ, &scene_begin) + ), + ( + "BeginLightingSceneReplace reply", + encode_frame( + Cmd::BeginLightingSceneReplace, + SEQ, + &Ok::(Ok(scene_transaction)) + ) + ), + ( + "PutLightingSceneChunk request", + encode_frame(Cmd::PutLightingSceneChunk, SEQ, &scene_put) + ), + ( + "PutLightingSceneChunk reply", + encode_frame( + Cmd::PutLightingSceneChunk, + SEQ, + &Ok::(Ok(())) + ) + ), + ( + "CommitLightingSceneReplace request", + encode_frame(Cmd::CommitLightingSceneReplace, SEQ, &scene_commit) + ), + ( + "CommitLightingSceneReplace reply", + encode_frame( + Cmd::CommitLightingSceneReplace, + SEQ, + &Ok::(Ok(state)) + ) + ), + ( + "AbortLightingSceneReplace request", + encode_frame(Cmd::AbortLightingSceneReplace, SEQ, &scene_abort) + ), + ( + "AbortLightingSceneReplace reply", + encode_frame( + Cmd::AbortLightingSceneReplace, + SEQ, + &Ok::(Ok(())) + ) + ), + ( + "SetLightingSceneCell inner Err(UnknownLayer)", + encode_frame( + Cmd::SetLightingSceneCell, + SEQ, + &Ok::(Err(LightingError::UnknownLayer { layer: 200 })) + ) + ), + ( + "SetLightingSceneCell inner Err(SceneFull)", + encode_frame( + Cmd::SetLightingSceneCell, + SEQ, + &Ok::(Err(LightingError::SceneFull { capacity: 256 })) + ) + ), ( "LightingChange topic", encode_frame(Cmd::LightingChange, 0, &LightingChanged) From 609bc2d2252c85cbfbb45d46fbd0933497b43b6f Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 20 Jul 2026 21:21:39 -0700 Subject: [PATCH 2/3] feat(lighting): add runtime-configurable per-layer scenes over Rynk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the standard engine an owned, fixed-capacity SceneTable (new SCENE_CAP const generic, default 0) holding per-layer, per-slot effects with the same EffectiveOnly/ActiveStack composition semantics as the static LayerScenes. The table composes in the layer band immediately after the board's static scenes — a runtime cell overrides a static default for the same slot — while the TTL overlay stays highest-priority. Scene mutations are revision-checked commands; whole-table replacement stages inside the engine via bounded chunks (Begin/Put/Commit/Abort) with inactivity expiry and an idempotent commit. Staging engine-side instead of reusing the host-side overlay staging keeps kilobyte-sized scene batches off the bounded mailbox channels, whose payloads are copied by value. Scene reads and transaction reservation need non-state readback, so the engine's reply type becomes the StandardReply enum and StandardState gains scene_len/scene_policy. The Rynk service exposes the table through nine endpoints for status, revision-pinned paged reads, single-cell set/unset, layer policy, and the chunked replacement transaction. It validates effects, layer bounds (against the live keymap), and stable LED ids (against the topology) before anything reaches the engine; boards opt in by advertising a capacity via RynkLightingController::with_scene_capacity, and scene endpoints reject with Unsupported when none is wired. Scene writes honor the same unlock gate as the other lighting writes. Scene configuration is durable config like the keymap: after every successful mutation the adapter reads the table back out of the engine and persists it as one header record (len + policy) plus chunk-sized shard records in wire (stable LED id) form. The storage task compares before writing so unchanged shards cost no flash traffic; a storage reset clears them. Boards load persisted scenes at boot with Storage::read_lighting_scenes + install_lighting_scenes, which skips cells whose LED id no longer resolves. Lighting featuresets join the CI test/clippy matrix. Composed with the split renderer replicas underneath: the runtime scene table travels inside StandardReplicaState, so replica renderers draw runtime scenes exactly like the authority without ever seeing the incremental scene mutation commands. StandardReplicaState/-Slot and StandardCommand gain a trailing SCENE_CAP const generic (default 0), ExportReplica snapshots the table and ApplyReplica installs it, and the replica round-trip test now covers a runtime scene cell. Co-Authored-By: Claude Fable 5 --- .github/ci/_lib.sh | 2 + rmk/CHANGELOG.md | 1 + rmk/src/host/mod.rs | 2 +- rmk/src/host/rynk/handlers/lighting.rs | 604 ++++++++++++- rmk/src/host/rynk/lighting.rs | 470 +++++++++- rmk/src/host/rynk/mod.rs | 29 +- rmk/src/host/via/rgb_matrix.rs | 22 +- rmk/src/lighting/mod.rs | 5 +- rmk/src/lighting/processor.rs | 13 + rmk/src/lighting/standard.rs | 1153 +++++++++++++++++++++++- rmk/src/storage/mod.rs | 95 ++ 11 files changed, 2304 insertions(+), 92 deletions(-) diff --git a/.github/ci/_lib.sh b/.github/ci/_lib.sh index 319e6cb11..95eeb2897 100644 --- a/.github/ci/_lib.sh +++ b/.github/ci/_lib.sh @@ -51,6 +51,8 @@ RMK_FEATURESETS=( "split,vial,storage,async_matrix,_ble,steno" "rynk,_ble,split,storage,async_matrix" "rynk,storage" + "rynk,lighting" + "rynk,storage,lighting" ) # Examples auto-discovery skiplist. Reasons: diff --git a/rmk/CHANGELOG.md b/rmk/CHANGELOG.md index d627184d0..8da9260be 100644 --- a/rmk/CHANGELOG.md +++ b/rmk/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Add [Rynk](https://rmk.rs/docs/features/rynk), RMK's native host protocol for on-the-fly configuration over USB and BLE — an opt-in alternative to Vial that covers every RMK feature (keymap, layers, encoders, combos, forks, tap-dance/morse, macros, and behavior config), plus live status (current layer, matrix tester, WPM, HID indicators, battery, connection/BLE profile) and device management (reboot, bootloader, storage reset). Enable with the `rynk` Cargo feature and `[host] rynk_enabled = true`; it is mutually exclusive with Vial. Dangerous operations (bootloader, storage reset, matrix tester, clearing a BLE bond) are gated behind a physical-presence unlock (`[host].unlock_keys`). Host client crates live in the new `rynk/` workspace ([#962](https://github.com/HaoboGu/rmk/pull/962)) +- Add runtime-configurable per-layer lighting scenes over Rynk: hosts can store per-layer, per-LED effects on the keyboard (`GetLightingSceneStatus`/`GetLightingScenes`, `SetLightingSceneCell`/`UnsetLightingSceneCell`, an atomic `BeginLightingSceneReplace`/`PutLightingSceneChunk`/`CommitLightingSceneReplace`/`AbortLightingSceneReplace` transaction, and `SetLightingLayerPolicy`). Scene cells compose in the layer band — above the board's static layer scenes, below the transient TTL overlay — and persist via RMK storage, loading on boot and clearing with a storage reset. Boards opt in by choosing a scene capacity on the standard lighting engine and advertising it with `RynkLightingController::with_scene_capacity` - Add Azoteq IQS5xx (IQS550 / IQS572 / IQS525) trackpad driver, used by Azoteq's TPS43/TPS65 modules. Supports operation with or without an `RDY` pin and is configurable via `keyboard.toml` on nRF52 / RP2040; currently publishes single-finger relative cursor movement only ([#29](https://github.com/HaoboGu/rmk/issues/29)) - Add PMW3360 / PMW3389 optical mouse sensor support - Add `report_hz` option for Pmw3610Device diff --git a/rmk/src/host/mod.rs b/rmk/src/host/mod.rs index bee2fe24c..94f49458e 100644 --- a/rmk/src/host/mod.rs +++ b/rmk/src/host/mod.rs @@ -29,7 +29,7 @@ pub use rynk::run_rynk_uart; #[cfg(all(feature = "rynk", feature = "lighting"))] pub use rynk::{ RYNK_LIGHTING_TRANSACTION_CAPACITY, RynkLightingController, RynkLightingDescriptor, RynkLightingMailbox, - StandardRynkLightingAdapter, + RynkLightingReadback, StandardRynkLightingAdapter, install_lighting_scenes, }; #[cfg(feature = "vial")] pub use via::VialService as HostService; diff --git a/rmk/src/host/rynk/handlers/lighting.rs b/rmk/src/host/rynk/handlers/lighting.rs index 6995e8723..2a755a0c7 100644 --- a/rmk/src/host/rynk/handlers/lighting.rs +++ b/rmk/src/host/rynk/handlers/lighting.rs @@ -4,24 +4,30 @@ use embassy_time::Instant; use heapless::{String, Vec}; use rmk_types::protocol::rynk::command::{ ClearLightingOverlay, GetLightingCapabilities, GetLightingKeys, GetLightingLeds, GetLightingOutputs, - GetLightingPhysicalKeys, GetLightingRoutes, GetLightingState, GetLightingZoneMemberships, GetLightingZones, - SetLightingOverlay, SetLightingState, UnsetLightingOverlay, + GetLightingPhysicalKeys, GetLightingRoutes, GetLightingSceneStatus, GetLightingScenes, GetLightingState, + GetLightingZoneMemberships, GetLightingZones, SetLightingLayerPolicy, SetLightingOverlay, SetLightingSceneCell, + SetLightingState, UnsetLightingOverlay, UnsetLightingSceneCell, }; use rmk_types::protocol::rynk::{ - AbortLightingOverlayReplaceRequest, BeginLightingOverlayReplaceRequest, ClearLightingOverlayRequest, - CommitLightingOverlayReplaceRequest, LIGHTING_PAGE_SIZE, LIGHTING_ZONE_NAME_SIZE, LightingCapabilities, - LightingCapabilitiesResult, LightingEffectFlags, LightingError, LightingFeatureFlags, LightingKeysPage, - LightingKeysPageResult, LightingLed, LightingLedId, LightingLedsPage, LightingLedsPageResult, + AbortLightingOverlayReplaceRequest, AbortLightingSceneReplaceRequest, BeginLightingOverlayReplaceRequest, + BeginLightingSceneReplaceRequest, ClearLightingOverlayRequest, CommitLightingOverlayReplaceRequest, + CommitLightingSceneReplaceRequest, LIGHTING_PAGE_SIZE, LIGHTING_SCENE_CHUNK_SIZE, LIGHTING_ZONE_NAME_SIZE, + LightingCapabilities, LightingCapabilitiesResult, LightingEffectFlags, LightingError, LightingFeatureFlags, + LightingKeysPage, LightingKeysPageResult, LightingLed, LightingLedId, LightingLedsPage, LightingLedsPageResult, LightingMatrixPosition, LightingOutput, LightingOutputCapabilities, LightingOutputCoverage, LightingOutputsPage, LightingOutputsPageResult, LightingOverlayCell, LightingOverlayTransaction, LightingOverlayTransactionResult, LightingPageRequest, LightingPhysicalKey, LightingPhysicalKeysPage, LightingPhysicalKeysPageResult, LightingPoint3, - LightingResult, LightingRoute, LightingRoutesPage, LightingRoutesPageResult, LightingState, LightingStateResult, - LightingUnitResult, LightingZone, LightingZoneId, LightingZoneMembershipsPage, LightingZoneMembershipsPageResult, - LightingZonesPage, LightingZonesPageResult, PutLightingOverlayChunkRequest, RynkError, RynkMessage, - SetLightingOverlayRequest, SetLightingStateRequest, UnsetLightingOverlayRequest, + LightingResult, LightingRoute, LightingRoutesPage, LightingRoutesPageResult, LightingScenePageRequest, + LightingSceneStatus, LightingSceneStatusResult, LightingSceneTransactionResult, LightingScenesPageResult, + LightingState, LightingStateResult, LightingUnitResult, LightingZone, LightingZoneId, LightingZoneMembershipsPage, + LightingZoneMembershipsPageResult, LightingZonesPage, LightingZonesPageResult, PutLightingOverlayChunkRequest, + PutLightingSceneChunkRequest, RynkError, RynkMessage, SetLightingLayerPolicyRequest, SetLightingOverlayRequest, + SetLightingSceneCellRequest, SetLightingStateRequest, UnsetLightingOverlayRequest, UnsetLightingSceneCellRequest, }; -use super::super::lighting::{RYNK_LIGHTING_TRANSACTION_CAPACITY, RynkLightingCommand, RynkLightingController}; +use super::super::lighting::{ + RYNK_LIGHTING_TRANSACTION_CAPACITY, RynkLightingCommand, RynkLightingController, RynkLightingReadback, +}; use super::super::{RynkService, RynkSession}; use super::Handle; use crate::lighting::OutputCoverage; @@ -32,6 +38,16 @@ fn controller<'a>(service: &RynkService<'a>) -> LightingResult(service: &RynkService<'a>) -> LightingResult> { + let controller = controller(service)?; + if controller.scene_capacity == 0 { + return Err(LightingError::Unsupported); + } + Ok(controller) +} + impl Handle for RynkService<'_> { async fn handle(&self, _: ()) -> Result { Ok(controller(self).map(capabilities)) @@ -41,7 +57,7 @@ impl Handle for RynkService<'_> { impl Handle for RynkService<'_> { async fn handle(&self, _: ()) -> Result { Ok(match controller(self) { - Ok(controller) => controller.request(RynkLightingCommand::ReadState).await, + Ok(controller) => controller.request_state(RynkLightingCommand::ReadState).await, Err(error) => Err(error), }) } @@ -52,7 +68,7 @@ impl Handle for RynkService<'_> { let result = match controller(self) { Ok(controller) => { controller - .request(RynkLightingCommand::SetState { + .request_state(RynkLightingCommand::SetState { expected_revision: req.expected_revision, state: req.state, }) @@ -72,7 +88,7 @@ impl Handle for RynkService<'_> { let result = match controller(self) { Ok(controller) => { controller - .request(RynkLightingCommand::SetOverlay { + .request_state(RynkLightingCommand::SetOverlay { expected_revision: req.expected_revision, cell: req.cell, }) @@ -89,7 +105,7 @@ impl Handle for RynkService<'_> { let result = match controller(self) { Ok(controller) => { controller - .request(RynkLightingCommand::UnsetOverlay { + .request_state(RynkLightingCommand::UnsetOverlay { expected_revision: req.expected_revision, led_id: req.led_id, }) @@ -106,8 +122,126 @@ impl Handle for RynkService<'_> { let result = match controller(self) { Ok(controller) => { controller - .request(RynkLightingCommand::ClearOverlay { + .request_state(RynkLightingCommand::ClearOverlay { + expected_revision: req.expected_revision, + }) + .await + } + Err(error) => Err(error), + }; + Ok(result) + } +} + +impl RynkService<'_> { + /// `UnknownLayer` for a scene layer outside the live keymap. + fn check_scene_layer(&self, layer: u8) -> LightingResult<()> { + let (_, _, num_layers) = self.ctx.keymap_dimensions(); + if (layer as usize) < num_layers { + Ok(()) + } else { + Err(LightingError::UnknownLayer { layer }) + } + } +} + +impl Handle for RynkService<'_> { + async fn handle(&self, _: ()) -> Result { + let result = match scene_controller(self) { + Ok(controller) => match controller.request(RynkLightingCommand::ReadSceneStatus).await { + Ok(RynkLightingReadback::SceneStatus { + revision, + scene_len, + policy, + }) => Ok(LightingSceneStatus { + revision, + capacity: controller.scene_capacity, + scene_len, + policy, + chunk_capacity: LIGHTING_SCENE_CHUNK_SIZE as u8, + }), + Ok(_) => Err(LightingError::InvalidRequest), + Err(error) => Err(error), + }, + Err(error) => Err(error), + }; + Ok(result) + } +} + +impl Handle for RynkService<'_> { + async fn handle(&self, req: LightingScenePageRequest) -> Result { + let result = match scene_controller(self) { + Ok(controller) => match controller + .request(RynkLightingCommand::ReadScenes { + expected_revision: req.revision, + offset: req.offset, + }) + .await + { + Ok(RynkLightingReadback::ScenesPage(page)) => Ok(page), + Ok(_) => Err(LightingError::InvalidRequest), + Err(error) => Err(error), + }, + Err(error) => Err(error), + }; + Ok(result) + } +} + +impl Handle for RynkService<'_> { + async fn handle(&self, req: SetLightingSceneCellRequest) -> Result { + if let Err(error) = req + .cell + .validate() + .and_then(|()| self.check_scene_layer(req.cell.layer)) + { + return Ok(Err(error)); + } + let result = match scene_controller(self) { + Ok(controller) => { + controller + .request_state(RynkLightingCommand::SetSceneCell { + expected_revision: req.expected_revision, + cell: req.cell, + }) + .await + } + Err(error) => Err(error), + }; + Ok(result) + } +} + +impl Handle for RynkService<'_> { + async fn handle(&self, req: UnsetLightingSceneCellRequest) -> Result { + if let Err(error) = self.check_scene_layer(req.layer) { + return Ok(Err(error)); + } + let result = match scene_controller(self) { + Ok(controller) => { + controller + .request_state(RynkLightingCommand::UnsetSceneCell { + expected_revision: req.expected_revision, + layer: req.layer, + led_id: req.led_id, + }) + .await + } + Err(error) => Err(error), + }; + Ok(result) + } +} + +impl Handle for RynkService<'_> { + async fn handle(&self, req: SetLightingLayerPolicyRequest) -> Result { + let result = match scene_controller(self) { + Ok(controller) => { + controller + .request_state(RynkLightingCommand::SetLayerPolicy { expected_revision: req.expected_revision, + policy: req.policy, }) .await } @@ -117,6 +251,95 @@ impl Handle for RynkService<'_> { } } +impl Handle for RynkService<'_> { + async fn handle(&self, req: BeginLightingSceneReplaceRequest) -> Result { + let result = match scene_controller(self) { + Ok(controller) => { + if req.cell_count > controller.scene_capacity { + Err(LightingError::SceneFull { + capacity: controller.scene_capacity, + }) + } else { + match controller + .request(RynkLightingCommand::BeginSceneReplace { + expected_revision: req.expected_revision, + cell_count: req.cell_count, + }) + .await + { + Ok(RynkLightingReadback::SceneTransaction(transaction)) => Ok(transaction), + Ok(_) => Err(LightingError::InvalidRequest), + Err(error) => Err(error), + } + } + } + Err(error) => Err(error), + }; + Ok(result) + } +} + +impl Handle for RynkService<'_> { + async fn handle(&self, req: PutLightingSceneChunkRequest) -> Result { + for cell in &req.cells { + if let Err(error) = cell.validate().and_then(|()| self.check_scene_layer(cell.layer)) { + return Ok(Err(error)); + } + } + let result = match scene_controller(self) { + Ok(controller) => match controller + .request(RynkLightingCommand::PutSceneChunk { + transaction_id: req.transaction_id, + offset: req.offset, + cells: req.cells, + }) + .await + { + Ok(RynkLightingReadback::Unit) => Ok(()), + Ok(_) => Err(LightingError::InvalidRequest), + Err(error) => Err(error), + }, + Err(error) => Err(error), + }; + Ok(result) + } +} + +impl Handle for RynkService<'_> { + async fn handle(&self, req: CommitLightingSceneReplaceRequest) -> Result { + let result = match scene_controller(self) { + Ok(controller) => { + controller + .request_state(RynkLightingCommand::CommitSceneReplace { + transaction_id: req.transaction_id, + }) + .await + } + Err(error) => Err(error), + }; + Ok(result) + } +} + +impl Handle for RynkService<'_> { + async fn handle(&self, req: AbortLightingSceneReplaceRequest) -> Result { + let result = match scene_controller(self) { + Ok(controller) => match controller + .request(RynkLightingCommand::AbortSceneReplace { + transaction_id: req.transaction_id, + }) + .await + { + Ok(RynkLightingReadback::Unit) => Ok(()), + Ok(_) => Err(LightingError::InvalidRequest), + Err(error) => Err(error), + }, + Err(error) => Err(error), + }; + Ok(result) + } +} + impl Handle for RynkService<'_> { async fn handle(&self, req: LightingPageRequest) -> Result { Ok(controller(self).and_then(|binding| keys_page(binding, req))) @@ -177,6 +400,9 @@ fn capabilities(binding: RynkLightingController<'_>) -> LightingCapabilities { if !routing.routes.is_empty() || !routing.outputs.is_empty() { features.0 |= LightingFeatureFlags::ROUTING; } + if binding.scene_capacity > 0 { + features.0 |= LightingFeatureFlags::LAYER_SCENES; + } LightingCapabilities { topology_revision: descriptor.topology_revision, logical_key_count: count(topology.keys.len()), @@ -1007,7 +1233,7 @@ mod tests { } _ => panic!("expected replacement"), } - mailbox.reply(pending.id, Ok(committed)); + mailbox.reply(pending.id, Ok(RynkLightingReadback::State(committed))); }, ) .await; @@ -1097,11 +1323,353 @@ mod tests { expected_revision: 9, } if staged.is_empty() )); - mailbox.reply(pending.id, Ok(cleared)); + mailbox.reply(pending.id, Ok(RynkLightingReadback::State(cleared))); }, ) .await; assert_eq!(response.unwrap(), Ok(cleared)); }); } + + fn wire_scene_cell(layer: u8, led: u16) -> rmk_types::protocol::rynk::LightingSceneCell { + rmk_types::protocol::rynk::LightingSceneCell { + layer, + led_id: LightingLedId(led), + effect: rmk_types::protocol::rynk::LightingEffect::Solid { + color: rmk_types::protocol::rynk::LightingRgb8 { r: 5, g: 6, b: 7 }, + }, + } + } + + #[test] + fn scene_endpoints_reject_unsupported_without_a_scene_capacity() { + block_on(async { + let mut behavior = BehaviorConfig::default(); + let positional: PositionalConfig<1, 3> = PositionalConfig::default(); + let mut data: KeymapData<1, 3, 1, 0> = KeymapData::new([[[KeyAction::No; 3]]]); + let keymap = KeyMap::new(&mut data, &mut behavior, &positional).await; + let mut config = RmkConfig::default(); + config.lock_config.insecure = true; + let mailbox = RynkLightingMailbox::new(); + // Lighting is bound, but the board wired no runtime scene table. + let service = RynkService::new(&keymap, &config).with_lighting(RynkLightingController::new( + &mailbox, + descriptor(), + 8, + )); + let session = session(&keymap, &config); + + let lighting = capabilities(service.lighting.unwrap()); + assert!(!lighting.features.contains(LightingFeatureFlags::LAYER_SCENES)); + + let status = call::(&service, &session, &()) + .await + .unwrap(); + assert_eq!(status, Err(LightingError::Unsupported)); + let set = call::( + &service, + &session, + &rmk_types::protocol::rynk::SetLightingSceneCellRequest { + expected_revision: 0, + cell: wire_scene_cell(0, 10), + }, + ) + .await + .unwrap(); + assert_eq!(set, Err(LightingError::Unsupported)); + }); + } + + /// Full stack: handler validation → protocol mailbox → adapter → engine, + /// with scene persistence observed on the flash channel. + #[test] + fn scene_endpoints_flow_through_adapter_and_engine() { + use embassy_futures::select::{Either3, select3}; + use rmk_types::protocol::rynk::command::{ + AbortLightingSceneReplace, BeginLightingSceneReplace, CommitLightingSceneReplace, GetLightingSceneStatus, + GetLightingScenes, PutLightingSceneChunk, SetLightingLayerPolicy, SetLightingSceneCell, + UnsetLightingSceneCell, + }; + use rmk_types::protocol::rynk::{ + BeginLightingSceneReplaceRequest, CommitLightingSceneReplaceRequest, LightingLayerPolicy, + LightingScenePageRequest, PutLightingSceneChunkRequest, SetLightingLayerPolicyRequest, + SetLightingSceneCellRequest, UnsetLightingSceneCellRequest, + }; + + use crate::lighting::{ + BackgroundState, EmptySource, LayerPolicy, LayerScenes, LightingContext, LightingEngine, LightingMailbox, + StandardCommand, StandardError, StandardLightingEngine, StandardReply, + }; + + block_on(async { + let mut behavior = BehaviorConfig::default(); + let positional: PositionalConfig<1, 3> = PositionalConfig::default(); + let mut data: KeymapData<1, 3, 2, 0> = KeymapData::new([[[KeyAction::No; 3]]; 2]); + let keymap = KeyMap::new(&mut data, &mut behavior, &positional).await; + let mut config = RmkConfig::default(); + config.lock_config.insecure = true; + let mailbox = RynkLightingMailbox::new(); + let service = RynkService::new(&keymap, &config) + .with_lighting(RynkLightingController::new(&mailbox, descriptor(), 8).with_scene_capacity(4)); + let session = session(&keymap, &config); + + let core = LightingMailbox::, StandardReply, StandardError, 1>::new(); + let mut adapter = super::super::super::lighting::StandardRynkLightingAdapter::<2, 1, 4>::new( + &mailbox, + &core, + descriptor().topology, + ); + let mut engine: StandardLightingEngine<'static, EmptySource, EmptySource, 2, 2, 4> = + StandardLightingEngine::new( + BackgroundState::default(), + LayerScenes { + scenes: &[], + policy: LayerPolicy::ActiveStack, + }, + EmptySource, + EmptySource, + ); + + let lighting = capabilities(service.lighting.unwrap()); + assert!(lighting.features.contains(LightingFeatureFlags::LAYER_SCENES)); + + let client = async { + let status = call::(&service, &session, &()) + .await + .unwrap() + .unwrap(); + assert_eq!(status.capacity, 4); + assert_eq!(status.scene_len, 0); + assert_eq!(status.policy, LightingLayerPolicy::ActiveStack); + assert_eq!( + status.chunk_capacity as usize, + rmk_types::protocol::rynk::LIGHTING_SCENE_CHUNK_SIZE + ); + + // Handler-side bounds: an out-of-keymap layer and an unknown + // stable LED never reach the engine. + let bad_layer = call::( + &service, + &session, + &SetLightingSceneCellRequest { + expected_revision: 0, + cell: wire_scene_cell(9, 10), + }, + ) + .await + .unwrap(); + assert_eq!(bad_layer, Err(LightingError::UnknownLayer { layer: 9 })); + let bad_led = call::( + &service, + &session, + &SetLightingSceneCellRequest { + expected_revision: 0, + cell: wire_scene_cell(1, 7), + }, + ) + .await + .unwrap(); + assert_eq!( + bad_led, + Err(LightingError::UnknownLed { + led_id: LightingLedId(7) + }) + ); + + let state = call::( + &service, + &session, + &SetLightingSceneCellRequest { + expected_revision: 0, + cell: wire_scene_cell(1, 42), + }, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(state.revision, 1); + + // Pinned page reads round-trip stable LED identity. + let page = + call::(&service, &session, &LightingScenePageRequest { revision: 1, offset: 0 }) + .await + .unwrap() + .unwrap(); + assert_eq!(page.total_count, 1); + assert_eq!(page.items[0], wire_scene_cell(1, 42)); + let stale = + call::(&service, &session, &LightingScenePageRequest { revision: 0, offset: 0 }) + .await + .unwrap(); + assert_eq!( + stale, + Err(LightingError::StateRevisionConflict { + expected: 0, + current: 1 + }) + ); + + // A whole-table replacement above capacity fails locally. + let over = call::( + &service, + &session, + &BeginLightingSceneReplaceRequest { + expected_revision: 1, + cell_count: 9, + }, + ) + .await + .unwrap(); + assert_eq!(over, Err(LightingError::SceneFull { capacity: 4 })); + + let transaction = call::( + &service, + &session, + &BeginLightingSceneReplaceRequest { + expected_revision: 1, + cell_count: 1, + }, + ) + .await + .unwrap() + .unwrap(); + let mut cells = Vec::new(); + cells.push(wire_scene_cell(0, 10)).unwrap(); + call::( + &service, + &session, + &PutLightingSceneChunkRequest { + transaction_id: transaction.id, + offset: 0, + cells, + }, + ) + .await + .unwrap() + .unwrap(); + let committed = call::( + &service, + &session, + &CommitLightingSceneReplaceRequest { + transaction_id: transaction.id, + }, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(committed.revision, 2); + + // The pre-replace cell is gone; the replacement is visible. + let page = + call::(&service, &session, &LightingScenePageRequest { revision: 2, offset: 0 }) + .await + .unwrap() + .unwrap(); + assert_eq!(page.total_count, 1); + assert_eq!(page.items[0], wire_scene_cell(0, 10)); + + let state = call::( + &service, + &session, + &SetLightingLayerPolicyRequest { + expected_revision: 2, + policy: LightingLayerPolicy::EffectiveOnly, + }, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(state.revision, 3); + let status = call::(&service, &session, &()) + .await + .unwrap() + .unwrap(); + assert_eq!(status.policy, LightingLayerPolicy::EffectiveOnly); + assert_eq!(status.scene_len, 1); + + // Unset through stable identity, then abort of a dead + // transaction reports it as unknown. + let state = call::( + &service, + &session, + &UnsetLightingSceneCellRequest { + expected_revision: 3, + layer: 0, + led_id: LightingLedId(10), + }, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(state.revision, 4); + let aborted = call::( + &service, + &session, + &rmk_types::protocol::rynk::AbortLightingSceneReplaceRequest { transaction_id: 999 }, + ) + .await + .unwrap(); + assert_eq!(aborted, Err(LightingError::InvalidTransaction)); + }; + + let adapter_loop = async { + loop { + adapter.process_next().await; + } + }; + let context = LightingContext::default(); + let engine_loop = async { + loop { + let (id, command) = core.receive_request().await; + let result = engine.handle_command(0, command, &context).map(|outcome| outcome.reply); + core.publish_reply(id, result); + } + }; + #[cfg(feature = "storage")] + let persisted = core::cell::RefCell::new(alloc::vec::Vec::new()); + let flash_drain = async { + #[cfg(feature = "storage")] + loop { + persisted + .borrow_mut() + .push(crate::channel::FLASH_CHANNEL.receive().await); + } + #[cfg(not(feature = "storage"))] + core::future::pending::<()>().await + }; + + match select3( + client, + adapter_loop, + embassy_futures::join::join(engine_loop, flash_drain), + ) + .await + { + Either3::First(()) => {} + _ => panic!("service loops must not finish"), + } + + // Every scene mutation rewrote the durable table: the last + // header reflects the final one-cell removal and policy change. + #[cfg(feature = "storage")] + { + use crate::storage::FlashOperationMessage; + let persisted = persisted.into_inner(); + assert!(!persisted.is_empty()); + let last_table = persisted + .iter() + .rev() + .find_map(|message| match message { + FlashOperationMessage::LightingSceneTable { len, policy } => Some((*len, *policy)), + _ => None, + }) + .expect("scene mutations persist a table header"); + assert_eq!(last_table, (0, LightingLayerPolicy::EffectiveOnly)); + assert!(persisted.iter().any(|message| matches!( + message, + FlashOperationMessage::LightingSceneShard { index: 0, cells } if cells.len() == 1 + ))); + } + }); + } } diff --git a/rmk/src/host/rynk/lighting.rs b/rmk/src/host/rynk/lighting.rs index c9dd328d0..99c2d8467 100644 --- a/rmk/src/host/rynk/lighting.rs +++ b/rmk/src/host/rynk/lighting.rs @@ -15,17 +15,24 @@ use embassy_sync::mutex::Mutex; use embassy_sync::signal::Signal; use heapless::Vec; use rmk_types::protocol::rynk::{ - LightingBackgroundMode, LightingBackgroundState, LightingError, LightingMutableState, LightingOverlayCell, - LightingResult, LightingRgb8, LightingState, + LIGHTING_SCENE_CHUNK_SIZE, LightingBackgroundMode, LightingBackgroundState, LightingError, LightingLayerPolicy, + LightingMutableState, LightingOverlayCell, LightingResult, LightingRgb8, LightingSceneCell, + LightingSceneTransaction, LightingScenesPage, LightingState, }; use crate::RawMutex; use crate::core_traits::Runnable; use crate::lighting::{ - BackgroundMode, BackgroundState, BuiltinEffect, LedId, LightingMailbox, LightingRouting, LightingTopology, - OverlayBatch, OverlayCell, OverlayError, Rgb8, StandardCommand, StandardError, StandardMutableState, StandardState, + BackgroundMode, BackgroundState, BuiltinEffect, LayerPolicy, LedId, LightingMailbox, LightingRouting, + LightingTopology, OverlayBatch, OverlayCell, OverlayError, Rgb8, SceneChunk, SceneTableCell, StandardCommand, + StandardError, StandardLightingEngine, StandardMutableState, StandardReply, StandardState, }; +const _: () = core::assert!( + crate::lighting::SCENE_CHUNK_SIZE == LIGHTING_SCENE_CHUNK_SIZE, + "engine scene chunk must match the wire chunk so adapters forward chunks unmodified" +); + /// Maximum number of cells staged by one Rynk overlay replacement. /// /// A concrete engine may advertise a smaller capacity. Keeping the protocol @@ -47,6 +54,10 @@ pub struct RynkLightingDescriptor<'a> { pub struct RynkLightingController<'a> { pub(super) descriptor: RynkLightingDescriptor<'a>, pub(super) overlay_capacity: u16, + /// Advertised runtime scene-cell capacity. `0` means the board did not + /// wire a scene table; hosts gate on it and every scene endpoint rejects + /// with `Unsupported`. + pub(super) scene_capacity: u16, mailbox: &'a RynkLightingMailbox, } @@ -66,10 +77,18 @@ impl<'a> RynkLightingController<'a> { } else { staged_capacity }, + scene_capacity: 0, mailbox, } } + /// Advertise runtime scene support. Pass the engine's scene capacity; + /// boards without a scene table simply skip this call. + pub const fn with_scene_capacity(mut self, scene_capacity: u16) -> Self { + self.scene_capacity = scene_capacity; + self + } + pub const fn descriptor(&self) -> RynkLightingDescriptor<'a> { self.descriptor } @@ -78,16 +97,50 @@ impl<'a> RynkLightingController<'a> { self.overlay_capacity } - pub(super) async fn request(&self, command: RynkLightingCommand) -> LightingResult { + pub const fn scene_capacity(&self) -> u16 { + self.scene_capacity + } + + pub(super) async fn request(&self, command: RynkLightingCommand) -> LightingResult { self.mailbox.request(command).await } + /// Request expecting authoritative state readback. + pub(super) async fn request_state(&self, command: RynkLightingCommand) -> LightingResult { + expect_state(self.mailbox.request(command).await) + } + pub(super) async fn replace_overlay( &self, expected_revision: u32, cells: &Vec, ) -> LightingResult { - self.mailbox.request_replace(expected_revision, cells).await + expect_state(self.mailbox.request_replace(expected_revision, cells).await) + } +} + +/// Typed readback carried by the protocol mailbox. Most commands answer with +/// wire state; scene reads and transaction reservation have their own shapes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RynkLightingReadback { + State(LightingState), + SceneStatus { + revision: u32, + scene_len: u16, + policy: LightingLayerPolicy, + }, + ScenesPage(LightingScenesPage), + SceneTransaction(LightingSceneTransaction), + Unit, +} + +fn expect_state(result: LightingResult) -> LightingResult { + match result? { + RynkLightingReadback::State(state) => Ok(state), + _ => { + debug_assert!(false, "adapter answered a state command with a non-state readback"); + Err(LightingError::InvalidRequest) + } } } @@ -115,7 +168,7 @@ pub(in crate::host::rynk) struct MailboxRequest { struct MailboxResponse { id: u32, - result: LightingResult, + result: LightingResult, } impl RynkLightingMailbox { @@ -131,7 +184,7 @@ impl RynkLightingMailbox { } } - async fn request(&self, command: RynkLightingCommand) -> LightingResult { + async fn request(&self, command: RynkLightingCommand) -> LightingResult { let _caller = self.caller.lock().await; let id = self.allocate_id(); self.send_and_wait(id, command).await @@ -141,7 +194,7 @@ impl RynkLightingMailbox { &self, expected_revision: u32, cells: &Vec, - ) -> LightingResult { + ) -> LightingResult { let _caller = self.caller.lock().await; while self.replacement.lock(|replacement| replacement.borrow().is_some()) { self.replacement_available.wait().await; @@ -176,12 +229,12 @@ impl RynkLightingMailbox { }) } - async fn send_and_wait(&self, id: u32, command: RynkLightingCommand) -> LightingResult { + async fn send_and_wait(&self, id: u32, command: RynkLightingCommand) -> LightingResult { self.requests.send(MailboxRequest { id, command }).await; self.wait_for_reply(id).await } - async fn wait_for_reply(&self, id: u32) -> LightingResult { + async fn wait_for_reply(&self, id: u32) -> LightingResult { loop { let response = self.response.wait().await; if response.id == id { @@ -194,7 +247,7 @@ impl RynkLightingMailbox { self.requests.receive().await } - pub(in crate::host::rynk) fn reply(&self, id: u32, result: LightingResult) { + pub(in crate::host::rynk) fn reply(&self, id: u32, result: LightingResult) { self.response.signal(MailboxResponse { id, result }); } @@ -228,6 +281,39 @@ impl Default for RynkLightingMailbox { pub(super) enum RynkLightingCommand { ReadState, + ReadSceneStatus, + ReadScenes { + expected_revision: u32, + offset: u16, + }, + SetSceneCell { + expected_revision: u32, + cell: LightingSceneCell, + }, + UnsetSceneCell { + expected_revision: u32, + layer: u8, + led_id: rmk_types::protocol::rynk::LightingLedId, + }, + SetLayerPolicy { + expected_revision: u32, + policy: LightingLayerPolicy, + }, + BeginSceneReplace { + expected_revision: u32, + cell_count: u16, + }, + PutSceneChunk { + transaction_id: u32, + offset: u16, + cells: Vec, + }, + CommitSceneReplace { + transaction_id: u32, + }, + AbortSceneReplace { + transaction_id: u32, + }, SetState { expected_revision: u32, state: LightingMutableState, @@ -250,20 +336,30 @@ pub(super) enum RynkLightingCommand { /// Bridges type-erased Rynk commands into one concrete standard lighting /// mailbox. Boards spawn this alongside `LightingProcessor`. -pub struct StandardRynkLightingAdapter<'a, const OVERLAY_CAPACITY: usize, const CORE_COMMAND_CAPACITY: usize> { +pub struct StandardRynkLightingAdapter< + 'a, + const OVERLAY_CAPACITY: usize, + const CORE_COMMAND_CAPACITY: usize, + const SCENE_CAP: usize = 0, +> { protocol: &'a RynkLightingMailbox, - core: &'a LightingMailbox, StandardState, StandardError, CORE_COMMAND_CAPACITY>, + core: &'a LightingMailbox< + StandardCommand, + StandardReply, + StandardError, + CORE_COMMAND_CAPACITY, + >, topology: LightingTopology<'a>, } -impl<'a, const OVERLAY_CAPACITY: usize, const CORE_COMMAND_CAPACITY: usize> - StandardRynkLightingAdapter<'a, OVERLAY_CAPACITY, CORE_COMMAND_CAPACITY> +impl<'a, const OVERLAY_CAPACITY: usize, const CORE_COMMAND_CAPACITY: usize, const SCENE_CAP: usize> + StandardRynkLightingAdapter<'a, OVERLAY_CAPACITY, CORE_COMMAND_CAPACITY, SCENE_CAP> { pub const fn new( protocol: &'a RynkLightingMailbox, core: &'a LightingMailbox< - StandardCommand, - StandardState, + StandardCommand, + StandardReply, StandardError, CORE_COMMAND_CAPACITY, >, @@ -284,9 +380,214 @@ impl<'a, const OVERLAY_CAPACITY: usize, const CORE_COMMAND_CAPACITY: usize> self.protocol.reply(request.id, result); } - async fn dispatch(&self, request_id: u32, command: RynkLightingCommand) -> LightingResult { + async fn request_core( + &self, + command: StandardCommand, + ) -> LightingResult { + self.core + .request(command) + .await + .map_err(|error| map_standard_error(error, OVERLAY_CAPACITY)) + } + + async fn request_core_state( + &self, + command: StandardCommand, + ) -> LightingResult { + match self.request_core(command).await? { + StandardReply::State(state) => Ok(state), + _ => Err(LightingError::InvalidRequest), + } + } + + /// Run a scene mutation, then persist the whole authoritative scene + /// configuration. Persisting by readback keeps the engine the single + /// source of truth instead of mirroring its insertion algorithm here. + async fn scene_mutation( + &self, + command: StandardCommand, + ) -> LightingResult { + let state = self.request_core_state(command).await?; + self.persist_scenes(&state).await; + Ok(state) + } + + #[cfg(feature = "storage")] + async fn persist_scenes(&self, state: &StandardState) { + use crate::channel::FLASH_CHANNEL; + use crate::storage::FlashOperationMessage; + + let mut total = state.scene_len.min(u16::MAX as usize) as u16; + let mut offset: u16 = 0; + let mut shard: u8 = 0; + while offset < total { + let Ok(StandardReply::ScenesPage(page)) = self.request_core(StandardCommand::ReadScenes { offset }).await + else { + return; + }; + let cells = page.cells.as_slice(); + if cells.is_empty() { + break; + } + let mut wire_cells: Vec = Vec::new(); + for cell in cells { + let Some(wire) = self.scene_cell_to_wire(*cell) else { + return; + }; + let _ = wire_cells.push(wire); + } + FLASH_CHANNEL + .send(FlashOperationMessage::LightingSceneShard { + index: shard, + cells: wire_cells, + }) + .await; + offset += cells.len() as u16; + shard = shard.saturating_add(1); + total = page.total; + } + FLASH_CHANNEL + .send(FlashOperationMessage::LightingSceneTable { + len: offset, + policy: policy_to_wire(state.scene_policy), + }) + .await; + } + + #[cfg(not(feature = "storage"))] + async fn persist_scenes(&self, _state: &StandardState) {} + + async fn dispatch(&self, request_id: u32, command: RynkLightingCommand) -> LightingResult { let core_command = match command { RynkLightingCommand::ReadState => StandardCommand::ReadState, + RynkLightingCommand::ReadSceneStatus => { + let state = self.request_core_state(StandardCommand::ReadState).await?; + return Ok(RynkLightingReadback::SceneStatus { + revision: state.revision, + scene_len: state.scene_len.min(u16::MAX as usize) as u16, + policy: policy_to_wire(state.scene_policy), + }); + } + RynkLightingCommand::ReadScenes { + expected_revision, + offset, + } => { + let page = match self.request_core(StandardCommand::ReadScenes { offset }).await? { + StandardReply::ScenesPage(page) => page, + _ => return Err(LightingError::InvalidRequest), + }; + // The page is read atomically by the engine; pinning only has + // to compare the revision it was served under. + if page.revision != expected_revision { + return Err(LightingError::StateRevisionConflict { + expected: expected_revision, + current: page.revision, + }); + } + let mut items: Vec = Vec::new(); + for cell in page.cells.as_slice() { + let wire = self.scene_cell_to_wire(*cell).ok_or(LightingError::InvalidRequest)?; + items.push(wire).map_err(|_| LightingError::InvalidRequest)?; + } + return Ok(RynkLightingReadback::ScenesPage(LightingScenesPage { + revision: page.revision, + total_count: page.total, + items, + })); + } + RynkLightingCommand::SetSceneCell { + expected_revision, + cell, + } => { + let cell = self.scene_cell_from_wire(cell)?; + let state = self + .scene_mutation(StandardCommand::SetSceneCellIfRevision { + expected_revision, + cell, + }) + .await?; + return Ok(RynkLightingReadback::State(state_to_wire(state))); + } + RynkLightingCommand::UnsetSceneCell { + expected_revision, + layer, + led_id, + } => { + let slot = self + .topology + .slot(LedId(led_id.0)) + .ok_or(LightingError::UnknownLed { led_id })?; + let state = self + .scene_mutation(StandardCommand::UnsetSceneCellIfRevision { + expected_revision, + layer, + slot, + }) + .await?; + return Ok(RynkLightingReadback::State(state_to_wire(state))); + } + RynkLightingCommand::SetLayerPolicy { + expected_revision, + policy, + } => { + let state = self + .scene_mutation(StandardCommand::SetLayerPolicyIfRevision { + expected_revision, + policy: policy_from_wire(policy), + }) + .await?; + return Ok(RynkLightingReadback::State(state_to_wire(state))); + } + RynkLightingCommand::BeginSceneReplace { + expected_revision, + cell_count, + } => { + let reply = self + .request_core(StandardCommand::BeginSceneReplace { + expected_revision, + cell_count, + }) + .await?; + return match reply { + StandardReply::SceneTransaction { id, cell_count } => { + Ok(RynkLightingReadback::SceneTransaction(LightingSceneTransaction { + id, + cell_count, + })) + } + _ => Err(LightingError::InvalidRequest), + }; + } + RynkLightingCommand::PutSceneChunk { + transaction_id, + offset, + cells, + } => { + let mut chunk = SceneChunk::new(); + for cell in &cells { + chunk + .push(self.scene_cell_from_wire(*cell)?) + .map_err(|error| map_standard_error(error, OVERLAY_CAPACITY))?; + } + self.request_core_state(StandardCommand::PutSceneChunk { + transaction_id, + offset, + cells: chunk, + }) + .await?; + return Ok(RynkLightingReadback::Unit); + } + RynkLightingCommand::CommitSceneReplace { transaction_id } => { + let state = self + .scene_mutation(StandardCommand::CommitSceneReplace { transaction_id }) + .await?; + return Ok(RynkLightingReadback::State(state_to_wire(state))); + } + RynkLightingCommand::AbortSceneReplace { transaction_id } => { + self.request_core_state(StandardCommand::AbortSceneReplace { transaction_id }) + .await?; + return Ok(RynkLightingReadback::Unit); + } RynkLightingCommand::SetState { expected_revision, state, @@ -333,11 +634,10 @@ impl<'a, const OVERLAY_CAPACITY: usize, const CORE_COMMAND_CAPACITY: usize> } }; - self.core - .request(core_command) + self.request_core_state(core_command) .await .map(state_to_wire) - .map_err(|error| map_standard_error(error, OVERLAY_CAPACITY)) + .map(RynkLightingReadback::State) } fn overlay_cell(&self, cell: LightingOverlayCell) -> LightingResult { @@ -352,10 +652,60 @@ impl<'a, const OVERLAY_CAPACITY: usize, const CORE_COMMAND_CAPACITY: usize> ttl_ms: cell.ttl_ms.and_then(NonZeroU32::new), }) } + + fn scene_cell_from_wire(&self, cell: LightingSceneCell) -> LightingResult { + cell.validate()?; + let slot = self + .topology + .slot(LedId(cell.led_id.0)) + .ok_or(LightingError::UnknownLed { led_id: cell.led_id })?; + Ok(SceneTableCell { + layer: cell.layer, + slot, + effect: effect_from_wire(cell.effect), + }) + } + + fn scene_cell_to_wire(&self, cell: SceneTableCell) -> Option { + let led = self.topology.led(cell.slot)?; + Some(LightingSceneCell { + layer: cell.layer, + led_id: rmk_types::protocol::rynk::LightingLedId(led.id.0), + effect: effect_to_wire(cell.effect), + }) + } +} + +/// Install persisted scene configuration into a standard engine at startup, +/// before the engine begins serving commands. Cells whose stable LED id no +/// longer resolves against the current topology are skipped rather than +/// failing the boot. +pub fn install_lighting_scenes( + engine: &mut StandardLightingEngine<'_, Extension, Status, N, OVERLAY_CAP, SCENE_CAP>, + topology: &LightingTopology<'_>, + cells: &[LightingSceneCell], + policy: Option, +) { + if let Some(policy) = policy { + engine.install_scene_policy(policy_from_wire(policy)); + } + for cell in cells { + if cell.validate().is_err() { + continue; + } + let Some(slot) = topology.slot(LedId(cell.led_id.0)) else { + continue; + }; + let _ = engine.install_scene_cell(SceneTableCell { + layer: cell.layer, + slot, + effect: effect_from_wire(cell.effect), + }); + } } -impl Runnable - for StandardRynkLightingAdapter<'_, OVERLAY_CAPACITY, CORE_COMMAND_CAPACITY> +impl Runnable + for StandardRynkLightingAdapter<'_, OVERLAY_CAPACITY, CORE_COMMAND_CAPACITY, SCENE_CAP> { async fn run(&mut self) -> ! { loop { @@ -438,6 +788,60 @@ const fn rgb_from_wire(color: LightingRgb8) -> Rgb8 { Rgb8::new(color.r, color.g, color.b) } +const fn rgb_to_wire(color: Rgb8) -> LightingRgb8 { + LightingRgb8 { + r: color.r, + g: color.g, + b: color.b, + } +} + +fn effect_to_wire(effect: BuiltinEffect) -> rmk_types::protocol::rynk::LightingEffect { + use rmk_types::protocol::rynk::LightingEffect; + + match effect { + BuiltinEffect::Solid { color } => LightingEffect::Solid { + color: rgb_to_wire(color), + }, + BuiltinEffect::Blink { + color, + period_ms, + phase_ms, + duty, + } => LightingEffect::Blink { + color: rgb_to_wire(color), + period_ms, + phase_ms, + duty, + }, + BuiltinEffect::Breathe { + color, + period_ms, + phase_ms, + step_ms, + } => LightingEffect::Breathe { + color: rgb_to_wire(color), + period_ms, + phase_ms, + step_ms, + }, + } +} + +pub(super) const fn policy_from_wire(policy: LightingLayerPolicy) -> LayerPolicy { + match policy { + LightingLayerPolicy::EffectiveOnly => LayerPolicy::EffectiveOnly, + LightingLayerPolicy::ActiveStack => LayerPolicy::ActiveStack, + } +} + +pub(super) const fn policy_to_wire(policy: LayerPolicy) -> LightingLayerPolicy { + match policy { + LayerPolicy::EffectiveOnly => LightingLayerPolicy::EffectiveOnly, + LayerPolicy::ActiveStack => LightingLayerPolicy::ActiveStack, + } +} + fn map_standard_error(error: StandardError, capacity: usize) -> LightingError { match error { StandardError::RevisionConflict { expected, current } => { @@ -447,6 +851,16 @@ fn map_standard_error(error: StandardError, capacity: usize) -> LightingError { StandardError::DeadlineOverflow => LightingError::InvalidTtl, StandardError::ReplicaSlot(_) => LightingError::Unsupported, StandardError::Render(_) => LightingError::Unsupported, + StandardError::SceneFull { capacity } => LightingError::SceneFull { + capacity: capacity.min(u16::MAX as usize) as u16, + }, + StandardError::SceneSlotOutOfRange { .. } | StandardError::InvalidSceneRequest => LightingError::InvalidRequest, + StandardError::SceneTransactionBusy => LightingError::TransactionBusy, + StandardError::InvalidSceneTransaction => LightingError::InvalidTransaction, + StandardError::SceneTransactionExpired => LightingError::TransactionExpired, + StandardError::SceneTransactionIncomplete { expected, received } => { + LightingError::TransactionIncomplete { expected, received } + } } } @@ -540,7 +954,7 @@ mod tests { )); let abandoned_cells = mailbox.take_replacement(abandoned.id).await; assert_eq!(abandoned_cells.as_slice(), &[cell(10)]); - mailbox.reply(abandoned.id, Ok(state(1))); + mailbox.reply(abandoned.id, Ok(RynkLightingReadback::State(state(1)))); let current = mailbox.receive().await; assert!(matches!( @@ -549,15 +963,15 @@ mod tests { )); let current_cells = mailbox.take_replacement(current.id).await; assert_eq!(current_cells.as_slice(), &[cell(20)]); - mailbox.reply(current.id, Ok(state(2))); + mailbox.reply(current.id, Ok(RynkLightingReadback::State(state(2)))); })); - assert_eq!(reply, Ok(state(2))); + assert_eq!(reply, Ok(RynkLightingReadback::State(state(2)))); } #[test] fn replacement_rejects_duplicate_stable_ids_before_reaching_the_core() { let protocol = RynkLightingMailbox::new(); - let core = LightingMailbox::, StandardState, StandardError, 1>::new(); + let core = LightingMailbox::, StandardReply, StandardError, 1>::new(); let mut adapter = StandardRynkLightingAdapter::new(&protocol, &core, topology()); let mut cells = Vec::new(); cells.push(cell(10)).unwrap(); diff --git a/rmk/src/host/rynk/mod.rs b/rmk/src/host/rynk/mod.rs index 3c0097f0e..696dbdd68 100644 --- a/rmk/src/host/rynk/mod.rs +++ b/rmk/src/host/rynk/mod.rs @@ -14,7 +14,7 @@ use embedded_io_async::{Read, Write}; #[cfg(feature = "lighting")] pub use lighting::{ RYNK_LIGHTING_TRANSACTION_CAPACITY, RynkLightingController, RynkLightingDescriptor, RynkLightingMailbox, - StandardRynkLightingAdapter, + RynkLightingReadback, StandardRynkLightingAdapter, install_lighting_scenes, }; use rmk_types::constants::RYNK_BUFFER_SIZE; use rmk_types::protocol::rynk::{Cmd, FirmwareVersion, RYNK_HEADER_SIZE, RynkError, RynkHeader, RynkMessage, command}; @@ -116,7 +116,14 @@ impl<'a> RynkService<'a> { | Cmd::BeginLightingOverlayReplace | Cmd::PutLightingOverlayChunk | Cmd::CommitLightingOverlayReplace - | Cmd::AbortLightingOverlayReplace => self.lock_config.write_requires_unlock, + | Cmd::AbortLightingOverlayReplace + | Cmd::SetLightingSceneCell + | Cmd::UnsetLightingSceneCell + | Cmd::SetLightingLayerPolicy + | Cmd::BeginLightingSceneReplace + | Cmd::PutLightingSceneChunk + | Cmd::CommitLightingSceneReplace + | Cmd::AbortLightingSceneReplace => self.lock_config.write_requires_unlock, _ => false, } } @@ -224,6 +231,24 @@ impl<'a> RynkService<'a> { Cmd::CommitLightingOverlayReplace => handlers::lighting::serve_commit(self, session, msg).await, #[cfg(feature = "lighting")] Cmd::AbortLightingOverlayReplace => handlers::lighting::serve_abort(self, session, msg).await, + #[cfg(feature = "lighting")] + Cmd::GetLightingSceneStatus => Serve::::serve(self, msg).await, + #[cfg(feature = "lighting")] + Cmd::GetLightingScenes => Serve::::serve(self, msg).await, + #[cfg(feature = "lighting")] + Cmd::SetLightingSceneCell => Serve::::serve(self, msg).await, + #[cfg(feature = "lighting")] + Cmd::UnsetLightingSceneCell => Serve::::serve(self, msg).await, + #[cfg(feature = "lighting")] + Cmd::SetLightingLayerPolicy => Serve::::serve(self, msg).await, + #[cfg(feature = "lighting")] + Cmd::BeginLightingSceneReplace => Serve::::serve(self, msg).await, + #[cfg(feature = "lighting")] + Cmd::PutLightingSceneChunk => Serve::::serve(self, msg).await, + #[cfg(feature = "lighting")] + Cmd::CommitLightingSceneReplace => Serve::::serve(self, msg).await, + #[cfg(feature = "lighting")] + Cmd::AbortLightingSceneReplace => Serve::::serve(self, msg).await, _ => Err(RynkError::UnknownCmd), } { diff --git a/rmk/src/host/via/rgb_matrix.rs b/rmk/src/host/via/rgb_matrix.rs index c18dca989..56d0648ec 100644 --- a/rmk/src/host/via/rgb_matrix.rs +++ b/rmk/src/host/via/rgb_matrix.rs @@ -210,17 +210,19 @@ mod standard_control { BackgroundMode as ViaMode, BackgroundPatch as ViaPatch, BackgroundState as ViaState, ViaRgbMatrixControl, }; use crate::lighting::processor::LightingMailbox; - use crate::lighting::standard::{BackgroundMode, BackgroundPatch, StandardCommand, StandardError, StandardState}; + use crate::lighting::standard::{ + BackgroundMode, BackgroundPatch, StandardCommand, StandardError, StandardReply, StandardState, + }; /// Mailbox-backed live control. Persistence remains unavailable until RMK /// has a correlated durable completion path. pub struct StandardControl<'a, const OVERLAY_CAP: usize, const MAILBOX_CAP: usize> { - mailbox: &'a LightingMailbox, StandardState, StandardError, MAILBOX_CAP>, + mailbox: &'a LightingMailbox, StandardReply, StandardError, MAILBOX_CAP>, } impl<'a, const OVERLAY_CAP: usize, const MAILBOX_CAP: usize> StandardControl<'a, OVERLAY_CAP, MAILBOX_CAP> { pub const fn new( - mailbox: &'a LightingMailbox, StandardState, StandardError, MAILBOX_CAP>, + mailbox: &'a LightingMailbox, StandardReply, StandardError, MAILBOX_CAP>, ) -> Self { Self { mailbox } } @@ -246,6 +248,10 @@ mod standard_control { } } + fn reply_state(reply: StandardReply) -> Result { + reply.state().ok_or(Error::PersistenceUnavailable) + } + fn patch(patch: ViaPatch) -> BackgroundPatch { BackgroundPatch { enabled: patch.enabled, @@ -269,16 +275,18 @@ mod standard_control { self.mailbox .request(StandardCommand::ReadState) .await - .map(state) .map_err(Error::Lighting) + .and_then(reply_state) + .map(state) } async fn patch_background(&self, update: ViaPatch) -> Result { self.mailbox .request(StandardCommand::PatchBackground(patch(update))) .await - .map(state) .map_err(Error::Lighting) + .and_then(reply_state) + .map(state) } async fn save_background(&self) -> Result<(), Self::Error> { @@ -474,9 +482,9 @@ mod tests { #[test] fn standard_mailbox_control_does_not_claim_persistence() { use crate::lighting::processor::LightingMailbox; - use crate::lighting::standard::{StandardCommand, StandardError, StandardState}; + use crate::lighting::standard::{StandardCommand, StandardError, StandardReply}; - let mailbox = LightingMailbox::, StandardState, StandardError, 1>::new(); + let mailbox = LightingMailbox::, StandardReply, StandardError, 1>::new(); let control = StandardControl::new(&mailbox); assert_eq!( block_on(control.save_background()), diff --git a/rmk/src/lighting/mod.rs b/rmk/src/lighting/mod.rs index 437762692..eefd00209 100644 --- a/rmk/src/lighting/mod.rs +++ b/rmk/src/lighting/mod.rs @@ -42,8 +42,9 @@ pub use source::{ }; pub use standard::{ BackgroundMode, BackgroundPatch, BackgroundState, EmptySource, OverlayBatch, OverlayCell, ReplicaSlotError, - StandardCommand, StandardError, StandardInput, StandardLightingEngine, StandardMutableState, StandardReplicaSlot, - StandardReplicaState, StandardState, UniformBackground, + SCENE_CHUNK_SIZE, SCENE_TRANSACTION_TIMEOUT_MS, SceneChunk, ScenePage, SceneTable, SceneTableCell, StandardCommand, + StandardError, StandardInput, StandardLightingEngine, StandardMutableState, StandardReplicaSlot, + StandardReplicaState, StandardReply, StandardState, UniformBackground, }; pub use topology::*; diff --git a/rmk/src/lighting/processor.rs b/rmk/src/lighting/processor.rs index a176ea667..2bbf649c8 100644 --- a/rmk/src/lighting/processor.rs +++ b/rmk/src/lighting/processor.rs @@ -75,6 +75,19 @@ impl LightingMailbox (u32, Command) { + let request = self.requests.receive().await; + (request.id, request.command) + } + + /// Service side: publish the reply for a previously received command. + pub fn publish_reply(&self, id: u32, result: Result) { + self.response.signal(MailboxResponse { id, result }); + } + async fn receive(&self) -> MailboxRequest { self.requests.receive().await } diff --git a/rmk/src/lighting/standard.rs b/rmk/src/lighting/standard.rs index 02e2e2e70..db8f0906e 100644 --- a/rmk/src/lighting/standard.rs +++ b/rmk/src/lighting/standard.rs @@ -12,18 +12,25 @@ use core::num::NonZeroU32; use embassy_sync::blocking_mutex::Mutex as BlockingMutex; 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::context::{LightingContext, 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::source::{LayerPolicy, LayerScenes, OverlayError, OverlayUpdate, TtlOverlay}; use super::topology::LedSlot; -use super::{LightingContext, Rgb8}; use crate::RawMutex; +/// Cells per scene page/replacement chunk. Kept equal to the wire chunk size +/// so protocol adapters can forward chunks without re-batching. +pub const SCENE_CHUNK_SIZE: usize = 8; + +/// A staged scene replacement expires after this much command inactivity. +pub const SCENE_TRANSACTION_TIMEOUT_MS: u64 = 5_000; + /// Stable default priority bands. Equal-priority call order remains stable. pub mod priority { pub const BACKGROUND: u8 = 0; @@ -223,8 +230,252 @@ impl Default for OverlayBatch { } } +/// One durable scene cell: an effect bound to a local slot on one layer. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct SceneTableCell { + pub layer: u8, + pub slot: LedSlot, + pub effect: BuiltinEffect, +} + +const EMPTY_SCENE_CELL: SceneTableCell = SceneTableCell { + layer: 0, + slot: LedSlot(0), + effect: BuiltinEffect::Solid { color: Rgb8::BLACK }, +}; + +/// Fixed-capacity, owned scene chunk suitable for a bounded async mailbox. +/// One chunk is both a page of readback and a replacement-staging step. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct SceneChunk { + cells: [SceneTableCell; SCENE_CHUNK_SIZE], + len: usize, +} + +impl Default for SceneChunk { + fn default() -> Self { + Self::new() + } +} + +impl SceneChunk { + pub const fn new() -> Self { + Self { + cells: [EMPTY_SCENE_CELL; SCENE_CHUNK_SIZE], + len: 0, + } + } + + pub fn push(&mut self, cell: SceneTableCell) -> Result<(), StandardError> { + if self.len == SCENE_CHUNK_SIZE { + return Err(StandardError::InvalidSceneRequest); + } + self.cells[self.len] = cell; + self.len += 1; + Ok(()) + } + + pub fn as_slice(&self) -> &[SceneTableCell] { + &self.cells[..self.len] + } +} + +/// Fixed-capacity runtime scene table with layer-aware composition. +/// +/// Cells are unique per `(layer, slot)` and stored in insertion order; order +/// is irrelevant to rendering because layer precedence comes from the policy +/// and same-layer cells can never target the same slot. +#[derive(Copy, Clone, Debug)] +pub struct SceneTable { + cells: [SceneTableCell; CAP], + len: usize, + policy: LayerPolicy, +} + +impl SceneTable { + pub const fn new() -> Self { + Self { + cells: [EMPTY_SCENE_CELL; CAP], + len: 0, + policy: LayerPolicy::ActiveStack, + } + } + + pub const fn len(&self) -> usize { + self.len + } + + pub const fn is_empty(&self) -> bool { + self.len == 0 + } + + pub const fn policy(&self) -> LayerPolicy { + self.policy + } + + pub fn set_policy(&mut self, policy: LayerPolicy) -> bool { + let changed = self.policy != policy; + self.policy = policy; + changed + } + + pub fn as_slice(&self) -> &[SceneTableCell] { + &self.cells[..self.len] + } + + /// Insert or update the cell addressed by `(layer, slot)`. + pub fn set(&mut self, cell: SceneTableCell) -> Result<(), StandardError> { + if let Some(existing) = self.cells[..self.len] + .iter_mut() + .find(|existing| existing.layer == cell.layer && existing.slot == cell.slot) + { + *existing = cell; + return Ok(()); + } + if self.len == CAP { + return Err(StandardError::SceneFull { capacity: CAP }); + } + self.cells[self.len] = cell; + self.len += 1; + Ok(()) + } + + /// Remove the cell addressed by `(layer, slot)`. + pub fn unset(&mut self, layer: u8, slot: LedSlot) -> bool { + if let Some(index) = self.cells[..self.len] + .iter() + .position(|cell| cell.layer == layer && cell.slot == slot) + { + self.len -= 1; + self.cells[index] = self.cells[self.len]; + true + } else { + false + } + } + + pub fn clear(&mut self) { + self.len = 0; + } + + /// One readback page starting at `offset`, clamped at the table's end. + pub fn page(&self, offset: u16) -> SceneChunk { + let start = (offset as usize).min(self.len); + let end = (start + SCENE_CHUNK_SIZE).min(self.len); + let mut chunk = SceneChunk::new(); + for cell in &self.cells[start..end] { + chunk.push(*cell).expect("page is chunk-bounded"); + } + chunk + } + + fn cell_for_layer(&self, layer: u8, wanted: &mut usize) -> Option<&SceneTableCell> { + for cell in self.cells[..self.len].iter().filter(|cell| cell.layer == layer) { + if *wanted == 0 { + return Some(cell); + } + *wanted -= 1; + } + None + } + + fn cell_at(&self, context: &LightingContext, mut wanted: usize) -> &SceneTableCell { + 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.cells[..self.len] + .iter() + .filter(|cell| match self.policy { + LayerPolicy::EffectiveOnly => cell.layer == effective, + LayerPolicy::ActiveStack => { + cell.layer == context.layers.default + || cell.layer == effective + || context.layers.is_active(cell.layer) + } + }) + .count() + } +} + +impl Default for SceneTable { + fn default() -> Self { + Self::new() + } +} + +impl PartialEq for SceneTable { + fn eq(&self, other: &Self) -> bool { + // Cells past `len` are stale storage, not state. + self.policy == other.policy && self.as_slice() == other.as_slice() + } +} + +impl Eq for SceneTable {} + +impl LightingSource for SceneTable +where + Context: LightingContextProvider, +{ + fn len(&self, input: &SourceRenderInput<'_, Context>) -> usize { + self.included_len(input.context.lighting_context()) + } + + fn slot(&self, index: usize, input: &SourceRenderInput<'_, Context>) -> LedSlot { + self.cell_at(input.context.lighting_context(), index).slot + } + + fn contribution(&mut self, index: usize, input: &SourceRenderInput<'_, Context>) -> Contribution { + Contribution::Opaque( + self.cell_at(input.context.lighting_context(), index) + .effect + .sample(input.now_ms), + ) + } +} + +/// One in-progress, chunk-staged atomic scene replacement. +/// +/// The overlay replacement stages host-side because a whole overlay batch +/// fits one mailbox command. A scene table is an order of magnitude larger, +/// so its transaction stages inside the engine via bounded chunks instead of +/// forcing kilobyte-sized command payloads through every mailbox. +struct SceneReplace { + id: u32, + expected_revision: u32, + expected_count: u16, + cells: [SceneTableCell; CAP], + len: usize, + last_activity_ms: u64, +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum StandardCommand { +pub enum StandardCommand { SetOutputEnabled(bool), SetOutputBrightness(u8), SetBackground(BackgroundState), @@ -255,11 +506,45 @@ pub enum StandardCommand { /// Atomically export all state needed by a renderer replica. The larger /// snapshot moves through the referenced slot so it does not inflate /// every element of the bounded command queue. - ExportReplica(&'static StandardReplicaSlot), + ExportReplica(&'static StandardReplicaSlot), /// Atomically install a snapshot previously placed in the referenced /// slot. Intended for a renderer replica, not a second authority. - ApplyReplica(&'static StandardReplicaSlot), + ApplyReplica(&'static StandardReplicaSlot), ReadState, + SetSceneCellIfRevision { + expected_revision: u32, + cell: SceneTableCell, + }, + UnsetSceneCellIfRevision { + expected_revision: u32, + layer: u8, + slot: LedSlot, + }, + SetLayerPolicyIfRevision { + expected_revision: u32, + policy: LayerPolicy, + }, + /// Reserve the engine's single scene-staging transaction. The expected + /// revision is recorded here and enforced atomically at commit. + BeginSceneReplace { + expected_revision: u32, + cell_count: u16, + }, + PutSceneChunk { + transaction_id: u32, + offset: u16, + cells: SceneChunk, + }, + CommitSceneReplace { + transaction_id: u32, + }, + AbortSceneReplace { + transaction_id: u32, + }, + /// One page of the stored scene table starting at `offset`. + ReadScenes { + offset: u16, + }, } /// Mutable standard state excluding the transient overlay contents and the @@ -278,6 +563,36 @@ pub struct StandardState { pub output_brightness: u8, pub background: BackgroundState, pub overlay_len: usize, + pub scene_len: usize, + pub scene_policy: LayerPolicy, +} + +/// One page of stored scene cells with the revision it was read under. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct ScenePage { + pub revision: u32, + pub total: u16, + pub cells: SceneChunk, +} + +/// Protocol-independent readback for [`StandardCommand`]s. Most commands +/// answer with authoritative [`StandardState`]; scene reads and transaction +/// reservation carry their own shapes. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum StandardReply { + State(StandardState), + ScenesPage(ScenePage), + SceneTransaction { id: u32, cell_count: u16 }, +} + +impl StandardReply { + /// The state readback carried by state-shaped replies. + pub const fn state(self) -> Option { + match self { + Self::State(state) => Some(state), + _ => None, + } + } } /// Complete declarative state needed by a standard-engine renderer replica. @@ -286,11 +601,16 @@ pub struct StandardState { /// Applying the snapshot anchors the replica's local monotonic clock to that /// value; subsequent animation frames are sampled locally without link /// traffic. Overlay TTLs are remaining lifetimes at the same instant. +/// +/// The runtime scene table travels with the snapshot: replicas must render +/// host-configured per-layer scenes exactly like the authority, and they +/// never receive the incremental scene mutation commands themselves. #[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub struct StandardReplicaState { +pub struct StandardReplicaState { pub revision: u32, pub mutable: StandardMutableState, pub overlay: OverlayBatch, + pub scenes: SceneTable, pub context: LightingContext, pub sample_time_ms: u64, } @@ -306,18 +626,18 @@ pub enum ReplicaSlotError { /// The lighting mailbox serializes access. Keeping the large snapshot out of /// [`StandardCommand`] prevents command-channel capacity from multiplying its /// RAM cost on small MCUs. -pub struct StandardReplicaSlot { - value: BlockingMutex>>>, +pub struct StandardReplicaSlot { + value: BlockingMutex>>>, } -impl StandardReplicaSlot { +impl StandardReplicaSlot { pub const fn new() -> Self { Self { value: BlockingMutex::new(RefCell::new(None)), } } - pub fn put(&self, state: StandardReplicaState) -> Result<(), ReplicaSlotError> { + pub fn put(&self, state: StandardReplicaState) -> Result<(), ReplicaSlotError> { self.value.lock(|value| { let mut value = value.borrow_mut(); if value.is_some() { @@ -329,31 +649,31 @@ impl StandardReplicaSlot { }) } - pub fn take(&self) -> Result, ReplicaSlotError> { + pub fn take(&self) -> Result, ReplicaSlotError> { self.value .lock(|value| value.borrow_mut().take().ok_or(ReplicaSlotError::Empty)) } } -impl Default for StandardReplicaSlot { +impl Default for StandardReplicaSlot { fn default() -> Self { Self::new() } } -impl fmt::Debug for StandardReplicaSlot { +impl fmt::Debug for StandardReplicaSlot { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("StandardReplicaSlot").finish_non_exhaustive() } } -impl PartialEq for StandardReplicaSlot { +impl PartialEq for StandardReplicaSlot { fn eq(&self, other: &Self) -> bool { core::ptr::eq(self, other) } } -impl Eq for StandardReplicaSlot {} +impl Eq for StandardReplicaSlot {} #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum StandardError { @@ -361,7 +681,26 @@ pub enum StandardError { Overlay(OverlayError), DeadlineOverflow, ReplicaSlot(ReplicaSlotError), - RevisionConflict { expected: u32, current: u32 }, + RevisionConflict { + expected: u32, + current: u32, + }, + SceneFull { + capacity: usize, + }, + SceneSlotOutOfRange { + slot: LedSlot, + }, + /// Malformed scene request: bad chunk order, count overflow, or a + /// duplicate `(layer, slot)` within one staged replacement. + InvalidSceneRequest, + SceneTransactionBusy, + InvalidSceneTransaction, + SceneTransactionExpired, + SceneTransactionIncomplete { + expected: u16, + received: u16, + }, } impl From for StandardError { @@ -419,21 +758,38 @@ impl From for StandardInput { /// 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> { +/// +/// Static board layer scenes and the runtime [`SceneTable`] share the layer +/// band: runtime cells apply after the static defaults, so a host-configured +/// cell overrides a board default for the same slot while the TTL overlay +/// still wins above both. +pub struct StandardLightingEngine< + 'scenes, + Extension, + Status, + const N: usize, + const OVERLAY_CAP: usize, + const SCENE_CAP: usize = 0, +> { compositor: Compositor, background: UniformBackground, extension: Extension, layers: LayerScenes<'scenes, BuiltinEffect>, + scenes: SceneTable, overlay: TtlOverlay, status: Status, animation_clock: AnimationClock, + scene_replace: Option>, + scene_next_transaction: u32, + scene_expired_transaction: Option, + scene_committed_transaction: Option, 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> +impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize, const SCENE_CAP: usize> + StandardLightingEngine<'scenes, Extension, Status, N, OVERLAY_CAP, SCENE_CAP> { pub fn new( background: BackgroundState, @@ -446,9 +802,14 @@ impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize> background: UniformBackground::new(background), extension, layers, + scenes: SceneTable::new(), overlay: TtlOverlay::new(), status, animation_clock: AnimationClock::local(), + scene_replace: None, + scene_next_transaction: 1, + scene_expired_transaction: None, + scene_committed_transaction: None, revision: 0, output_enabled: true, output_brightness: u8::MAX, @@ -462,9 +823,31 @@ impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize> output_brightness: self.output_brightness, background: self.background.state(), overlay_len: self.overlay.active_len(), + scene_len: self.scenes.len(), + scene_policy: self.scenes.policy(), } } + pub const fn scene_capacity() -> usize { + SCENE_CAP + } + + pub const fn scenes(&self) -> &SceneTable { + &self.scenes + } + + /// Install one persisted scene cell during startup, before the engine is + /// serving commands. Does not advance the concurrency revision. + pub fn install_scene_cell(&mut self, cell: SceneTableCell) -> Result<(), StandardError> { + Self::check_scene_slot(cell.slot)?; + self.scenes.set(cell) + } + + /// Install the persisted layer policy during startup. + pub fn install_scene_policy(&mut self, policy: LayerPolicy) { + self.scenes.set_policy(policy); + } + pub const fn extension(&self) -> &Extension { &self.extension } @@ -554,7 +937,7 @@ impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize> &self, local_now_ms: u64, context: &Context, - ) -> Result, StandardError> { + ) -> Result, StandardError> { let sample_time_ms = self.animation_clock.sample_time(local_now_ms); let mut overlay = OverlayBatch::new(); for update in self.overlay.active_updates() { @@ -576,6 +959,7 @@ impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize> revision: self.revision, mutable: self.mutable_state(), overlay, + scenes: self.scenes, context: *context.lighting_context(), sample_time_ms, }) @@ -584,7 +968,7 @@ impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize> fn apply_replica( &mut self, local_now_ms: u64, - replica: StandardReplicaState, + replica: StandardReplicaState, ) -> Result<(), StandardError> { let mut updates = [OverlayUpdate { slot: LedSlot(0), @@ -606,6 +990,7 @@ impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize> self.overlay .replace(replica.sample_time_ms, &updates[..replica.overlay.as_slice().len()])?; self.set_mutable_state(replica.mutable); + self.scenes = replica.scenes; self.revision = replica.revision; self.animation_clock.anchor(local_now_ms, replica.sample_time_ms); Ok(()) @@ -628,10 +1013,142 @@ impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize> expires_ms: Self::expires_at(now_ms, cell.ttl_ms)?, }) } + + fn check_scene_slot(slot: LedSlot) -> Result<(), StandardError> { + if slot.index() < N { + Ok(()) + } else { + Err(StandardError::SceneSlotOutOfRange { slot }) + } + } + + fn expire_scene_replace(&mut self, now_ms: u64) { + if self + .scene_replace + .as_ref() + .is_some_and(|replace| now_ms.saturating_sub(replace.last_activity_ms) >= SCENE_TRANSACTION_TIMEOUT_MS) + { + self.scene_expired_transaction = self.scene_replace.as_ref().map(|replace| replace.id); + self.scene_replace = None; + } + } + + fn scene_transaction_error(&self, id: u32) -> StandardError { + if self.scene_expired_transaction == Some(id) { + StandardError::SceneTransactionExpired + } else { + StandardError::InvalidSceneTransaction + } + } + + fn begin_scene_replace( + &mut self, + now_ms: u64, + expected_revision: u32, + cell_count: u16, + ) -> Result { + self.expire_scene_replace(now_ms); + if self.scene_replace.is_some() { + return Err(StandardError::SceneTransactionBusy); + } + if cell_count as usize > SCENE_CAP { + return Err(StandardError::SceneFull { capacity: SCENE_CAP }); + } + let id = self.scene_next_transaction; + self.scene_next_transaction = self.scene_next_transaction.wrapping_add(1).max(1); + self.scene_expired_transaction = None; + self.scene_replace = Some(SceneReplace { + id, + expected_revision, + expected_count: cell_count, + cells: [EMPTY_SCENE_CELL; SCENE_CAP], + len: 0, + last_activity_ms: now_ms, + }); + Ok(id) + } + + fn put_scene_chunk( + &mut self, + now_ms: u64, + transaction_id: u32, + offset: u16, + cells: &SceneChunk, + ) -> Result<(), StandardError> { + self.expire_scene_replace(now_ms); + for cell in cells.as_slice() { + Self::check_scene_slot(cell.slot)?; + } + let error = self.scene_transaction_error(transaction_id); + let replace = self + .scene_replace + .as_mut() + .filter(|replace| replace.id == transaction_id) + .ok_or(error)?; + if offset as usize != replace.len || replace.len + cells.as_slice().len() > replace.expected_count as usize { + return Err(StandardError::InvalidSceneRequest); + } + for cell in cells.as_slice() { + if replace.cells[..replace.len] + .iter() + .any(|staged| staged.layer == cell.layer && staged.slot == cell.slot) + { + return Err(StandardError::InvalidSceneRequest); + } + replace.cells[replace.len] = *cell; + replace.len += 1; + } + replace.last_activity_ms = now_ms; + Ok(()) + } + + /// Atomically publish a complete staged replacement. A repeated commit of + /// the transaction most recently committed answers idempotently with + /// `changed == false` so a retried commit over a lossy link converges. + fn commit_scene_replace(&mut self, now_ms: u64, transaction_id: u32) -> Result { + self.expire_scene_replace(now_ms); + if self.scene_committed_transaction == Some(transaction_id) && self.scene_replace.is_none() { + return Ok(false); + } + let error = self.scene_transaction_error(transaction_id); + let replace = self + .scene_replace + .as_ref() + .filter(|replace| replace.id == transaction_id) + .ok_or(error)?; + if replace.len != replace.expected_count as usize { + return Err(StandardError::SceneTransactionIncomplete { + expected: replace.expected_count, + received: replace.len as u16, + }); + } + self.check_revision(replace.expected_revision)?; + let replace = self.scene_replace.take().expect("checked above"); + self.scenes.clear(); + for cell in &replace.cells[..replace.len] { + self.scenes.set(*cell).expect("staged length is table-bounded"); + } + self.scene_committed_transaction = Some(transaction_id); + self.scene_expired_transaction = None; + Ok(true) + } + + fn abort_scene_replace(&mut self, now_ms: u64, transaction_id: u32) -> Result<(), StandardError> { + self.expire_scene_replace(now_ms); + if self + .scene_replace + .as_ref() + .is_some_and(|replace| replace.id == transaction_id) + { + self.scene_replace = None; + return Ok(()); + } + Err(self.scene_transaction_error(transaction_id)) + } } -impl<'scenes, Context, Extension, Status, const N: usize, const OVERLAY_CAP: usize> LightingEngine - for StandardLightingEngine<'scenes, Extension, Status, N, OVERLAY_CAP> +impl<'scenes, Context, Extension, Status, const N: usize, const OVERLAY_CAP: usize, const SCENE_CAP: usize> + LightingEngine for StandardLightingEngine<'scenes, Extension, Status, N, OVERLAY_CAP, SCENE_CAP> where Context: LightingContextProvider, Extension: LightingSource, @@ -639,8 +1156,8 @@ where { type Frame = LogicalFrame; type Input = StandardInput; - type Command = StandardCommand; - type Reply = StandardState; + type Command = StandardCommand; + type Reply = StandardReply; type Error = StandardError; fn on_input(&mut self, input: Self::Input, _snapshot: &Context) -> Result { @@ -659,6 +1176,55 @@ where snapshot: &Context, ) -> Result, Self::Error> { let effect_now_ms = self.animation_clock.sample_time(now_ms); + // Scene reads and transaction bookkeeping have non-state replies or + // bespoke revision behavior; everything else falls through to the + // uniform state-readback path below. + match command { + StandardCommand::ReadScenes { offset } => { + return Ok(CommandResult::unchanged(StandardReply::ScenesPage(ScenePage { + revision: self.revision, + total: self.scenes.len().min(u16::MAX as usize) as u16, + cells: self.scenes.page(offset), + }))); + } + StandardCommand::BeginSceneReplace { + expected_revision, + cell_count, + } => { + let id = self.begin_scene_replace(now_ms, expected_revision, cell_count)?; + return Ok(CommandResult::unchanged(StandardReply::SceneTransaction { + id, + cell_count, + })); + } + StandardCommand::PutSceneChunk { + transaction_id, + offset, + cells, + } => { + self.put_scene_chunk(now_ms, transaction_id, offset, &cells)?; + return Ok(CommandResult::unchanged(StandardReply::State(self.state()))); + } + StandardCommand::CommitSceneReplace { transaction_id } => { + // A repeated commit of the last committed transaction is + // answered idempotently without advancing the revision. + let committed = self.commit_scene_replace(now_ms, transaction_id)?; + if committed { + self.advance_revision(); + return Ok(CommandResult::new( + StandardReply::State(self.state()), + Invalidation::Render, + )); + } + return Ok(CommandResult::unchanged(StandardReply::State(self.state()))); + } + StandardCommand::AbortSceneReplace { transaction_id } => { + self.abort_scene_replace(now_ms, transaction_id)?; + return Ok(CommandResult::unchanged(StandardReply::State(self.state()))); + } + _ => {} + } + let (mut invalidation, advances_revision) = match command { StandardCommand::SetOutputEnabled(enabled) => { self.output_enabled = enabled; @@ -787,7 +1353,52 @@ where self.apply_replica(now_ms, slot.take()?)?; (Invalidation::Render, false) } + StandardCommand::SetSceneCellIfRevision { + expected_revision, + cell, + } => { + self.check_revision(expected_revision)?; + Self::check_scene_slot(cell.slot)?; + self.scenes.set(cell)?; + (Invalidation::Render, true) + } + StandardCommand::UnsetSceneCellIfRevision { + expected_revision, + layer, + slot, + } => { + self.check_revision(expected_revision)?; + let changed = self.scenes.unset(layer, slot); + ( + if changed { + Invalidation::Render + } else { + Invalidation::None + }, + true, + ) + } + StandardCommand::SetLayerPolicyIfRevision { + expected_revision, + policy, + } => { + self.check_revision(expected_revision)?; + let changed = self.scenes.set_policy(policy); + ( + if changed { + Invalidation::Render + } else { + Invalidation::None + }, + true, + ) + } StandardCommand::ReadState => (Invalidation::None, false), + StandardCommand::ReadScenes { .. } + | StandardCommand::BeginSceneReplace { .. } + | StandardCommand::PutSceneChunk { .. } + | StandardCommand::CommitSceneReplace { .. } + | StandardCommand::AbortSceneReplace { .. } => unreachable!("handled above"), }; if advances_revision { self.advance_revision(); @@ -795,7 +1406,7 @@ where invalidation = Invalidation::StateChanged; } } - Ok(CommandResult::new(self.state(), invalidation)) + Ok(CommandResult::new(StandardReply::State(self.state()), invalidation)) } fn render( @@ -815,17 +1426,24 @@ where background, extension, layers, + scenes, overlay, status, animation_clock: _, output_enabled, output_brightness, + scene_replace: _, + scene_next_transaction: _, + scene_expired_transaction: _, + scene_committed_transaction: _, revision: _, } = self; let mut transaction = compositor.begin(effect_now_ms, input.snapshot, Rgb8::BLACK, frame); transaction.apply(priority::BACKGROUND, background)?; transaction.apply(priority::EXTENSION, extension)?; transaction.apply(priority::LAYER, layers)?; + // Same band, later call: runtime cells override static defaults. + transaction.apply(priority::LAYER, scenes)?; transaction.apply(priority::HOST_OVERLAY, overlay)?; transaction.apply(priority::STATUS, status)?; let mut transform = BrightnessTransform::new(if *output_enabled { *output_brightness } else { 0 }); @@ -912,8 +1530,23 @@ mod tests { } #[test] - fn replica_snapshot_preserves_state_context_ttl_and_animation_phase() { - let mut authority = engine(); + fn replica_snapshot_preserves_state_scenes_context_ttl_and_animation_phase() { + type SceneEngine = StandardLightingEngine<'static, EmptySource, EmptySource, 2, 2, 4>; + fn scene_engine() -> SceneEngine { + SceneEngine::new( + BackgroundState { + value: 10, + ..BackgroundState::default() + }, + LayerScenes { + scenes: &LAYERS, + policy: LayerPolicy::ActiveStack, + }, + EmptySource, + EmptySource, + ) + } + let mut authority = scene_engine(); let authority_context = context(3); authority .handle_command( @@ -937,8 +1570,23 @@ mod tests { &authority_context, ) .unwrap(); + let scene_cell = SceneTableCell { + layer: 3, + slot: LedSlot(0), + effect: BuiltinEffect::Solid { color: RED }, + }; + authority + .handle_command( + 100, + StandardCommand::SetSceneCellIfRevision { + expected_revision: 2, + cell: scene_cell, + }, + &authority_context, + ) + .unwrap(); - static EXPORT: StandardReplicaSlot<2> = StandardReplicaSlot::new(); + static EXPORT: StandardReplicaSlot<2, 4> = StandardReplicaSlot::new(); authority .handle_command(120, StandardCommand::ExportReplica(&EXPORT), &authority_context) .unwrap(); @@ -946,6 +1594,7 @@ mod tests { assert_eq!(snapshot.context, authority_context); assert_eq!(snapshot.sample_time_ms, 120); assert_eq!(snapshot.overlay.as_slice()[0].ttl_ms, NonZeroU32::new(80)); + assert_eq!(snapshot.scenes.as_slice(), &[scene_cell]); let mut authority_frame = LogicalFrame::new(Rgb8::BLACK); authority @@ -958,9 +1607,9 @@ mod tests { ) .unwrap(); - static APPLY: StandardReplicaSlot<2> = StandardReplicaSlot::new(); + static APPLY: StandardReplicaSlot<2, 4> = StandardReplicaSlot::new(); APPLY.put(snapshot).unwrap(); - let mut replica = engine(); + let mut replica = scene_engine(); replica .handle_command(1_000, StandardCommand::ApplyReplica(&APPLY), &authority_context) .unwrap(); @@ -976,6 +1625,7 @@ mod tests { .unwrap(); assert_eq!(replica.state().revision, snapshot.revision); + assert_eq!(replica.scenes().as_slice(), &[scene_cell]); assert_eq!(replica_frame, authority_frame); } @@ -990,6 +1640,7 @@ mod tests { background: BackgroundState::default(), }, overlay: OverlayBatch::new(), + scenes: SceneTable::new(), context: context(0), sample_time_ms: 9, }; @@ -1014,7 +1665,9 @@ mod tests { &snapshot, ) .unwrap() - .reply; + .reply + .state() + .unwrap(); assert_eq!(reply.background.value, 77); assert_eq!(reply.background.enabled, before.background.enabled); @@ -1079,7 +1732,9 @@ mod tests { &snapshot, ) .unwrap() - .reply; + .reply + .state() + .unwrap(); assert_eq!(reply.revision, 2); assert!(!reply.output_enabled); @@ -1262,4 +1917,434 @@ mod tests { ); assert!(!engine.state().output_enabled); } + + const BLUE: Rgb8 = Rgb8::new(0, 0, 200); + + type SceneEngine = StandardLightingEngine<'static, EmptySource, EmptySource, 2, 2, 4>; + + fn scene_engine() -> SceneEngine { + StandardLightingEngine::new( + BackgroundState { + value: 10, + ..BackgroundState::default() + }, + LayerScenes { + scenes: &LAYERS, + policy: LayerPolicy::ActiveStack, + }, + EmptySource, + EmptySource, + ) + } + + fn scene_cell(layer: u8, slot: u16, color: Rgb8) -> SceneTableCell { + SceneTableCell { + layer, + slot: LedSlot(slot), + effect: BuiltinEffect::Solid { color }, + } + } + + fn set_cell(engine: &mut SceneEngine, revision: u32, cell: SceneTableCell) -> StandardState { + engine + .handle_command( + 0, + StandardCommand::SetSceneCellIfRevision { + expected_revision: revision, + cell, + }, + &context(0), + ) + .unwrap() + .reply + .state() + .unwrap() + } + + fn scenes_page(engine: &mut SceneEngine, offset: u16) -> ScenePage { + match engine + .handle_command(0, StandardCommand::ReadScenes { offset }, &context(0)) + .unwrap() + .reply + { + StandardReply::ScenesPage(page) => page, + other => panic!("expected page, got {other:?}"), + } + } + + #[test] + fn scene_crud_is_revision_checked_and_keyed_by_layer_and_slot() { + let mut engine = scene_engine(); + let snapshot = context(0); + + let state = set_cell(&mut engine, 0, scene_cell(1, 0, GREEN)); + assert_eq!(state.revision, 1); + assert_eq!(state.scene_len, 1); + + // Same (layer, slot) updates in place; a new pair appends. + let state = set_cell(&mut engine, 1, scene_cell(1, 0, BLUE)); + assert_eq!(state.scene_len, 1); + let state = set_cell(&mut engine, 2, scene_cell(2, 0, RED)); + assert_eq!(state.scene_len, 2); + + assert_eq!( + engine.handle_command( + 0, + StandardCommand::SetSceneCellIfRevision { + expected_revision: 0, + cell: scene_cell(0, 1, RED), + }, + &snapshot, + ), + Err(StandardError::RevisionConflict { + expected: 0, + current: 3 + }) + ); + assert_eq!(engine.state().scene_len, 2, "a stale write is all-or-nothing"); + + // Out-of-range slots never reach the table. + assert_eq!( + engine.handle_command( + 0, + StandardCommand::SetSceneCellIfRevision { + expected_revision: 3, + cell: scene_cell(0, 9, RED), + }, + &snapshot, + ), + Err(StandardError::SceneSlotOutOfRange { slot: LedSlot(9) }) + ); + + // Unset of a missing cell keeps the revision moving but not the frame. + let result = engine + .handle_command( + 0, + StandardCommand::UnsetSceneCellIfRevision { + expected_revision: 3, + layer: 7, + slot: LedSlot(0), + }, + &snapshot, + ) + .unwrap(); + assert_eq!(result.invalidation, Invalidation::StateChanged); + let state = result.reply.state().unwrap(); + assert_eq!(state.scene_len, 2); + assert_eq!(state.revision, 4); + + let state = engine + .handle_command( + 0, + StandardCommand::UnsetSceneCellIfRevision { + expected_revision: 4, + layer: 1, + slot: LedSlot(0), + }, + &snapshot, + ) + .unwrap() + .reply + .state() + .unwrap(); + assert_eq!(state.scene_len, 1); + + // Capacity is enforced with the table's own limit. + for (revision, (layer, slot)) in [(5u32, (5u8, 0u16)), (6, (5, 1)), (7, (6, 0))] { + set_cell(&mut engine, revision, scene_cell(layer, slot, RED)); + } + assert_eq!( + engine.handle_command( + 0, + StandardCommand::SetSceneCellIfRevision { + expected_revision: 8, + cell: scene_cell(9, 1, RED), + }, + &snapshot, + ), + Err(StandardError::SceneFull { capacity: 4 }) + ); + } + + #[test] + fn scene_pages_echo_revision_and_clamp() { + let mut engine = scene_engine(); + set_cell(&mut engine, 0, scene_cell(0, 0, RED)); + set_cell(&mut engine, 1, scene_cell(0, 1, GREEN)); + + let page = scenes_page(&mut engine, 0); + assert_eq!(page.revision, 2); + assert_eq!(page.total, 2); + assert_eq!(page.cells.as_slice().len(), 2); + let tail = scenes_page(&mut engine, 99); + assert_eq!(tail.total, 2); + assert!(tail.cells.as_slice().is_empty()); + } + + #[test] + fn runtime_scene_overrides_static_layer_and_yields_to_overlay() { + let mut engine = scene_engine(); + let snapshot = context(1); + + // Static layer 1 paints slot 0 RED; runtime cell overrides to BLUE. + set_cell(&mut engine, 0, scene_cell(1, 0, BLUE)); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + engine + .render( + RenderInput { + now_ms: 0, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + assert_eq!(frame.as_slice()[0], BLUE); + + // The TTL overlay still wins above runtime scenes. + engine + .handle_command( + 0, + StandardCommand::SetOverlay(OverlayCell { + slot: LedSlot(0), + effect: BuiltinEffect::Solid { color: GREEN }, + ttl_ms: None, + }), + &snapshot, + ) + .unwrap(); + engine + .render( + RenderInput { + now_ms: 1, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + assert_eq!(frame.as_slice()[0], GREEN); + } + + #[test] + fn layer_policy_switches_between_effective_only_and_active_stack() { + let mut engine = scene_engine(); + // Base layer paints slot 1; effective layer paints slot 0. + set_cell(&mut engine, 0, scene_cell(0, 1, GREEN)); + set_cell(&mut engine, 1, scene_cell(1, 0, BLUE)); + let snapshot = context(1); + + let mut frame = LogicalFrame::new(Rgb8::BLACK); + engine + .render( + RenderInput { + now_ms: 0, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + assert_eq!(frame.as_slice(), &[BLUE, GREEN], "ActiveStack falls through sparsely"); + + let state = engine + .handle_command( + 0, + StandardCommand::SetLayerPolicyIfRevision { + expected_revision: 2, + policy: LayerPolicy::EffectiveOnly, + }, + &snapshot, + ) + .unwrap() + .reply + .state() + .unwrap(); + assert_eq!(state.scene_policy, LayerPolicy::EffectiveOnly); + + let background = Rgb8::new(10, 10, 10); + engine + .render( + RenderInput { + now_ms: 1, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + assert_eq!( + frame.as_slice(), + &[BLUE, background], + "EffectiveOnly hides base-layer cells" + ); + } + + #[test] + fn scene_replace_is_chunked_ordered_atomic_and_idempotent() { + let mut engine = scene_engine(); + let snapshot = context(0); + set_cell(&mut engine, 0, scene_cell(3, 0, RED)); + + let (id, _) = match engine + .handle_command( + 10, + StandardCommand::BeginSceneReplace { + expected_revision: 1, + cell_count: 2, + }, + &snapshot, + ) + .unwrap() + .reply + { + StandardReply::SceneTransaction { id, cell_count } => (id, cell_count), + other => panic!("expected transaction, got {other:?}"), + }; + + // A second begin while staged is busy; out-of-order chunks rejected. + assert_eq!( + engine.handle_command( + 11, + StandardCommand::BeginSceneReplace { + expected_revision: 1, + cell_count: 0, + }, + &snapshot, + ), + Err(StandardError::SceneTransactionBusy) + ); + let mut chunk = SceneChunk::new(); + chunk.push(scene_cell(0, 0, GREEN)).unwrap(); + assert_eq!( + engine.handle_command( + 12, + StandardCommand::PutSceneChunk { + transaction_id: id, + offset: 1, + cells: chunk, + }, + &snapshot, + ), + Err(StandardError::InvalidSceneRequest) + ); + assert_eq!( + engine.handle_command( + 13, + StandardCommand::CommitSceneReplace { transaction_id: id }, + &snapshot, + ), + Err(StandardError::SceneTransactionIncomplete { + expected: 2, + received: 0 + }) + ); + + let mut chunk = SceneChunk::new(); + chunk.push(scene_cell(0, 0, GREEN)).unwrap(); + chunk.push(scene_cell(0, 1, BLUE)).unwrap(); + engine + .handle_command( + 14, + StandardCommand::PutSceneChunk { + transaction_id: id, + offset: 0, + cells: chunk, + }, + &snapshot, + ) + .unwrap(); + + let committed = engine + .handle_command( + 15, + StandardCommand::CommitSceneReplace { transaction_id: id }, + &snapshot, + ) + .unwrap(); + assert_eq!(committed.invalidation, Invalidation::Render); + let state = committed.reply.state().unwrap(); + assert_eq!(state.scene_len, 2, "replacement drops the previous table"); + assert_eq!(state.revision, 2); + + // Retried commit answers idempotently without another revision bump. + let retried = engine + .handle_command( + 16, + StandardCommand::CommitSceneReplace { transaction_id: id }, + &snapshot, + ) + .unwrap(); + assert_eq!(retried.invalidation, Invalidation::None); + assert_eq!(retried.reply.state().unwrap().revision, 2); + + // A conflicting revision at commit leaves the table untouched. + let stale = match engine + .handle_command( + 20, + StandardCommand::BeginSceneReplace { + expected_revision: 0, + cell_count: 0, + }, + &snapshot, + ) + .unwrap() + .reply + { + StandardReply::SceneTransaction { id, .. } => id, + other => panic!("expected transaction, got {other:?}"), + }; + assert_eq!( + engine.handle_command( + 21, + StandardCommand::CommitSceneReplace { transaction_id: stale }, + &snapshot, + ), + Err(StandardError::RevisionConflict { + expected: 0, + current: 2 + }) + ); + assert_eq!(engine.state().scene_len, 2); + engine + .handle_command( + 22, + StandardCommand::AbortSceneReplace { transaction_id: stale }, + &snapshot, + ) + .unwrap(); + } + + #[test] + fn scene_transaction_expires_after_inactivity() { + let mut engine = scene_engine(); + let snapshot = context(0); + let id = match engine + .handle_command( + 0, + StandardCommand::BeginSceneReplace { + expected_revision: 0, + cell_count: 0, + }, + &snapshot, + ) + .unwrap() + .reply + { + StandardReply::SceneTransaction { id, .. } => id, + other => panic!("expected transaction, got {other:?}"), + }; + assert_eq!( + engine.handle_command( + SCENE_TRANSACTION_TIMEOUT_MS, + StandardCommand::CommitSceneReplace { transaction_id: id }, + &snapshot, + ), + Err(StandardError::SceneTransactionExpired) + ); + assert_eq!( + engine.handle_command( + SCENE_TRANSACTION_TIMEOUT_MS, + StandardCommand::AbortSceneReplace { transaction_id: 999 }, + &snapshot, + ), + Err(StandardError::InvalidSceneTransaction) + ); + } } diff --git a/rmk/src/storage/mod.rs b/rmk/src/storage/mod.rs index fa2e669d3..dee3c61a5 100644 --- a/rmk/src/storage/mod.rs +++ b/rmk/src/storage/mod.rs @@ -8,6 +8,8 @@ use embedded_storage_async::nor_flash::NorFlash as AsyncNorFlash; use postcard::experimental::max_size::MaxSize; use rmk_types::connection::ConnectionType; use rmk_types::morse::MorseProfile; +#[cfg(all(feature = "lighting", feature = "rynk"))] +use rmk_types::protocol::rynk::{LIGHTING_SCENE_CHUNK_SIZE, LightingLayerPolicy, LightingSceneCell}; use sequential_storage::Error as SSError; use sequential_storage::cache::NoCache; use sequential_storage::map::{Key, MapConfig, MapStorage, PostcardValue, SerializationError}; @@ -151,6 +153,18 @@ pub(crate) enum FlashOperationMessage { PriorIdleTime(u16), // Default morse profile containing all morse/tap-hold settings (mode, timeouts, unilateral_tap) MorseDefaultProfile(MorseProfile), + #[cfg(all(feature = "lighting", feature = "rynk"))] + // Lighting scene-table header: authoritative cell count and layer policy + LightingSceneTable { + len: u16, + policy: LightingLayerPolicy, + }, + #[cfg(all(feature = "lighting", feature = "rynk"))] + // One shard of the lighting scene table, in wire (stable LED id) form + LightingSceneShard { + index: u8, + cells: heapless::Vec, + }, #[cfg(feature = "_ble")] // Read bond info for the given slot; storage task replies via `BOND_INFO_RESPONSE`. ReadBleBondInfo(u8), @@ -197,6 +211,10 @@ pub(crate) enum StorageKey { ActiveBleProfile, #[cfg(feature = "_ble")] BondInfo(u8), + #[cfg(all(feature = "lighting", feature = "rynk"))] + LightingSceneTable, + #[cfg(all(feature = "lighting", feature = "rynk"))] + LightingSceneShard(u8), } impl StorageKey { @@ -278,10 +296,24 @@ pub(crate) enum StorageData { BondInfo(ProfileInfo), #[cfg(feature = "_ble")] ActiveBleProfile(u8), + #[cfg(all(feature = "lighting", feature = "rynk"))] + LightingSceneTable(LightingSceneTableRecord), + #[cfg(all(feature = "lighting", feature = "rynk"))] + LightingSceneShard(heapless::Vec), } impl<'a> PostcardValue<'a> for StorageData {} +/// Persisted lighting scene-table header. Shards beyond `len` are stale +/// leftovers from a larger previous table and are ignored at load. +#[cfg(all(feature = "lighting", feature = "rynk"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub(crate) struct LightingSceneTableRecord { + pub(crate) len: u16, + pub(crate) policy: LightingLayerPolicy, +} + #[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, MaxSize)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] pub(crate) struct LocalStorageConfig { @@ -521,6 +553,42 @@ impl( + &mut self, + cells: &mut heapless::Vec, + ) -> Option { + let Some(StorageData::LightingSceneTable(record)) = self.fetch_data(StorageKey::LightingSceneTable).await + else { + return None; + }; + let len = (record.len as usize).min(CAP); + let mut index: u8 = 0; + 'shards: while cells.len() < len { + let Some(StorageData::LightingSceneShard(shard)) = + self.fetch_data(StorageKey::LightingSceneShard(index)).await + else { + break; + }; + if shard.is_empty() { + break; + } + for cell in shard { + if cells.len() == len || cells.push(cell).is_err() { + break 'shards; + } + } + let Some(next) = index.checked_add(1) else { + break; + }; + index = next; + } + Some(record.policy) + } + async fn initialize_storage_with_config( &mut self, #[cfg(feature = "host")] keymap: &[[[KeyAction; COL]; ROW]; NUM_LAYER], @@ -817,6 +885,33 @@ impl { update_storage_field!(&mut self.flash, &mut self.buffer, BehaviorConfig, morse_default_profile) } + #[cfg(all(feature = "lighting", feature = "rynk"))] + FlashOperationMessage::LightingSceneTable { len, policy } => { + // Scene persistence rewrites the whole table on every + // mutation; comparing before writing keeps unchanged + // records from consuming flash on single-cell edits. + let record = LightingSceneTableRecord { len, policy }; + match self.fetch_data(StorageKey::LightingSceneTable).await { + Some(StorageData::LightingSceneTable(saved)) if saved == record => Ok(()), + _ => { + self.store_data(StorageKey::LightingSceneTable, &StorageData::LightingSceneTable(record)) + .await + } + } + } + #[cfg(all(feature = "lighting", feature = "rynk"))] + FlashOperationMessage::LightingSceneShard { index, cells } => { + match self.fetch_data(StorageKey::LightingSceneShard(index)).await { + Some(StorageData::LightingSceneShard(saved)) if saved == cells => Ok(()), + _ => { + self.store_data( + StorageKey::LightingSceneShard(index), + &StorageData::LightingSceneShard(cells), + ) + .await + } + } + } }; match write_result { From c7c090ca24070bcfd59f673d65d4418d8d8a7524 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 20 Jul 2026 21:21:39 -0700 Subject: [PATCH 3/3] feat(rynk): mirror the scene endpoints in the host client and wasm bindings Typed client methods for every scene endpoint, plus two alloc conveniences: read_all_lighting_scenes pages the whole table under one pinned revision and restarts on a concurrent-mutation conflict, and replace_all_lighting_scenes drives the begin/put/commit transaction with a best-effort abort on staging failure. The wasm client re-exports the same surface, so the generated TypeScript picks up the scene types. Co-Authored-By: Claude Fable 5 --- rynk/rynk-wasm/src/client.rs | 28 ++++-- rynk/src/api.rs | 181 +++++++++++++++++++++++++++++++++-- 2 files changed, 194 insertions(+), 15 deletions(-) diff --git a/rynk/rynk-wasm/src/client.rs b/rynk/rynk-wasm/src/client.rs index 365e463c9..dd4209c29 100644 --- a/rynk/rynk-wasm/src/client.rs +++ b/rynk/rynk-wasm/src/client.rs @@ -22,14 +22,17 @@ use rynk::rmk_types::fork::Fork; use rynk::rmk_types::led_indicator::LedIndicator; use rynk::rmk_types::morse::Morse; use rynk::rmk_types::protocol::rynk::{ - AbortLightingOverlayReplaceRequest, BeginLightingOverlayReplaceRequest, BehaviorConfig, - ClearLightingOverlayRequest, CommitLightingOverlayReplaceRequest, DeviceCapabilities, DeviceInfo, - GetComboBulkResponse, GetKeymapBulkResponse, GetMorseBulkResponse, LightingCapabilities, LightingKeysPage, - LightingLedsPage, LightingOutputsPage, LightingOverlayTransaction, LightingPageRequest, LightingPhysicalKeysPage, - LightingRoutesPage, LightingState, LightingZoneMembershipsPage, LightingZonesPage, LockStatus, MacroData, - MatrixState, PeripheralStatus, ProtocolVersion, PutLightingOverlayChunkRequest, SetComboBulkRequest, - SetKeymapBulkRequest, SetLightingOverlayRequest, SetLightingStateRequest, SetMorseBulkRequest, StorageResetMode, - UnsetLightingOverlayRequest, + AbortLightingOverlayReplaceRequest, AbortLightingSceneReplaceRequest, BeginLightingOverlayReplaceRequest, + BeginLightingSceneReplaceRequest, BehaviorConfig, ClearLightingOverlayRequest, CommitLightingOverlayReplaceRequest, + CommitLightingSceneReplaceRequest, DeviceCapabilities, DeviceInfo, GetComboBulkResponse, GetKeymapBulkResponse, + GetMorseBulkResponse, LightingCapabilities, LightingKeysPage, LightingLedsPage, LightingOutputsPage, + LightingOverlayTransaction, LightingPageRequest, LightingPhysicalKeysPage, LightingRoutesPage, + LightingScenePageRequest, LightingSceneStatus, LightingSceneTransaction, LightingScenesPage, LightingState, + LightingZoneMembershipsPage, LightingZonesPage, LockStatus, MacroData, MatrixState, PeripheralStatus, + ProtocolVersion, PutLightingOverlayChunkRequest, PutLightingSceneChunkRequest, SetComboBulkRequest, + SetKeymapBulkRequest, SetLightingLayerPolicyRequest, SetLightingOverlayRequest, SetLightingSceneCellRequest, + SetLightingStateRequest, SetMorseBulkRequest, StorageResetMode, UnsetLightingOverlayRequest, + UnsetLightingSceneCellRequest, }; use rynk::{Client, Driver, LayoutInfo, RynkDevice, RynkHostError, TopicEvent}; use wasm_bindgen::prelude::*; @@ -172,6 +175,15 @@ endpoints! { put_lighting_overlay_chunk(request: PutLightingOverlayChunkRequest) -> (), commit_lighting_overlay_replace(request: CommitLightingOverlayReplaceRequest) -> LightingState, abort_lighting_overlay_replace(request: AbortLightingOverlayReplaceRequest) -> (), + get_lighting_scene_status() -> LightingSceneStatus, + get_lighting_scenes(request: LightingScenePageRequest) -> LightingScenesPage, + set_lighting_scene_cell(request: SetLightingSceneCellRequest) -> LightingState, + unset_lighting_scene_cell(request: UnsetLightingSceneCellRequest) -> LightingState, + set_lighting_layer_policy(request: SetLightingLayerPolicyRequest) -> LightingState, + begin_lighting_scene_replace(request: BeginLightingSceneReplaceRequest) -> LightingSceneTransaction, + put_lighting_scene_chunk(request: PutLightingSceneChunkRequest) -> (), + commit_lighting_scene_replace(request: CommitLightingSceneReplaceRequest) -> LightingState, + abort_lighting_scene_replace(request: AbortLightingSceneReplaceRequest) -> (), // connection get_connection_type() -> ConnectionType, get_connection_status() -> ConnectionStatus, diff --git a/rynk/src/api.rs b/rynk/src/api.rs index fc6fe5a08..4afe29120 100644 --- a/rynk/src/api.rs +++ b/rynk/src/api.rs @@ -13,16 +13,19 @@ use rmk_types::fork::Fork; use rmk_types::led_indicator::LedIndicator; use rmk_types::morse::Morse; use rmk_types::protocol::rynk::{ - AbortLightingOverlayReplaceRequest, BeginLightingOverlayReplaceRequest, BehaviorConfig, - ClearLightingOverlayRequest, Cmd, CommitLightingOverlayReplaceRequest, DeviceCapabilities, DeviceInfo, + AbortLightingOverlayReplaceRequest, AbortLightingSceneReplaceRequest, BeginLightingOverlayReplaceRequest, + BeginLightingSceneReplaceRequest, BehaviorConfig, ClearLightingOverlayRequest, Cmd, + CommitLightingOverlayReplaceRequest, CommitLightingSceneReplaceRequest, DeviceCapabilities, DeviceInfo, GetComboBulkRequest, GetComboBulkResponse, GetEncoderRequest, GetKeymapBulkRequest, GetKeymapBulkResponse, GetMacroRequest, GetMorseBulkRequest, GetMorseBulkResponse, KeyPosition, LightingCapabilities, LightingKeysPage, LightingLedsPage, LightingOutputsPage, LightingOverlayTransaction, LightingPageRequest, LightingPhysicalKeysPage, - LightingResult, LightingRoutesPage, LightingState, LightingZoneMembershipsPage, LightingZonesPage, LockStatus, - MacroData, MatrixState, PeripheralStatus, ProtocolVersion, PutLightingOverlayChunkRequest, SetComboBulkRequest, - SetComboRequest, SetEncoderRequest, SetForkRequest, SetKeyRequest, SetKeymapBulkRequest, SetLightingOverlayRequest, - SetLightingStateRequest, SetMacroRequest, SetMorseBulkRequest, SetMorseRequest, StorageResetMode, - UnsetLightingOverlayRequest, command, + LightingResult, LightingRoutesPage, LightingScenePageRequest, LightingSceneStatus, LightingSceneTransaction, + LightingScenesPage, LightingState, LightingZoneMembershipsPage, LightingZonesPage, LockStatus, MacroData, + MatrixState, PeripheralStatus, ProtocolVersion, PutLightingOverlayChunkRequest, PutLightingSceneChunkRequest, + SetComboBulkRequest, SetComboRequest, SetEncoderRequest, SetForkRequest, SetKeyRequest, SetKeymapBulkRequest, + SetLightingLayerPolicyRequest, SetLightingOverlayRequest, SetLightingSceneCellRequest, SetLightingStateRequest, + SetMacroRequest, SetMorseBulkRequest, SetMorseRequest, StorageResetMode, UnsetLightingOverlayRequest, + UnsetLightingSceneCellRequest, command, }; use crate::driver::{Client, RynkHostError}; @@ -487,6 +490,83 @@ impl Client { Self::flatten_lighting(self.request::(&request).await?) } + /// Read scene limits and occupancy. Scene support is discovered through + /// [`LightingCapabilities::features`] (`LAYER_SCENES`) plus this endpoint; + /// firmware without a scene table rejects it with `Unsupported`. + pub async fn get_lighting_scene_status(&self) -> Result { + self.require_lighting(Cmd::GetLightingSceneStatus)?; + Self::flatten_lighting(self.request::(&()).await?) + } + + /// Read one page of stored scene cells, pinned to a state revision. + pub async fn get_lighting_scenes( + &self, + request: LightingScenePageRequest, + ) -> Result { + self.require_lighting(Cmd::GetLightingScenes)?; + Self::flatten_lighting(self.request::(&request).await?) + } + + /// Insert or update one durable scene cell when the revision matches. + pub async fn set_lighting_scene_cell( + &self, + request: SetLightingSceneCellRequest, + ) -> Result { + self.require_lighting(Cmd::SetLightingSceneCell)?; + Self::flatten_lighting(self.request::(&request).await?) + } + + /// Remove one durable scene cell when the revision matches. + pub async fn unset_lighting_scene_cell( + &self, + request: UnsetLightingSceneCellRequest, + ) -> Result { + self.require_lighting(Cmd::UnsetLightingSceneCell)?; + Self::flatten_lighting(self.request::(&request).await?) + } + + /// Set the scene layer-composition policy when the revision matches. + pub async fn set_lighting_layer_policy( + &self, + request: SetLightingLayerPolicyRequest, + ) -> Result { + self.require_lighting(Cmd::SetLightingLayerPolicy)?; + Self::flatten_lighting(self.request::(&request).await?) + } + + /// Reserve the bounded staging transaction for atomic scene replacement. + pub async fn begin_lighting_scene_replace( + &self, + request: BeginLightingSceneReplaceRequest, + ) -> Result { + self.require_lighting(Cmd::BeginLightingSceneReplace)?; + Self::flatten_lighting(self.request::(&request).await?) + } + + /// Stage one ordered scene chunk. It does not mutate the live table. + pub async fn put_lighting_scene_chunk(&self, request: PutLightingSceneChunkRequest) -> Result<(), RynkHostError> { + self.require_lighting(Cmd::PutLightingSceneChunk)?; + Self::flatten_lighting(self.request::(&request).await?) + } + + /// Atomically publish a complete staged scene replacement. + pub async fn commit_lighting_scene_replace( + &self, + request: CommitLightingSceneReplaceRequest, + ) -> Result { + self.require_lighting(Cmd::CommitLightingSceneReplace)?; + Self::flatten_lighting(self.request::(&request).await?) + } + + /// Discard a staged scene replacement without changing live state. + pub async fn abort_lighting_scene_replace( + &self, + request: AbortLightingSceneReplaceRequest, + ) -> Result<(), RynkHostError> { + self.require_lighting(Cmd::AbortLightingSceneReplace)?; + Self::flatten_lighting(self.request::(&request).await?) + } + /// Read the active connection type (USB / BLE). pub async fn get_connection_type(&self) -> Result { self.request::(&()).await @@ -584,6 +664,93 @@ impl Client { .await } + /// Read the whole stored scene table by paging `GetLightingScenes` under + /// one pinned revision. A concurrent lighting mutation invalidates the + /// pin; the read restarts from a fresh status, bounded by a few attempts. + pub async fn read_all_lighting_scenes( + &self, + ) -> Result<(u32, Vec), RynkHostError> { + const ATTEMPTS: usize = 4; + let mut last_error = None; + for _ in 0..ATTEMPTS { + let status = self.get_lighting_scene_status().await?; + let mut cells = Vec::new(); + let mut offset: u16 = 0; + let mut conflicted = false; + while offset < status.scene_len { + match self + .get_lighting_scenes(LightingScenePageRequest { + revision: status.revision, + offset, + }) + .await + { + Ok(page) => { + if page.items.is_empty() { + break; + } + offset += page.items.len() as u16; + cells.extend(page.items.iter().copied()); + } + Err( + error @ RynkHostError::LightingRejected( + rmk_types::protocol::rynk::LightingError::StateRevisionConflict { .. }, + ), + ) => { + last_error = Some(error); + conflicted = true; + break; + } + Err(error) => return Err(error), + } + } + if !conflicted { + return Ok((status.revision, cells)); + } + } + Err(last_error.expect("a retried read only exits with a recorded conflict")) + } + + /// Atomically replace the whole stored scene table: begin, stage in + /// chunk-sized pages, and commit. A staging failure is followed by a + /// best-effort abort so the firmware transaction is not left dangling. + pub async fn replace_all_lighting_scenes( + &self, + expected_revision: u32, + cells: &[rmk_types::protocol::rynk::LightingSceneCell], + ) -> Result { + let transaction = self + .begin_lighting_scene_replace(BeginLightingSceneReplaceRequest { + expected_revision, + cell_count: cells.len() as u16, + }) + .await?; + let mut offset: u16 = 0; + for chunk in cells.chunks(rmk_types::protocol::rynk::LIGHTING_SCENE_CHUNK_SIZE) { + let mut request = PutLightingSceneChunkRequest { + transaction_id: transaction.id, + offset, + cells: Default::default(), + }; + for cell in chunk { + request.cells.push(*cell).expect("chunks are chunk-size bounded"); + } + if let Err(error) = self.put_lighting_scene_chunk(request).await { + let _ = self + .abort_lighting_scene_replace(AbortLightingSceneReplaceRequest { + transaction_id: transaction.id, + }) + .await; + return Err(error); + } + offset += chunk.len() as u16; + } + self.commit_lighting_scene_replace(CommitLightingSceneReplaceRequest { + transaction_id: transaction.id, + }) + .await + } + /// Write every morse by paging `SetMorseBulk` in `max_bulk_configs` chunks. pub async fn write_all_morses(&self, configs: &[Morse]) -> Result<(), RynkHostError> { let page = self.capabilities.max_bulk_configs as usize;