diff --git a/.github/ci/_lib.sh b/.github/ci/_lib.sh index f465a4f62..9c26b289d 100644 --- a/.github/ci/_lib.sh +++ b/.github/ci/_lib.sh @@ -52,6 +52,8 @@ RMK_FEATURESETS=( "rynk,_ble,split,storage,async_matrix" "rynk,storage" "rynk" + "rynk,lighting" + "rynk,storage,lighting" ) # Behavioral coverage only; RMK_FEATURESETS remains the compile/clippy matrix. @@ -59,6 +61,7 @@ RMK_TEST_FEATURESETS=( "" "vial,host_lock,_no_usb,steno,passkey_entry" "rynk,_ble,split,async_matrix,storage" + "rynk,storage,lighting" ) # Examples auto-discovery skiplist. Reasons: diff --git a/docs/docs/main/docs/configuration/appendix.md b/docs/docs/main/docs/configuration/appendix.md index e5ff9a54d..01b09c1b1 100644 --- a/docs/docs/main/docs/configuration/appendix.md +++ b/docs/docs/main/docs/configuration/appendix.md @@ -353,6 +353,9 @@ split_peripherals_num = 0 ble_profiles_num = 3 # BLE Split Central sleep timeout in seconds (0 = disabled) split_central_sleep_timeout_seconds = 0 +# Maximum skipped split connection events while active (0 = lowest latency) +split_central_max_latency_powered = 30 +split_central_max_latency_battery = 30 # Maximum macro data bytes in one Rynk macro request or response protocol_macro_chunk_size = 64 # Rynk RX/TX buffer size in bytes. 488 bytes = 2*BLE maximum packet size @@ -487,6 +490,8 @@ unlock_keys = [[0, 0], [0, 1]] insecure = false # Rynk only: move config writes into the locked tier (default: false). write_requires_unlock = false +# Require physical unlock before central or split-peripheral bootloader entry. +bootloader_requires_unlock = true # Chip-specific configuration # To use the default configuration, ignore this section completely diff --git a/docs/docs/main/docs/configuration/host_config.md b/docs/docs/main/docs/configuration/host_config.md index d2386401d..3f26ac622 100644 --- a/docs/docs/main/docs/configuration/host_config.md +++ b/docs/docs/main/docs/configuration/host_config.md @@ -33,6 +33,11 @@ insecure = false # Rynk only: move config writes (SetKeyAction, SetMacro, …) into the locked # tier so they also require unlock (default: false). write_requires_unlock = false + +# Rynk only: require physical unlock before central or split-peripheral +# bootloader entry (default: true). Set false for host-managed deployment; +# other dangerous operations remain gated. +bootloader_requires_unlock = true ``` ## Common Setups diff --git a/docs/docs/main/docs/configuration/rmk_config.md b/docs/docs/main/docs/configuration/rmk_config.md index 45bde3d13..aa9c7458d 100644 --- a/docs/docs/main/docs/configuration/rmk_config.md +++ b/docs/docs/main/docs/configuration/rmk_config.md @@ -38,6 +38,9 @@ split_peripherals_num = 0 ble_profiles_num = 3 # BLE Split Central sleep timeout in seconds (0 = disabled) split_central_sleep_timeout_seconds = 0 +# Maximum skipped 7.5 ms split connection events while active +split_central_max_latency_powered = 30 +split_central_max_latency_battery = 30 # Maximum macro data bytes in one Rynk macro request or response protocol_macro_chunk_size = 64 # Maximum number of auto mouse layer entries (auto-derived from [[behavior.auto_mouse_layer]] if unset) @@ -98,6 +101,7 @@ These tune the [Rynk](../features/rynk) protocol and rarely need changing. - `ble_profiles_num`: The number of available Bluetooth profiles, default value is 3. This parameter defines how many Bluetooth paired devices the keyboard can store. - `split_central_sleep_timeout_seconds`: Sleep timeout for BLE split central in seconds, default value is 0 (disabled). When set to a non-zero value, the split central will enter sleep mode after this many seconds of inactivity to save power. Set to 0 to disable automatic sleep. +- `split_central_max_latency_powered` / `split_central_max_latency_battery`: Maximum number of BLE connection events the split peripheral may skip while active, selected by whether USB power is present. Both default to 30. Lower values reduce worst-case split input and central-to-peripheral update latency at the cost of more radio wakeups and peripheral battery use. Set to 0 for the lowest latency. Sleep mode uses its own low-power connection parameters. Rynk can replace these volatile values or force/clear a runtime override with `GetSplitCentralLatency` and `SetSplitCentralLatency`; reboot restores the build-time values. ### Auto Mouse Layer Configuration diff --git a/docs/docs/main/docs/development/rynk_protocol.md b/docs/docs/main/docs/development/rynk_protocol.md index 4f765947a..308e59837 100644 --- a/docs/docs/main/docs/development/rynk_protocol.md +++ b/docs/docs/main/docs/development/rynk_protocol.md @@ -19,74 +19,139 @@ Every transport (USB CDC, BLE GATT, BLE HID) carries the same frame — a 3-byte On the wire the whole frame is COBS-encoded and terminated by a single `0x00` delimiter, so the byte stream is self-synchronizing. - **Requests** use CMD `0x0000..=0x7FFF`. The response echoes CMD and SEQ and wraps its payload in postcard `Result` (`T = ()` for `Set*`). +- **Lighting responses** use a `Lighting*Result` as `T`, preserving domain-specific `LightingError` detail inside the outer Rynk result. - **Topics** use CMD `0x8000..=0xFFFF` (server → host push, SEQ `0`, bare payload). Which commands a firmware answers depends on the RMK Cargo features it was built with: a row with no **Feature** is present once `rynk` is on, and the rest need their feature (`_ble`, `split`, …) compiled in. A command the firmware wasn't built with answers `UnknownCmd`. ## Endpoints -| CMD | Name | Request | Response | Feature | Notes | -| -------- | --------------------- | ---------------------- | ----------------------- | ------- | ---------------------------------------------------------------------------- | -| `0x0001` | `GetVersion` | `()` | `ProtocolVersion` | | | -| `0x0002` | `GetCapabilities` | `()` | `DeviceCapabilities` | | | -| `0x0003` | `Reboot` | `()` | `()` | | | -| `0x0004` | `BootloaderJump` | `()` | `()` | | | -| `0x0005` | `StorageReset` | `StorageResetMode` | `()` | | | -| `0x0006` | `GetLockStatus` | `()` | `LockStatus` | | Pure read of the current lock state — no side effects. | -| `0x0007` | `UnlockPoll` | `()` | `LockStatus` | | Arms/refreshes the unlock attempt and samples the held challenge keys. | -| `0x0008` | `Lock` | `()` | `()` | | Relock immediately. | -| `0x0009` | `GetLayout` | `u32` | `LayoutChunk` | | Get layout blob chunk. `u32` is the byte offset. | -| `0x000A` | `GetDeviceInfo` | `()` | `DeviceInfo` | | Identity strings and USB ids; feature gating stays in `GetCapabilities`. | -| `0x0101` | `GetKeyAction` | `KeyPosition` | `KeyAction` | | | -| `0x0102` | `SetKeyAction` | `SetKeyRequest` | `()` | | | -| `0x0103` | `GetDefaultLayer` | `()` | `u8` | | | -| `0x0104` | `SetDefaultLayer` | `u8` | `()` | | | -| `0x0105` | `GetEncoderAction` | `GetEncoderRequest` | `EncoderAction` | | | -| `0x0106` | `SetEncoderAction` | `SetEncoderRequest` | `()` | | | -| `0x0107` | `GetKeymapBulk` | `GetKeymapBulkRequest` | `GetKeymapBulkResponse` | | | -| `0x0108` | `SetKeymapBulk` | `SetKeymapBulkRequest` | `()` | | | -| `0x0201` | `GetMacro` | `GetMacroRequest` | `MacroData` | | | -| `0x0202` | `SetMacro` | `SetMacroRequest` | `()` | | | -| `0x0301` | `GetCombo` | `u8` | `Combo` | | | -| `0x0302` | `SetCombo` | `SetComboRequest` | `()` | | | -| `0x0303` | `GetComboBulk` | `GetComboBulkRequest` | `GetComboBulkResponse` | | | -| `0x0304` | `SetComboBulk` | `SetComboBulkRequest` | `()` | | | -| `0x0401` | `GetMorse` | `u8` | `Morse` | | | -| `0x0402` | `SetMorse` | `SetMorseRequest` | `()` | | | -| `0x0403` | `GetMorseBulk` | `GetMorseBulkRequest` | `GetMorseBulkResponse` | | | -| `0x0404` | `SetMorseBulk` | `SetMorseBulkRequest` | `()` | | | -| `0x0501` | `GetFork` | `u8` | `Fork` | | | -| `0x0502` | `SetFork` | `SetForkRequest` | `()` | | | -| `0x0601` | `GetBehaviorConfig` | `()` | `BehaviorConfig` | | | -| `0x0602` | `SetBehaviorConfig` | `BehaviorConfig` | `()` | | | -| `0x0701` | `GetConnectionType` | `()` | `ConnectionType` | | | -| `0x0702` | `GetConnectionStatus` | `()` | `ConnectionStatus` | | Full `ConnectionStatus` snapshot. | -| `0x0703` | `GetBleStatus` | `()` | `BleStatus` | `_ble` | | -| `0x0704` | `SwitchBleProfile` | `u8` | `()` | `_ble` | | -| `0x0705` | `ClearBleProfile` | `u8` | `()` | `_ble` | | -| `0x0801` | `GetCurrentLayer` | `()` | `u8` | | | -| `0x0802` | `GetMatrixState` | `()` | `MatrixState` | | | -| `0x0803` | `GetBatteryStatus` | `()` | `BatteryStatus` | `_ble` | | -| `0x0804` | `GetPeripheralStatus` | `u8` | `PeripheralStatus` | `split` | | -| `0x0805` | `GetWpm` | `()` | `u16` | | Latest WPM, sourced from the `WpmUpdate` topic snapshot. | -| `0x0806` | `GetSleepState` | `()` | `bool` | | Latest sleep flag, sourced from the `SleepState` topic snapshot. | -| `0x0807` | `GetLedIndicator` | `()` | `LedIndicator` | | Latest HID LED bitmap, sourced from the `LedIndicatorChange` topic snapshot. | +| CMD | Name | Request | Response | Feature | Notes | +| -------- | ------------------------------------------------------ | -------------------------------------------------------- | ---------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0x0001` | `GetVersion` | `()` | `ProtocolVersion` | | | +| `0x0002` | `GetCapabilities` | `()` | `DeviceCapabilities` | | | +| `0x0003` | `Reboot` | `()` | `()` | | | +| `0x0004` | `BootloaderJump` | `()` | `()` | | | +| `0x0005` | `StorageReset` | `StorageResetMode` | `()` | | | +| `0x0006` | `GetLockStatus` | `()` | `LockStatus` | | Pure read of the current lock state — no side effects. | +| `0x0007` | `UnlockPoll` | `()` | `LockStatus` | | Arms/refreshes the unlock attempt and samples the held challenge keys. | +| `0x0008` | `Lock` | `()` | `()` | | Relock immediately. | +| `0x0009` | `GetLayout` | `u32` | `LayoutChunk` | | Get layout blob chunk. `u32` is the byte offset. | +| `0x000A` | `GetDeviceInfo` | `()` | `DeviceInfo` | | Identity strings and USB ids; feature gating stays in `GetCapabilities`. | +| `0x000B` | `GetBuildInfo` | `()` | `BuildInfo` | | Application-defined diagnostic build label; never used for compatibility. | +| `0x000C` | `PeripheralBootloaderJump` | `u8` | `()` | | Ask the application to route a bootloader jump to one split peripheral. | +| `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` | | +| `0x0706` | `GetSplitCentralLatency` | `()` | `SplitCentralLatencyState` | `_ble` + `split` | Read the volatile active-mode policy, current USB-power selection, and effective value. | +| `0x0707` | `SetSplitCentralLatency` | `SplitCentralLatencyPolicy` | `SplitCentralLatencyState` | `_ble` + `split` | Replace the volatile policy. Each connection-event count must be `0..=499`. | +| `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. | +| `0x0808` | `GetLayerState` | `()` | `LayerState` | | Default layer and complete active-layer bitmap. | +| `0x0809` | `GetModifierState` | `()` | `ModifierCombination` | | Final resolved modifier bitmap used by the HID keyboard report. | +| `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` | | +| `0x091B` | `GetLightingOverlay` | `LightingOverlayPageRequest` | `LightingOverlayPageResult` | `lighting` | Overlay pages are pinned to `LightingState.revision` for consistency. | +| `0x091C` | `GetLightingCompiledSceneStatus` | `()` | `LightingCompiledSceneStatusResult` | `lighting` | Discover the immutable board-compiled layer-scene source. | +| `0x091D` | `GetLightingCompiledScenes` | `LightingPageRequest` | `LightingCompiledScenesPageResult` | `lighting` | Compiled-scene pages are pinned to the firmware topology revision. | +| `0x091E` | `GetLightingConditionalSceneStatus` | `()` | `LightingConditionalSceneStatusResult` | `lighting` | Discover immutable conditional lighting compiled from board config. | +| `0x091F` | `GetLightingConditionalScenes` | `LightingPageRequest` | `LightingConditionalScenesPageResult` | `lighting` | Conditional-scene pages are pinned to the firmware topology revision. | +| `0x0920` | `GetLightingOutputMode` | `()` | `LightingOutputModeStateResult` | `lighting` | Read the configured three-state output policy and its live state. | +| `0x0921` | `GetLightingExtension` | `()` | `LightingExtensionResult` | `lighting` | Discover the animated extension band: name-list sizes and selection. | +| `0x0922` | `GetLightingExtensionNames` | `LightingExtensionNamesRequest` | `LightingExtensionNamesPageResult` | `lighting` | Extension names are static per firmware build; page until `total`. | +| `0x0923` | `SetLightingExtensionState` | `SetLightingExtensionStateRequest` | `LightingStateResult` | `lighting` | Replace the extension selection when the state revision matches. | +| `0x0924` | `SetLightingOutputMode` | `SetLightingOutputModeRequest` | `LightingOutputModeStateResult` | `lighting` | Set the three-state output policy with optimistic concurrency. | +| `0x0925` | `GetLightingRuntimeConditionalSceneStatus` | `()` | `LightingRuntimeConditionalSceneStatusResult` | `lighting` | Discover the mutable ordered conditional-scene table. | +| `0x0926` | `GetLightingRuntimeConditionalScenes` | `LightingRuntimeConditionalScenePageRequest` | `LightingRuntimeConditionalScenesPageResult` | `lighting` | Runtime conditional pages are pinned to `LightingState.revision`. Connection predicates are omitted; use the extended read command when `RUNTIME_CONNECTION_CONDITIONS` is advertised. A read-modify-write cycle performed entirely through the legacy commands therefore drops every stored connection predicate. | +| `0x0927` | `BeginLightingRuntimeConditionalSceneReplace` | `BeginLightingRuntimeConditionalSceneReplaceRequest` | `LightingRuntimeConditionalSceneTransactionResult` | `lighting` | | +| `0x0928` | `PutLightingRuntimeConditionalSceneChunk` | `PutLightingRuntimeConditionalSceneChunkRequest` | `LightingUnitResult` | `lighting` | Cells written through this legacy endpoint have no connection predicate. | +| `0x0929` | `CommitLightingRuntimeConditionalSceneReplace` | `CommitLightingRuntimeConditionalSceneReplaceRequest` | `LightingStateResult` | `lighting` | | +| `0x092A` | `AbortLightingRuntimeConditionalSceneReplace` | `AbortLightingRuntimeConditionalSceneReplaceRequest` | `LightingUnitResult` | `lighting` | | +| `0x092B` | `GetLightingExtensionParams` | `LightingExtensionParamsRequest` | `LightingExtensionParamsPageResult` | `lighting` | Per-effect tunable parameters: descriptors plus live values, pinned to `LightingState.revision`. Page until `total`. | +| `0x092C` | `SetLightingExtensionParam` | `SetLightingExtensionParamRequest` | `LightingStateResult` | `lighting` | Set one effect parameter when the state revision matches. | +| `0x092D` | `SetLightingWakeLayers` | `SetLightingWakeLayersRequest` | `LightingOutputModeStateResult` | `lighting` | Replace the wake-layer mask. Policy rather than lighting content, but dynamic so which layers wake lighting is not a firmware rebuild. | +| `0x092E` | `GetLightingExtensionLayers` | `()` | `LightingExtensionLayersResult` | `lighting` | Read the optional second effect layered over the primary extension. | +| `0x092F` | `SetLightingExtensionLayers` | `SetLightingExtensionLayersRequest` | `LightingStateResult` | `lighting` | Replace the optional second effect when the state revision matches. | +| `0x0930` | `GetLightingExtendedRuntimeConditionalSceneStatus` | `()` | `LightingRuntimeConditionalSceneStatusResult` | `lighting` | Discover connection-aware runtime conditional limits and occupancy. | +| `0x0931` | `GetLightingExtendedRuntimeConditionalScenes` | `LightingRuntimeConditionalScenePageRequest` | `LightingExtendedRuntimeConditionalScenesPageResult` | `lighting` | Read connection-aware runtime conditional cells under a pinned state revision. | +| `0x0932` | `BeginLightingExtendedRuntimeConditionalSceneReplace` | `BeginLightingRuntimeConditionalSceneReplaceRequest` | `LightingRuntimeConditionalSceneTransactionResult` | `lighting` | Begin an atomic replacement using extended conditional cells. | +| `0x0933` | `PutLightingExtendedRuntimeConditionalSceneChunk` | `PutLightingExtendedRuntimeConditionalSceneChunkRequest` | `LightingUnitResult` | `lighting` | Stage connection-aware cells for an extended replacement. | +| `0x0934` | `CommitLightingExtendedRuntimeConditionalSceneReplace` | `CommitLightingRuntimeConditionalSceneReplaceRequest` | `LightingStateResult` | `lighting` | Publish a complete extended conditional-table replacement. | +| `0x0935` | `AbortLightingExtendedRuntimeConditionalSceneReplace` | `AbortLightingRuntimeConditionalSceneReplaceRequest` | `LightingUnitResult` | `lighting` | Discard an extended conditional-table replacement. | +| `0x0936` | `GetLightingFrame` | `LightingFrameRequest` | `LightingFramePageResult` | `lighting` | Read back what one lighting node last presented to its LEDs, paged. `LightingFeatureFlags` has no bits left, so support is discovered by probing: firmware without it answers `UnknownCmd`. | +| `0x0937` | `GetLightingReplicaStatus` | `()` | `LightingReplicaStatusResult` | `lighting` | Read both sides of the split lighting replication handshake. Probed like `GetLightingFrame`. Boards may use a read to trigger a coalesced background refresh; reread after one bounded link round trip when a fresh peripheral report is required. | ## Topics Topics are best-effort pushes; the `Get*` endpoints above mirror their payloads so a host can recover a missed push. -| CMD | Name | Payload | Feature | Notes | -| -------- | --------------------- | ------------------ | ------- | ----- | -| `0x8001` | `LayerChange` | `u8` | | | -| `0x8002` | `WpmUpdate` | `u16` | | | -| `0x8003` | `ConnectionChange` | `ConnectionStatus` | | | -| `0x8004` | `SleepState` | `bool` | | | -| `0x8005` | `LedIndicatorChange` | `LedIndicator` | | | -| `0x8006` | `BatteryStatusChange` | `BatteryStatus` | `_ble` | | +| CMD | Name | Payload | Feature | Notes | +| -------- | --------------------- | --------------------- | ---------- | ----- | +| `0x8001` | `LayerChange` | `u8` | | | +| `0x8002` | `WpmUpdate` | `u16` | | | +| `0x8003` | `ConnectionChange` | `ConnectionStatus` | | | +| `0x8004` | `SleepState` | `bool` | | | +| `0x8005` | `LedIndicatorChange` | `LedIndicator` | | | +| `0x8006` | `BatteryStatusChange` | `BatteryStatus` | `_ble` | | +| `0x8007` | `LightingChange` | `LightingChanged` | `lighting` | | +| `0x8008` | `ModifierChange` | `ModifierCombination` | | | ## Compatibility - `GetVersion` (`0x0001`) and its `Result` reply are frozen across all versions. - Within a major version, adding a CMD or topic is a `minor` bump: old firmware answers `UnknownCmd`, old hosts ignore unknown topics. - Reshaping an existing request/response — including appending a field — is a `major` bump. +- Version numbers are minted upstream only. Downstream extensions never bump `ProtocolVersion`; hosts discover them through capability surfaces (`DeviceCapabilities`, `LightingCapabilities.features`, `GetLightingSceneStatus`) and per-command probing (an unsupported command answers `UnknownCmd`), never by comparing `minor`. diff --git a/docs/docs/main/docs/features/rynk.md b/docs/docs/main/docs/features/rynk.md index fedc5e55a..7e1289437 100644 --- a/docs/docs/main/docs/features/rynk.md +++ b/docs/docs/main/docs/features/rynk.md @@ -100,10 +100,10 @@ There are three tiers: - **Open** — everything you normally reach for: read the keymap, change keys, layers, combos, macros, switch BLE profiles, reboot. These stay available so on-the-fly configuration is friction-free. -- **Locked** — the dangerous ones, always gated: entering the bootloader, - resetting stored settings and bonds, reading the live key matrix (a keylogger - if left open), and clearing a BLE bond. A host tool gets a "locked" error - until you unlock. +- **Locked** — the dangerous ones: entering the bootloader, resetting stored + settings and bonds, reading the live key matrix (a keylogger if left open), + and clearing a BLE bond. A host tool gets a "locked" error until you unlock. + Bootloader entry may be opted out separately for managed deployment setups. - **Config writes** — open by default, because on-the-fly configuration is the point. Set `write_requires_unlock = true` to move every write (keymap, macros, …) into the locked tier as well. @@ -127,6 +127,17 @@ If you leave `unlock_keys` unset, the locked operations can never be unlocked a safe default, but it means a fresh config can't enter the bootloader over Rynk or use the matrix tester until you add the keys. +For a keyboard whose host is trusted to manage firmware deployment, allow only +central and split-peripheral bootloader entry without the physical challenge: + +```toml title="keyboard.toml" +[host] +bootloader_requires_unlock = false +``` + +Storage reset, matrix-state reads, and BLE bond clearing remain gated. This is +more narrowly scoped than `insecure = true`. + For local development you can bypass the gate entirely: ```toml title="keyboard.toml" diff --git a/examples/use_rust/nrf54lm20_ble/src/main.rs b/examples/use_rust/nrf54lm20_ble/src/main.rs index 05ee9ff57..0665f27f2 100644 --- a/examples/use_rust/nrf54lm20_ble/src/main.rs +++ b/examples/use_rust/nrf54lm20_ble/src/main.rs @@ -194,6 +194,7 @@ async fn main(spawner: Spawner) { unlock_keys: RYNK_UNLOCK_KEYS, insecure: false, write_requires_unlock: false, + bootloader_requires_unlock: true, }, storage_config, ..Default::default() diff --git a/examples/use_rust/qemu-riscv-rynk/src/main.rs b/examples/use_rust/qemu-riscv-rynk/src/main.rs index e1f8cb3cc..42a67f73e 100644 --- a/examples/use_rust/qemu-riscv-rynk/src/main.rs +++ b/examples/use_rust/qemu-riscv-rynk/src/main.rs @@ -122,6 +122,7 @@ async fn main(_spawner: Spawner) { let rmk_config = RMK_CONFIG.init(RmkConfig { lock_config: LockConfig { insecure: true, + bootloader_requires_unlock: true, ..Default::default() }, ..Default::default() diff --git a/rmk-config/src/default_config/event_default.toml b/rmk-config/src/default_config/event_default.toml index e6d054cfb..0c81357a3 100644 --- a/rmk-config/src/default_config/event_default.toml +++ b/rmk-config/src/default_config/event_default.toml @@ -39,6 +39,12 @@ channel_size = 1 pubs = 1 subs = 1 +# Unit invalidation published after authoritative lighting state changes. +[event.lighting_changed] +channel_size = 1 +pubs = 1 +subs = 1 + # Power events [event.battery_status] channel_size = 1 diff --git a/rmk-config/src/default_config/subscriber_default.toml b/rmk-config/src/default_config/subscriber_default.toml index 4c6824616..1973bb379 100644 --- a/rmk-config/src/default_config/subscriber_default.toml +++ b/rmk-config/src/default_config/subscriber_default.toml @@ -66,6 +66,13 @@ events = [ { name = "connection_status_change" }, { name = "sleep_state" }, { name = "led_indicator" }, + { name = "modifier" }, +] + +[[subscriber]] +features = ["rynk", "lighting"] +events = [ + { name = "lighting_changed" }, ] [[subscriber]] @@ -80,6 +87,25 @@ events = [ { name = "connection_status_change" }, { name = "sleep_state" }, { name = "led_indicator" }, + { name = "modifier" }, +] + +[[subscriber]] +features = ["rynk", "lighting", "_ble"] +events = [ + # Second Rynk session slot on dual-transport boards. + { name = "lighting_changed" }, +] + +[[subscriber]] +features = ["lighting"] +events = [ + # lighting/processor.rs: LightingProcessor subscribes to authoritative + # keyboard-state invalidations on every lighting-enabled board. + { name = "layer_change" }, + { name = "connection_status_change" }, + { name = "led_indicator" }, + { name = "sleep_state" }, ] # --- Split-gated internal subscribers --- diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index 275b2aaf8..cbcd79d6a 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -113,6 +113,12 @@ impl LayoutInfo { } } +pub(crate) struct ResolvedLayout { + pub blob: Vec, + pub keys: Vec<[u8; 2]>, + pub physical: crate::resolved::PhysicalLayout, +} + /// A resolved shape: every default applied. `rect2` is the L-key's second /// rectangle stored as center-relative offsets — its `x`/`y` are offsets from /// the primary center, not absolute positions (the walk resolves them). @@ -668,6 +674,101 @@ pub(crate) fn build_layout_blob( Ok(miniz_oxide::deflate::compress_to_vec(&bytes, 10)) } +/// Resolve both host-facing KLE data and the compact firmware geometry from +/// one parse/walk. This keeps `[layout].map` the only board-layout authority. +pub(crate) fn build_resolved_layout( + layout: &LayoutTomlConfig, + expected_encoders: Option, +) -> Result { + let Some(info) = build_layout_info(layout, expected_encoders)? else { + return Ok(ResolvedLayout { + blob: Vec::new(), + keys: Vec::new(), + physical: crate::resolved::PhysicalLayout::default(), + }); + }; + + let bytes = + postcard::to_allocvec(&info).map_err(|e| format!("keyboard.toml: layout blob serialize failed: {e}"))?; + let blob = miniz_oxide::deflate::compress_to_vec(&bytes, 10); + let keys = parse_map(layout.map.as_deref().unwrap_or_default(), layout.rows, layout.cols)? + .into_iter() + .filter_map(|token| match token { + MapToken::Key { row, col, .. } => Some([row, col]), + _ => None, + }) + .collect(); + let variant = &info.variants[info.default_variant as usize]; + let physical = crate::resolved::PhysicalLayout { + keys: variant + .keys + .iter() + .map(|key| { + let center = fixed_point(key.rect.x, key.rect.y) + .map_err(|reason| format!("layout.map key ({},{}) center {reason}", key.row, key.col))?; + let size = fixed_size(key.rect.w, key.rect.h) + .map_err(|reason| format!("layout.map key ({},{}) size {reason}", key.row, key.col))?; + let rotation_centidegrees = centidegrees(key.r) + .map_err(|reason| format!("layout.map key ({},{}) rotation {reason}", key.row, key.col))?; + Ok(crate::resolved::PhysicalKey { + matrix: [key.row, key.col], + center, + size, + rotation_centidegrees, + }) + }) + .collect::, String>>()?, + }; + + Ok(ResolvedLayout { blob, keys, physical }) +} + +fn fixed_point(x: f32, y: f32) -> Result { + fn axis(value: f32) -> Result { + if !value.is_finite() { + return Err("must be finite"); + } + let raw = (value * 256.0).round(); + if raw < i16::MIN as f32 || raw > i16::MAX as f32 { + return Err("does not fit signed Q8.8 key-pitch units"); + } + Ok(raw as i16) + } + Ok(crate::resolved::FixedPoint3 { + x: axis(x)?, + y: axis(y)?, + z: 0, + }) +} + +fn fixed_size(width: f32, height: f32) -> Result { + fn axis(value: f32) -> Result { + if !value.is_finite() || value <= 0.0 { + return Err("must be finite and positive"); + } + let raw = (value * 256.0).round(); + if raw < 1.0 || raw > u16::MAX as f32 { + return Err("does not fit unsigned Q8.8 key-pitch units"); + } + Ok(raw as u16) + } + Ok(crate::resolved::FixedSize2 { + width: axis(width)?, + height: axis(height)?, + }) +} + +fn centidegrees(degrees: f32) -> Result { + if !degrees.is_finite() { + return Err("must be finite"); + } + let raw = (degrees * 100.0).round(); + if raw < i16::MIN as f32 || raw > i16::MAX as f32 { + return Err("does not fit signed centidegrees"); + } + Ok(raw as i16) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index d858da563..a478b215d 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -89,6 +89,9 @@ pub struct KeyboardTomlConfig { /// Layout config: the physical key arrangement (`map`) plus the rendered layout. /// For split keyboards, the total row/col is defined in this section. layout: Option, + /// Topology-aware lighting. Key geometry is always derived from + /// `[layout].map`; emitters add semantic identity and electrical routing. + lighting: Option, /// Behavior config behavior: Option, /// Light config @@ -298,6 +301,14 @@ pub(crate) struct RmkConstantsConfig { /// BLE Split Central sleep timeout in seconds (0 = disabled) #[serde_inline_default(0)] pub split_central_sleep_timeout_seconds: u32, + /// Maximum BLE peripheral latency on external power, in active connection events. + #[serde_inline_default(30)] + #[serde(deserialize_with = "check_split_central_max_latency")] + pub split_central_max_latency_powered: u16, + /// Maximum BLE peripheral latency on battery, in active connection events. + #[serde_inline_default(30)] + #[serde(deserialize_with = "check_split_central_max_latency")] + pub split_central_max_latency_battery: u16, /// Maximum macro data chunk size for protocol transfers (bytes). /// Smaller values reduce firmware RAM usage but require more round-trips. #[serde_inline_default(64)] @@ -377,6 +388,19 @@ where Ok(value) } +fn check_split_central_max_latency<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let value = Deserialize::deserialize(deserializer)?; + if value >= 500 { + return Err(de::Error::custom(format!( + "split_central_max_latency must be between 0 and 499, got {value}" + ))); + } + Ok(value) +} + /// This separate Default impl is needed when `[rmk]` section is not set in keyboard.toml impl Default for RmkConstantsConfig { fn default() -> Self { @@ -397,6 +421,8 @@ impl Default for RmkConstantsConfig { split_peripherals_num: 0, ble_profiles_num: 3, split_central_sleep_timeout_seconds: 0, + split_central_max_latency_powered: 30, + split_central_max_latency_battery: 30, protocol_macro_chunk_size: 64, auto_mouse_layer_max_num: None, rynk_buffer_size: 488, @@ -466,6 +492,7 @@ define_event_config!( wpm_update, led_indicator, sleep_state, + lighting_changed, // Power events battery_status, battery_adc, @@ -526,6 +553,224 @@ pub(crate) struct VariantToml { pub hidden: Option>, } +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingTomlConfig { + #[serde(default = "default_topology_revision")] + pub topology_revision: u32, + #[serde(default, rename = "zone")] + pub zones: Vec, + #[serde(default, rename = "output")] + pub outputs: Vec, + #[serde(default, rename = "emitter")] + pub emitters: Vec, + #[serde(default, rename = "layer_scene")] + pub layer_scenes: Vec, + #[serde(default, rename = "conditional_scene")] + pub conditional_scenes: Vec, + pub controls: Option, + pub background: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingControlsTomlConfig { + pub output_toggle_user_action: Option, + pub output_mode_cycle_user_action: Option, + /// Layers that wake lighting while held. A list, since any set of layers + /// may wake it; the host can replace the resolved mask at runtime. + pub wake_layers: Option>, + #[serde(default)] + pub initial_output_mode: LightingOutputModeToml, + #[serde(default)] + pub powered_only_scope: LightingPoweredOnlyScopeToml, + pub output_mode_indicator: Option, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LightingPoweredOnlyScopeToml { + #[default] + Authority, + Local, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LightingOutputModeToml { + #[default] + AlwaysOn, + AlwaysOff, + PoweredOnly, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingOutputModeIndicatorTomlConfig { + pub target: LightingTargetTomlConfig, + pub always_on: LightingEffectTomlConfig, + pub always_off: LightingEffectTomlConfig, + pub powered_only: LightingEffectTomlConfig, +} + +fn default_topology_revision() -> u32 { + 1 +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingZoneTomlConfig { + pub id: u8, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingOutputTomlConfig { + pub node: u8, + pub id: u8, + pub pixel_count: u16, + pub capabilities: Vec, + #[serde(default)] + pub sparse: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingEmitterTomlConfig { + pub id: u16, + pub key: Option<[u8; 2]>, + pub position: Option<[f32; 3]>, + #[serde(default)] + pub zones: Vec, + pub node: u8, + pub output: u8, + pub physical_index: u16, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingLayerSceneTomlConfig { + pub layer: u8, + #[serde(default, rename = "cell")] + pub cells: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingConditionalSceneTomlConfig { + pub layer: Option, + pub battery: Option, + pub output_mode: Option, + #[serde(default, rename = "cell")] + pub cells: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingLayerConditionTomlConfig { + pub layer: u8, + #[serde(default = "default_true")] + pub active: bool, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingBatteryConditionTomlConfig { + pub node: u8, + pub min_level: Option, + pub max_level: Option, + #[serde(default)] + pub charge: LightingChargeConditionToml, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LightingChargeConditionToml { + #[default] + Any, + Charging, + Discharging, + Unknown, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingSceneCellTomlConfig { + pub target: LightingTargetTomlConfig, + pub effect: LightingEffectTomlConfig, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum LightingTargetTomlConfig { + Led { led: u16 }, + KeyId { key: u16 }, + Key { key: [u8; 2] }, + Zone { zone: u8 }, + All { all: bool }, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum LightingEffectTomlConfig { + Solid { + color: [u8; 3], + }, + Blink { + color: [u8; 3], + period_ms: u32, + #[serde(default)] + phase_ms: u32, + duty_percent: u8, + }, + Breathe { + color: [u8; 3], + period_ms: u32, + #[serde(default)] + phase_ms: u32, + #[serde(default = "default_breathe_step_ms")] + step_ms: u16, + }, +} + +fn default_breathe_step_ms() -> u16 { + 16 +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LightingBackgroundTomlConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub hue: u8, + #[serde(default)] + pub saturation: u8, + #[serde(default = "default_background_value")] + pub value: u8, + #[serde(default = "default_background_speed")] + pub speed: u8, + #[serde(default)] + pub mode: LightingBackgroundModeToml, +} + +fn default_background_value() -> u8 { + 32 +} + +fn default_background_speed() -> u8 { + 128 +} + +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LightingBackgroundModeToml { + #[default] + Solid, + Breathe, +} + /// The `[keymap]` section: layer count plus the per-layer key actions. #[derive(Clone, Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] @@ -1088,6 +1333,10 @@ pub(crate) struct HostConfig { /// locked set, so writes also require unlock (default: false). #[serde_inline_default(false)] pub write_requires_unlock: bool, + /// Require the Rynk physical-presence unlock before entering either the + /// central or a split peripheral bootloader (default: true). + #[serde_inline_default(true)] + pub bootloader_requires_unlock: bool, } impl Default for HostConfig { @@ -1098,6 +1347,7 @@ impl Default for HostConfig { unlock_keys: None, insecure: false, write_requires_unlock: false, + bootloader_requires_unlock: true, } } } @@ -1434,6 +1684,10 @@ mod tests { assert_eq!(config.led_indicator.pubs, 2); assert_eq!(config.led_indicator.subs, 3); + assert_eq!(config.lighting_changed.channel_size, 1); + assert_eq!(config.lighting_changed.pubs, 1); + assert_eq!(config.lighting_changed.subs, 1); + assert_eq!(config.pointing.channel_size, 8); assert_eq!(config.pointing.subs, 2); @@ -1495,6 +1749,33 @@ fork_max_num = 255 } } + #[test] + fn split_central_max_latency_matches_ble_limit() { + let ok: KeyboardTomlConfig = toml::from_str( + r#" +[rmk] +split_central_max_latency_powered = 499 +split_central_max_latency_battery = 498 +"#, + ) + .unwrap(); + assert_eq!(ok.rmk.split_central_max_latency_powered, 499); + assert_eq!(ok.rmk.split_central_max_latency_battery, 498); + + let err = toml::from_str::( + r#" +[rmk] +split_central_max_latency_battery = 500 +"#, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("split_central_max_latency must be between 0 and 499"), + "{err}" + ); + } + #[test] fn test_event_config_partial_override_with_event_defaults_loader() { let user_toml = r#" diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index 7c4ef3f54..f1718cd1a 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -47,6 +47,8 @@ pub struct BuildConstants { pub split_peripherals_num: usize, pub ble_profiles_num: usize, pub split_central_sleep_timeout_seconds: u32, + pub split_central_max_latency_powered: u16, + pub split_central_max_latency_battery: u16, pub protocol_macro_chunk_size: usize, pub auto_mouse_layer_max_num: usize, /// Rynk RX/TX buffer size (bytes). @@ -105,6 +107,7 @@ impl crate::KeyboardTomlConfig { wpm_update, led_indicator, sleep_state, + lighting_changed, battery_status, battery_adc, charging_state, @@ -196,6 +199,8 @@ impl crate::KeyboardTomlConfig { split_peripherals_num, ble_profiles_num: rmk.ble_profiles_num, split_central_sleep_timeout_seconds: rmk.split_central_sleep_timeout_seconds, + split_central_max_latency_powered: rmk.split_central_max_latency_powered, + split_central_max_latency_battery: rmk.split_central_max_latency_battery, protocol_macro_chunk_size: rmk.protocol_macro_chunk_size, auto_mouse_layer_max_num, rynk_buffer_size: rmk.rynk_buffer_size, @@ -267,15 +272,26 @@ mod tests { #[test] fn reserves_led_subscribers_for_display_split_and_dual_rynk_sessions() { let config: KeyboardTomlConfig = toml::from_str("").unwrap(); - let constants = config.build_constants(&["display", "split", "rynk", "_ble"]).unwrap(); + let constants = config + .build_constants(&["display", "split", "rynk", "lighting", "_ble"]) + .unwrap(); let led_indicator = constants .events .iter() .find(|event| event.name == "led_indicator") .unwrap(); - // Three indicator processors, the display, two split peripherals, and USB/BLE Rynk sessions. - assert_eq!(led_indicator.subs, 8); + // Three indicator processors, the display, two split peripherals, + // USB/BLE Rynk sessions, and the lighting processor. + assert_eq!(led_indicator.subs, 9); + + let lighting_changed = constants + .events + .iter() + .find(|event| event.name == "lighting_changed") + .unwrap(); + // One public subscriber plus USB and BLE Rynk sessions. + assert_eq!(lighting_changed.subs, 3); } #[test] diff --git a/rmk-config/src/resolved/host.rs b/rmk-config/src/resolved/host.rs index 7c7f35104..415eb956e 100644 --- a/rmk-config/src/resolved/host.rs +++ b/rmk-config/src/resolved/host.rs @@ -7,6 +7,7 @@ pub struct Host { pub unlock_keys: Vec<[u8; 2]>, pub insecure: bool, pub write_requires_unlock: bool, + pub bootloader_requires_unlock: bool, } impl crate::KeyboardTomlConfig { @@ -24,6 +25,7 @@ impl crate::KeyboardTomlConfig { unlock_keys, insecure: host_toml.insecure, write_requires_unlock: host_toml.write_requires_unlock, + bootloader_requires_unlock: host_toml.bootloader_requires_unlock, } } } diff --git a/rmk-config/src/resolved/layout.rs b/rmk-config/src/resolved/layout.rs index dbdb32416..150cda842 100644 --- a/rmk-config/src/resolved/layout.rs +++ b/rmk-config/src/resolved/layout.rs @@ -1,16 +1,83 @@ -/// Resolved physical layout: the compressed, opaque blob the firmware streams -/// verbatim over `GetLayout`. Empty when there's no `[layout].map`. +/// Signed Q8.8 board-space point in key-pitch units. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FixedPoint3 { + pub x: i16, + pub y: i16, + pub z: i16, +} + +/// Unsigned Q8.8 key size in key-pitch units. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FixedSize2 { + pub width: u16, + pub height: u16, +} + +/// Fixed-point geometry for one key in the selected/default KLE variant. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhysicalKey { + pub matrix: [u8; 2], + pub center: FixedPoint3, + pub size: FixedSize2, + /// Clockwise rotation in hundredths of one degree. + pub rotation_centidegrees: i16, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PhysicalLayout { + pub keys: Vec, +} + +/// Resolved physical layout. `blob` preserves the complete variant-aware KLE +/// representation streamed over `GetLayout`; `physical` is the allocation-free +/// firmware geometry derived from its selected/default variant. `keys` is the +/// variant-independent set of logical matrix positions from `[layout].map`. pub struct Layout { pub blob: Vec, + pub rows: u8, + pub cols: u8, + pub keys: Vec<[u8; 2]>, + pub physical: PhysicalLayout, } impl crate::KeyboardTomlConfig { /// Resolve the physical layout blob from the `[layout]` section. pub fn layout(&self) -> Result { - let blob = match &self.layout { - Some(l) => crate::layout::build_layout_blob(l, Some(self.total_encoders()))?, - None => Vec::new(), + let (blob, keys, physical, rows, cols) = match &self.layout { + Some(l) => { + let resolved = crate::layout::build_resolved_layout(l, Some(self.total_encoders()))?; + (resolved.blob, resolved.keys, resolved.physical, l.rows, l.cols) + } + None => (Vec::new(), Vec::new(), PhysicalLayout::default(), 0, 0), + }; + Ok(Layout { + blob, + rows, + cols, + keys, + physical, + }) + } + + /// Resolve the `[layout]` section without consulting the board config. + /// + /// Firmware that wires its scan hardware by hand has no `[matrix]` or + /// `[split]` section, so encoder counts cannot be validated against the + /// layout map; everything else resolves as in [`Self::layout`]. + pub fn layout_standalone(&self) -> Result { + let (blob, keys, physical, rows, cols) = match &self.layout { + Some(l) => { + let resolved = crate::layout::build_resolved_layout(l, None)?; + (resolved.blob, resolved.keys, resolved.physical, l.rows, l.cols) + } + None => (Vec::new(), Vec::new(), PhysicalLayout::default(), 0, 0), }; - Ok(Layout { blob }) + Ok(Layout { + blob, + rows, + cols, + keys, + physical, + }) } } diff --git a/rmk-config/src/resolved/lighting.rs b/rmk-config/src/resolved/lighting.rs new file mode 100644 index 000000000..409b8b79e --- /dev/null +++ b/rmk-config/src/resolved/lighting.rs @@ -0,0 +1,1013 @@ +use std::collections::{HashMap, HashSet}; + +use super::Keymap; +use super::layout::{FixedPoint3, Layout}; +use crate::{ + LightingBackgroundModeToml, LightingChargeConditionToml, LightingEffectTomlConfig, LightingOutputModeToml, + LightingPoweredOnlyScopeToml, LightingTargetTomlConfig, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LightingKey { + pub matrix: [u8; 2], +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LightingZone { + pub id: u8, + pub name: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingEmitter { + pub id: u16, + pub key: Option<[u8; 2]>, + pub position: Option, + pub zone_start: u16, + pub zone_len: u8, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingOutput { + pub node: u8, + pub id: u8, + pub pixel_count: u16, + pub capabilities: u8, + pub sparse: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingRoute { + pub slot: u16, + pub node: u8, + pub output: u8, + pub physical_index: u16, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LightingEffect { + Solid { + color: [u8; 3], + }, + Blink { + color: [u8; 3], + period_ms: u32, + phase_ms: u32, + duty_percent: u8, + }, + Breathe { + color: [u8; 3], + period_ms: u32, + phase_ms: u32, + step_ms: u16, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingSceneCell { + pub slot: u16, + pub effect: LightingEffect, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LightingLayerScene { + pub layer: u8, + pub cells: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingLayerCondition { + pub layer: u8, + pub active: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LightingChargeCondition { + Any, + Charging, + Discharging, + Unknown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingBatteryCondition { + pub node: u8, + pub min_level: Option, + pub max_level: Option, + pub charge: LightingChargeCondition, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingConditionSet { + pub layer: Option, + pub battery: Option, + pub output_mode: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingConditionalSceneCell { + pub conditions: LightingConditionSet, + pub slot: u16, + pub effect: LightingEffect, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum LightingOutputMode { + #[default] + AlwaysOn, + AlwaysOff, + PoweredOnly, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum LightingPoweredOnlyScope { + #[default] + Authority, + Local, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingOutputModeIndicator { + pub slot: u16, + pub always_on: LightingEffect, + pub always_off: LightingEffect, + pub powered_only: LightingEffect, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct LightingControls { + pub output_toggle_user_action: Option, + pub output_mode_cycle_user_action: Option, + pub wake_layers: u64, + pub initial_output_mode: LightingOutputMode, + pub powered_only_scope: LightingPoweredOnlyScope, + pub output_mode_indicator: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LightingBackgroundMode { + Solid, + Breathe, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LightingBackground { + pub enabled: bool, + pub hue: u8, + pub saturation: u8, + pub value: u8, + pub speed: u8, + pub mode: LightingBackgroundMode, +} + +impl Default for LightingBackground { + fn default() -> Self { + Self { + enabled: true, + hue: 0, + saturation: 0, + value: 32, + speed: 128, + mode: LightingBackgroundMode::Solid, + } + } +} + +/// Fully validated build-time lighting data. Key identities and fallback key +/// geometry come from the already-resolved `[layout].map`; emitters and routes +/// add semantic and electrical topology without redefining the board layout. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Lighting { + pub topology_revision: u32, + pub matrix: [u8; 2], + pub keys: Vec, + pub zones: Vec, + pub emitters: Vec, + pub zone_memberships: Vec, + pub outputs: Vec, + pub routes: Vec, + pub layer_scenes: Vec, + pub conditional_scene_cells: Vec, + pub controls: LightingControls, + pub background: LightingBackground, +} + +impl crate::KeyboardTomlConfig { + /// Resolve `[lighting]` without a resolved board keymap, for firmware + /// that defines its keymap in Rust instead of `[[keymap.layer]]`. + /// + /// The layer count for scene validation comes from `[keymap].layers`, + /// falling back to the number of `[[keymap.layer]]` blocks. + pub fn lighting_standalone(&self, layout: &Layout) -> Result, String> { + let layers = match &self.keymap { + Some(k) => k.layers.unwrap_or(k.layer.len() as u8), + None => 0, + }; + let keymap = Keymap { + rows: layout.rows, + cols: layout.cols, + layers, + keymap: Vec::new(), + encoder_map: Vec::new(), + key_info: Vec::new(), + num_encoder: 0, + }; + self.lighting(layout, &keymap) + } + + pub fn lighting(&self, layout: &Layout, keymap: &Keymap) -> Result, String> { + let Some(config) = &self.lighting else { + return Ok(None); + }; + if layout.keys.is_empty() { + return Err("[lighting] requires `[layout].map` as the canonical logical key layout".into()); + } + if config.emitters.is_empty() { + return Err("[lighting] must define at least one [[lighting.emitter]]".into()); + } + if config.emitters.len() > u16::MAX as usize { + return Err("[lighting] emitter count exceeds LedSlot u16 capacity".into()); + } + + let zones = resolve_zones(&config.zones)?; + let zone_ids: HashSet = zones.iter().map(|zone| zone.id).collect(); + let mut emitter_ids = HashSet::new(); + let mut zone_memberships = Vec::new(); + let mut emitters = Vec::with_capacity(config.emitters.len()); + let mut routes = Vec::with_capacity(config.emitters.len()); + for (slot, emitter) in config.emitters.iter().enumerate() { + if !emitter_ids.insert(emitter.id) { + return Err(format!("duplicate lighting emitter id {}", emitter.id)); + } + if let Some(key) = emitter.key + && !layout.keys.contains(&key) + { + return Err(format!( + "lighting emitter {} key [{}, {}] is not a logical key in layout.map", + emitter.id, key[0], key[1] + )); + } + let mut local_zones = HashSet::new(); + for zone in &emitter.zones { + if !zone_ids.contains(zone) { + return Err(format!( + "lighting emitter {} references unknown zone {zone}", + emitter.id + )); + } + if !local_zones.insert(*zone) { + return Err(format!("lighting emitter {} repeats zone {zone}", emitter.id)); + } + } + if emitter.zones.len() > u8::MAX as usize + || zone_memberships.len() + emitter.zones.len() > u16::MAX as usize + { + return Err("lighting zone membership table exceeds bounded representation".into()); + } + let zone_start = zone_memberships.len() as u16; + zone_memberships.extend_from_slice(&emitter.zones); + emitters.push(LightingEmitter { + id: emitter.id, + key: emitter.key, + position: emitter + .position + .map(to_fixed_point) + .transpose() + .map_err(str::to_owned)?, + zone_start, + zone_len: emitter.zones.len() as u8, + }); + routes.push(LightingRoute { + slot: slot as u16, + node: emitter.node, + output: emitter.output, + physical_index: emitter.physical_index, + }); + } + + let outputs = resolve_outputs(&config.outputs)?; + validate_routes(&outputs, &routes)?; + let layer_scenes = resolve_layer_scenes( + keymap.layers, + &config.layer_scenes, + &layout.keys, + &emitters, + &zone_memberships, + &zone_ids, + )?; + let conditional_scene_cells = resolve_conditional_scenes( + keymap.layers, + &config.conditional_scenes, + &layout.keys, + &emitters, + &zone_memberships, + &zone_ids, + )?; + let controls = config + .controls + .clone() + .map_or(Ok(LightingControls::default()), |controls| { + let mut wake_layers = 0u64; + for layer in controls.wake_layers.clone().unwrap_or_default() { + if layer >= keymap.layers { + return Err(format!( + "lighting.controls wake_layers {} is outside keymap layer count {}", + layer, keymap.layers + )); + } + wake_layers |= 1 << layer; + } + Ok(LightingControls { + output_toggle_user_action: controls.output_toggle_user_action, + output_mode_cycle_user_action: controls.output_mode_cycle_user_action, + wake_layers, + initial_output_mode: match controls.initial_output_mode { + LightingOutputModeToml::AlwaysOn => LightingOutputMode::AlwaysOn, + LightingOutputModeToml::AlwaysOff => LightingOutputMode::AlwaysOff, + LightingOutputModeToml::PoweredOnly => LightingOutputMode::PoweredOnly, + }, + powered_only_scope: match controls.powered_only_scope { + LightingPoweredOnlyScopeToml::Authority => LightingPoweredOnlyScope::Authority, + LightingPoweredOnlyScopeToml::Local => LightingPoweredOnlyScope::Local, + }, + output_mode_indicator: controls + .output_mode_indicator + .as_ref() + .map(|indicator| { + let id_to_slot: HashMap = emitters + .iter() + .enumerate() + .map(|(slot, emitter)| (emitter.id, slot as u16)) + .collect(); + let slots = resolve_target_slots( + &indicator.target, + &id_to_slot, + &layout.keys, + &emitters, + &zone_memberships, + &zone_ids, + )?; + if slots.len() != 1 { + return Err( + "lighting.controls output_mode_indicator must resolve to exactly one LED" + .to_owned(), + ); + } + Ok(LightingOutputModeIndicator { + slot: slots[0], + always_on: resolve_effect(&indicator.always_on)?, + always_off: resolve_effect(&indicator.always_off)?, + powered_only: resolve_effect(&indicator.powered_only)?, + }) + }) + .transpose()?, + }) + })?; + let background = config + .background + .as_ref() + .map(|background| LightingBackground { + enabled: background.enabled, + hue: background.hue, + saturation: background.saturation, + value: background.value, + speed: background.speed, + mode: match background.mode { + LightingBackgroundModeToml::Solid => LightingBackgroundMode::Solid, + LightingBackgroundModeToml::Breathe => LightingBackgroundMode::Breathe, + }, + }) + .unwrap_or_default(); + + Ok(Some(Lighting { + topology_revision: config.topology_revision, + matrix: [layout.rows, layout.cols], + keys: layout + .keys + .iter() + .copied() + .map(|matrix| LightingKey { matrix }) + .collect(), + zones, + emitters, + zone_memberships, + outputs, + routes, + layer_scenes, + conditional_scene_cells, + controls, + background, + })) + } +} + +fn to_fixed_point(point: [f32; 3]) -> Result { + fn axis(value: f32) -> Result { + if !value.is_finite() { + return Err("must contain finite coordinates"); + } + let raw = (value * 256.0).round(); + if raw < i16::MIN as f32 || raw > i16::MAX as f32 { + return Err("does not fit signed Q8.8 key-pitch units"); + } + Ok(raw as i16) + } + Ok(FixedPoint3 { + x: axis(point[0])?, + y: axis(point[1])?, + z: axis(point[2])?, + }) +} + +fn resolve_zones(config: &[crate::LightingZoneTomlConfig]) -> Result, String> { + let mut ids = HashSet::new(); + let mut names = HashSet::new(); + config + .iter() + .map(|zone| { + if !ids.insert(zone.id) { + return Err(format!("duplicate lighting zone id {}", zone.id)); + } + if zone.name.is_empty() || !names.insert(zone.name.clone()) { + return Err(format!("duplicate or empty lighting zone name {:?}", zone.name)); + } + Ok(LightingZone { + id: zone.id, + name: zone.name.clone(), + }) + }) + .collect() +} + +fn resolve_outputs(config: &[crate::LightingOutputTomlConfig]) -> Result, String> { + let mut ids = HashSet::new(); + config + .iter() + .map(|output| { + if !ids.insert((output.node, output.id)) { + return Err(format!( + "duplicate lighting output node {} id {}", + output.node, output.id + )); + } + if output.pixel_count == 0 { + return Err(format!( + "lighting output node {} id {} has zero pixels", + output.node, output.id + )); + } + let mut capabilities = 0u8; + for capability in &output.capabilities { + let bit = match capability.as_str() { + "binary" => 1 << 0, + "intensity" => 1 << 1, + "rgb" => 1 << 2, + "white" => 1 << 3, + "rgbw" => (1 << 2) | (1 << 3), + "addressable" => 1 << 4, + other => return Err(format!("unknown lighting output capability {other:?}")), + }; + if capabilities & bit != 0 { + return Err(format!( + "lighting output node {} id {} repeats capability {capability:?}", + output.node, output.id + )); + } + capabilities |= bit; + } + if capabilities & 0b1111 == 0 { + return Err(format!( + "lighting output node {} id {} has no color capability", + output.node, output.id + )); + } + Ok(LightingOutput { + node: output.node, + id: output.id, + pixel_count: output.pixel_count, + capabilities, + sparse: output.sparse, + }) + }) + .collect() +} + +fn validate_routes(outputs: &[LightingOutput], routes: &[LightingRoute]) -> Result<(), String> { + let output_map: HashMap<(u8, u8), &LightingOutput> = outputs + .iter() + .map(|output| ((output.node, output.id), output)) + .collect(); + let mut addresses = HashSet::new(); + for route in routes { + let Some(output) = output_map.get(&(route.node, route.output)) else { + return Err(format!( + "lighting slot {} routes to unknown node {} output {}", + route.slot, route.node, route.output + )); + }; + if route.physical_index >= output.pixel_count { + return Err(format!( + "lighting slot {} physical index {} is outside node {} output {} length {}", + route.slot, route.physical_index, route.node, route.output, output.pixel_count + )); + } + if !addresses.insert((route.node, route.output, route.physical_index)) { + return Err(format!( + "duplicate lighting physical route node {} output {} index {}", + route.node, route.output, route.physical_index + )); + } + } + for output in outputs.iter().filter(|output| !output.sparse) { + for physical_index in 0..output.pixel_count { + if !addresses.contains(&(output.node, output.id, physical_index)) { + return Err(format!( + "complete lighting output node {} id {} has hole at index {}", + output.node, output.id, physical_index + )); + } + } + } + Ok(()) +} + +fn resolve_layer_scenes( + layer_count: u8, + config: &[crate::LightingLayerSceneTomlConfig], + keys: &[[u8; 2]], + emitters: &[LightingEmitter], + zone_memberships: &[u8], + zone_ids: &HashSet, +) -> Result, String> { + let id_to_slot: HashMap = emitters + .iter() + .enumerate() + .map(|(slot, emitter)| (emitter.id, slot as u16)) + .collect(); + let mut scenes = Vec::with_capacity(config.len()); + for scene in config { + if scene.layer >= layer_count { + return Err(format!( + "lighting layer scene {} is outside configured layer count {}", + scene.layer, layer_count + )); + } + if scene.cells.is_empty() { + return Err(format!("lighting layer scene {} has no cells", scene.layer)); + } + let mut cells = Vec::new(); + for cell in &scene.cells { + let slots = resolve_target_slots(&cell.target, &id_to_slot, keys, emitters, zone_memberships, zone_ids)?; + if slots.is_empty() { + return Err(format!( + "lighting layer scene {} target resolves to no emitters", + scene.layer + )); + } + let effect = resolve_effect(&cell.effect)?; + cells.extend(slots.into_iter().map(|slot| LightingSceneCell { slot, effect })); + } + scenes.push(LightingLayerScene { + layer: scene.layer, + cells, + }); + } + Ok(scenes) +} + +fn resolve_conditional_scenes( + layer_count: u8, + config: &[crate::LightingConditionalSceneTomlConfig], + keys: &[[u8; 2]], + emitters: &[LightingEmitter], + zone_memberships: &[u8], + zone_ids: &HashSet, +) -> Result, String> { + let id_to_slot: HashMap = emitters + .iter() + .enumerate() + .map(|(slot, emitter)| (emitter.id, slot as u16)) + .collect(); + let mut resolved = Vec::new(); + for (index, scene) in config.iter().enumerate() { + if scene.cells.is_empty() { + return Err(format!("lighting conditional scene {index} has no cells")); + } + let layer = scene + .layer + .map(|condition| { + if condition.layer >= layer_count { + return Err(format!( + "lighting conditional scene {index} layer {} is outside configured layer count {layer_count}", + condition.layer + )); + } + Ok(LightingLayerCondition { + layer: condition.layer, + active: condition.active, + }) + }) + .transpose()?; + let battery = scene + .battery + .map(|condition| { + if condition.min_level.is_some_and(|level| level > 100) + || condition.max_level.is_some_and(|level| level > 100) + || matches!((condition.min_level, condition.max_level), (Some(min), Some(max)) if min > max) + { + return Err(format!( + "lighting conditional scene {index} has invalid battery level range" + )); + } + Ok(LightingBatteryCondition { + node: condition.node, + min_level: condition.min_level, + max_level: condition.max_level, + charge: match condition.charge { + LightingChargeConditionToml::Any => LightingChargeCondition::Any, + LightingChargeConditionToml::Charging => LightingChargeCondition::Charging, + LightingChargeConditionToml::Discharging => LightingChargeCondition::Discharging, + LightingChargeConditionToml::Unknown => LightingChargeCondition::Unknown, + }, + }) + }) + .transpose()?; + let output_mode = scene.output_mode.map(|mode| match mode { + LightingOutputModeToml::AlwaysOn => LightingOutputMode::AlwaysOn, + LightingOutputModeToml::AlwaysOff => LightingOutputMode::AlwaysOff, + LightingOutputModeToml::PoweredOnly => LightingOutputMode::PoweredOnly, + }); + let conditions = LightingConditionSet { + layer, + battery, + output_mode, + }; + for cell in &scene.cells { + let slots = resolve_target_slots(&cell.target, &id_to_slot, keys, emitters, zone_memberships, zone_ids)?; + if slots.is_empty() { + return Err(format!( + "lighting conditional scene {index} target resolves to no emitters" + )); + } + let effect = resolve_effect(&cell.effect)?; + resolved.extend(slots.into_iter().map(|slot| LightingConditionalSceneCell { + conditions, + slot, + effect, + })); + } + } + Ok(resolved) +} + +fn resolve_target_slots( + target: &LightingTargetTomlConfig, + id_to_slot: &HashMap, + keys: &[[u8; 2]], + emitters: &[LightingEmitter], + zone_memberships: &[u8], + zone_ids: &HashSet, +) -> Result, String> { + Ok(match *target { + LightingTargetTomlConfig::Led { led } => vec![ + *id_to_slot + .get(&led) + .ok_or_else(|| format!("lighting scene references unknown emitter id {led}"))?, + ], + LightingTargetTomlConfig::KeyId { key: key_id } => { + let key = *keys + .get(key_id as usize) + .ok_or_else(|| format!("lighting scene references unknown logical key id {key_id}"))?; + emitters + .iter() + .enumerate() + .filter(|(_, emitter)| emitter.key == Some(key)) + .map(|(slot, _)| slot as u16) + .collect() + } + LightingTargetTomlConfig::Key { key } => emitters + .iter() + .enumerate() + .filter(|(_, emitter)| emitter.key == Some(key)) + .map(|(slot, _)| slot as u16) + .collect(), + LightingTargetTomlConfig::Zone { zone } => { + if !zone_ids.contains(&zone) { + return Err(format!("lighting scene references unknown zone {zone}")); + } + emitters + .iter() + .enumerate() + .filter(|(_, emitter)| { + let start = emitter.zone_start as usize; + let end = start + emitter.zone_len as usize; + zone_memberships[start..end].contains(&zone) + }) + .map(|(slot, _)| slot as u16) + .collect() + } + LightingTargetTomlConfig::All { all: true } => (0..emitters.len() as u16).collect(), + LightingTargetTomlConfig::All { all: false } => { + return Err("lighting target `{ all = false }` is invalid".into()); + } + }) +} + +fn resolve_effect(config: &LightingEffectTomlConfig) -> Result { + Ok(match *config { + LightingEffectTomlConfig::Solid { color } => LightingEffect::Solid { color }, + LightingEffectTomlConfig::Blink { + color, + period_ms, + phase_ms, + duty_percent, + } => { + if period_ms == 0 { + return Err("blink period_ms must be greater than zero".into()); + } + if duty_percent > 100 { + return Err(format!("blink duty_percent {duty_percent} exceeds 100")); + } + LightingEffect::Blink { + color, + period_ms, + phase_ms, + duty_percent, + } + } + LightingEffectTomlConfig::Breathe { + color, + period_ms, + phase_ms, + step_ms, + } => { + if period_ms < 2 { + return Err("breathe period_ms must be at least two".into()); + } + if step_ms == 0 || u32::from(step_ms) >= period_ms { + return Err(format!( + "breathe step_ms {step_ms} must be greater than zero and less than period_ms {period_ms}" + )); + } + LightingEffect::Breathe { + color, + period_ms, + phase_ms, + step_ms, + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::{LightingOutputMode, LightingPoweredOnlyScope}; + + fn parse(config: &str) -> crate::KeyboardTomlConfig { + toml::from_str(config).unwrap() + } + + const BASE: &str = r#" +[matrix] +row_pins = ["r0"] +col_pins = ["c0", "c1"] + +[layout] +rows = 1 +cols = 2 +map = "(0,0,@wide) (0,1)" + +[layout.shapes] +wide = { w = 1.5, r = -7.5 } + +[keymap] +layers = 2 +[[keymap.layer]] +keys = "A B" +[[keymap.layer]] +keys = "A B" + +[lighting] +topology_revision = 7 +[[lighting.zone]] +id = 1 +name = "keys" +[[lighting.output]] +node = 0 +id = 0 +pixel_count = 2 +capabilities = ["rgb", "addressable"] +[[lighting.emitter]] +id = 10 +key = [0, 0] +zones = [1] +node = 0 +output = 0 +physical_index = 1 +[[lighting.emitter]] +id = 20 +key = [0, 1] +position = [1.0, 0.0, 0.25] +zones = [1] +node = 0 +output = 0 +physical_index = 0 +[[lighting.layer_scene]] +layer = 1 +[[lighting.layer_scene.cell]] +target = { zone = 1 } +effect = { kind = "solid", color = [1, 2, 3] } +"#; + + #[test] + fn derives_geometry_and_logical_keys_from_layout_map() { + let config = parse(BASE); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + assert_eq!(layout.keys, vec![[0, 0], [0, 1]]); + assert_eq!(layout.physical.keys[0].size.width, 384); + assert_eq!(layout.physical.keys[0].rotation_centidegrees, -750); + let lighting = config.lighting(&layout, &keymap).unwrap().unwrap(); + assert_eq!(lighting.keys.len(), 2); + assert_eq!(lighting.emitters.len(), 2); + assert_eq!(lighting.routes[0].physical_index, 1); + assert_eq!(lighting.layer_scenes[0].cells.len(), 2); + } + + #[test] + fn resolves_logical_key_ids_before_lowering_to_led_slots() { + let source = BASE.replace("target = { zone = 1 }", "target = { key = 0 }"); + let config = parse(&source); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + let lighting = config.lighting(&layout, &keymap).unwrap().unwrap(); + + assert_eq!(layout.keys[0], [0, 0]); + assert_eq!(lighting.layer_scenes[0].cells.len(), 1); + assert_eq!(lighting.layer_scenes[0].cells[0].slot, 0); + } + + #[test] + fn rejects_unknown_logical_key_ids() { + let source = BASE.replace("target = { zone = 1 }", "target = { key = 2 }"); + let config = parse(&source); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + let error = config.lighting(&layout, &keymap).unwrap_err(); + + assert!(error.contains("unknown logical key id 2"), "{error}"); + } + + #[test] + fn rejects_emitter_key_that_is_only_inside_matrix_bounds() { + let hole = BASE + .replace("col_pins = [\"c0\", \"c1\"]", "col_pins = [\"c0\", \"c1\", \"c2\"]") + .replace("cols = 2", "cols = 3") + .replace("(0,1)", "(0,2)"); + let config = parse(&hole); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + let error = config.lighting(&layout, &keymap).unwrap_err(); + assert!(error.contains("not a logical key in layout.map"), "{error}"); + } + + #[test] + fn hidden_default_variant_key_remains_a_logical_emitter_key_without_geometry() { + let source = BASE.replace( + "map = \"(0,0,@wide) (0,1)\"", + r#"map = "(0,0,@wide) (0,1)" +default_variant = "compact" +[[layout.variant]] +name = "full" +[[layout.variant]] +name = "compact" +hidden = ["(0,1)"]"#, + ); + let config = parse(&source); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + + assert!(layout.keys.contains(&[0, 1]), "logical identity is variant-independent"); + assert!( + layout.physical.keys.iter().all(|key| key.matrix != [0, 1]), + "the selected/default variant has no fallback center for its hidden key" + ); + let lighting = config.lighting(&layout, &keymap).unwrap().unwrap(); + assert_eq!(lighting.emitters[1].key, Some([0, 1])); + assert_eq!(lighting.emitters[1].position.unwrap().z, 64); + } + + #[test] + fn resolves_conditional_layer_battery_and_output_mode_scenes() { + let source = format!( + r#"{BASE} +[[lighting.conditional_scene]] +layer = {{ layer = 1, active = true }} +battery = {{ node = 0, min_level = 21, max_level = 40, charge = "discharging" }} +output_mode = "powered_only" +[[lighting.conditional_scene.cell]] +target = {{ zone = 1 }} +effect = {{ kind = "solid", color = [9, 8, 7] }} +"# + ); + let config = parse(&source); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + let lighting = config.lighting(&layout, &keymap).unwrap().unwrap(); + assert_eq!(lighting.conditional_scene_cells.len(), 2); + let conditions = lighting.conditional_scene_cells[0].conditions; + assert_eq!(conditions.layer.unwrap().layer, 1); + assert!(conditions.layer.unwrap().active); + let battery = conditions.battery.unwrap(); + assert_eq!(battery.node, 0); + assert_eq!(battery.min_level, Some(21)); + assert_eq!(battery.max_level, Some(40)); + assert_eq!(battery.charge, super::LightingChargeCondition::Discharging); + assert_eq!(conditions.output_mode, Some(LightingOutputMode::PoweredOnly)); + } + + #[test] + fn resolves_and_validates_lighting_controls() { + let source = format!( + r#"{BASE} +[lighting.controls] +output_toggle_user_action = 13 +output_mode_cycle_user_action = 14 +wake_layers = [1] +initial_output_mode = "powered_only" +powered_only_scope = "local" + +[lighting.controls.output_mode_indicator] +target = {{ led = 10 }} +always_on = {{ kind = "solid", color = [0, 9, 0] }} +always_off = {{ kind = "solid", color = [9, 0, 0] }} +powered_only = {{ kind = "solid", color = [0, 0, 9] }} +"# + ); + let config = parse(&source); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + let lighting = config.lighting(&layout, &keymap).unwrap().unwrap(); + assert_eq!(lighting.controls.output_toggle_user_action, Some(13)); + assert_eq!(lighting.controls.output_mode_cycle_user_action, Some(14)); + assert_eq!(lighting.controls.wake_layers, 1 << 1); + assert_eq!(lighting.controls.initial_output_mode, LightingOutputMode::PoweredOnly); + assert_eq!(lighting.controls.powered_only_scope, LightingPoweredOnlyScope::Local); + assert_eq!(lighting.controls.output_mode_indicator.unwrap().slot, 0); + + let invalid = parse(&source.replace("wake_layers = [1]", "wake_layers = [2]")); + let error = invalid.lighting(&layout, &keymap).unwrap_err(); + assert!(error.contains("outside keymap layer count"), "{error}"); + } + + #[test] + fn rejects_invalid_conditional_battery_range() { + let source = format!( + r#"{BASE} +[[lighting.conditional_scene]] +battery = {{ node = 0, min_level = 80, max_level = 20 }} +[[lighting.conditional_scene.cell]] +target = {{ led = 10 }} +effect = {{ kind = "solid", color = [9, 8, 7] }} +"# + ); + let config = parse(&source); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + let error = config.lighting(&layout, &keymap).unwrap_err(); + assert!(error.contains("invalid battery level range"), "{error}"); + } + + #[test] + fn rejects_degenerate_animated_effects() { + for (source, expected) in [ + ( + BASE.replace( + "effect = { kind = \"solid\", color = [1, 2, 3] }", + "effect = { kind = \"blink\", color = [1, 2, 3], period_ms = 0, duty_percent = 50 }", + ), + "blink period_ms", + ), + ( + BASE.replace( + "effect = { kind = \"solid\", color = [1, 2, 3] }", + "effect = { kind = \"breathe\", color = [1, 2, 3], period_ms = 1, step_ms = 1 }", + ), + "breathe period_ms", + ), + ( + BASE.replace( + "effect = { kind = \"solid\", color = [1, 2, 3] }", + "effect = { kind = \"breathe\", color = [1, 2, 3], period_ms = 100, step_ms = 100 }", + ), + "breathe step_ms", + ), + ] { + let config = parse(&source); + let layout = config.layout().unwrap(); + let keymap = config.keymap().unwrap(); + let error = config.lighting(&layout, &keymap).unwrap_err(); + assert!(error.contains(expected), "{error}"); + } + } +} diff --git a/rmk-config/src/resolved/mod.rs b/rmk-config/src/resolved/mod.rs index e5957ded6..6fa30a97a 100644 --- a/rmk-config/src/resolved/mod.rs +++ b/rmk-config/src/resolved/mod.rs @@ -32,6 +32,7 @@ pub mod host; pub mod identity; pub mod keymap; pub mod layout; +pub mod lighting; pub use behavior::Behavior; pub use build_constants::BuildConstants; @@ -39,7 +40,8 @@ pub use hardware::Hardware; pub use host::Host; pub use identity::Identity; pub use keymap::Keymap; -pub use layout::Layout; +pub use layout::{FixedPoint3, FixedSize2, Layout, PhysicalKey, PhysicalLayout}; +pub use lighting::Lighting; // Re-export constants used by codegen pub use crate::keycode_alias::KEYCODE_ALIAS; diff --git a/rmk-macro/src/codegen/keyboard_config.rs b/rmk-macro/src/codegen/keyboard_config.rs index e134a252e..25a662453 100644 --- a/rmk-macro/src/codegen/keyboard_config.rs +++ b/rmk-macro/src/codegen/keyboard_config.rs @@ -79,11 +79,13 @@ pub(crate) fn expand_lock_config(host: &Host) -> proc_macro2::TokenStream { let unlock_keys = unlock_keys_tokens(host); let insecure = host.insecure; let write_requires_unlock = host.write_requires_unlock; + let bootloader_requires_unlock = host.bootloader_requires_unlock; quote! { const LOCK_CONFIG: ::rmk::config::LockConfig = ::rmk::config::LockConfig { unlock_keys: #unlock_keys, insecure: #insecure, write_requires_unlock: #write_requires_unlock, + bootloader_requires_unlock: #bootloader_requires_unlock, }; } } diff --git a/rmk-macro/src/codegen/lighting.rs b/rmk-macro/src/codegen/lighting.rs new file mode 100644 index 000000000..aeeb94091 --- /dev/null +++ b/rmk-macro/src/codegen/lighting.rs @@ -0,0 +1,1143 @@ +//! Generate flash-resident shared geometry and semantic lighting topology. + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use rmk_config::resolved::lighting::{ + Lighting, LightingBackgroundMode, LightingChargeCondition, LightingConditionalSceneCell, + LightingEffect, LightingOutputMode, LightingPoweredOnlyScope, LightingSceneCell, +}; +use rmk_config::resolved::{FixedPoint3, PhysicalLayout}; + +/// Statics for a hand-written main: resolve `[layout]` and `[lighting]` +/// directly from `KEYBOARD_TOML_PATH` without the full `#[rmk_keyboard]` +/// pipeline (which would require a `[matrix]` or `[split]` section). +pub(crate) fn expand_standalone_lighting_config() -> TokenStream2 { + // Load without the chip-default layer: it requires `[keyboard].chip` and + // only fills in sections ([storage], [ble], ...) that a hand-written main + // configures in Rust. rmk-types' build.rs treats such tomls the same way. + let config_toml_path = std::env::var("KEYBOARD_TOML_PATH") + .expect("[ERROR]: KEYBOARD_TOML_PATH should be set in `.cargo/config.toml`"); + let config = + rmk_config::KeyboardTomlConfig::new_from_toml_path_with_event_defaults(&config_toml_path); + let layout = config + .layout_standalone() + .expect("failed to resolve layout config"); + let lighting = config + .lighting_standalone(&layout) + .expect("failed to resolve lighting config"); + let physical_layout = expand_physical_layout(&layout.physical); + let topology = expand_lighting_topology(lighting.as_ref()); + let blob_lit = proc_macro2::Literal::byte_string(&layout.blob); + quote! { + #physical_layout + #topology + pub static LAYOUT_BLOB: &[u8] = #blob_lit; + } +} + +pub(crate) fn expand_physical_layout(layout: &PhysicalLayout) -> TokenStream2 { + let keys = layout.keys.iter().map(|key| { + let [row, col] = key.matrix; + let center = expand_point(key.center); + let width = key.size.width; + let height = key.size.height; + let rotation = key.rotation_centidegrees; + quote! { + ::rmk::physical_layout::PhysicalKey { + matrix: ::rmk::physical_layout::KeyPosition::new(#row, #col), + center: #center, + size: ::rmk::physical_layout::KeySize::new( + ::rmk::physical_layout::Extent::from_raw(#width), + ::rmk::physical_layout::Extent::from_raw(#height), + ), + rotation: ::rmk::physical_layout::Rotation::from_centidegrees(#rotation), + } + } + }); + let len = layout.keys.len(); + + quote! { + pub static PHYSICAL_KEYS: [::rmk::physical_layout::PhysicalKey; #len] = [#(#keys),*]; + pub const PHYSICAL_LAYOUT: ::rmk::physical_layout::PhysicalLayout<'static> = + ::rmk::physical_layout::PhysicalLayout::new(&PHYSICAL_KEYS); + } +} + +/// Generate the flash-resident topology, routing, and built-in semantic +/// lighting configuration for a resolved `[lighting]` section. +pub(crate) fn expand_lighting_topology(lighting: Option<&Lighting>) -> TokenStream2 { + let Some(lighting) = lighting else { + return TokenStream2::new(); + }; + let revision = lighting.topology_revision; + let [rows, cols] = lighting.matrix; + let led_count = lighting.emitters.len(); + + let keys = lighting.keys.iter().map(|key| { + let [row, col] = key.matrix; + quote! { ::rmk::lighting::topology::MatrixPosition::new(#row, #col) } + }); + let key_count = lighting.keys.len(); + let zones = lighting.zones.iter().map(|zone| { + let id = zone.id; + let name = &zone.name; + quote! { + ::rmk::lighting::topology::ZoneMetadata { + id: ::rmk::lighting::topology::ZoneId(#id), + name: #name, + } + } + }); + let zone_count = lighting.zones.len(); + let emitters = lighting.emitters.iter().map(|emitter| { + let id = emitter.id; + let key = match emitter.key { + Some([row, col]) => quote! { + ::core::option::Option::Some(::rmk::lighting::topology::MatrixPosition::new(#row, #col)) + }, + None => quote! { ::core::option::Option::None }, + }; + let position = match emitter.position { + Some(point) => { + let point = expand_point(point); + quote! { ::core::option::Option::Some(#point) } + } + None => quote! { ::core::option::Option::None }, + }; + let zone_start = emitter.zone_start; + let zone_len = emitter.zone_len; + quote! { + ::rmk::lighting::topology::LedMetadata { + id: ::rmk::lighting::topology::LedId(#id), + key: #key, + position: #position, + zones: ::rmk::lighting::topology::ZoneSpan::new(#zone_start, #zone_len), + } + } + }); + let memberships = lighting.zone_memberships.iter().map(|id| { + quote! { ::rmk::lighting::topology::ZoneId(#id) } + }); + let membership_count = lighting.zone_memberships.len(); + let outputs = lighting.outputs.iter().map(|output| { + let node = output.node; + let id = output.id; + let pixel_count = output.pixel_count; + let capabilities = output.capabilities; + let coverage = if output.sparse { + quote! { ::rmk::lighting::topology::OutputCoverage::Sparse } + } else { + quote! { ::rmk::lighting::topology::OutputCoverage::Complete } + }; + quote! { + ::rmk::lighting::topology::OutputMetadata { + node: ::rmk::lighting::topology::LightingNodeId(#node), + id: ::rmk::lighting::topology::OutputId(#id), + pixel_count: #pixel_count, + capabilities: ::rmk::lighting::topology::OutputCapabilities::from_bits(#capabilities) + .expect("rmk-config emitted validated output capabilities"), + coverage: #coverage, + } + } + }); + let output_count = lighting.outputs.len(); + let routes = lighting.routes.iter().map(|route| { + let slot = route.slot; + let node = route.node; + let output = route.output; + let physical_index = route.physical_index; + quote! { + ::rmk::lighting::topology::PhysicalRoute { + slot: ::rmk::lighting::topology::LedSlot(#slot), + node: ::rmk::lighting::topology::LightingNodeId(#node), + output: ::rmk::lighting::topology::OutputId(#output), + physical_index: #physical_index, + } + } + }); + let route_count = lighting.routes.len(); + let renderer_config = expand_lighting_renderer_config(Some(lighting)); + + quote! { + pub const LIGHTING_TOPOLOGY_REVISION: u32 = #revision; + pub const LIGHTING_LED_COUNT: usize = #led_count; + pub static LIGHTING_KEYS: [::rmk::lighting::topology::MatrixPosition; #key_count] = [#(#keys),*]; + pub static LIGHTING_ZONES: [::rmk::lighting::topology::ZoneMetadata<'static>; #zone_count] = [#(#zones),*]; + pub static LIGHTING_EMITTERS: [::rmk::lighting::topology::LedMetadata; #led_count] = [#(#emitters),*]; + pub static LIGHTING_ZONE_MEMBERSHIPS: [::rmk::lighting::topology::ZoneId; #membership_count] = [#(#memberships),*]; + pub static LIGHTING_OUTPUTS: [::rmk::lighting::topology::OutputMetadata; #output_count] = [#(#outputs),*]; + pub static LIGHTING_ROUTES: [::rmk::lighting::topology::PhysicalRoute; #route_count] = [#(#routes),*]; + pub const LIGHTING_TOPOLOGY: ::rmk::lighting::topology::LightingTopology<'static> = + ::rmk::lighting::topology::LightingTopology { + matrix: ::rmk::lighting::topology::MatrixSize::new(#rows, #cols), + keys: &LIGHTING_KEYS, + physical_layout: PHYSICAL_LAYOUT, + leds: &LIGHTING_EMITTERS, + zones: &LIGHTING_ZONES, + zone_memberships: &LIGHTING_ZONE_MEMBERSHIPS, + }; + pub const LIGHTING_ROUTING: ::rmk::lighting::topology::LightingRouting<'static> = + ::rmk::lighting::topology::LightingRouting { + outputs: &LIGHTING_OUTPUTS, + routes: &LIGHTING_ROUTES, + }; + + #renderer_config + } +} + +/// Generate the semantic configuration required by a local renderer. Split +/// peripherals do not need the central's host-facing topology and routing, +/// but they do need the same built-in scenes and background configuration. +pub(crate) fn expand_lighting_renderer_config(lighting: Option<&Lighting>) -> TokenStream2 { + let Some(lighting) = lighting else { + return TokenStream2::new(); + }; + let layer_scene_cells = lighting.layer_scenes.iter().enumerate().map(|(index, scene)| { + let name = quote::format_ident!("LIGHTING_LAYER_SCENE_{index}_CELLS"); + let cells = scene.cells.iter().map(expand_scene_cell); + let len = scene.cells.len(); + quote! { + pub static #name: [::rmk::lighting::SceneCell<::rmk::lighting::BuiltinEffect>; #len] = + [#(#cells),*]; + } + }); + let layer_scene_table = lighting + .layer_scenes + .iter() + .enumerate() + .map(|(index, scene)| { + let name = quote::format_ident!("LIGHTING_LAYER_SCENE_{index}_CELLS"); + let layer = scene.layer; + quote! { + ::rmk::lighting::LayerScene { + layer: #layer, + cells: &#name, + } + } + }); + let layer_scene_count = lighting.layer_scenes.len(); + let conditional_cells = lighting + .conditional_scene_cells + .iter() + .map(expand_conditional_scene_cell); + let conditional_cell_count = lighting.conditional_scene_cells.len(); + let output_toggle_user_action = match lighting.controls.output_toggle_user_action { + Some(action) => quote! { Some(#action) }, + None => quote! { None }, + }; + let output_mode_cycle_user_action = match lighting.controls.output_mode_cycle_user_action { + Some(action) => quote! { Some(#action) }, + None => quote! { None }, + }; + let wake_layers = lighting.controls.wake_layers; + let initial_output_mode = match lighting.controls.initial_output_mode { + LightingOutputMode::AlwaysOn => quote! { ::rmk::lighting::OutputMode::AlwaysOn }, + LightingOutputMode::AlwaysOff => quote! { ::rmk::lighting::OutputMode::AlwaysOff }, + LightingOutputMode::PoweredOnly => quote! { ::rmk::lighting::OutputMode::PoweredOnly }, + }; + let powered_only_scope = match lighting.controls.powered_only_scope { + LightingPoweredOnlyScope::Authority => { + quote! { ::rmk::lighting::PoweredOnlyScope::Authority } + } + LightingPoweredOnlyScope::Local => quote! { ::rmk::lighting::PoweredOnlyScope::Local }, + }; + let output_mode_indicator = match lighting.controls.output_mode_indicator { + Some(indicator) => { + let slot = indicator.slot; + let always_on = expand_effect(indicator.always_on); + let always_off = expand_effect(indicator.always_off); + let powered_only = expand_effect(indicator.powered_only); + quote! { + Some(::rmk::lighting::OutputModeIndicator { + slot: ::rmk::lighting::LedSlot(#slot), + always_on: #always_on, + always_off: #always_off, + powered_only: #powered_only, + }) + } + } + None => quote! { None }, + }; + let background = &lighting.background; + let background_enabled = background.enabled; + let background_hue = background.hue; + let background_saturation = background.saturation; + let background_value = background.value; + let background_speed = background.speed; + let background_mode = match background.mode { + LightingBackgroundMode::Solid => quote! { ::rmk::lighting::BackgroundMode::Solid }, + LightingBackgroundMode::Breathe => quote! { ::rmk::lighting::BackgroundMode::Breathe }, + }; + + quote! { + #(#layer_scene_cells)* + pub static LIGHTING_LAYER_SCENE_TABLE: + [::rmk::lighting::LayerScene<'static, ::rmk::lighting::BuiltinEffect>; #layer_scene_count] = + [#(#layer_scene_table),*]; + pub const LIGHTING_LAYER_SCENES: + ::rmk::lighting::LayerScenes<'static, ::rmk::lighting::BuiltinEffect> = + ::rmk::lighting::LayerScenes { + scenes: &LIGHTING_LAYER_SCENE_TABLE, + policy: ::rmk::lighting::LayerPolicy::ActiveStack, + }; + pub static LIGHTING_CONDITIONAL_SCENE_CELLS: + [::rmk::lighting::ConditionalSceneCell<::rmk::lighting::BuiltinEffect>; #conditional_cell_count] = + [#(#conditional_cells),*]; + pub const LIGHTING_CONTROLS: ::rmk::lighting::LightingControls = + ::rmk::lighting::LightingControls { + output_toggle_user_action: #output_toggle_user_action, + output_mode_cycle_user_action: #output_mode_cycle_user_action, + wake_layers: #wake_layers, + initial_output_mode: #initial_output_mode, + powered_only_scope: #powered_only_scope, + output_mode_indicator: #output_mode_indicator, + }; + pub const LIGHTING_BACKGROUND: ::rmk::lighting::BackgroundState = + ::rmk::lighting::BackgroundState { + enabled: #background_enabled, + hue: #background_hue, + saturation: #background_saturation, + value: #background_value, + speed: #background_speed, + mode: #background_mode, + }; + } +} + +fn expand_conditional_scene_cell(cell: &LightingConditionalSceneCell) -> TokenStream2 { + let slot = cell.slot; + let effect = expand_effect(cell.effect); + let layer = match cell.conditions.layer { + Some(condition) => { + let layer = condition.layer; + let active = condition.active; + quote! { + ::core::option::Option::Some(::rmk::lighting::LayerCondition { + layer: #layer, + active: #active, + }) + } + } + None => quote! { ::core::option::Option::None }, + }; + let battery = match cell.conditions.battery { + Some(condition) => { + let node = condition.node; + let min_level = expand_option_u8(condition.min_level); + let max_level = expand_option_u8(condition.max_level); + let charge = match condition.charge { + LightingChargeCondition::Any => quote! { ::rmk::lighting::ChargeCondition::Any }, + LightingChargeCondition::Charging => { + quote! { ::rmk::lighting::ChargeCondition::Charging } + } + LightingChargeCondition::Discharging => { + quote! { ::rmk::lighting::ChargeCondition::Discharging } + } + LightingChargeCondition::Unknown => { + quote! { ::rmk::lighting::ChargeCondition::Unknown } + } + }; + quote! { + ::core::option::Option::Some(::rmk::lighting::BatteryCondition { + node: #node, + min_level: #min_level, + max_level: #max_level, + charge: #charge, + }) + } + } + None => quote! { ::core::option::Option::None }, + }; + let output_mode = match cell.conditions.output_mode { + Some(LightingOutputMode::AlwaysOn) => { + quote! { ::core::option::Option::Some(::rmk::lighting::OutputMode::AlwaysOn) } + } + Some(LightingOutputMode::AlwaysOff) => { + quote! { ::core::option::Option::Some(::rmk::lighting::OutputMode::AlwaysOff) } + } + Some(LightingOutputMode::PoweredOnly) => { + quote! { ::core::option::Option::Some(::rmk::lighting::OutputMode::PoweredOnly) } + } + None => quote! { ::core::option::Option::None }, + }; + quote! { + ::rmk::lighting::ConditionalSceneCell { + conditions: ::rmk::lighting::ConditionSet { + layer: #layer, + battery: #battery, + output_mode: #output_mode, + connection: ::core::option::Option::None, + effects: ::core::option::Option::None, + }, + slot: ::rmk::lighting::LedSlot(#slot), + effect: #effect, + } + } +} + +fn expand_option_u8(value: Option) -> TokenStream2 { + match value { + Some(value) => quote! { ::core::option::Option::Some(#value) }, + None => quote! { ::core::option::Option::None }, + } +} + +fn expand_scene_cell(cell: &LightingSceneCell) -> TokenStream2 { + let slot = cell.slot; + let effect = expand_effect(cell.effect); + quote! { + ::rmk::lighting::SceneCell { + slot: ::rmk::lighting::topology::LedSlot(#slot), + effect: #effect, + } + } +} + +fn expand_effect(effect: LightingEffect) -> TokenStream2 { + match effect { + LightingEffect::Solid { color } => { + let [r, g, b] = color; + quote! { + ::rmk::lighting::BuiltinEffect::Solid { + color: ::rmk::lighting::Rgb8::new(#r, #g, #b), + } + } + } + LightingEffect::Blink { + color, + period_ms, + phase_ms, + duty_percent, + } => { + let [r, g, b] = color; + quote! { + ::rmk::lighting::BuiltinEffect::Blink { + color: ::rmk::lighting::Rgb8::new(#r, #g, #b), + period_ms: #period_ms, + phase_ms: #phase_ms, + duty: #duty_percent, + } + } + } + LightingEffect::Breathe { + color, + period_ms, + phase_ms, + step_ms, + } => { + let [r, g, b] = color; + quote! { + ::rmk::lighting::BuiltinEffect::Breathe { + color: ::rmk::lighting::Rgb8::new(#r, #g, #b), + period_ms: #period_ms, + phase_ms: #phase_ms, + step_ms: #step_ms, + } + } + } + } +} + +fn expand_point(point: FixedPoint3) -> TokenStream2 { + let x = point.x; + let y = point.y; + let z = point.z; + quote! { + ::rmk::physical_layout::Point3::new( + ::rmk::physical_layout::Coordinate::from_raw(#x), + ::rmk::physical_layout::Coordinate::from_raw(#y), + ::rmk::physical_layout::Coordinate::from_raw(#z), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rmk_config::resolved::lighting::{ + LightingBackground, LightingEmitter, LightingKey, LightingLayerScene, LightingOutput, + LightingRoute, LightingSceneCell, LightingZone, + }; + use rmk_config::resolved::{FixedSize2, PhysicalKey}; + + #[test] + fn emits_shared_geometry_and_topology_without_electrical_order_leaking_into_ids() { + let physical = PhysicalLayout { + keys: vec![PhysicalKey { + matrix: [0, 0], + center: FixedPoint3 { + x: -128, + y: 256, + z: 0, + }, + size: FixedSize2 { + width: 384, + height: 256, + }, + rotation_centidegrees: -750, + }], + }; + let geometry = expand_physical_layout(&physical).to_string(); + assert!(geometry.contains("PHYSICAL_LAYOUT")); + assert!(geometry.contains("from_raw (- 128i16)")); + + let lighting = Lighting { + topology_revision: 4, + matrix: [1, 1], + keys: vec![LightingKey { matrix: [0, 0] }], + zones: vec![LightingZone { + id: 1, + name: "keys".into(), + }], + emitters: vec![LightingEmitter { + id: 42, + key: Some([0, 0]), + position: None, + zone_start: 0, + zone_len: 1, + }], + zone_memberships: vec![1], + outputs: vec![LightingOutput { + node: 2, + id: 3, + pixel_count: 2, + capabilities: 0b10100, + sparse: true, + }], + routes: vec![LightingRoute { + slot: 0, + node: 2, + output: 3, + physical_index: 1, + }], + layer_scenes: vec![ + LightingLayerScene { + layer: 0, + cells: vec![LightingSceneCell { + slot: 0, + effect: LightingEffect::Solid { color: [1, 2, 3] }, + }], + }, + LightingLayerScene { + layer: 1, + cells: vec![ + LightingSceneCell { + slot: 0, + effect: LightingEffect::Blink { + color: [4, 5, 6], + period_ms: 1000, + phase_ms: 250, + duty_percent: 40, + }, + }, + LightingSceneCell { + slot: 0, + effect: LightingEffect::Breathe { + color: [7, 8, 9], + period_ms: 2000, + phase_ms: 500, + step_ms: 20, + }, + }, + ], + }, + ], + conditional_scene_cells: Vec::new(), + controls: Default::default(), + background: LightingBackground { + enabled: false, + hue: 11, + saturation: 22, + value: 33, + speed: 44, + mode: LightingBackgroundMode::Breathe, + }, + }; + let topology = expand_lighting_topology(Some(&lighting)).to_string(); + assert!(topology.contains("LedId (42u16)")); + assert!(topology.contains("physical_index : 1u16")); + assert!(topology.contains("physical_layout : PHYSICAL_LAYOUT")); + assert!(topology.contains("LIGHTING_LAYER_SCENE_0_CELLS")); + assert!(topology.contains("LIGHTING_LAYER_SCENE_1_CELLS")); + assert!(topology.contains("LIGHTING_LAYER_SCENE_TABLE")); + assert!(topology.contains("LIGHTING_LAYER_SCENES")); + assert!(topology.contains("LayerPolicy :: ActiveStack")); + assert!(topology.contains("BuiltinEffect :: Solid")); + assert!(topology.contains("BuiltinEffect :: Blink")); + assert!(topology.contains("duty : 40u8")); + assert!(topology.contains("BuiltinEffect :: Breathe")); + assert!(topology.contains("step_ms : 20u16")); + assert!(topology.contains("LIGHTING_BACKGROUND")); + assert!(topology.contains("enabled : false")); + assert!(topology.contains("hue : 11u8")); + assert!(topology.contains("mode : :: rmk :: lighting :: BackgroundMode :: Breathe")); + + let renderer = expand_lighting_renderer_config(Some(&lighting)).to_string(); + assert!(renderer.contains("LIGHTING_LAYER_SCENES")); + assert!(renderer.contains("LIGHTING_BACKGROUND")); + assert!(!renderer.contains("LIGHTING_TOPOLOGY")); + assert!(!renderer.contains("LIGHTING_ROUTING")); + } + + #[test] + fn omits_all_lighting_symbols_without_resolved_lighting() { + assert!(expand_lighting_topology(None).is_empty()); + assert!(expand_lighting_renderer_config(None).is_empty()); + } + + // Exact copy of rmk-zsa-voyager/keyboard.toml: a board-wide 12x7 layout + // with 52 emitters split across two IS31FL3731 outputs on a single node. + const VOYAGER_TOML: &str = r#"# Consumed at build time by rmk-types (event channel sizing) and by the +# `rmk_lighting_config!` macro in main.rs (physical layout + `[lighting]` +# statics). The scan hardware and keymap stay in Rust: the left half is a +# direct-GPIO matrix and the right half arrives over an MCP23018, which the +# `#[rmk_keyboard]` generated main cannot express. + +# The logical 12x7 matrix. Rows 0-5 are the left half, rows 6-11 the right +# half; see src/keymap.rs for the physical wiring behind this arrangement. +[layout] +rows = 12 +cols = 7 +map = """ +(0,1) (0,2) (0,3) (0,4) (0,5) (0,6) +(1,1) (1,2) (1,3) (1,4) (1,5) (1,6) +(2,1) (2,2) (2,3) (2,4) (2,5) (2,6) +(3,1) (3,2) (3,3) (3,4) (3,5) +(4,4) +(5,0) (5,1) +(6,0) (6,1) (6,2) (6,3) (6,4) (6,5) +(7,0) (7,1) (7,2) (7,3) (7,4) (7,5) +(8,0) (8,1) (8,2) (8,3) (8,4) (8,5) +(9,1) (9,2) (9,3) (9,4) (9,5) +(10,2) +(11,5) (11,6) +""" + +# Layer count only; the default keymap itself lives in src/keymap.rs. +[keymap] +layers = 3 + +# Board-wide lighting topology: one node (the Voyager is not an RMK split), +# two IS31FL3731 chips as separate outputs. Emitter ids 0-25 are the left +# chip and 26-51 the right chip, in `LED_TABLE` order (src/is31fl3731.rs); +# `physical_index` is the chip-relative index into that table. +[lighting] +topology_revision = 1 + +[[lighting.zone]] +id = 1 +name = "per-key" + +[[lighting.output]] +node = 0 +id = 0 +pixel_count = 26 +capabilities = ["rgb", "addressable"] + +[[lighting.output]] +node = 0 +id = 1 +pixel_count = 26 +capabilities = ["rgb", "addressable"] + +# The animated base-layer background comes from the rmk-palettefx extension +# source, not the uniform background. +[lighting.background] +enabled = false +hue = 0 +saturation = 0 +value = 0 +speed = 128 +mode = "solid" + +[lighting.controls] +initial_output_mode = "always_on" + +# Non-base layers replace the animation with a solid wash, exactly covering +# every emitter so the extension band sleeps while a layer is held. +[[lighting.layer_scene]] +layer = 1 + +[[lighting.layer_scene.cell]] +target = { all = true } +effect = { kind = "solid", color = [0, 16, 64] } # symbols/F-keys: cool blue + +[[lighting.layer_scene]] +layer = 2 + +[[lighting.layer_scene.cell]] +target = { all = true } +effect = { kind = "solid", color = [48, 0, 48] } # media/nav: magenta + +# Left chip (0x74), LED_TABLE entries 0-25. +[[lighting.emitter]] +id = 0 +key = [0, 1] +zones = [1] +node = 0 +output = 0 +physical_index = 0 + +[[lighting.emitter]] +id = 1 +key = [0, 2] +zones = [1] +node = 0 +output = 0 +physical_index = 1 + +[[lighting.emitter]] +id = 2 +key = [0, 3] +zones = [1] +node = 0 +output = 0 +physical_index = 2 + +[[lighting.emitter]] +id = 3 +key = [0, 4] +zones = [1] +node = 0 +output = 0 +physical_index = 3 + +[[lighting.emitter]] +id = 4 +key = [0, 5] +zones = [1] +node = 0 +output = 0 +physical_index = 4 + +[[lighting.emitter]] +id = 5 +key = [0, 6] +zones = [1] +node = 0 +output = 0 +physical_index = 5 + +[[lighting.emitter]] +id = 6 +key = [1, 1] +zones = [1] +node = 0 +output = 0 +physical_index = 6 + +[[lighting.emitter]] +id = 7 +key = [1, 2] +zones = [1] +node = 0 +output = 0 +physical_index = 7 + +[[lighting.emitter]] +id = 8 +key = [1, 3] +zones = [1] +node = 0 +output = 0 +physical_index = 8 + +[[lighting.emitter]] +id = 9 +key = [1, 4] +zones = [1] +node = 0 +output = 0 +physical_index = 9 + +[[lighting.emitter]] +id = 10 +key = [1, 5] +zones = [1] +node = 0 +output = 0 +physical_index = 10 + +[[lighting.emitter]] +id = 11 +key = [1, 6] +zones = [1] +node = 0 +output = 0 +physical_index = 11 + +[[lighting.emitter]] +id = 12 +key = [2, 1] +zones = [1] +node = 0 +output = 0 +physical_index = 12 + +[[lighting.emitter]] +id = 13 +key = [2, 2] +zones = [1] +node = 0 +output = 0 +physical_index = 13 + +[[lighting.emitter]] +id = 14 +key = [2, 3] +zones = [1] +node = 0 +output = 0 +physical_index = 14 + +[[lighting.emitter]] +id = 15 +key = [2, 4] +zones = [1] +node = 0 +output = 0 +physical_index = 15 + +[[lighting.emitter]] +id = 16 +key = [2, 5] +zones = [1] +node = 0 +output = 0 +physical_index = 16 + +[[lighting.emitter]] +id = 17 +key = [2, 6] +zones = [1] +node = 0 +output = 0 +physical_index = 17 + +[[lighting.emitter]] +id = 18 +key = [3, 1] +zones = [1] +node = 0 +output = 0 +physical_index = 18 + +[[lighting.emitter]] +id = 19 +key = [3, 2] +zones = [1] +node = 0 +output = 0 +physical_index = 19 + +[[lighting.emitter]] +id = 20 +key = [3, 3] +zones = [1] +node = 0 +output = 0 +physical_index = 20 + +[[lighting.emitter]] +id = 21 +key = [3, 4] +zones = [1] +node = 0 +output = 0 +physical_index = 21 + +[[lighting.emitter]] +id = 22 +key = [3, 5] +zones = [1] +node = 0 +output = 0 +physical_index = 22 + +[[lighting.emitter]] +id = 23 +key = [4, 4] +zones = [1] +node = 0 +output = 0 +physical_index = 23 + +[[lighting.emitter]] +id = 24 +key = [5, 0] +zones = [1] +node = 0 +output = 0 +physical_index = 24 + +[[lighting.emitter]] +id = 25 +key = [5, 1] +zones = [1] +node = 0 +output = 0 +physical_index = 25 + +# Right chip (0x77), LED_TABLE entries 26-51. +[[lighting.emitter]] +id = 26 +key = [6, 0] +zones = [1] +node = 0 +output = 1 +physical_index = 0 + +[[lighting.emitter]] +id = 27 +key = [6, 1] +zones = [1] +node = 0 +output = 1 +physical_index = 1 + +[[lighting.emitter]] +id = 28 +key = [6, 2] +zones = [1] +node = 0 +output = 1 +physical_index = 2 + +[[lighting.emitter]] +id = 29 +key = [6, 3] +zones = [1] +node = 0 +output = 1 +physical_index = 3 + +[[lighting.emitter]] +id = 30 +key = [6, 4] +zones = [1] +node = 0 +output = 1 +physical_index = 4 + +[[lighting.emitter]] +id = 31 +key = [6, 5] +zones = [1] +node = 0 +output = 1 +physical_index = 5 + +[[lighting.emitter]] +id = 32 +key = [7, 0] +zones = [1] +node = 0 +output = 1 +physical_index = 6 + +[[lighting.emitter]] +id = 33 +key = [7, 1] +zones = [1] +node = 0 +output = 1 +physical_index = 7 + +[[lighting.emitter]] +id = 34 +key = [7, 2] +zones = [1] +node = 0 +output = 1 +physical_index = 8 + +[[lighting.emitter]] +id = 35 +key = [7, 3] +zones = [1] +node = 0 +output = 1 +physical_index = 9 + +[[lighting.emitter]] +id = 36 +key = [7, 4] +zones = [1] +node = 0 +output = 1 +physical_index = 10 + +[[lighting.emitter]] +id = 37 +key = [7, 5] +zones = [1] +node = 0 +output = 1 +physical_index = 11 + +[[lighting.emitter]] +id = 38 +key = [8, 0] +zones = [1] +node = 0 +output = 1 +physical_index = 12 + +[[lighting.emitter]] +id = 39 +key = [8, 1] +zones = [1] +node = 0 +output = 1 +physical_index = 13 + +[[lighting.emitter]] +id = 40 +key = [8, 2] +zones = [1] +node = 0 +output = 1 +physical_index = 14 + +[[lighting.emitter]] +id = 41 +key = [8, 3] +zones = [1] +node = 0 +output = 1 +physical_index = 15 + +[[lighting.emitter]] +id = 42 +key = [8, 4] +zones = [1] +node = 0 +output = 1 +physical_index = 16 + +[[lighting.emitter]] +id = 43 +key = [8, 5] +zones = [1] +node = 0 +output = 1 +physical_index = 17 + +[[lighting.emitter]] +id = 44 +key = [10, 2] +zones = [1] +node = 0 +output = 1 +physical_index = 18 + +[[lighting.emitter]] +id = 45 +key = [9, 1] +zones = [1] +node = 0 +output = 1 +physical_index = 19 + +[[lighting.emitter]] +id = 46 +key = [9, 2] +zones = [1] +node = 0 +output = 1 +physical_index = 20 + +[[lighting.emitter]] +id = 47 +key = [9, 3] +zones = [1] +node = 0 +output = 1 +physical_index = 21 + +[[lighting.emitter]] +id = 48 +key = [9, 4] +zones = [1] +node = 0 +output = 1 +physical_index = 22 + +[[lighting.emitter]] +id = 49 +key = [9, 5] +zones = [1] +node = 0 +output = 1 +physical_index = 23 + +[[lighting.emitter]] +id = 50 +key = [11, 5] +zones = [1] +node = 0 +output = 1 +physical_index = 24 + +[[lighting.emitter]] +id = 51 +key = [11, 6] +zones = [1] +node = 0 +output = 1 +physical_index = 25 + +# Event channel sizing beyond the defaults: the status-LED task and the +# lighting processor both subscribe to layer changes. +[event.layer_change] +subs = 2 +"#; + + #[test] + fn resolves_and_expands_the_voyager_keyboard_toml() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "rmk-macro-voyager-{}-{unique}.toml", + std::process::id() + )); + std::fs::write(&path, VOYAGER_TOML).unwrap(); + let config = rmk_config::KeyboardTomlConfig::new_from_toml_path_with_event_defaults(&path); + let _ = std::fs::remove_file(&path); + + let layout = config.layout_standalone().unwrap(); + assert_eq!(layout.keys.len(), 52); + let lighting = config.lighting_standalone(&layout).unwrap().unwrap(); + assert_eq!(lighting.emitters.len(), 52); + assert_eq!(lighting.outputs.len(), 2); + assert_eq!(lighting.routes.len(), 52); + let scenes: Vec<_> = lighting + .layer_scenes + .iter() + .map(|scene| (scene.layer, scene.cells.len())) + .collect(); + assert_eq!( + scenes, + vec![(1, 52), (2, 52)], + "target {{ all = true }} must expand to every emitter slot" + ); + + let geometry = expand_physical_layout(&layout.physical).to_string(); + assert!(geometry.contains("PHYSICAL_LAYOUT")); + + let topology = expand_lighting_topology(Some(&lighting)).to_string(); + assert!(topology.contains("LIGHTING_LED_COUNT : usize = 52usize")); + for symbol in [ + "LIGHTING_TOPOLOGY", + "LIGHTING_ROUTING", + "LIGHTING_LAYER_SCENES", + "LIGHTING_CONTROLS", + "LIGHTING_BACKGROUND", + ] { + assert!(topology.contains(symbol), "missing {symbol}"); + } + } +} diff --git a/rmk-macro/src/codegen/mod.rs b/rmk-macro/src/codegen/mod.rs index 0908c6755..935d4cbcd 100644 --- a/rmk-macro/src/codegen/mod.rs +++ b/rmk-macro/src/codegen/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod import; pub(crate) mod input_device; pub(crate) mod keyboard_config; pub(crate) mod keymap; +pub(crate) mod lighting; pub(crate) mod matrix; pub(crate) mod orchestrator; pub(crate) mod override_helper; diff --git a/rmk-macro/src/codegen/orchestrator.rs b/rmk-macro/src/codegen/orchestrator.rs index 3583a6722..75928523e 100644 --- a/rmk-macro/src/codegen/orchestrator.rs +++ b/rmk-macro/src/codegen/orchestrator.rs @@ -22,6 +22,7 @@ use super::keyboard_config::{ expand_keyboard_info, expand_lock_config, expand_vial_config, read_keyboard_toml_config, }; use super::keymap::expand_default_keymap; +use super::lighting::{expand_lighting_topology, expand_physical_layout}; use super::matrix::{expand_bootmagic_check, expand_matrix_config}; use super::registered_processor::expand_registered_processor_init; use super::split::central::expand_split_central_config; @@ -56,18 +57,29 @@ pub(crate) fn parse_keyboard_mod(item_mod: syn::ItemMod) -> TokenStream2 { let layout = keyboard_config .layout() .expect("failed to resolve layout config"); + let lighting = keyboard_config + .lighting(&layout, &keymap) + .expect("failed to resolve lighting config"); validate_feature_config_parity( &rmk_features, hardware.storage.is_some(), host.vial_enabled, host.rynk_enabled, + lighting.is_some(), ) .unwrap_or_else(|err| panic!("{err}")); // Generate imports and statics - let imports_and_statics = - expand_imports_and_constants(&identity, &host, &hardware, &behavior, &keymap); + let imports_and_statics = expand_imports_and_constants( + &identity, + &host, + &hardware, + &behavior, + &keymap, + &layout, + lighting.as_ref(), + ); // Generate main function body let main_function = expand_main( @@ -92,6 +104,7 @@ fn validate_feature_config_parity( storage_in_config: bool, vial_in_config: bool, rynk_in_config: bool, + lighting_in_config: bool, ) -> Result<(), String> { // A feature enabled in keyboard.toml must have its rmk Cargo feature enabled, and vice versa. // (Cargo feature, keyboard.toml field, enabled in keyboard.toml?) @@ -120,6 +133,17 @@ fn validate_feature_config_parity( ); } + // Unlike the host/storage integrations, lighting also has a public Rust + // construction path, so enabling the feature without TOML is valid. A + // TOML section does require the feature because codegen emits lighting + // runtime types. + if lighting_in_config && !is_feature_enabled(rmk_features, "lighting") { + return Err( + "A `[lighting]` section in keyboard.toml requires enabling the \"lighting\" Cargo feature for rmk." + .to_string(), + ); + } + Ok(()) } @@ -129,6 +153,8 @@ pub(crate) fn expand_imports_and_constants( hardware: &Hardware, behavior: &Behavior, keymap: &Keymap, + layout: &Layout, + lighting: Option<&rmk_config::resolved::Lighting>, ) -> TokenStream2 { // Generate keyboard info and number of rows/cols/layers let keyboard_info_static_var = expand_keyboard_info(identity, keymap); @@ -138,6 +164,8 @@ pub(crate) fn expand_imports_and_constants( let vial_static_var = expand_vial_config(host); // Generate rynk lock-gate config let lock_static_var = expand_lock_config(host); + let physical_layout = expand_physical_layout(&layout.physical); + let lighting_topology = expand_lighting_topology(lighting); // Generate extra imports, panic handler and logger let imports = match hardware.chip.series { @@ -178,6 +206,8 @@ pub(crate) fn expand_imports_and_constants( #vial_static_var #lock_static_var #default_keymap + #physical_layout + #lighting_topology } } @@ -193,22 +223,45 @@ mod tests { #[test] fn accepts_matching_storage_vial_rynk_feature_states() { assert!( - validate_feature_config_parity(&features(&["storage", "vial"]), true, true, false) + validate_feature_config_parity( + &features(&["storage", "vial"]), + true, + true, + false, + false + ) + .is_ok() + ); + assert!(validate_feature_config_parity(&features(&[]), false, false, false, false).is_ok()); + assert!( + validate_feature_config_parity(&features(&["storage"]), true, false, false, false) .is_ok() ); - assert!(validate_feature_config_parity(&features(&[]), false, false, false).is_ok()); assert!( - validate_feature_config_parity(&features(&["storage"]), true, false, false).is_ok() + validate_feature_config_parity( + &features(&["storage", "rynk"]), + true, + false, + true, + false + ) + .is_ok() + ); + assert!( + validate_feature_config_parity(&features(&["lighting"]), false, false, false, false) + .is_ok(), + "the lighting feature supports public Rust construction without TOML" ); assert!( - validate_feature_config_parity(&features(&["storage", "rynk"]), true, false, true) + validate_feature_config_parity(&features(&["lighting"]), false, false, false, true) .is_ok() ); } #[test] fn rejects_storage_enabled_in_config_without_feature() { - let err = validate_feature_config_parity(&features(&[]), true, false, false).unwrap_err(); + let err = + validate_feature_config_parity(&features(&[]), true, false, false, false).unwrap_err(); assert_eq!( err, "If the \"storage\" Cargo feature is disabled, `storage.enabled` must be set to false in keyboard.toml." @@ -217,8 +270,9 @@ mod tests { #[test] fn rejects_storage_feature_without_config() { - let err = validate_feature_config_parity(&features(&["storage"]), false, false, false) - .unwrap_err(); + let err = + validate_feature_config_parity(&features(&["storage"]), false, false, false, false) + .unwrap_err(); assert_eq!( err, "`storage.enabled = false` in keyboard.toml requires disabling the \"storage\" Cargo feature for rmk in Cargo.toml (for example with `default-features = false` and explicitly re-enabling the features you need)." @@ -227,7 +281,8 @@ mod tests { #[test] fn rejects_vial_enabled_in_config_without_feature() { - let err = validate_feature_config_parity(&features(&[]), false, true, false).unwrap_err(); + let err = + validate_feature_config_parity(&features(&[]), false, true, false, false).unwrap_err(); assert_eq!( err, "If the \"vial\" Cargo feature is disabled, `host.vial_enabled` must be set to false in keyboard.toml." @@ -236,8 +291,8 @@ mod tests { #[test] fn rejects_vial_feature_without_config() { - let err = - validate_feature_config_parity(&features(&["vial"]), false, false, false).unwrap_err(); + let err = validate_feature_config_parity(&features(&["vial"]), false, false, false, false) + .unwrap_err(); assert_eq!( err, "`host.vial_enabled = false` in keyboard.toml requires disabling the \"vial\" Cargo feature for rmk in Cargo.toml (for example with `default-features = false` and explicitly re-enabling the features you need)." @@ -246,7 +301,8 @@ mod tests { #[test] fn rejects_rynk_enabled_in_config_without_feature() { - let err = validate_feature_config_parity(&features(&[]), false, false, true).unwrap_err(); + let err = + validate_feature_config_parity(&features(&[]), false, false, true, false).unwrap_err(); assert_eq!( err, "If the \"rynk\" Cargo feature is disabled, `host.rynk_enabled` must be set to false in keyboard.toml." @@ -255,8 +311,8 @@ mod tests { #[test] fn rejects_rynk_feature_without_config() { - let err = - validate_feature_config_parity(&features(&["rynk"]), false, false, false).unwrap_err(); + let err = validate_feature_config_parity(&features(&["rynk"]), false, false, false, false) + .unwrap_err(); assert_eq!( err, "`host.rynk_enabled = false` in keyboard.toml requires disabling the \"rynk\" Cargo feature for rmk in Cargo.toml (for example with `default-features = false` and explicitly re-enabling the features you need)." @@ -265,13 +321,24 @@ mod tests { #[test] fn rejects_vial_and_rynk_both_enabled() { - let err = validate_feature_config_parity(&features(&["vial", "rynk"]), false, true, true) - .unwrap_err(); + let err = + validate_feature_config_parity(&features(&["vial", "rynk"]), false, true, true, false) + .unwrap_err(); assert_eq!( err, "`host.vial_enabled` and `host.rynk_enabled` are mutually exclusive — set exactly one to true (the underlying Cargo features for rmk also conflict)." ); } + + #[test] + fn rejects_lighting_config_without_feature() { + let err = + validate_feature_config_parity(&features(&[]), false, false, false, true).unwrap_err(); + assert_eq!( + err, + "A `[lighting]` section in keyboard.toml requires enabling the \"lighting\" Cargo feature for rmk." + ); + } } fn expand_main( diff --git a/rmk-macro/src/codegen/simulator.rs b/rmk-macro/src/codegen/simulator.rs index 0e98f6997..a9ba91173 100644 --- a/rmk-macro/src/codegen/simulator.rs +++ b/rmk-macro/src/codegen/simulator.rs @@ -317,6 +317,7 @@ fn expand_rmk_config(host: &Host, layout_blob: &[u8]) -> TokenStream2 { quote! { (#row, #col) } }); let (insecure, write_requires_unlock) = (host.insecure, host.write_requires_unlock); + let bootloader_requires_unlock = host.bootloader_requires_unlock; let blob = proc_macro2::Literal::byte_string(layout_blob); quote! { .rmk_config(::rmk::config::RmkConfig { @@ -324,6 +325,7 @@ fn expand_rmk_config(host: &Host, layout_blob: &[u8]) -> TokenStream2 { unlock_keys: &[#(#keys),*], insecure: #insecure, write_requires_unlock: #write_requires_unlock, + bootloader_requires_unlock: #bootloader_requires_unlock, }, layout_blob: #blob, ..Default::default() diff --git a/rmk-macro/src/codegen/split/peripheral.rs b/rmk-macro/src/codegen/split/peripheral.rs index 67f413a07..8da1f57a7 100644 --- a/rmk-macro/src/codegen/split/peripheral.rs +++ b/rmk-macro/src/codegen/split/peripheral.rs @@ -23,6 +23,7 @@ use crate::codegen::input_device::iqs5xx::{expand_iqs5xx_device, expand_iqs5xx_i use crate::codegen::input_device::pmw33xx::expand_pmw33xx_device; use crate::codegen::input_device::pmw3610::expand_pmw3610_device; use crate::codegen::keyboard_config::read_keyboard_toml_config; +use crate::codegen::lighting::expand_lighting_renderer_config; use crate::codegen::matrix::{ expand_bootmagic_check, expand_matrix_direct_pins, expand_matrix_input_output_pins, }; @@ -55,6 +56,16 @@ pub(crate) fn parse_split_peripheral_mod( let identity = toml_config .identity() .expect("failed to resolve identity config"); + let keymap = toml_config + .keymap() + .expect("failed to resolve keymap config"); + let layout = toml_config + .layout() + .expect("failed to resolve layout config"); + let lighting = toml_config + .lighting(&layout, &keymap) + .expect("failed to resolve lighting config"); + let lighting_renderer_config = expand_lighting_renderer_config(lighting.as_ref()); let dfu_enabled = is_feature_enabled(&rmk_features, "dfu_rp") || is_feature_enabled(&rmk_features, "dfu_nrf"); @@ -108,6 +119,7 @@ pub(crate) fn parse_split_peripheral_mod( quote! { #device_config + #lighting_renderer_config #main_function_sig { // ::defmt::info!("RMK start!"); #main_function diff --git a/rmk-macro/src/lib.rs b/rmk-macro/src/lib.rs index 84819c10a..9408dace3 100644 --- a/rmk-macro/src/lib.rs +++ b/rmk-macro/src/lib.rs @@ -62,6 +62,18 @@ pub fn rmk_peripheral(attr: TokenStream, item: TokenStream) -> TokenStream { parse_split_peripheral_mod(peripheral_id, attr, item_mod).into() } +/// Emit the flash-resident physical layout and `[lighting]` statics from +/// `KEYBOARD_TOML_PATH` for firmware with a hand-written main function. +/// +/// `#[rmk_keyboard]` emits the same statics as part of full main generation; +/// boards whose hardware does not fit the generated main (custom matrix +/// drivers, nonstandard USB bring-up) invoke this at module scope instead and +/// wire the lighting engine themselves. +#[proc_macro] +pub fn rmk_lighting_config(_item: TokenStream) -> TokenStream { + codegen::lighting::expand_standalone_lighting_config().into() +} + /// Marker attribute for coordinating Runnable generation between macros. /// Do not use directly. #[doc(hidden)] diff --git a/rmk-types/Cargo.toml b/rmk-types/Cargo.toml index f26d49d27..1b604bfff 100644 --- a/rmk-types/Cargo.toml +++ b/rmk-types/Cargo.toml @@ -42,7 +42,7 @@ _codegen = [] # Enable RMK's Rynk protocol rynk = ["dep:cobs"] # Host tool -host = ["rynk", "_ble", "split", "steno", "serde/alloc"] +host = ["rynk", "_ble", "split", "steno", "lighting", "serde/alloc"] # TypeScript type + wasm ABI export for the web client. Enabled by a codegen/wasm # build, never by firmware. wasm = ["dep:tsify", "dep:wasm-bindgen", "dep:serde-wasm-bindgen", "host"] @@ -50,6 +50,7 @@ wasm = ["dep:tsify", "dep:wasm-bindgen", "dep:serde-wasm-bindgen", "host"] _ble = [] split = [] display = [] +lighting = [] passkey_entry = [] # DFU firmware update support dfu = [] diff --git a/rmk-types/build.rs b/rmk-types/build.rs index 697166c32..2074374f8 100644 --- a/rmk-types/build.rs +++ b/rmk-types/build.rs @@ -71,6 +71,14 @@ fn generate_constants(bc: &BuildConstants, config: &KeyboardTomlConfig) -> Strin "pub const SPLIT_CENTRAL_SLEEP_TIMEOUT_SECONDS: u32 = {};", bc.split_central_sleep_timeout_seconds )); + lines.push(format!( + "pub const SPLIT_CENTRAL_MAX_LATENCY_POWERED: u16 = {};", + bc.split_central_max_latency_powered + )); + lines.push(format!( + "pub const SPLIT_CENTRAL_MAX_LATENCY_BATTERY: u16 = {};", + bc.split_central_max_latency_battery + )); lines.push(format!("pub const MORSE_MAX_NUM: usize = {};", bc.morse_max_num)); lines.push(format!( "pub const MORSE_PROFILE_MAX_NUM: usize = {};", diff --git a/rmk-types/src/action/light.rs b/rmk-types/src/action/light.rs index 5ea4b2c82..459b51721 100644 --- a/rmk-types/src/action/light.rs +++ b/rmk-types/src/action/light.rs @@ -40,4 +40,6 @@ pub enum LightAction { // Not in vial RgbModeRgbtest, RgbModeTwinkle, + /// Cycle standard lighting through always-on, always-off, and powered-only. + OutputModeCycle, } diff --git a/rmk-types/src/connection.rs b/rmk-types/src/connection.rs index 037a2cc2c..5e081caa1 100644 --- a/rmk-types/src/connection.rs +++ b/rmk-types/src/connection.rs @@ -31,6 +31,13 @@ pub enum UsbState { Suspended, } +impl UsbState { + /// Whether VBUS is present, independent of enumeration or suspension. + pub const fn is_powered(self) -> bool { + !matches!(self, Self::Disabled) + } +} + /// Unified connection status: the single source of truth for transport /// availability and routing. The active transport is derived on demand via /// [`Self::decide_active`] from the input fields below. @@ -65,7 +72,9 @@ impl Default for ConnectionStatus { } impl ConnectionStatus { - fn usb_ready(&self) -> bool { + /// Whether USB is plugged and routable (configured or suspended), + /// independent of whether it is the active transport. + pub fn usb_ready(&self) -> bool { matches!(self.usb, UsbState::Configured | UsbState::Suspended) } diff --git a/rmk-types/src/key.rs b/rmk-types/src/key.rs new file mode 100644 index 000000000..850ded1e6 --- /dev/null +++ b/rmk-types/src/key.rs @@ -0,0 +1,6 @@ +//! Stable semantic identities for physical keys. + +/// Stable identity of one logical key within a topology revision. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[repr(transparent)] +pub struct KeyId(pub u16); diff --git a/rmk-types/src/lib.rs b/rmk-types/src/lib.rs index a89065883..db7a6752c 100644 --- a/rmk-types/src/lib.rs +++ b/rmk-types/src/lib.rs @@ -45,6 +45,7 @@ pub mod constants; pub mod dfu; pub mod fmt; pub mod fork; +pub mod key; pub mod keycode; pub mod led_indicator; pub mod modifier; diff --git a/rmk-types/src/protocol/rynk/command.rs b/rmk-types/src/protocol/rynk/command.rs index 270ed34aa..67b67e152 100644 --- a/rmk-types/src/protocol/rynk/command.rs +++ b/rmk-types/src/protocol/rynk/command.rs @@ -14,11 +14,11 @@ use serde::de::DeserializeOwned; use super::message::{RynkHeader, encode_frame}; use super::{ - BehaviorConfig, DeviceCapabilities, DeviceInfo, GetComboBulkRequest, GetComboBulkResponse, GetEncoderRequest, - GetKeymapBulkRequest, GetKeymapBulkResponse, GetMacroRequest, GetMorseBulkRequest, GetMorseBulkResponse, - KeyPosition, LayoutChunk, LockStatus, MacroData, MatrixState, ProtocolVersion, RynkError, SetComboBulkRequest, - SetComboRequest, SetEncoderRequest, SetForkRequest, SetKeyRequest, SetKeymapBulkRequest, SetMacroRequest, - SetMorseBulkRequest, SetMorseRequest, StorageResetMode, + BehaviorConfig, BuildInfo, DeviceCapabilities, DeviceInfo, GetComboBulkRequest, GetComboBulkResponse, + GetEncoderRequest, GetKeymapBulkRequest, GetKeymapBulkResponse, GetMacroRequest, GetMorseBulkRequest, + GetMorseBulkResponse, KeyPosition, LayerState, LayoutChunk, LockStatus, MacroData, MatrixState, ProtocolVersion, + RynkError, SetComboBulkRequest, SetComboRequest, SetEncoderRequest, SetForkRequest, SetKeyRequest, + SetKeymapBulkRequest, SetMacroRequest, SetMorseBulkRequest, SetMorseRequest, StorageResetMode, }; use crate::action::{EncoderAction, KeyAction}; #[cfg(feature = "_ble")] @@ -29,9 +29,36 @@ use crate::combo::Combo; use crate::connection::{ConnectionStatus, ConnectionType}; use crate::fork::Fork; use crate::led_indicator::LedIndicator; +use crate::modifier::ModifierCombination; use crate::morse::Morse; #[cfg(feature = "split")] use crate::protocol::rynk::PeripheralStatus; +#[cfg(feature = "lighting")] +use crate::protocol::rynk::{ + AbortLightingOverlayReplaceRequest, AbortLightingRuntimeConditionalSceneReplaceRequest, + AbortLightingSceneReplaceRequest, BeginLightingOverlayReplaceRequest, + BeginLightingRuntimeConditionalSceneReplaceRequest, BeginLightingSceneReplaceRequest, ClearLightingOverlayRequest, + CommitLightingOverlayReplaceRequest, CommitLightingRuntimeConditionalSceneReplaceRequest, + CommitLightingSceneReplaceRequest, LightingCapabilitiesResult, LightingChanged, LightingCompiledSceneStatusResult, + LightingCompiledScenesPageResult, LightingConditionalSceneStatusResult, LightingConditionalScenesPageResult, + LightingExtendedRuntimeConditionalScenesPageResult, LightingExtensionLayersResult, + LightingExtensionNamesPageResult, LightingExtensionNamesRequest, LightingExtensionParamsPageResult, + LightingExtensionParamsRequest, LightingExtensionResult, LightingFramePageResult, LightingFrameRequest, + LightingKeysPageResult, LightingLedsPageResult, LightingOutputModeStateResult, LightingOutputsPageResult, + LightingOverlayPageRequest, LightingOverlayPageResult, LightingOverlayTransactionResult, LightingPageRequest, + LightingPhysicalKeysPageResult, LightingReplicaStatusResult, LightingRoutesPageResult, + LightingRuntimeConditionalScenePageRequest, LightingRuntimeConditionalSceneStatusResult, + LightingRuntimeConditionalSceneTransactionResult, LightingRuntimeConditionalScenesPageResult, + LightingScenePageRequest, LightingSceneStatusResult, LightingSceneTransactionResult, LightingScenesPageResult, + LightingStateResult, LightingUnitResult, LightingZoneMembershipsPageResult, LightingZonesPageResult, + PutLightingExtendedRuntimeConditionalSceneChunkRequest, PutLightingOverlayChunkRequest, + PutLightingRuntimeConditionalSceneChunkRequest, PutLightingSceneChunkRequest, SetLightingExtensionLayersRequest, + SetLightingExtensionParamRequest, SetLightingExtensionStateRequest, SetLightingLayerPolicyRequest, + SetLightingOutputModeRequest, SetLightingOverlayRequest, SetLightingSceneCellRequest, SetLightingStateRequest, + SetLightingWakeLayersRequest, UnsetLightingOverlayRequest, UnsetLightingSceneCellRequest, +}; +#[cfg(all(feature = "_ble", feature = "split"))] +use crate::protocol::rynk::{SplitCentralLatencyPolicy, SplitCentralLatencyState}; /// CMD high bit marking a topic (server → host push). const RYNK_TOPIC_BIT: u16 = 0x8000; @@ -278,6 +305,10 @@ endpoints! { GetLayout = 0x0009: u32 => LayoutChunk; /// Identity strings and USB ids; feature gating stays in `GetCapabilities`. GetDeviceInfo = 0x000A: () => DeviceInfo; + /// Application-defined diagnostic build label; never used for compatibility. + GetBuildInfo = 0x000B: () => BuildInfo; + /// Ask the application to route a bootloader jump to one split peripheral. + PeripheralBootloaderJump = 0x000C: u8 => (); // Keymap (0x01xx) — includes encoder. GetKeyAction = 0x0101: KeyPosition => KeyAction; @@ -323,6 +354,12 @@ endpoints! { SwitchBleProfile = 0x0704: u8 => (); #[cfg(feature = "_ble")] ClearBleProfile = 0x0705: u8 => (); + #[cfg(all(feature = "_ble", feature = "split"))] + /// Read the volatile active-mode policy, current USB-power selection, and effective value. + GetSplitCentralLatency = 0x0706: () => SplitCentralLatencyState; + #[cfg(all(feature = "_ble", feature = "split"))] + /// Replace the volatile policy. Each connection-event count must be `0..=499`. + SetSplitCentralLatency = 0x0707: SplitCentralLatencyPolicy => SplitCentralLatencyState; // Status (0x08xx). GetCurrentLayer = 0x0801: () => u8; @@ -337,6 +374,164 @@ endpoints! { GetSleepState = 0x0806: () => bool; /// Latest HID LED bitmap, sourced from the `LedIndicatorChange` topic snapshot. GetLedIndicator = 0x0807: () => LedIndicator; + /// Default layer and complete active-layer bitmap. + GetLayerState = 0x0808: () => LayerState; + /// Final resolved modifier bitmap used by the HID keyboard report. + GetModifierState = 0x0809: () => ModifierCombination; + + // Lighting (0x09xx). Lighting-domain errors are nested inside Rynk's + // outer protocol result so hosts retain precise rejection reasons. + #[cfg(feature = "lighting")] + GetLightingCapabilities = 0x0901: () => LightingCapabilitiesResult; + #[cfg(feature = "lighting")] + GetLightingState = 0x0902: () => LightingStateResult; + #[cfg(feature = "lighting")] + SetLightingState = 0x0903: SetLightingStateRequest => LightingStateResult; + #[cfg(feature = "lighting")] + GetLightingPhysicalKeys = 0x0904: LightingPageRequest => LightingPhysicalKeysPageResult; + #[cfg(feature = "lighting")] + GetLightingLeds = 0x0905: LightingPageRequest => LightingLedsPageResult; + #[cfg(feature = "lighting")] + GetLightingZones = 0x0906: LightingPageRequest => LightingZonesPageResult; + #[cfg(feature = "lighting")] + GetLightingZoneMemberships = 0x0907: LightingPageRequest => LightingZoneMembershipsPageResult; + #[cfg(feature = "lighting")] + GetLightingOutputs = 0x0908: LightingPageRequest => LightingOutputsPageResult; + #[cfg(feature = "lighting")] + GetLightingRoutes = 0x0909: LightingPageRequest => LightingRoutesPageResult; + #[cfg(feature = "lighting")] + SetLightingOverlay = 0x090A: SetLightingOverlayRequest => LightingStateResult; + #[cfg(feature = "lighting")] + UnsetLightingOverlay = 0x090B: UnsetLightingOverlayRequest => LightingStateResult; + #[cfg(feature = "lighting")] + ClearLightingOverlay = 0x090C: ClearLightingOverlayRequest => LightingStateResult; + #[cfg(feature = "lighting")] + BeginLightingOverlayReplace = 0x090D: BeginLightingOverlayReplaceRequest => LightingOverlayTransactionResult; + #[cfg(feature = "lighting")] + PutLightingOverlayChunk = 0x090E: PutLightingOverlayChunkRequest => LightingUnitResult; + #[cfg(feature = "lighting")] + CommitLightingOverlayReplace = 0x090F: CommitLightingOverlayReplaceRequest => LightingStateResult; + #[cfg(feature = "lighting")] + AbortLightingOverlayReplace = 0x0910: AbortLightingOverlayReplaceRequest => LightingUnitResult; + /// Logical matrix keys are distinct from optional physical geometry. + #[cfg(feature = "lighting")] + GetLightingKeys = 0x0911: LightingPageRequest => LightingKeysPageResult; + /// 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; + /// Overlay pages are pinned to `LightingState.revision` for consistency. + #[cfg(feature = "lighting")] + GetLightingOverlay = 0x091B: LightingOverlayPageRequest => LightingOverlayPageResult; + /// Discover the immutable board-compiled layer-scene source. + #[cfg(feature = "lighting")] + GetLightingCompiledSceneStatus = 0x091C: () => LightingCompiledSceneStatusResult; + /// Compiled-scene pages are pinned to the firmware topology revision. + #[cfg(feature = "lighting")] + GetLightingCompiledScenes = 0x091D: LightingPageRequest => LightingCompiledScenesPageResult; + /// Discover immutable conditional lighting compiled from board config. + #[cfg(feature = "lighting")] + GetLightingConditionalSceneStatus = 0x091E: () => LightingConditionalSceneStatusResult; + /// Conditional-scene pages are pinned to the firmware topology revision. + #[cfg(feature = "lighting")] + GetLightingConditionalScenes = 0x091F: LightingPageRequest => LightingConditionalScenesPageResult; + /// Read the configured three-state output policy and its live state. + #[cfg(feature = "lighting")] + GetLightingOutputMode = 0x0920: () => LightingOutputModeStateResult; + /// Discover the animated extension band: name-list sizes and selection. + #[cfg(feature = "lighting")] + GetLightingExtension = 0x0921: () => LightingExtensionResult; + /// Extension names are static per firmware build; page until `total`. + #[cfg(feature = "lighting")] + GetLightingExtensionNames = 0x0922: LightingExtensionNamesRequest => LightingExtensionNamesPageResult; + /// Replace the extension selection when the state revision matches. + #[cfg(feature = "lighting")] + SetLightingExtensionState = 0x0923: SetLightingExtensionStateRequest => LightingStateResult; + /// Set the three-state output policy with optimistic concurrency. + #[cfg(feature = "lighting")] + SetLightingOutputMode = 0x0924: SetLightingOutputModeRequest => LightingOutputModeStateResult; + /// Discover the mutable ordered conditional-scene table. + #[cfg(feature = "lighting")] + GetLightingRuntimeConditionalSceneStatus = 0x0925: () => LightingRuntimeConditionalSceneStatusResult; + /// Runtime conditional pages are pinned to `LightingState.revision`. + /// Connection predicates are omitted; use the extended read command when + /// `RUNTIME_CONNECTION_CONDITIONS` is advertised. A read-modify-write + /// cycle performed entirely through the legacy commands therefore drops + /// every stored connection predicate. + #[cfg(feature = "lighting")] + GetLightingRuntimeConditionalScenes = 0x0926: LightingRuntimeConditionalScenePageRequest => LightingRuntimeConditionalScenesPageResult; + #[cfg(feature = "lighting")] + BeginLightingRuntimeConditionalSceneReplace = 0x0927: BeginLightingRuntimeConditionalSceneReplaceRequest => LightingRuntimeConditionalSceneTransactionResult; + #[cfg(feature = "lighting")] + /// Cells written through this legacy endpoint have no connection predicate. + PutLightingRuntimeConditionalSceneChunk = 0x0928: PutLightingRuntimeConditionalSceneChunkRequest => LightingUnitResult; + #[cfg(feature = "lighting")] + CommitLightingRuntimeConditionalSceneReplace = 0x0929: CommitLightingRuntimeConditionalSceneReplaceRequest => LightingStateResult; + #[cfg(feature = "lighting")] + AbortLightingRuntimeConditionalSceneReplace = 0x092A: AbortLightingRuntimeConditionalSceneReplaceRequest => LightingUnitResult; + /// Per-effect tunable parameters: descriptors plus live values, pinned to + /// `LightingState.revision`. Page until `total`. + #[cfg(feature = "lighting")] + GetLightingExtensionParams = 0x092B: LightingExtensionParamsRequest => LightingExtensionParamsPageResult; + /// Set one effect parameter when the state revision matches. + #[cfg(feature = "lighting")] + SetLightingExtensionParam = 0x092C: SetLightingExtensionParamRequest => LightingStateResult; + /// Replace the wake-layer mask. Policy rather than lighting content, but + /// dynamic so which layers wake lighting is not a firmware rebuild. + #[cfg(feature = "lighting")] + SetLightingWakeLayers = 0x092D: SetLightingWakeLayersRequest => LightingOutputModeStateResult; + /// Read the optional second effect layered over the primary extension. + #[cfg(feature = "lighting")] + GetLightingExtensionLayers = 0x092E: () => LightingExtensionLayersResult; + /// Replace the optional second effect when the state revision matches. + #[cfg(feature = "lighting")] + SetLightingExtensionLayers = 0x092F: SetLightingExtensionLayersRequest => LightingStateResult; + /// Discover connection-aware runtime conditional limits and occupancy. + #[cfg(feature = "lighting")] + GetLightingExtendedRuntimeConditionalSceneStatus = 0x0930: () => LightingRuntimeConditionalSceneStatusResult; + /// Read connection-aware runtime conditional cells under a pinned state revision. + #[cfg(feature = "lighting")] + GetLightingExtendedRuntimeConditionalScenes = 0x0931: LightingRuntimeConditionalScenePageRequest => LightingExtendedRuntimeConditionalScenesPageResult; + /// Begin an atomic replacement using extended conditional cells. + #[cfg(feature = "lighting")] + BeginLightingExtendedRuntimeConditionalSceneReplace = 0x0932: BeginLightingRuntimeConditionalSceneReplaceRequest => LightingRuntimeConditionalSceneTransactionResult; + /// Stage connection-aware cells for an extended replacement. + #[cfg(feature = "lighting")] + PutLightingExtendedRuntimeConditionalSceneChunk = 0x0933: PutLightingExtendedRuntimeConditionalSceneChunkRequest => LightingUnitResult; + /// Publish a complete extended conditional-table replacement. + #[cfg(feature = "lighting")] + CommitLightingExtendedRuntimeConditionalSceneReplace = 0x0934: CommitLightingRuntimeConditionalSceneReplaceRequest => LightingStateResult; + /// Discard an extended conditional-table replacement. + #[cfg(feature = "lighting")] + AbortLightingExtendedRuntimeConditionalSceneReplace = 0x0935: AbortLightingRuntimeConditionalSceneReplaceRequest => LightingUnitResult; + /// Read back what one lighting node last presented to its LEDs, paged. + /// `LightingFeatureFlags` has no bits left, so support is discovered by + /// probing: firmware without it answers `UnknownCmd`. + #[cfg(feature = "lighting")] + GetLightingFrame = 0x0936: LightingFrameRequest => LightingFramePageResult; + /// Read both sides of the split lighting replication handshake. Probed + /// like `GetLightingFrame`. Boards may use a read to trigger a coalesced + /// background refresh; reread after one bounded link round trip when a + /// fresh peripheral report is required. + #[cfg(feature = "lighting")] + GetLightingReplicaStatus = 0x0937: () => LightingReplicaStatusResult; } // Define topics: `Name = value: Payload;` @@ -349,6 +544,10 @@ topics! { LedIndicatorChange = 0x8005: LedIndicator; #[cfg(feature = "_ble")] BatteryStatusChange = 0x8006: BatteryStatus; + #[cfg(feature = "lighting")] + LightingChange = 0x8007: LightingChanged; + // Final resolved modifier bitmap changed. + ModifierChange = 0x8008: ModifierCombination; } /// The payload budget advertised to hosts must cover the largest payload diff --git a/rmk-types/src/protocol/rynk/payload/lighting.rs b/rmk-types/src/protocol/rynk/payload/lighting.rs new file mode 100644 index 000000000..93110494e --- /dev/null +++ b/rmk-types/src/protocol/rynk/payload/lighting.rs @@ -0,0 +1,2012 @@ +//! Lighting protocol types. +//! +//! The wire model deliberately separates stable, board-visible identities +//! from dense compositor slots and electrical chain order. Hosts address +//! lights by [`LightingLedId`]; topology and routing readback explain key +//! association, geometry, zones, split-node ownership, and physical outputs. + +use heapless::{String, Vec}; +use postcard::experimental::max_size::MaxSize; +use serde::{Deserialize, Serialize}; + +use crate::ble::BleState; + +/// Maximum postcard payload admitted by this first lighting ICD. +pub const LIGHTING_PAYLOAD_SIZE: usize = 256; +/// Number of metadata records in one topology page. +pub const LIGHTING_PAGE_SIZE: usize = 8; +/// Number of overlay cells in one replacement chunk. +pub const LIGHTING_OVERLAY_CHUNK_SIZE: usize = 8; +/// Number of scene cells in one scene page or replacement chunk. +pub const LIGHTING_SCENE_CHUNK_SIZE: usize = 8; +/// Number of immutable conditional cells in one readback page. +pub const LIGHTING_CONDITIONAL_SCENE_CHUNK_SIZE: usize = 7; +/// Number of extended conditional cells in one page/chunk. Lower than the +/// legacy chunk because each cell carries the connection, bonded-slot, and +/// effects predicates and the page still has to fit `LIGHTING_PAYLOAD_SIZE`. +pub const LIGHTING_EXTENDED_CONDITIONAL_SCENE_CHUNK_SIZE: usize = 5; +/// Number of RGB cells in one presented-frame page. +/// +/// Deliberately far below what [`LIGHTING_PAYLOAD_SIZE`] would allow: a page +/// for a remote split node has to be assembled from application packets on +/// the split link, whose per-message ceiling and shallow, lossy queues make a +/// large page a large number of chances to lose one. +pub const LIGHTING_FRAME_CHUNK_SIZE: usize = 24; +/// Maximum UTF-8 byte length of a zone name. +pub const LIGHTING_ZONE_NAME_SIZE: usize = 24; +/// Maximum UTF-8 byte length of one extension effect or palette name. +pub const LIGHTING_EXTENSION_NAME_SIZE: usize = 16; +/// Number of names in one extension-names page. +pub const LIGHTING_EXTENSION_NAME_CHUNK: usize = 8; +/// Number of per-effect parameter rows in one extension-params page. +pub const LIGHTING_EXTENSION_PARAM_CHUNK: usize = 8; + +macro_rules! wire_type { + ($item:item) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)] + #[cfg_attr(feature = "defmt", derive(defmt::Format))] + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] + #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] + $item + }; +} + +wire_type! { + /// Stable, board-wide identity of one independently controllable light. + #[repr(transparent)] + pub struct LightingLedId(pub u16); +} + +wire_type! { + /// Stable identity of one semantic lighting zone. + #[repr(transparent)] + pub struct LightingZoneId(pub u8); +} + +wire_type! { + /// Identity of a lighting processor, such as one half of a split keyboard. + #[repr(transparent)] + pub struct LightingNodeId(pub u8); +} + +wire_type! { + /// Identity of one physical output owned by a lighting node. + #[repr(transparent)] + pub struct LightingOutputId(pub u8); +} + +wire_type! { + /// One real key in RMK's logical matrix. Matrix holes have no record. + pub struct LightingMatrixPosition { + pub row: u8, + pub col: u8, + } +} + +wire_type! { + /// Board-global Q8.8 point in key-pitch units. + pub struct LightingPoint3 { + pub x: i16, + pub y: i16, + pub z: i16, + } +} + +wire_type! { + /// Positive Q8.8 key dimensions in key-pitch units. + pub struct LightingKeySize { + pub width: u16, + pub height: u16, + } +} + +wire_type! { + /// Shared physical-key geometry consumed by lighting, displays, and hosts. + pub struct LightingPhysicalKey { + pub matrix: LightingMatrixPosition, + pub center: LightingPoint3, + pub size: LightingKeySize, + /// Clockwise rotation in hundredths of one degree. + pub rotation: i16, + } +} + +wire_type! { + /// One semantic light. It may have key association, explicit geometry, + /// both, or neither. + pub struct LightingLed { + pub id: LightingLedId, + pub key: Option, + pub position: Option, + /// Span into the flat zone-membership table. + pub zone_start: u16, + pub zone_len: u8, + } +} + +/// One named semantic zone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct LightingZone { + pub id: LightingZoneId, + #[cfg_attr(feature = "wasm", tsify(type = "string"))] + pub name: String, +} + +impl MaxSize for LightingZone { + const POSTCARD_MAX_SIZE: usize = + LightingZoneId::POSTCARD_MAX_SIZE + crate::heapless_vec_max_size::(); +} + +wire_type! { + /// Color and addressability capabilities of a physical output. + #[repr(transparent)] + pub struct LightingOutputCapabilities(pub u8); +} + +impl LightingOutputCapabilities { + pub const BINARY: u8 = 1 << 0; + pub const INTENSITY: u8 = 1 << 1; + pub const RGB: u8 = 1 << 2; + pub const WHITE: u8 = 1 << 3; + pub const ADDRESSABLE: u8 = 1 << 4; + + pub const fn contains(self, bits: u8) -> bool { + self.0 & bits == bits + } +} + +wire_type! { + /// Whether all physical pixels of an output must have a logical route. + pub enum LightingOutputCoverage { + Complete, + Sparse, + } +} + +wire_type! { + /// One concrete output on one lighting node. + pub struct LightingOutput { + pub node: LightingNodeId, + pub id: LightingOutputId, + pub pixel_count: u16, + pub capabilities: LightingOutputCapabilities, + pub coverage: LightingOutputCoverage, + } +} + +wire_type! { + /// Stable-light to physical-address mapping. Dense compositor slots are + /// intentionally not part of the public protocol. + pub struct LightingRoute { + pub led_id: LightingLedId, + pub node: LightingNodeId, + pub output: LightingOutputId, + pub physical_index: u16, + } +} + +wire_type! { + /// Optional capabilities beyond the mandatory state/topology surface. + #[repr(transparent)] + pub struct LightingFeatureFlags(pub u16); +} + +impl LightingFeatureFlags { + pub const PHYSICAL_GEOMETRY: u16 = 1 << 0; + pub const ZONES: u16 = 1 << 1; + pub const ROUTING: u16 = 1 << 2; + pub const OVERLAY_TTL: u16 = 1 << 3; + pub const ATOMIC_OVERLAY_REPLACE: u16 = 1 << 4; + pub const LAYER_AWARE: u16 = 1 << 5; + /// Runtime-configurable per-layer scenes stored on the device. + pub const LAYER_SCENES: u16 = 1 << 6; + /// Revision-pinned readback of the transient overlay. + pub const OVERLAY_READBACK: u16 = 1 << 7; + /// Read-only board-compiled layer scenes, separate from runtime scenes. + pub const COMPILED_LAYER_SCENES: u16 = 1 << 8; + /// Read-only board-compiled rules driven by layer and battery state. + pub const COMPILED_CONDITIONAL_SCENES: u16 = 1 << 9; + /// Declarative three-state output policy and live readback. + pub const OUTPUT_MODE: u16 = 1 << 10; + /// Host-selectable animated extension effects served by an effect pack. + pub const EXTENSION_EFFECTS: u16 = 1 << 11; + /// Persistent, ordered conditional rules authored at runtime. + pub const RUNTIME_CONDITIONAL_SCENES: u16 = 1 << 12; + /// A second effect from the extension's ordinary effect list can be + /// rendered over the primary effect. + pub const EXTENSION_LAYERING: u16 = 1 << 13; + /// Runtime conditional rules can match transport and BLE connection state + /// through the extended conditional-scene endpoints. + pub const RUNTIME_CONNECTION_CONDITIONS: u16 = 1 << 14; + /// The extended conditional-scene cell also carries an effects predicate. + /// This bit describes the cell's encoding, not just an added predicate: + /// firmware advertising only `RUNTIME_CONNECTION_CONDITIONS` speaks the + /// earlier extended cell, so a host that cannot see this bit must use the + /// legacy endpoints rather than risk a misparse. + pub const RUNTIME_EFFECTS_CONDITIONS: u16 = 1 << 15; + + pub const fn contains(self, bits: u16) -> bool { + self.0 & bits == bits + } +} + +wire_type! { + /// Built-in effects accepted by this firmware. + #[repr(transparent)] + pub struct LightingEffectFlags(pub u8); +} + +impl LightingEffectFlags { + pub const SOLID: u8 = 1 << 0; + pub const BLINK: u8 = 1 << 1; + pub const BREATHE: u8 = 1 << 2; + + pub const fn contains(self, bits: u8) -> bool { + self.0 & bits == bits + } +} + +wire_type! { + /// Static limits and topology identity for a lighting-enabled device. + pub struct LightingCapabilities { + pub topology_revision: u32, + /// Real logical matrix keys, including keys without measured geometry. + pub logical_key_count: u16, + pub physical_key_count: u16, + pub led_count: u16, + pub zone_count: u16, + pub zone_membership_count: u16, + pub output_count: u16, + pub route_count: u16, + pub overlay_capacity: u16, + pub page_capacity: u8, + pub overlay_chunk_capacity: u8, + pub features: LightingFeatureFlags, + pub effects: LightingEffectFlags, + } +} + +wire_type! { + /// Revision-pinned request for one metadata page. + pub struct LightingPageRequest { + pub topology_revision: u32, + pub offset: u16, + } +} + +macro_rules! page_type { + ($name:ident, $item:ty, $ts:literal) => { + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[cfg_attr(feature = "defmt", derive(defmt::Format))] + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] + #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] + pub struct $name { + pub topology_revision: u32, + pub total_count: u16, + #[cfg_attr(feature = "wasm", tsify(type = $ts))] + pub items: Vec<$item, LIGHTING_PAGE_SIZE>, + } + + impl MaxSize for $name { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::<$item, LIGHTING_PAGE_SIZE>(); + } + }; +} + +page_type!(LightingKeysPage, LightingMatrixPosition, "LightingMatrixPosition[]"); +page_type!(LightingPhysicalKeysPage, LightingPhysicalKey, "LightingPhysicalKey[]"); +page_type!(LightingLedsPage, LightingLed, "LightingLed[]"); +page_type!(LightingZonesPage, LightingZone, "LightingZone[]"); +page_type!(LightingZoneMembershipsPage, LightingZoneId, "LightingZoneId[]"); +page_type!(LightingOutputsPage, LightingOutput, "LightingOutput[]"); +page_type!(LightingRoutesPage, LightingRoute, "LightingRoute[]"); + +wire_type! { + /// Device-independent linear RGB sample. + pub struct LightingRgb8 { + pub r: u8, + pub g: u8, + pub b: u8, + } +} + +wire_type! { + /// Bounded set of standard effects understood by the RMK lighting engine. + pub enum LightingEffect { + Solid { + color: LightingRgb8, + }, + Blink { + color: LightingRgb8, + period_ms: u32, + phase_ms: u32, + duty: u8, + }, + Breathe { + color: LightingRgb8, + period_ms: u32, + phase_ms: u32, + step_ms: u16, + }, + } +} + +impl LightingEffect { + /// Validate parameters before adapting this wire value to the standard + /// engine. Invalid effects never partially mutate live lighting state. + pub const fn validate(&self) -> LightingResult<()> { + match *self { + Self::Solid { .. } => Ok(()), + Self::Blink { period_ms, duty, .. } if period_ms != 0 && duty <= 100 => Ok(()), + Self::Breathe { period_ms, step_ms, .. } + if period_ms >= 2 && step_ms != 0 && (step_ms as u32) < period_ms => + { + Ok(()) + } + _ => Err(LightingError::InvalidEffect), + } + } +} + +wire_type! { + pub enum LightingBackgroundMode { + Solid, + Breathe, + } +} + +wire_type! { + /// VIA-compatible designated background. It is only the lowest standard + /// source; disabling it does not disable layers, overlays, or status. + pub struct LightingBackgroundState { + pub enabled: bool, + pub hue: u8, + pub saturation: u8, + pub value: u8, + pub speed: u8, + pub mode: LightingBackgroundMode, + } +} + +wire_type! { + pub struct LightingMutableState { + pub output_enabled: bool, + pub output_brightness: u8, + pub background: LightingBackgroundState, + } +} + +wire_type! { + /// Authoritative mutable state and optimistic-concurrency revision. + pub struct LightingState { + pub revision: u32, + pub output_enabled: bool, + pub output_brightness: u8, + pub background: LightingBackgroundState, + pub overlay_len: u16, + } +} + +wire_type! { + pub struct SetLightingStateRequest { + pub expected_revision: u32, + pub state: LightingMutableState, + } +} + +wire_type! { + /// One transient overlay cell addressed by stable LED identity. +pub struct LightingOverlayCell { + pub led_id: LightingLedId, + pub effect: LightingEffect, + /// Relative lifetime. `None` lasts until unset, clear, or reboot; + /// `Some(0)` is invalid. + pub ttl_ms: Option, + } +} + +wire_type! { + /// Revision-pinned request for one transient overlay page. + pub struct LightingOverlayPageRequest { + /// Expected [`LightingState::revision`]. + pub revision: u32, + pub offset: u16, + } +} + +/// One atomically sampled page of transient overlay cells. Cell TTLs are +/// remaining relative lifetimes at the sample time, never firmware deadlines. +#[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 LightingOverlayPage { + pub revision: u32, + pub total_count: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingOverlayCell[]"))] + pub items: Vec, +} + +impl MaxSize for LightingOverlayPage { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +impl LightingOverlayCell { + /// Validate the effect and relative lifetime. `None` is persistent and a + /// positive TTL expires in firmware time; zero is never ambiguous. + pub const fn validate(&self) -> LightingResult<()> { + if matches!(self.ttl_ms, Some(0)) { + return Err(LightingError::InvalidTtl); + } + self.effect.validate() + } +} + +wire_type! { + pub struct SetLightingOverlayRequest { + pub expected_revision: u32, + pub cell: LightingOverlayCell, + } +} + +wire_type! { + pub struct UnsetLightingOverlayRequest { + pub expected_revision: u32, + pub led_id: LightingLedId, + } +} + +wire_type! { + pub struct ClearLightingOverlayRequest { + pub expected_revision: u32, + } +} + +wire_type! { + /// Begin an atomic, multi-packet overlay replacement. + pub struct BeginLightingOverlayReplaceRequest { + pub expected_revision: u32, + pub cell_count: u16, + } +} + +wire_type! { + /// Opaque transaction token allocated by the firmware. + pub struct LightingOverlayTransaction { + pub id: u32, + pub cell_count: u16, + } +} + +/// One ordered transaction chunk. Chunks are applied only by commit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct PutLightingOverlayChunkRequest { + pub transaction_id: u32, + pub offset: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingOverlayCell[]"))] + pub cells: Vec, +} + +impl MaxSize for PutLightingOverlayChunkRequest { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +wire_type! { + pub struct CommitLightingOverlayReplaceRequest { + pub transaction_id: u32, + } +} + +wire_type! { + pub struct AbortLightingOverlayReplaceRequest { + pub transaction_id: u32, + } +} + +wire_type! { + /// 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! { + /// Occupancy of the immutable board-compiled layer-scene source. + /// + /// This source is distinct from [`LightingSceneStatus`]'s mutable table + /// and is pinned to the topology revision for the firmware build. + pub struct LightingCompiledSceneStatus { + pub topology_revision: u32, + pub scene_len: u16, + /// Composition policy of the immutable compiled source. This is + /// independent from the mutable runtime scene table's policy. + pub policy: LightingLayerPolicy, + 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, +} + +/// One page of immutable board-compiled layer scenes. +#[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 LightingCompiledScenesPage { + pub topology_revision: u32, + pub total_count: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingSceneCell[]"))] + pub items: Vec, +} + +impl MaxSize for LightingCompiledScenesPage { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +wire_type! { + pub struct LightingLayerCondition { + pub layer: u8, + pub active: bool, + } +} + +wire_type! { + pub enum LightingChargeCondition { + Any, + Charging, + Discharging, + Unknown, + } +} + +wire_type! { + pub struct LightingBatteryCondition { + pub node: LightingNodeId, + pub min_level: Option, + pub max_level: Option, + pub charge: LightingChargeCondition, + } +} + +wire_type! { + /// A conjunction of optional layer, battery and output-mode predicates. + pub struct LightingConditionSet { + pub layer: Option, + pub battery: Option, + /// Gate on the live output-mode policy. This is what lets the mode + /// indicator be an ordinary conditional rule a host can edit, rather + /// than something the board has to compile in. + pub output_mode: Option, + } +} + +wire_type! { + /// One immutable conditional effect compiled from keyboard configuration. + pub struct LightingConditionalSceneCell { + pub conditions: LightingConditionSet, + pub led_id: LightingLedId, + pub effect: LightingEffect, + } +} + +impl LightingConditionalSceneCell { + pub fn validate(&self) -> LightingResult<()> { + if let Some(battery) = self.conditions.battery + && (battery.min_level.is_some_and(|level| level > 100) + || battery.max_level.is_some_and(|level| level > 100) + || matches!((battery.min_level, battery.max_level), (Some(min), Some(max)) if min > max)) + { + return Err(LightingError::InvalidRequest); + } + self.effect.validate() + } +} + +wire_type! { + /// Active transport selected by `ConnectionStatus::decide_active`. + pub enum LightingActiveTransport { + Usb, + Ble, + NoneActive, + } +} + +wire_type! { + /// Gate on one slot holding (or not holding) a stored bond, regardless of + /// which profile is active. + pub struct LightingBondedSlotCondition { + pub slot: u8, + pub bonded: bool, + } +} + +wire_type! { + /// Optional connection predicates. Present fields form a conjunction; an + /// empty condition matches every connection state. + pub struct LightingConnectionCondition { + pub transport: Option, + pub profile: Option, + pub ble_state: Option, + pub bonded: Option, + /// Gate on USB being plugged and routable, whether or not it is the + /// active transport. + pub usb_connected: Option, + } +} + +wire_type! { + /// Gate on whether the extension band is rendering. An extension is + /// enabled while its value is non-zero, so this tracks `RgbTog`. + pub struct LightingEffectsCondition { + pub enabled: bool, + } +} + +wire_type! { + /// Additive runtime conditional cell used by the extended endpoints. The + /// nested legacy cell keeps its established postcard field order intact. + pub struct LightingExtendedConditionalSceneCell { + pub cell: LightingConditionalSceneCell, + pub connection: Option, + pub effects: Option, + } +} + +impl LightingExtendedConditionalSceneCell { + pub fn validate(&self) -> LightingResult<()> { + self.cell.validate() + } +} + +wire_type! { + /// Key/layer controls that gate the configured lighting presentation. + pub struct LightingControls { + pub output_toggle_user_action: Option, + /// Layers that wake lighting while held, as a bitmask. A set rather + /// than a single layer so waking is not tied to one "magic" layer. + pub wake_layers: u64, + } +} + +wire_type! { + /// Persistent policy selected by the board's configured cycle action. + pub enum LightingOutputMode { + AlwaysOn, + AlwaysOff, + PoweredOnly, + } +} + +wire_type! { + /// Power source used by split renderers in `PoweredOnly` mode. + pub enum LightingPoweredOnlyScope { + Authority, + Local, + } +} + +wire_type! { + /// Configured status LED and its mode-specific effects. + pub struct LightingOutputModeIndicator { + pub led_id: LightingLedId, + pub always_on: LightingEffect, + pub always_off: LightingEffect, + pub powered_only: LightingEffect, + } +} + +wire_type! { + /// Authoritative output policy plus the inputs that determine whether the + /// LEDs are physically enabled right now. + pub struct LightingOutputModeState { + pub mode: LightingOutputMode, + pub powered: bool, + pub wake_active: bool, + pub effective_enabled: bool, + pub powered_only_scope: LightingPoweredOnlyScope, + pub cycle_user_action: Option, + /// Layers that wake lighting while held, as a bitmask. A set rather + /// than a single layer so waking is not tied to one "magic" layer. + pub wake_layers: u64, + pub indicator: Option, + } +} + +wire_type! { + /// Current selection of the firmware's animated extension band. Indices + /// address the name lists served by `GetLightingExtensionNames`. + pub struct LightingExtensionState { + pub effect: u8, + pub palette: u8, + pub value: u8, + pub speed: u8, + } +} + +wire_type! { + /// Extension-effects discovery: name-list sizes plus the live selection, + /// revision-pinned like every other lighting mutation surface. + pub struct LightingExtension { + pub revision: u32, + pub effect_count: u8, + pub palette_count: u8, + pub state: LightingExtensionState, + } +} + +wire_type! { + /// Which extension name list a page request addresses. + pub enum LightingExtensionNameKind { + Effects, + Palettes, + } +} + +wire_type! { + pub struct LightingExtensionNamesRequest { + pub kind: LightingExtensionNameKind, + pub offset: u8, + } +} + +/// One page of extension effect or palette names. Names are static for a +/// firmware build, so pages carry no revision pin. +#[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 LightingExtensionNamesPage { + pub total: u8, + #[cfg_attr(feature = "wasm", tsify(type = "string[]"))] + pub items: Vec, LIGHTING_EXTENSION_NAME_CHUNK>, +} + +impl MaxSize for LightingExtensionNamesPage { + const POSTCARD_MAX_SIZE: usize = u8::POSTCARD_MAX_SIZE + + crate::varint_max_size(LIGHTING_EXTENSION_NAME_CHUNK) + + LIGHTING_EXTENSION_NAME_CHUNK * crate::heapless_vec_max_size::(); +} + +wire_type! { + pub struct SetLightingExtensionStateRequest { + pub expected_revision: u32, + pub state: LightingExtensionState, + } +} + +wire_type! { + /// Optional second effect layered over the primary extension selection. + /// The effect indexes the same list as `LightingExtensionState.effect`. + pub struct LightingExtensionLayers { + pub revision: u32, + pub overlay: Option, + } +} + +wire_type! { + pub struct SetLightingExtensionLayersRequest { + pub expected_revision: u32, + pub overlay: Option, + } +} + +/// One tunable parameter advertised by an extension effect: its static +/// descriptor plus the source's live value. Descriptor and value travel in +/// one row so a host can render a control from a single read. +/// +/// Parameters are generic: firmware names them, bounds them, and applies +/// them; the protocol ascribes no meaning to any particular name. +#[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 LightingExtensionParam { + #[cfg_attr(feature = "wasm", tsify(type = "string"))] + pub name: String, + pub min: u8, + pub max: u8, + pub default: u8, + pub value: u8, +} + +impl MaxSize for LightingExtensionParam { + const POSTCARD_MAX_SIZE: usize = + crate::heapless_vec_max_size::() + 4 * u8::POSTCARD_MAX_SIZE; +} + +wire_type! { + /// Which effect's parameter list a page request addresses. `effect` + /// indexes the effect-name list served by `GetLightingExtensionNames`; + /// it need not be the active effect. + pub struct LightingExtensionParamsRequest { + pub effect: u8, + pub offset: u8, + } +} + +/// One page of an effect's parameters. Unlike name pages these carry live +/// values, so they are pinned to `LightingState.revision` like every other +/// mutable lighting read. +#[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 LightingExtensionParamsPage { + pub revision: u32, + pub total: u8, + #[cfg_attr(feature = "wasm", tsify(type = "LightingExtensionParam[]"))] + pub items: Vec, +} + +impl MaxSize for LightingExtensionParamsPage { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u8::POSTCARD_MAX_SIZE + + crate::varint_max_size(LIGHTING_EXTENSION_PARAM_CHUNK) + + LIGHTING_EXTENSION_PARAM_CHUNK * LightingExtensionParam::POSTCARD_MAX_SIZE; +} + +wire_type! { + /// Set one parameter of one effect. `index` is the ordinal within that + /// effect's parameter list. Setting a parameter of an inactive effect is + /// allowed if the source accepts it. + pub struct SetLightingExtensionParamRequest { + pub expected_revision: u32, + pub effect: u8, + pub index: u8, + pub value: u8, + } +} + +wire_type! { + pub struct LightingConditionalSceneStatus { + pub topology_revision: u32, + pub cell_len: u16, + pub chunk_capacity: u8, + pub controls: LightingControls, + } +} + +#[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 LightingConditionalScenesPage { + pub topology_revision: u32, + pub total_count: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingConditionalSceneCell[]"))] + pub items: Vec, +} + +wire_type! { + /// Mutable ordered conditional-table status. Unlike the compiled source, + /// this table participates in the lighting state revision. + pub struct LightingRuntimeConditionalSceneStatus { + pub revision: u32, + pub capacity: u16, + pub cell_len: u16, + pub chunk_capacity: u8, + } +} + +wire_type! { + pub struct LightingRuntimeConditionalScenePageRequest { + pub revision: u32, + pub offset: u16, + } +} + +#[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 LightingRuntimeConditionalScenesPage { + pub revision: u32, + pub total_count: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingConditionalSceneCell[]"))] + pub items: Vec, +} + +impl MaxSize for LightingRuntimeConditionalScenesPage { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +#[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 LightingExtendedRuntimeConditionalScenesPage { + pub revision: u32, + pub total_count: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingExtendedConditionalSceneCell[]"))] + pub items: Vec, +} + +impl MaxSize for LightingExtendedRuntimeConditionalScenesPage { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::< + LightingExtendedConditionalSceneCell, + LIGHTING_EXTENDED_CONDITIONAL_SCENE_CHUNK_SIZE, + >(); +} + +wire_type! { + pub struct SetLightingOutputModeRequest { + pub expected_revision: u32, + pub mode: LightingOutputMode, + } +} + +wire_type! { + /// Replace the set of layers that wake lighting while held. A mask, so any + /// combination of layers can wake it rather than one designated layer. + pub struct SetLightingWakeLayersRequest { + pub expected_revision: u32, + pub layers: u64, + } +} + +wire_type! { + pub struct BeginLightingRuntimeConditionalSceneReplaceRequest { + pub expected_revision: u32, + pub cell_count: u16, + } +} + +wire_type! { + pub struct LightingRuntimeConditionalSceneTransaction { + pub id: u32, + pub cell_count: u16, + } +} + +#[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 PutLightingRuntimeConditionalSceneChunkRequest { + pub transaction_id: u32, + pub offset: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingConditionalSceneCell[]"))] + pub cells: Vec, +} + +impl MaxSize for PutLightingRuntimeConditionalSceneChunkRequest { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +#[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 PutLightingExtendedRuntimeConditionalSceneChunkRequest { + pub transaction_id: u32, + pub offset: u16, + #[cfg_attr(feature = "wasm", tsify(type = "LightingExtendedConditionalSceneCell[]"))] + pub cells: Vec, +} + +impl MaxSize for PutLightingExtendedRuntimeConditionalSceneChunkRequest { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::< + LightingExtendedConditionalSceneCell, + LIGHTING_EXTENDED_CONDITIONAL_SCENE_CHUNK_SIZE, + >(); +} + +wire_type! { + pub struct CommitLightingRuntimeConditionalSceneReplaceRequest { + pub transaction_id: u32, + } +} + +wire_type! { + pub struct AbortLightingRuntimeConditionalSceneReplaceRequest { + pub transaction_id: u32, + } +} + +impl MaxSize for LightingConditionalScenesPage { + const POSTCARD_MAX_SIZE: usize = u32::POSTCARD_MAX_SIZE + + u16::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +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! { + /// Request one page of a lighting node's last presented frame. + /// + /// `offset` is the first logical frame slot wanted, matching every other + /// lighting page request; the reply echoes it as + /// [`LightingFramePage::start`]. Frames are not revision-pinned: a stale + /// page is the observation being made, not an error, and pinning would + /// make the frame unreadable exactly while it is changing. + pub struct LightingFrameRequest { + pub node: LightingNodeId, + pub offset: u16, + } +} + +/// One page of the colors a lighting node last presented to its output. +/// +/// Cells are the post-brightness logical frame: RMK applies output +/// brightness as a frame transform before the frame is written and +/// committed, so these are the values the driver received. Any further +/// scaling a board's driver performs on the way to the wire is below this +/// layer and is not reflected here. +/// +/// Cell order is compositor slot order, meaningful only against a validated +/// topology; `GetLightingRoutes` maps each LED to its node, output, and +/// physical index. +#[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 LightingFramePage { + pub node: LightingNodeId, + /// Engine revision the frame was captured at. `None` when the node has + /// not presented a frame yet, when the cells are still its fill color. + pub revision: Option, + /// Slots in this node's logical frame. + pub total_leds: u16, + /// Index of `cells[0]` within that frame. + pub start: u16, + /// How long ago the capture happened. Always `0` for the node serving the + /// request; for a remote node it is the round-trip staleness of the + /// answer, which is what makes a frozen half distinguishable from a + /// correct one. + pub age_ms: u32, + #[cfg_attr(feature = "wasm", tsify(type = "LightingRgb8[]"))] + pub cells: Vec, +} + +impl MaxSize for LightingFramePage { + const POSTCARD_MAX_SIZE: usize = LightingNodeId::POSTCARD_MAX_SIZE + + as MaxSize>::POSTCARD_MAX_SIZE + + 2 * u16::POSTCARD_MAX_SIZE + + u32::POSTCARD_MAX_SIZE + + crate::heapless_vec_max_size::(); +} + +/// Canonical digest schema implemented by the first lighting replica +/// attestation protocol. +pub const LIGHTING_REPLICA_DIGEST_SCHEMA_V1: u8 = 1; + +wire_type! { + /// FNV-1a-32 digests of the durable right-half projection at `revision`. + /// + /// Expiring-overlay lifetime and fast context such as layers, batteries, + /// connection state, and powered/wake state are deliberately excluded. + /// They are covered by sequence/revision freshness and direct status + /// comparison instead. + pub struct LightingReplicaDigests { + pub schema: u8, + pub revision: u32, + pub settings: u32, + pub overlay: u32, + pub scenes: u32, + pub conditional_scenes: u32, + } +} + +wire_type! { + /// Board-reported state of the replication recovery machine. + /// + /// Hosts still derive `UNAVAILABLE` from link/report presence and + /// `UNATTESTED` from absent digest sets; neither is a recovery-machine + /// state in its own right. + pub enum LightingReplicationHealth { + Healthy, + Resynchronizing, + Stale, + Diverged, + Halted, + } +} + +wire_type! { + /// The lighting authority's own state, as the central half sees it. + pub struct LightingCentralReplicaState { + /// Live engine revision — what the authority would replicate now. + pub revision: u32, + /// Engine revision of the last frame the central presented. `None` + /// before its output has accepted one. + pub presented_revision: Option, + /// Layer state the central's last presented frame was rendered from. + /// Zeroed before that first frame. + pub effective_layer: u8, + pub default_layer: u8, + pub active_bits: u64, + /// Live USB/VBUS power as the engine sees it. + pub powered: bool, + /// Whether a configured wake layer currently overrides output policy. + pub wake_active: bool, + /// Final live output decision after mode, power, and wake inputs. + pub effective_output_enabled: bool, + } +} + +wire_type! { + /// Central-side replication machine, supplied by the board. + /// + /// This is the half of the handshake the protocol cannot infer: whether a + /// snapshot is outstanding, which revision was last acknowledged, and + /// whether the application link is up at all. + pub struct LightingReplicationMachine { + /// Last revision the peripheral acknowledged. `None` when it has + /// never acknowledged one since boot. + pub last_acked_revision: Option, + /// A snapshot is in flight and its acknowledgement is still pending. + pub awaiting_ack: bool, + /// Bumped whenever the central restarts replication, so a host can + /// tell a resend apart from a stuck retry. + pub generation: u8, + pub link_up: bool, + /// Durable state changed but no full snapshot carrying it has yet + /// been acknowledged. + pub durable_dirty: bool, + /// Fast context changed but no matching update has yet been + /// acknowledged. + pub context_dirty: bool, + pub health: LightingReplicationHealth, + /// Digest set the central expects the peripheral to hold. `None` + /// means the board or peer does not support attestation yet. + pub expected_digests: Option, + /// Age of the last successful digest comparison. `None` means no + /// attestation has succeeded since boot. + pub last_attested_age_ms: Option, + /// Consecutive mismatches observed after a recovery snapshot. + pub mismatch_count: u8, + } +} + +wire_type! { + /// Last state heard from a peripheral renderer. + /// + /// Deliberately stale-tolerant: the board answers from whatever it last + /// received rather than blocking on a round trip, and `age_ms` says how + /// old that is. A large age is itself the diagnosis. + pub struct LightingPeripheralReplicaState { + pub node: LightingNodeId, + /// Central revision whose snapshot the peripheral last applied. + /// `None` when it has applied none. + pub applied_revision: Option, + /// The peripheral engine's own revision, which advances locally as + /// well and so is not comparable to `applied_revision`. + pub engine_revision: u32, + /// Layer state the peripheral is rendering from — the replicated + /// context, which is where staleness shows up first. + pub effective_layer: u8, + pub default_layer: u8, + pub active_bits: u64, + pub powered: bool, + pub wake_active: bool, + pub effective_output_enabled: bool, + pub age_ms: u32, + /// Digest set recomputed from the state this renderer applied. + /// `None` distinguishes an older/unattested peer from a zero digest. + pub digests: Option, + } +} + +wire_type! { + /// Both sides of the lighting replication handshake in one read. + pub struct LightingReplicaStatus { + pub central: LightingCentralReplicaState, + /// `None` when the board wired no replication machine, as an + /// unsplit build does. + pub replication: Option, + /// `None` when the board has heard nothing from the peripheral since + /// boot, which is distinct from having heard something stale. + pub peripheral: Option, + } +} + +wire_type! { + /// Lighting-domain rejection carried inside Rynk's outer protocol result. + pub enum LightingError { + Unsupported, + InvalidRequest, + InvalidEffect, + InvalidTtl, + TopologyRevisionConflict { expected: u32, current: u32 }, + StateRevisionConflict { expected: u32, current: u32 }, + UnknownLed { led_id: LightingLedId }, + OverlayFull { capacity: u16 }, + TransactionBusy, + InvalidTransaction, + TransactionExpired, + TransactionIncomplete { expected: u16, received: u16 }, + // 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 }, + ConditionalSceneFull { capacity: u16 }, + // Appended for the observability endpoints; only they can produce + // these, so older hosts never decode them. + /// No lighting node with this id exists in the device's routing. + UnknownNode { node: LightingNodeId }, + /// The node exists but could not answer: the application link is + /// down, the reply timed out, or the board wired no source for it. + /// Distinct from `Unsupported`, which means the firmware never + /// answers this node — retrying is pointless there and reasonable + /// here. + NodeUnavailable { node: LightingNodeId }, + } +} + +/// Detailed lighting result nested inside Rynk's transport/protocol result. +pub type LightingResult = Result; +pub type LightingCapabilitiesResult = LightingResult; +pub type LightingStateResult = LightingResult; +pub type LightingKeysPageResult = LightingResult; +pub type LightingPhysicalKeysPageResult = LightingResult; +pub type LightingLedsPageResult = LightingResult; +pub type LightingZonesPageResult = LightingResult; +pub type LightingZoneMembershipsPageResult = LightingResult; +pub type LightingOutputsPageResult = LightingResult; +pub type LightingRoutesPageResult = LightingResult; +pub type LightingOverlayPageResult = LightingResult; +pub type LightingOverlayTransactionResult = LightingResult; +pub type LightingSceneStatusResult = LightingResult; +pub type LightingScenesPageResult = LightingResult; +pub type LightingCompiledSceneStatusResult = LightingResult; +pub type LightingCompiledScenesPageResult = LightingResult; +pub type LightingConditionalSceneStatusResult = LightingResult; +pub type LightingConditionalScenesPageResult = LightingResult; +pub type LightingOutputModeStateResult = LightingResult; +pub type LightingExtensionResult = LightingResult; +pub type LightingExtensionLayersResult = LightingResult; +pub type LightingExtensionNamesPageResult = LightingResult; +pub type LightingExtensionParamsPageResult = LightingResult; +pub type LightingSceneTransactionResult = LightingResult; +pub type LightingRuntimeConditionalSceneStatusResult = LightingResult; +pub type LightingRuntimeConditionalScenesPageResult = LightingResult; +pub type LightingExtendedRuntimeConditionalScenesPageResult = + LightingResult; +pub type LightingRuntimeConditionalSceneTransactionResult = LightingResult; +pub type LightingFramePageResult = LightingResult; +pub type LightingReplicaStatusResult = LightingResult; +pub type LightingUnitResult = LightingResult<()>; + +wire_type! { + /// Best-effort invalidation marker. Hosts recover current authoritative + /// state with `GetLightingState`; events never carry a second state copy. + pub struct LightingChanged; +} + +const _: () = { + use crate::protocol::rynk::RynkError; + + macro_rules! assert_endpoint_fits { + ($req:ty, $resp:ty) => { + core::assert!(<$req as MaxSize>::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + core::assert!( as MaxSize>::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + }; + } + + assert_endpoint_fits!((), LightingCapabilitiesResult); + assert_endpoint_fits!((), LightingStateResult); + assert_endpoint_fits!(SetLightingStateRequest, LightingStateResult); + assert_endpoint_fits!(LightingPageRequest, LightingKeysPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingPhysicalKeysPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingLedsPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingZonesPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingZoneMembershipsPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingOutputsPageResult); + assert_endpoint_fits!(LightingPageRequest, LightingRoutesPageResult); + assert_endpoint_fits!(LightingOverlayPageRequest, LightingOverlayPageResult); + assert_endpoint_fits!(SetLightingOverlayRequest, LightingStateResult); + assert_endpoint_fits!(UnsetLightingOverlayRequest, LightingStateResult); + assert_endpoint_fits!(ClearLightingOverlayRequest, LightingStateResult); + assert_endpoint_fits!(BeginLightingOverlayReplaceRequest, LightingOverlayTransactionResult); + assert_endpoint_fits!(PutLightingOverlayChunkRequest, LightingUnitResult); + assert_endpoint_fits!(CommitLightingOverlayReplaceRequest, LightingStateResult); + assert_endpoint_fits!(AbortLightingOverlayReplaceRequest, LightingUnitResult); + assert_endpoint_fits!((), LightingSceneStatusResult); + assert_endpoint_fits!(LightingScenePageRequest, LightingScenesPageResult); + assert_endpoint_fits!((), LightingCompiledSceneStatusResult); + assert_endpoint_fits!(LightingPageRequest, LightingCompiledScenesPageResult); + assert_endpoint_fits!((), LightingConditionalSceneStatusResult); + assert_endpoint_fits!(LightingPageRequest, LightingConditionalScenesPageResult); + assert_endpoint_fits!((), LightingOutputModeStateResult); + assert_endpoint_fits!((), LightingExtensionResult); + assert_endpoint_fits!(LightingExtensionNamesRequest, LightingExtensionNamesPageResult); + assert_endpoint_fits!(SetLightingExtensionStateRequest, LightingStateResult); + assert_endpoint_fits!((), LightingExtensionLayersResult); + assert_endpoint_fits!(SetLightingExtensionLayersRequest, LightingStateResult); + assert_endpoint_fits!(LightingExtensionParamsRequest, LightingExtensionParamsPageResult); + assert_endpoint_fits!(SetLightingExtensionParamRequest, LightingStateResult); + assert_endpoint_fits!(SetLightingOutputModeRequest, LightingOutputModeStateResult); + assert_endpoint_fits!((), LightingRuntimeConditionalSceneStatusResult); + assert_endpoint_fits!( + LightingRuntimeConditionalScenePageRequest, + LightingRuntimeConditionalScenesPageResult + ); + assert_endpoint_fits!( + BeginLightingRuntimeConditionalSceneReplaceRequest, + LightingRuntimeConditionalSceneTransactionResult + ); + assert_endpoint_fits!(PutLightingRuntimeConditionalSceneChunkRequest, LightingUnitResult); + assert_endpoint_fits!( + PutLightingExtendedRuntimeConditionalSceneChunkRequest, + LightingUnitResult + ); + assert_endpoint_fits!( + LightingRuntimeConditionalScenePageRequest, + LightingExtendedRuntimeConditionalScenesPageResult + ); + assert_endpoint_fits!(CommitLightingRuntimeConditionalSceneReplaceRequest, LightingStateResult); + assert_endpoint_fits!(AbortLightingRuntimeConditionalSceneReplaceRequest, LightingUnitResult); + 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); + assert_endpoint_fits!(LightingFrameRequest, LightingFramePageResult); + assert_endpoint_fits!((), LightingReplicaStatusResult); +}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::rynk::tests::{assert_max_size_bound, round_trip}; + + fn cell(id: u16) -> LightingOverlayCell { + LightingOverlayCell { + led_id: LightingLedId(id), + effect: LightingEffect::Blink { + color: LightingRgb8 { r: 1, g: 2, b: 3 }, + period_ms: u32::MAX, + phase_ms: u32::MAX, + duty: 100, + }, + ttl_ms: Some(u32::MAX), + } + } + + #[test] + fn geometry_and_key_association_round_trip() { + round_trip(&LightingLed { + id: LightingLedId(42), + key: Some(LightingMatrixPosition { row: 3, col: 7 }), + position: Some(LightingPoint3 { x: -128, y: 256, z: 64 }), + zone_start: 2, + zone_len: 3, + }); + round_trip(&LightingLed { + id: LightingLedId(1000), + key: None, + position: None, + zone_start: 0, + zone_len: 0, + }); + } + + #[test] + fn maximum_overlay_chunk_and_page_respect_bound() { + let mut cells = Vec::new(); + for id in 0..LIGHTING_OVERLAY_CHUNK_SIZE as u16 { + cells.push(cell(id)).unwrap(); + } + let request = PutLightingOverlayChunkRequest { + transaction_id: u32::MAX, + offset: u16::MAX, + cells: cells.clone(), + }; + round_trip(&request); + assert_max_size_bound(&request); + assert!(PutLightingOverlayChunkRequest::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + + let page = LightingOverlayPage { + revision: u32::MAX, + total_count: u16::MAX, + items: cells, + }; + round_trip(&page); + assert_max_size_bound(&page); + assert!(LightingOverlayPage::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + } + + #[test] + fn maximum_zone_page_respects_bound() { + let mut items = Vec::new(); + for id in 0..LIGHTING_PAGE_SIZE as u8 { + let mut name = String::new(); + for _ in 0..LIGHTING_ZONE_NAME_SIZE { + name.push('x').unwrap(); + } + items + .push(LightingZone { + id: LightingZoneId(id), + name, + }) + .unwrap(); + } + let page = LightingZonesPage { + topology_revision: u32::MAX, + total_count: u16::MAX, + items, + }; + round_trip(&page); + assert_max_size_bound(&page); + } + + 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(&LightingCompiledSceneStatus { + topology_revision: u32::MAX, + scene_len: 12, + policy: LightingLayerPolicy::EffectiveOnly, + 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.clone(), + }; + round_trip(&page); + assert_max_size_bound(&page); + assert!(LightingScenesPage::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + + let compiled_page = LightingCompiledScenesPage { + topology_revision: u32::MAX, + total_count: u16::MAX, + items: cells, + }; + round_trip(&compiled_page); + assert_max_size_bound(&compiled_page); + assert!(LightingCompiledScenesPage::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + } + + #[test] + fn frame_page_round_trips_at_capacity() { + let mut cells = Vec::new(); + for index in 0..LIGHTING_FRAME_CHUNK_SIZE as u8 { + cells + .push(LightingRgb8 { + r: index, + g: u8::MAX - index, + b: u8::MAX, + }) + .unwrap(); + } + let page = LightingFramePage { + node: LightingNodeId(u8::MAX), + revision: Some(u32::MAX), + total_leds: u16::MAX, + start: u16::MAX, + age_ms: u32::MAX, + cells, + }; + round_trip(&page); + assert_max_size_bound(&page); + assert!(LightingFramePage::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + + // The unpresented and empty-tail cases hosts hit while paging. + round_trip(&LightingFramePage { + revision: None, + cells: Vec::new(), + ..page + }); + round_trip(&LightingFrameRequest { + node: LightingNodeId(1), + offset: u16::MAX, + }); + } + + #[test] + fn replica_status_round_trips_present_and_absent_sides() { + let digests = LightingReplicaDigests { + schema: LIGHTING_REPLICA_DIGEST_SCHEMA_V1, + revision: u32::MAX - 2, + settings: 1, + overlay: 2, + scenes: 3, + conditional_scenes: 4, + }; + let full = LightingReplicaStatus { + central: LightingCentralReplicaState { + revision: u32::MAX, + presented_revision: Some(u32::MAX - 1), + effective_layer: 3, + default_layer: 1, + active_bits: u64::MAX, + powered: true, + wake_active: true, + effective_output_enabled: false, + }, + replication: Some(LightingReplicationMachine { + last_acked_revision: Some(u32::MAX - 2), + awaiting_ack: true, + generation: u8::MAX, + link_up: true, + durable_dirty: true, + context_dirty: false, + health: LightingReplicationHealth::Resynchronizing, + expected_digests: Some(digests), + last_attested_age_ms: Some(u32::MAX), + mismatch_count: 1, + }), + peripheral: Some(LightingPeripheralReplicaState { + node: LightingNodeId(1), + applied_revision: Some(u32::MAX - 3), + engine_revision: u32::MAX - 4, + effective_layer: 2, + default_layer: 0, + active_bits: 1 << 63, + powered: false, + wake_active: true, + effective_output_enabled: false, + age_ms: u32::MAX, + digests: Some(LightingReplicaDigests { + revision: u32::MAX - 3, + ..digests + }), + }), + }; + round_trip(&full); + assert_max_size_bound(&full); + assert!(LightingReplicaStatus::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + + // Never-heard-from is encoded as absence, not as a zero snapshot. + round_trip(&LightingReplicaStatus { + replication: None, + peripheral: None, + central: LightingCentralReplicaState { + presented_revision: None, + ..full.central + }, + }); + } + + #[test] + fn node_errors_round_trip() { + round_trip(&LightingError::UnknownNode { + node: LightingNodeId(u8::MAX), + }); + round_trip(&LightingError::NodeUnavailable { + node: LightingNodeId(1), + }); + } + + #[test] + fn conditional_scene_page_round_trips_at_capacity() { + let mut items = Vec::new(); + for id in 0..LIGHTING_CONDITIONAL_SCENE_CHUNK_SIZE as u16 { + items + .push(LightingConditionalSceneCell { + conditions: LightingConditionSet { + layer: Some(LightingLayerCondition { + layer: u8::MAX, + active: true, + }), + battery: Some(LightingBatteryCondition { + node: LightingNodeId(u8::MAX), + min_level: Some(1), + max_level: Some(100), + charge: LightingChargeCondition::Charging, + }), + output_mode: None, + }, + led_id: LightingLedId(id), + effect: LightingEffect::Solid { + color: LightingRgb8 { + r: u8::MAX, + g: u8::MAX, + b: u8::MAX, + }, + }, + }) + .unwrap(); + } + let page = LightingConditionalScenesPage { + topology_revision: u32::MAX, + total_count: u16::MAX, + items, + }; + round_trip(&page); + assert_max_size_bound(&page); + assert!(LightingConditionalScenesPage::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + } + + #[test] + fn extended_conditional_scene_types_round_trip_at_capacity() { + let base = LightingConditionalSceneCell { + conditions: LightingConditionSet { + layer: Some(LightingLayerCondition { layer: 2, active: true }), + battery: Some(LightingBatteryCondition { + node: LightingNodeId(1), + min_level: Some(20), + max_level: Some(80), + charge: LightingChargeCondition::Discharging, + }), + output_mode: Some(LightingOutputMode::PoweredOnly), + }, + led_id: LightingLedId(42), + effect: LightingEffect::Solid { + color: LightingRgb8 { r: 7, g: 8, b: 9 }, + }, + }; + let cell = LightingExtendedConditionalSceneCell { + cell: base, + connection: Some(LightingConnectionCondition { + transport: Some(LightingActiveTransport::Ble), + profile: Some(3), + ble_state: Some(BleState::Connected), + bonded: Some(LightingBondedSlotCondition { slot: 2, bonded: true }), + usb_connected: Some(true), + }), + effects: Some(LightingEffectsCondition { enabled: true }), + }; + round_trip(&cell); + + let mut cells = Vec::new(); + for _ in 0..LIGHTING_EXTENDED_CONDITIONAL_SCENE_CHUNK_SIZE { + cells.push(cell).unwrap(); + } + let page = LightingExtendedRuntimeConditionalScenesPage { + revision: u32::MAX, + total_count: u16::MAX, + items: cells.clone(), + }; + round_trip(&page); + assert_max_size_bound(&page); + assert!(LightingExtendedRuntimeConditionalScenesPage::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + + let request = PutLightingExtendedRuntimeConditionalSceneChunkRequest { + transaction_id: u32::MAX, + offset: u16::MAX, + cells, + }; + round_trip(&request); + assert_max_size_bound(&request); + assert!(PutLightingExtendedRuntimeConditionalSceneChunkRequest::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + } + + #[test] + fn extension_types_round_trip() { + round_trip(&LightingExtensionState { + effect: 1, + palette: 2, + value: 3, + speed: 4, + }); + round_trip(&LightingExtensionLayers { + revision: 5, + overlay: Some(6), + }); + round_trip(&LightingExtensionLayers { + revision: 5, + overlay: None, + }); + round_trip(&SetLightingExtensionLayersRequest { + expected_revision: 5, + overlay: Some(6), + }); + round_trip(&LightingExtension { + revision: u32::MAX, + effect_count: 6, + palette_count: 16, + state: LightingExtensionState { + effect: 5, + palette: 15, + value: u8::MAX, + speed: 0, + }, + }); + round_trip(&LightingExtensionNamesRequest { + kind: LightingExtensionNameKind::Effects, + offset: 0, + }); + round_trip(&LightingExtensionNamesRequest { + kind: LightingExtensionNameKind::Palettes, + offset: LIGHTING_EXTENSION_NAME_CHUNK as u8, + }); + round_trip(&SetLightingExtensionStateRequest { + expected_revision: u32::MAX, + state: LightingExtensionState { + effect: 0, + palette: 1, + value: 2, + speed: 3, + }, + }); + } + + #[test] + fn extension_param_types_round_trip() { + round_trip(&LightingExtensionParam { + name: String::try_from("Density").unwrap(), + min: 1, + max: 8, + default: 3, + value: 5, + }); + round_trip(&LightingExtensionParamsRequest { effect: 0, offset: 0 }); + round_trip(&LightingExtensionParamsRequest { + effect: u8::MAX, + offset: LIGHTING_EXTENSION_PARAM_CHUNK as u8, + }); + round_trip(&SetLightingExtensionParamRequest { + expected_revision: u32::MAX, + effect: 2, + index: 1, + value: u8::MAX, + }); + } + + #[test] + fn maximum_extension_params_page_respects_bound() { + let mut items = Vec::new(); + for _ in 0..LIGHTING_EXTENSION_PARAM_CHUNK { + let mut name = String::new(); + for _ in 0..LIGHTING_EXTENSION_NAME_SIZE { + name.push('x').unwrap(); + } + items + .push(LightingExtensionParam { + name, + min: 0, + max: u8::MAX, + default: u8::MAX, + value: u8::MAX, + }) + .unwrap(); + } + let page = LightingExtensionParamsPage { + revision: u32::MAX, + total: u8::MAX, + items, + }; + round_trip(&page); + assert_max_size_bound(&page); + assert!(LightingExtensionParamsPage::POSTCARD_MAX_SIZE <= LIGHTING_PAYLOAD_SIZE); + } + + #[test] + fn maximum_extension_names_page_respects_bound() { + let mut items = Vec::new(); + for _ in 0..LIGHTING_EXTENSION_NAME_CHUNK { + let mut name = String::new(); + for _ in 0..LIGHTING_EXTENSION_NAME_SIZE { + name.push('x').unwrap(); + } + items.push(name).unwrap(); + } + let page = LightingExtensionNamesPage { total: u8::MAX, items }; + round_trip(&page); + assert_max_size_bound(&page); + assert!(LightingExtensionNamesPage::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); + assert_eq!(valid.validate(), Ok(())); + valid.ttl_ms = Some(0); + assert_eq!(valid.validate(), Err(LightingError::InvalidTtl)); + valid.ttl_ms = None; + valid.effect = LightingEffect::Breathe { + color: LightingRgb8 { r: 1, g: 2, b: 3 }, + period_ms: 100, + phase_ms: 0, + step_ms: 100, + }; + assert_eq!(valid.validate(), Err(LightingError::InvalidEffect)); + } +} diff --git a/rmk-types/src/protocol/rynk/payload/mod.rs b/rmk-types/src/protocol/rynk/payload/mod.rs index f2f8ec5d0..283b01863 100644 --- a/rmk-types/src/protocol/rynk/payload/mod.rs +++ b/rmk-types/src/protocol/rynk/payload/mod.rs @@ -6,6 +6,8 @@ mod encoder; mod fork; mod keymap; mod layout; +#[cfg(feature = "lighting")] +mod lighting; mod macro_data; mod morse; mod status; @@ -17,6 +19,8 @@ pub use self::encoder::*; pub use self::fork::*; pub use self::keymap::*; pub use self::layout::*; +#[cfg(feature = "lighting")] +pub use self::lighting::*; pub use self::macro_data::*; pub use self::morse::*; pub use self::status::*; diff --git a/rmk-types/src/protocol/rynk/payload/status.rs b/rmk-types/src/protocol/rynk/payload/status.rs index b687b4b36..098047bb3 100644 --- a/rmk-types/src/protocol/rynk/payload/status.rs +++ b/rmk-types/src/protocol/rynk/payload/status.rs @@ -8,6 +8,35 @@ use serde::{Deserialize, Serialize}; /// from DeviceCapabilities. pub const MATRIX_BITMAP_SIZE: usize = 32; +/// Number of layers represented by [`LayerState`]. +pub const LAYER_STATE_CAPACITY: usize = 64; +/// Serialized byte width of [`LayerState::active_bitmap`]. +pub const LAYER_STATE_BITMAP_SIZE: usize = LAYER_STATE_CAPACITY / 8; + +/// Authoritative snapshot of every layer participating in key resolution. +/// +/// RMK stores the default layer separately from its mutable layer mask. The +/// corresponding bit in `active_bitmap` is nevertheless always set by the +/// firmware so callers can treat this as the complete active set. Bit `n` +/// reports layer `n`, least-significant bit first within each byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct LayerState { + pub default_layer: u8, + #[cfg_attr(feature = "wasm", tsify(type = "number[]"))] + pub active_bitmap: [u8; LAYER_STATE_BITMAP_SIZE], +} + +impl LayerState { + /// Whether `layer` participates in the sampled active layer stack. + pub const fn is_active(&self, layer: u8) -> bool { + let index = layer as usize; + index < LAYER_STATE_CAPACITY && self.active_bitmap[index / 8] & (1_u8 << (index % 8)) != 0 + } +} + /// Current matrix key-press state as a bitmap. /// Bit ordering: row-major, bit 0 = col 0, bit 1 = col 1, etc. /// Total meaningful bytes = num_rows * ceil(num_cols / 8). @@ -43,6 +72,25 @@ mod tests { use super::*; use crate::protocol::rynk::tests::{assert_max_size_bound, round_trip}; + #[test] + fn round_trip_layer_state_covers_all_64_layers() { + let mut active_bitmap = [0; LAYER_STATE_BITMAP_SIZE]; + active_bitmap[0] = 0b0010_0001; + active_bitmap[7] = 0b1000_0000; + let state = LayerState { + default_layer: 5, + active_bitmap, + }; + + round_trip(&state); + assert_max_size_bound(&state); + assert!(state.is_active(0)); + assert!(state.is_active(5)); + assert!(!state.is_active(6)); + assert!(state.is_active(63)); + assert!(!state.is_active(64)); + } + #[test] fn round_trip_matrix_state() { let mut bitmap = Vec::new(); diff --git a/rmk-types/src/protocol/rynk/payload/system.rs b/rmk-types/src/protocol/rynk/payload/system.rs index 112dbe664..d5f3e58cd 100644 --- a/rmk-types/src/protocol/rynk/payload/system.rs +++ b/rmk-types/src/protocol/rynk/payload/system.rs @@ -9,6 +9,9 @@ use serde::{Deserialize, Serialize}; /// Maximum byte length of each `DeviceInfo` string field. pub const DEVICE_INFO_STRING_SIZE: usize = 32; +/// Maximum byte length of the application-defined build label. +pub const BUILD_INFO_STRING_SIZE: usize = 128; + /// Protocol version advertised during the connection handshake. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -21,9 +24,35 @@ pub struct ProtocolVersion { impl ProtocolVersion { /// Current protocol version for this firmware release. /// Now the protocol is still being developed, so the version is v0.1 + /// + /// Version numbers are minted upstream (HaoboGu/rmk) only — downstream + /// extensions must never bump this constant, or the same number would + /// eventually name two different protocols. Extensions are discovered + /// through capability surfaces instead: [`DeviceCapabilities`] flags, + /// domain capability endpoints (e.g. `GetLightingCapabilities` / + /// `GetLightingSceneStatus`), and per-command probing — firmware answers + /// `UnknownCmd` for any command it does not implement. pub const CURRENT: Self = Self { major: 0, minor: 1 }; } +/// Human-readable identity of the firmware build. +/// +/// Unlike [`ProtocolVersion`], this label is deliberately application-defined: +/// it is for diagnostics and display, never compatibility decisions. RMK +/// supplies an RMK-only default and downstream firmware may replace it with a +/// label containing its own package, source revision, or configuration name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct BuildInfo { + #[cfg_attr(feature = "wasm", tsify(type = "string"))] + pub label: String, +} + +impl MaxSize for BuildInfo { + const POSTCARD_MAX_SIZE: usize = crate::heapless_vec_max_size::(); +} + /// Device capabilities discovered during the connection handshake. /// /// The host reads this once after connecting to learn the firmware's layout, @@ -157,6 +186,31 @@ pub struct BehaviorConfig { pub tap_capslock_interval_ms: u16, } +/// Active-mode split BLE latency policy. Values count connection events. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct SplitCentralLatencyPolicy { + /// Maximum skipped connection events when USB power is present. + pub powered: u16, + /// Maximum skipped connection events when running on battery. + pub battery: u16, + /// Forced value for both power states; `None` restores automatic selection. + pub override_latency: Option, +} + +/// Current split BLE latency selection and its policy inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct SplitCentralLatencyState { + pub policy: SplitCentralLatencyPolicy, + /// Whether USB power is currently present. + pub powered: bool, + /// Value currently requested for the active split connection. + pub effective: u16, +} + #[cfg(test)] mod tests { use super::*; @@ -240,6 +294,15 @@ mod tests { assert_max_size_bound(&info); } + #[test] + fn round_trip_build_info() { + let full: String = + String::try_from("x".repeat(BUILD_INFO_STRING_SIZE).as_str()).unwrap(); + let info = BuildInfo { label: full }; + round_trip(&info); + assert_max_size_bound(&info); + } + #[test] fn round_trip_lock_status() { // Locked, no attempt armed, challenge advertised. @@ -289,4 +352,19 @@ mod tests { tap_capslock_interval_ms: 20, }); } + + #[test] + fn round_trip_split_central_latency() { + let policy = SplitCentralLatencyPolicy { + powered: 0, + battery: 4, + override_latency: Some(2), + }; + round_trip(&policy); + round_trip(&SplitCentralLatencyState { + policy, + powered: true, + effective: 2, + }); + } } diff --git a/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap b/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap new file mode 100644 index 000000000..e4b31f39a --- /dev/null +++ b/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap @@ -0,0 +1,119 @@ +# Lighting wire-format FRAME snapshot — DO NOT edit by hand. +# File: snapshots/lighting_wire_frames.snap +# Each entry is one complete feature-gated lighting Rynk frame. The nested +# Ok/Err exemplars pin the outer Rynk result and inner lighting result. +# UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features rynk --features lighting lighting_wire_frames +# Format: