diff --git a/docs/docs/main/docs/development/rynk_protocol.md b/docs/docs/main/docs/development/rynk_protocol.md index ca7a0bd5f..308e59837 100644 --- a/docs/docs/main/docs/development/rynk_protocol.md +++ b/docs/docs/main/docs/development/rynk_protocol.md @@ -131,6 +131,8 @@ Which commands a firmware answers depends on the RMK Cargo features it was built | `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 diff --git a/rmk-types/src/protocol/rynk/command.rs b/rmk-types/src/protocol/rynk/command.rs index 86ee7a873..67b67e152 100644 --- a/rmk-types/src/protocol/rynk/command.rs +++ b/rmk-types/src/protocol/rynk/command.rs @@ -43,9 +43,10 @@ use crate::protocol::rynk::{ LightingCompiledScenesPageResult, LightingConditionalSceneStatusResult, LightingConditionalScenesPageResult, LightingExtendedRuntimeConditionalScenesPageResult, LightingExtensionLayersResult, LightingExtensionNamesPageResult, LightingExtensionNamesRequest, LightingExtensionParamsPageResult, - LightingExtensionParamsRequest, LightingExtensionResult, LightingKeysPageResult, LightingLedsPageResult, - LightingOutputModeStateResult, LightingOutputsPageResult, LightingOverlayPageRequest, LightingOverlayPageResult, - LightingOverlayTransactionResult, LightingPageRequest, LightingPhysicalKeysPageResult, LightingRoutesPageResult, + LightingExtensionParamsRequest, LightingExtensionResult, LightingFramePageResult, LightingFrameRequest, + LightingKeysPageResult, LightingLedsPageResult, LightingOutputModeStateResult, LightingOutputsPageResult, + LightingOverlayPageRequest, LightingOverlayPageResult, LightingOverlayTransactionResult, LightingPageRequest, + LightingPhysicalKeysPageResult, LightingReplicaStatusResult, LightingRoutesPageResult, LightingRuntimeConditionalScenePageRequest, LightingRuntimeConditionalSceneStatusResult, LightingRuntimeConditionalSceneTransactionResult, LightingRuntimeConditionalScenesPageResult, LightingScenePageRequest, LightingSceneStatusResult, LightingSceneTransactionResult, LightingScenesPageResult, @@ -520,6 +521,17 @@ endpoints! { /// 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;` diff --git a/rmk-types/src/protocol/rynk/payload/lighting.rs b/rmk-types/src/protocol/rynk/payload/lighting.rs index b92be70ac..93110494e 100644 --- a/rmk-types/src/protocol/rynk/payload/lighting.rs +++ b/rmk-types/src/protocol/rynk/payload/lighting.rs @@ -25,6 +25,13 @@ pub const LIGHTING_CONDITIONAL_SCENE_CHUNK_SIZE: usize = 7; /// 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. @@ -1156,6 +1163,195 @@ wire_type! { } } +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 { @@ -1176,6 +1372,16 @@ wire_type! { 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 }, } } @@ -1209,6 +1415,8 @@ pub type LightingRuntimeConditionalScenesPageResult = LightingResult; pub type LightingRuntimeConditionalSceneTransactionResult = LightingResult; +pub type LightingFramePageResult = LightingResult; +pub type LightingReplicaStatusResult = LightingResult; pub type LightingUnitResult = LightingResult<()>; wire_type! { @@ -1287,6 +1495,8 @@ const _: () = { 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)] @@ -1447,6 +1657,117 @@ mod tests { 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(); diff --git a/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap b/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap index 119cb1f67..e4b31f39a 100644 --- a/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap +++ b/rmk-types/src/protocol/rynk/snapshots/lighting_wire_frames.snap @@ -50,6 +50,9 @@ GetLightingExtensionNames reply 04 22 09 01 01 09 GetLightingExtensionNames request 06 22 09 01 01 08 00 GetLightingExtensionParams reply 04 2b 09 01 01 10 09 02 01 07 44 65 6e 73 69 74 79 01 08 03 05 00 GetLightingExtensionParams request 05 2b 09 01 01 01 00 +GetLightingFrame reply 04 36 09 01 01 0b 01 01 09 50 18 25 01 01 02 03 00 +GetLightingFrame reply Err(NodeUnavailable) 04 36 09 01 04 01 10 01 00 +GetLightingFrame request 06 36 09 01 01 18 00 GetLightingKeys reply 04 11 09 01 01 06 01 01 01 01 02 00 GetLightingKeys request 05 11 09 01 01 01 00 GetLightingLeds reply 04 05 09 01 01 11 01 01 01 2a 01 01 02 01 ff 01 80 04 80 01 03 01 00 @@ -62,6 +65,8 @@ GetLightingOverlay reply 04 1b 09 01 01 11 GetLightingOverlay request 05 1b 09 01 09 01 00 GetLightingPhysicalKeys reply 04 04 09 01 01 12 01 01 01 01 02 ff 01 80 04 80 01 80 02 80 03 db 0b 00 GetLightingPhysicalKeys request 05 04 09 01 01 01 00 +GetLightingReplicaStatus reply 04 37 09 01 01 08 09 01 08 02 01 06 01 09 01 01 01 07 01 04 01 01 14 01 01 01 07 0b 0c 0d 0e 01 7d 01 01 01 01 06 05 01 01 02 02 01 0a fa 01 01 01 06 15 16 17 18 00 +GetLightingReplicaStatus request 04 37 09 01 00 GetLightingRoutes reply 04 09 09 01 01 08 01 01 01 2a 01 02 07 00 GetLightingRoutes request 05 09 09 01 01 01 00 GetLightingRuntimeConditionalSceneStatus reply 04 25 09 01 01 05 09 40 01 07 00 diff --git a/rmk-types/src/protocol/rynk/tests.rs b/rmk-types/src/protocol/rynk/tests.rs index eac5ae55d..84e183878 100644 --- a/rmk-types/src/protocol/rynk/tests.rs +++ b/rmk-types/src/protocol/rynk/tests.rs @@ -1462,6 +1462,69 @@ fn lighting_wire_frames_locked() { offset: 0, cells: one(extended_conditional_cell), }; + let frame_request = LightingFrameRequest { + node: LightingNodeId(1), + offset: 24, + }; + let frame_page = LightingFramePage { + node: frame_request.node, + revision: Some(state.revision), + total_leds: 80, + start: frame_request.offset, + age_ms: 37, + cells: one(LightingRgb8 { r: 1, g: 2, b: 3 }), + }; + let replica_status = LightingReplicaStatus { + central: LightingCentralReplicaState { + revision: state.revision, + presented_revision: Some(state.revision - 1), + effective_layer: 2, + default_layer: 1, + active_bits: 0b110, + powered: true, + wake_active: false, + effective_output_enabled: true, + }, + replication: Some(LightingReplicationMachine { + last_acked_revision: Some(state.revision - 2), + awaiting_ack: true, + generation: 4, + link_up: true, + durable_dirty: true, + context_dirty: false, + health: LightingReplicationHealth::Resynchronizing, + expected_digests: Some(LightingReplicaDigests { + schema: LIGHTING_REPLICA_DIGEST_SCHEMA_V1, + revision: state.revision - 2, + settings: 11, + overlay: 12, + scenes: 13, + conditional_scenes: 14, + }), + last_attested_age_ms: Some(125), + mismatch_count: 1, + }), + peripheral: Some(LightingPeripheralReplicaState { + node: frame_request.node, + applied_revision: Some(state.revision - 3), + engine_revision: 5, + effective_layer: 1, + default_layer: 1, + active_bits: 0b10, + powered: false, + wake_active: true, + effective_output_enabled: false, + age_ms: 250, + digests: Some(LightingReplicaDigests { + schema: LIGHTING_REPLICA_DIGEST_SCHEMA_V1, + revision: state.revision - 3, + settings: 21, + overlay: 22, + scenes: 23, + conditional_scenes: 24, + }), + }), + }; let entries: alloc::vec::Vec<(&str, alloc::vec::Vec)> = alloc::vec![ ( @@ -2156,6 +2219,40 @@ fn lighting_wire_frames_locked() { &Ok::(Ok(state)) ) ), + ( + "GetLightingFrame request", + encode_frame(Cmd::GetLightingFrame, SEQ, &frame_request) + ), + ( + "GetLightingFrame reply", + encode_frame( + Cmd::GetLightingFrame, + SEQ, + &Ok::(Ok(frame_page)) + ) + ), + ( + "GetLightingFrame reply Err(NodeUnavailable)", + encode_frame( + Cmd::GetLightingFrame, + SEQ, + &Ok::(Err(LightingError::NodeUnavailable { + node: frame_request.node + })) + ) + ), + ( + "GetLightingReplicaStatus request", + encode_frame(Cmd::GetLightingReplicaStatus, SEQ, &()) + ), + ( + "GetLightingReplicaStatus reply", + encode_frame( + Cmd::GetLightingReplicaStatus, + SEQ, + &Ok::(Ok(replica_status)) + ) + ), ( "LightingChange topic", encode_frame(Cmd::LightingChange, 0, &LightingChanged) diff --git a/rmk/src/host/mod.rs b/rmk/src/host/mod.rs index 5817c80fc..e77c36658 100644 --- a/rmk/src/host/mod.rs +++ b/rmk/src/host/mod.rs @@ -24,14 +24,15 @@ pub(crate) mod via; /// two are mutually exclusive). #[cfg(feature = "rynk")] pub use rynk::RynkService as HostService; -/// RMK's semantic version, available to downstream firmware build labels. -#[cfg(feature = "rynk")] -pub use rynk::{RMK_VERSION, RMK_VERSION_STRING}; #[cfg(all(feature = "rynk", feature = "lighting"))] pub use rynk::{ - RYNK_LIGHTING_TRANSACTION_CAPACITY, RynkLightingController, RynkLightingDescriptor, RynkLightingMailbox, - RynkLightingReadback, StandardRynkLightingAdapter, install_lighting_runtime_conditional_scenes, - install_lighting_scenes, + LightingReplicationStatus, PeripheralReplicaStatus, RYNK_LIGHTING_TRANSACTION_CAPACITY, RemoteFrame, + RemoteFramePort, RemoteFrameRequest, ReplicaDigests, ReplicationHealth, ReplicationMachineState, + RynkLightingController, RynkLightingDescriptor, RynkLightingMailbox, RynkLightingReadback, + StandardRynkLightingAdapter, install_lighting_runtime_conditional_scenes, install_lighting_scenes, }; +/// RMK's semantic version, available to downstream firmware build labels. +#[cfg(feature = "rynk")] +pub use rynk::{RMK_VERSION, RMK_VERSION_STRING}; #[cfg(feature = "vial")] pub use via::VialService as HostService; diff --git a/rmk/src/host/rynk/handlers/lighting.rs b/rmk/src/host/rynk/handlers/lighting.rs index 18e499066..dd78f5235 100644 --- a/rmk/src/host/rynk/handlers/lighting.rs +++ b/rmk/src/host/rynk/handlers/lighting.rs @@ -10,8 +10,8 @@ use rmk_types::protocol::rynk::command::{ GetLightingCompiledScenes, GetLightingConditionalSceneStatus, GetLightingConditionalScenes, GetLightingExtendedRuntimeConditionalSceneStatus, GetLightingExtendedRuntimeConditionalScenes, GetLightingExtension, GetLightingExtensionLayers, GetLightingExtensionNames, GetLightingExtensionParams, - GetLightingKeys, GetLightingLeds, GetLightingOutputMode, GetLightingOutputs, GetLightingOverlay, - GetLightingPhysicalKeys, GetLightingRoutes, GetLightingRuntimeConditionalSceneStatus, + GetLightingFrame, GetLightingKeys, GetLightingLeds, GetLightingOutputMode, GetLightingOutputs, GetLightingOverlay, + GetLightingPhysicalKeys, GetLightingReplicaStatus, GetLightingRoutes, GetLightingRuntimeConditionalSceneStatus, GetLightingRuntimeConditionalScenes, GetLightingSceneStatus, GetLightingScenes, GetLightingState, GetLightingZoneMemberships, GetLightingZones, PutLightingExtendedRuntimeConditionalSceneChunk, PutLightingRuntimeConditionalSceneChunk, SetLightingExtensionLayers, SetLightingExtensionParam, @@ -25,19 +25,21 @@ use rmk_types::protocol::rynk::{ CommitLightingOverlayReplaceRequest, CommitLightingRuntimeConditionalSceneReplaceRequest, CommitLightingSceneReplaceRequest, LIGHTING_CONDITIONAL_SCENE_CHUNK_SIZE, LIGHTING_EXTENDED_CONDITIONAL_SCENE_CHUNK_SIZE, LIGHTING_PAGE_SIZE, LIGHTING_SCENE_CHUNK_SIZE, - LIGHTING_ZONE_NAME_SIZE, LightingCapabilities, LightingCapabilitiesResult, LightingCompiledSceneStatus, - LightingCompiledSceneStatusResult, LightingCompiledScenesPageResult, LightingConditionalSceneCell, - LightingConditionalSceneStatus, LightingConditionalSceneStatusResult, LightingConditionalScenesPage, - LightingConditionalScenesPageResult, LightingEffectFlags, LightingError, + LIGHTING_ZONE_NAME_SIZE, LightingCapabilities, LightingCapabilitiesResult, LightingCentralReplicaState, + LightingCompiledSceneStatus, LightingCompiledSceneStatusResult, LightingCompiledScenesPageResult, + LightingConditionalSceneCell, LightingConditionalSceneStatus, LightingConditionalSceneStatusResult, + LightingConditionalScenesPage, LightingConditionalScenesPageResult, LightingEffectFlags, LightingError, LightingExtendedRuntimeConditionalScenesPageResult, LightingExtensionLayersResult, LightingExtensionNamesPageResult, LightingExtensionNamesRequest, LightingExtensionParamsPageResult, - LightingExtensionParamsRequest, LightingExtensionResult, LightingFeatureFlags, LightingKeysPage, - LightingKeysPageResult, LightingLed, LightingLedId, LightingLedsPage, LightingLedsPageResult, - LightingMatrixPosition, LightingOutput, LightingOutputCapabilities, LightingOutputCoverage, - LightingOutputModeStateResult, LightingOutputsPage, LightingOutputsPageResult, LightingOverlayCell, - LightingOverlayPageRequest, LightingOverlayPageResult, LightingOverlayTransaction, - LightingOverlayTransactionResult, LightingPageRequest, LightingPhysicalKey, LightingPhysicalKeysPage, - LightingPhysicalKeysPageResult, LightingPoint3, LightingResult, LightingRoute, LightingRoutesPage, + LightingExtensionParamsRequest, LightingExtensionResult, LightingFeatureFlags, LightingFramePage, + LightingFramePageResult, LightingFrameRequest, LightingKeysPage, LightingKeysPageResult, LightingLed, + LightingLedId, LightingLedsPage, LightingLedsPageResult, LightingMatrixPosition, LightingNodeId, LightingOutput, + LightingOutputCapabilities, LightingOutputCoverage, LightingOutputModeStateResult, LightingOutputsPage, + LightingOutputsPageResult, LightingOverlayCell, LightingOverlayPageRequest, LightingOverlayPageResult, + LightingOverlayTransaction, LightingOverlayTransactionResult, LightingPageRequest, LightingPeripheralReplicaState, + LightingPhysicalKey, LightingPhysicalKeysPage, LightingPhysicalKeysPageResult, LightingPoint3, + LightingReplicaDigests, LightingReplicaStatus, LightingReplicaStatusResult, LightingReplicationHealth, + LightingReplicationMachine, LightingResult, LightingRgb8, LightingRoute, LightingRoutesPage, LightingRoutesPageResult, LightingRuntimeConditionalScenePageRequest, LightingRuntimeConditionalSceneStatus, LightingRuntimeConditionalSceneStatusResult, LightingRuntimeConditionalSceneTransactionResult, LightingRuntimeConditionalScenesPageResult, LightingScenePageRequest, LightingSceneStatus, @@ -53,7 +55,8 @@ use rmk_types::protocol::rynk::{ }; use super::super::lighting::{ - RYNK_LIGHTING_TRANSACTION_CAPACITY, RynkLightingCommand, RynkLightingController, RynkLightingReadback, + RYNK_LIGHTING_TRANSACTION_CAPACITY, ReplicaDigests, ReplicationHealth, RynkLightingCommand, RynkLightingController, + RynkLightingReadback, }; use super::super::{RynkService, RynkSession}; use super::Handle; @@ -995,6 +998,130 @@ impl Handle for RynkService<'_> { } } +impl Handle for RynkService<'_> { + async fn handle(&self, req: LightingFrameRequest) -> Result { + Ok(match controller(self) { + Ok(controller) => frame_page(controller, req).await, + Err(error) => Err(error), + }) + } +} + +async fn frame_page( + controller: RynkLightingController<'_>, + req: LightingFrameRequest, +) -> LightingResult { + let (page, age_ms) = controller + .frame_page(crate::lighting::LightingNodeId(req.node.0), req.offset) + .await?; + let mut cells = Vec::new(); + for cell in page.cells() { + cells + .push(LightingRgb8 { + r: cell.r, + g: cell.g, + b: cell.b, + }) + .expect("engine and wire frame pages hold the same number of cells"); + } + Ok(LightingFramePage { + node: req.node, + revision: page.revision, + total_leds: page.total, + start: page.start, + age_ms, + cells, + }) +} + +impl Handle for RynkService<'_> { + async fn handle(&self, _: ()) -> Result { + Ok(match controller(self) { + Ok(controller) => replica_status(controller).await, + Err(error) => Err(error), + }) + } +} + +async fn replica_status(controller: RynkLightingController<'_>) -> LightingResult { + // `ReadOutputMode` is the mailbox's full `StandardState` readback; the + // name records its first caller, not its contents. + let state = match controller.request(RynkLightingCommand::ReadOutputMode).await? { + RynkLightingReadback::OutputMode(state) => state, + _ => return Err(LightingError::InvalidRequest), + }; + let presented = state.presented; + let central = LightingCentralReplicaState { + revision: state.revision, + presented_revision: presented.map(|presented| presented.revision), + effective_layer: presented.map_or(0, |presented| presented.context.layers.effective), + default_layer: presented.map_or(0, |presented| presented.context.layers.default), + active_bits: presented.map_or(0, |presented| presented.context.layers.active_bits()), + powered: state.powered, + wake_active: state.wake_active, + effective_output_enabled: state.output_enabled, + }; + + let Some(status) = controller.replication else { + return Ok(LightingReplicaStatus { + central, + replication: None, + peripheral: None, + }); + }; + status.request_refresh(); + let machine = status.central(); + let peripheral = controller.peripheral_node().and_then(|node| { + let peripheral = status.peripheral(node)?; + Some(LightingPeripheralReplicaState { + node: LightingNodeId(node.0), + applied_revision: peripheral.applied_revision, + engine_revision: peripheral.engine_revision, + effective_layer: peripheral.layers.effective, + default_layer: peripheral.layers.default, + active_bits: peripheral.layers.active_bits(), + powered: peripheral.powered, + wake_active: peripheral.wake_active, + effective_output_enabled: peripheral.effective_output_enabled, + age_ms: peripheral.age_ms, + digests: peripheral.digests.map(replica_digests_to_wire), + }) + }); + Ok(LightingReplicaStatus { + central, + replication: Some(LightingReplicationMachine { + last_acked_revision: machine.last_acked_revision, + awaiting_ack: machine.awaiting_ack, + generation: machine.generation, + link_up: machine.link_up, + durable_dirty: machine.durable_dirty, + context_dirty: machine.context_dirty, + health: match machine.health { + ReplicationHealth::Healthy => LightingReplicationHealth::Healthy, + ReplicationHealth::Resynchronizing => LightingReplicationHealth::Resynchronizing, + ReplicationHealth::Stale => LightingReplicationHealth::Stale, + ReplicationHealth::Diverged => LightingReplicationHealth::Diverged, + ReplicationHealth::Halted => LightingReplicationHealth::Halted, + }, + expected_digests: machine.expected_digests.map(replica_digests_to_wire), + last_attested_age_ms: machine.last_attested_age_ms, + mismatch_count: machine.mismatch_count, + }), + peripheral, + }) +} + +fn replica_digests_to_wire(digests: ReplicaDigests) -> LightingReplicaDigests { + LightingReplicaDigests { + schema: digests.schema, + revision: digests.revision, + settings: digests.settings, + overlay: digests.overlay, + scenes: digests.scenes, + conditional_scenes: digests.conditional_scenes, + } +} + impl Handle for RynkService<'_> { async fn handle(&self, req: LightingPageRequest) -> Result { Ok(controller(self).and_then(|binding| keys_page(binding, req))) diff --git a/rmk/src/host/rynk/lighting.rs b/rmk/src/host/rynk/lighting.rs index e34298ba9..6ac96269c 100644 --- a/rmk/src/host/rynk/lighting.rs +++ b/rmk/src/host/rynk/lighting.rs @@ -32,10 +32,11 @@ use rmk_types::protocol::rynk::{ use crate::RawMutex; use crate::core_traits::Runnable; use crate::lighting::{ - BackgroundMode, BackgroundState, BuiltinEffect, ConditionalSceneCell, LayerPolicy, LedId, LightingControls, - LightingMailbox, LightingRouting, LightingTopology, OVERLAY_CHUNK_SIZE, OutputMode, OverlayBatch, OverlayCell, - OverlayError, Rgb8, RuntimeConditionalSceneCell, RuntimeConditionalSceneChunk, SceneChunk, SceneTableCell, - StandardCommand, StandardError, StandardLightingEngine, StandardMutableState, StandardReply, StandardState, + BackgroundMode, BackgroundState, BuiltinEffect, ConditionalSceneCell, FramePage, LayerPolicy, LayerState, LedId, + LightingControls, LightingMailbox, LightingNodeId, LightingRouting, LightingTopology, OVERLAY_CHUNK_SIZE, + OutputMode, OverlayBatch, OverlayCell, OverlayError, Rgb8, RuntimeConditionalSceneCell, + RuntimeConditionalSceneChunk, SceneChunk, SceneTableCell, StandardCommand, StandardError, StandardLightingEngine, + StandardMutableState, StandardReply, StandardState, }; const _: () = core::assert!( @@ -60,6 +61,190 @@ pub const RYNK_LIGHTING_TRANSACTION_CAPACITY: usize = 64; const RYNK_LIGHTING_COMMAND_CAPACITY: usize = 4; +/// How long a remote-frame fetch waits before reporting the node unavailable. +/// +/// Boards may need several bounded split-packet round trips to assemble a +/// page. Keep this outer deadline long enough for that recovery while still +/// bounding a host request when the remote half is gone. +const REMOTE_FRAME_TIMEOUT_MS: u64 = 1_000; + +/// One node's presented-frame page plus how stale it is. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct RemoteFrame { + /// The page as the remote node's engine reported it. `page.start` must + /// echo the request's offset; `page.total` is the remote frame's length, + /// which need not match the local one. + pub page: FramePage, + /// Milliseconds between the remote capture and this answer. + pub age_ms: u32, +} + +/// One outstanding fetch on a [`RemoteFramePort`]. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct RemoteFrameRequest { + /// Correlation token. Answer with exactly this id: an answer to a fetch + /// that already timed out is then discarded rather than mistaken for the + /// answer to the next one. + pub id: u32, + pub node: LightingNodeId, + /// First logical frame slot wanted. Answering with fewer than + /// [`crate::lighting::FRAME_CHUNK_SIZE`] cells is fine; answering with a + /// different `start` is not. + pub offset: u16, +} + +struct RemoteFrameResponse { + id: u32, + frame: Option, +} + +/// Board-provided source for another lighting node's presented frame. +/// +/// RMK owns no split transport for frames, so the port is a rendezvous: the +/// Rynk handler parks on it, a board task takes the request, performs +/// whatever round trip its link needs, and answers. It is shaped like +/// [`RynkLightingMailbox`] for the same reasons — it lives in a `static`, so +/// an ordinary embassy task holds `&'static Self` with no allocation, and no +/// generic parameter reaches [`super::RynkService`]. +pub struct RemoteFramePort { + request: Signal, + response: Signal, + caller: Mutex, + next_id: BlockingMutex>, +} + +impl RemoteFramePort { + pub const fn new() -> Self { + Self { + request: Signal::new(), + response: Signal::new(), + caller: Mutex::new(()), + next_id: BlockingMutex::new(Cell::new(0)), + } + } + + /// Board side: wait for the next fetch. Cancel-safe. + pub async fn receive(&self) -> RemoteFrameRequest { + self.request.wait().await + } + + /// Board side: answer the fetch identified by `id`. `None` reports that + /// the node could not answer — link down, no reply, or a malformed one — + /// and surfaces to the host as `NodeUnavailable`. Answering late is safe; + /// the correlation token makes the stale answer inert. + pub fn reply(&self, id: u32, frame: Option) { + self.response.signal(RemoteFrameResponse { id, frame }); + } + + /// Protocol side: fetch one page, or `None` on failure or timeout. + async fn fetch(&self, node: LightingNodeId, offset: u16) -> Option { + let _caller = self.caller.lock().await; + let id = self.next_id.lock(|next| { + let id = next.get(); + next.set(id.wrapping_add(1)); + id + }); + self.request.signal(RemoteFrameRequest { id, node, offset }); + embassy_time::with_timeout(embassy_time::Duration::from_millis(REMOTE_FRAME_TIMEOUT_MS), async { + loop { + let response = self.response.wait().await; + if response.id == id { + return response.frame; + } + } + }) + .await + .ok() + .flatten() + } +} + +impl Default for RemoteFramePort { + fn default() -> Self { + Self::new() + } +} + +/// Canonical digests of one durable replica projection. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct ReplicaDigests { + pub schema: u8, + pub revision: u32, + pub settings: u32, + pub overlay: u32, + pub scenes: u32, + pub conditional_scenes: u32, +} + +/// Recovery state reported by the board's replication machine. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum ReplicationHealth { + #[default] + Healthy, + Resynchronizing, + Stale, + Diverged, + Halted, +} + +/// Central-side state of the split lighting replication machine. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct ReplicationMachineState { + /// Last revision a peripheral acknowledged, or `None` since boot. + pub last_acked_revision: Option, + /// A snapshot is in flight and unacknowledged. + pub awaiting_ack: bool, + /// Bumped whenever replication restarts, so a host can tell a resend + /// apart from a retry that is stuck. + pub generation: u8, + pub link_up: bool, + pub durable_dirty: bool, + pub context_dirty: bool, + pub health: ReplicationHealth, + pub expected_digests: Option, + pub last_attested_age_ms: Option, + pub mismatch_count: u8, +} + +/// The last renderer state a peripheral reported. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct PeripheralReplicaStatus { + /// Central revision whose snapshot the peripheral last applied. + pub applied_revision: Option, + /// The peripheral engine's own revision, which also advances locally. + pub engine_revision: u32, + /// Layer state the peripheral is rendering from — the replicated + /// context, so a divergence from the central's is the stale-replica + /// symptom made visible. + pub layers: LayerState, + pub powered: bool, + pub wake_active: bool, + pub effective_output_enabled: bool, + /// Age of this report at the time it is read. + pub age_ms: u32, + /// Digests recomputed from the applied durable state. `None` means the + /// peer has not supplied attestation data. + pub digests: Option, +} + +/// Board-provided view of the split lighting replication handshake. +/// +/// Synchronous on purpose: the board keeps this in a `static` its split +/// tasks update, and reads answer from the last thing heard rather than +/// blocking on the link. A read that blocked could not distinguish a slow +/// link from a dead one, which is the distinction being debugged. +pub trait LightingReplicationStatus { + /// Ask the board to refresh its cached peripheral view without blocking + /// this protocol read. Implementations should coalesce requests; a host + /// that needs the freshest value can reread after one bounded round trip. + fn request_refresh(&self) {} + + fn central(&self) -> ReplicationMachineState; + + /// The last status heard from `node`, or `None` if none ever arrived. + fn peripheral(&self, node: LightingNodeId) -> Option; +} + #[derive(Clone, Copy)] pub struct RynkLightingDescriptor<'a> { pub topology_revision: u32, @@ -83,6 +268,10 @@ pub struct RynkLightingController<'a> { /// `EXTENSION_EFFECTS` capability bit and the extension endpoints. pub(super) extension_effects: bool, pub(super) extension_layering: bool, + /// Which routing node this half renders locally. + pub(super) local_node: LightingNodeId, + pub(super) remote_frames: Option<&'a RemoteFramePort>, + pub(super) replication: Option<&'a dyn LightingReplicationStatus>, mailbox: &'a RynkLightingMailbox, } @@ -115,10 +304,68 @@ impl<'a> RynkLightingController<'a> { }, extension_effects: false, extension_layering: false, + local_node: LightingNodeId(0), + remote_frames: None, + replication: None, mailbox, } } + /// Name the routing node this half renders. Frame reads for it are served + /// from the local engine; every other known node goes through + /// [`Self::with_remote_frames`]. Defaults to node `0`. + pub const fn with_local_node(mut self, node: LightingNodeId) -> Self { + self.local_node = node; + self + } + + /// Wire a board source for other nodes' presented frames. Without it, + /// reading a remote node's frame answers `Unsupported`. + pub const fn with_remote_frames(mut self, port: &'a RemoteFramePort) -> Self { + self.remote_frames = Some(port); + self + } + + /// Wire the board's view of the replication handshake. Without it, the + /// replica-status read still reports the central's own engine, and the + /// machine and peripheral halves are absent rather than fabricated. + pub const fn with_replication_status(mut self, status: &'a dyn LightingReplicationStatus) -> Self { + self.replication = Some(status); + self + } + + /// Fetch one page of `node`'s presented frame, resolving local versus + /// remote. `age_ms` is `0` for the local node, which has no round trip. + pub(super) async fn frame_page(&self, node: LightingNodeId, offset: u16) -> LightingResult<(FramePage, u32)> { + if node == self.local_node { + return match self.request(RynkLightingCommand::ReadFrame { offset }).await? { + RynkLightingReadback::FramePage(page) => Ok((page, 0)), + _ => Err(LightingError::InvalidRequest), + }; + } + let wire_node = rmk_types::protocol::rynk::LightingNodeId(node.0); + if !self.descriptor.routing.outputs.iter().any(|output| output.node == node) { + return Err(LightingError::UnknownNode { node: wire_node }); + } + let port = self.remote_frames.ok_or(LightingError::Unsupported)?; + let remote = port + .fetch(node, offset) + .await + .ok_or(LightingError::NodeUnavailable { node: wire_node })?; + Ok((remote.page, remote.age_ms)) + } + + /// The first routing node that is not this half — the peripheral whose + /// replica status the handshake read reports. + pub(super) fn peripheral_node(&self) -> Option { + self.descriptor + .routing + .outputs + .iter() + .map(|output| output.node) + .find(|node| *node != self.local_node) + } + /// Advertise the engine's host-selectable animated extension source. /// Boards whose extension band is not user-selectable skip this call. pub const fn with_extension_effects(mut self) -> Self { @@ -244,6 +491,9 @@ pub enum RynkLightingReadback { State(LightingState), OutputMode(StandardState), OverlayPage(LightingOverlayPage), + /// The engine's page of the frame it last presented, forwarded + /// unconverted so remote and local pages share one shape. + FramePage(FramePage), SceneStatus { revision: u32, scene_len: u16, @@ -430,6 +680,9 @@ pub(super) enum RynkLightingCommand { expected_revision: u32, offset: u16, }, + ReadFrame { + offset: u16, + }, ReadSceneStatus, ReadScenes { expected_revision: u32, @@ -870,6 +1123,15 @@ impl<'a, const OVERLAY_CAPACITY: usize, const CORE_COMMAND_CAPACITY: usize, cons items, })); } + RynkLightingCommand::ReadFrame { offset } => { + // Unpinned: a frame read observes whatever is on the LEDs, + // and rejecting a stale one would hide the very divergence + // this endpoint exists to show. + return match self.request_core(StandardCommand::ReadFrame { offset }).await? { + StandardReply::FramePage(page) => Ok(RynkLightingReadback::FramePage(page)), + _ => Err(LightingError::InvalidRequest), + }; + } RynkLightingCommand::ReadSceneStatus => { let state = self.request_core_state(StandardCommand::ReadState).await?; return Ok(RynkLightingReadback::SceneStatus { @@ -1835,7 +2097,7 @@ mod tests { }; use super::*; - use crate::lighting::{LedMetadata, MatrixSize, ZoneSpan}; + use crate::lighting::{FRAME_CHUNK_SIZE, LedMetadata, MatrixSize, ZoneSpan}; use crate::physical_layout::PhysicalLayout; use crate::test_support::test_block_on as block_on; @@ -1919,6 +2181,147 @@ mod tests { assert_eq!(reply, Ok(RynkLightingReadback::State(state(2)))); } + static TEST_OUTPUTS: [crate::lighting::OutputMetadata; 2] = [ + crate::lighting::OutputMetadata { + node: LightingNodeId(0), + id: crate::lighting::OutputId(0), + pixel_count: 1, + capabilities: crate::lighting::OutputCapabilities::RGB, + coverage: crate::lighting::OutputCoverage::Complete, + }, + crate::lighting::OutputMetadata { + node: LightingNodeId(1), + id: crate::lighting::OutputId(0), + pixel_count: 1, + capabilities: crate::lighting::OutputCapabilities::RGB, + coverage: crate::lighting::OutputCoverage::Complete, + }, + ]; + + fn controller<'a>(mailbox: &'a RynkLightingMailbox) -> RynkLightingController<'a> { + RynkLightingController::new( + mailbox, + RynkLightingDescriptor { + topology_revision: 1, + topology: topology(), + routing: LightingRouting { + outputs: &TEST_OUTPUTS, + routes: &[], + }, + }, + 2, + ) + } + + fn frame_page(start: u16, red: u8) -> FramePage { + let mut cells = [Rgb8::BLACK; FRAME_CHUNK_SIZE]; + cells[0] = Rgb8::new(red, 0, 0); + FramePage { + revision: Some(7), + total: 2, + start, + len: 1, + cells, + } + } + + #[test] + fn local_frame_reads_come_from_the_engine_with_no_age() { + let mailbox = RynkLightingMailbox::new(); + let controller = controller(&mailbox); + let (result, ()) = block_on(join(controller.frame_page(LightingNodeId(0), 24), async { + let request = mailbox.receive().await; + assert!(matches!(request.command, RynkLightingCommand::ReadFrame { offset: 24 })); + mailbox.reply(request.id, Ok(RynkLightingReadback::FramePage(frame_page(24, 5)))); + })); + assert_eq!(result, Ok((frame_page(24, 5), 0))); + } + + #[test] + fn remote_frame_reads_separate_unknown_unwired_and_unreachable_nodes() { + let mailbox = RynkLightingMailbox::new(); + let bare = controller(&mailbox); + + // A node with no output in routing does not exist at all. + assert_eq!( + block_on(bare.frame_page(LightingNodeId(9), 0)), + Err(LightingError::UnknownNode { + node: rmk_types::protocol::rynk::LightingNodeId(9) + }), + ); + // A real node the board wired no source for is never answerable. + assert_eq!( + block_on(bare.frame_page(LightingNodeId(1), 0)), + Err(LightingError::Unsupported), + ); + + // A wired node that does not answer is unavailable, not unsupported: + // the distinction is whether retrying is worth anything. + let port = RemoteFramePort::new(); + let wired = controller(&mailbox).with_remote_frames(&port); + let (result, ()) = block_on(join(wired.frame_page(LightingNodeId(1), 0), async { + let request = port.receive().await; + port.reply(request.id, None); + })); + assert_eq!( + result, + Err(LightingError::NodeUnavailable { + node: rmk_types::protocol::rynk::LightingNodeId(1) + }), + ); + + let (result, ()) = block_on(join(wired.frame_page(LightingNodeId(1), 24), async { + let request = port.receive().await; + assert_eq!((request.node, request.offset), (LightingNodeId(1), 24)); + port.reply( + request.id, + Some(RemoteFrame { + page: frame_page(24, 9), + age_ms: 42, + }), + ); + })); + assert_eq!(result, Ok((frame_page(24, 9), 42))); + } + + #[test] + fn a_silent_remote_node_times_out_and_its_late_answer_is_inert() { + let port = RemoteFramePort::new(); + // Nobody answers: the fetch must not park forever behind a dead half. + let (fetched, id) = block_on(join(port.fetch(LightingNodeId(1), 0), async { + port.receive().await.id + })); + assert_eq!(fetched, None); + + // The abandoned answer arrives after the fact. The next fetch carries + // a fresh token, so it waits for its own answer instead of taking it. + port.reply( + id, + Some(RemoteFrame { + page: frame_page(0, 1), + age_ms: 0, + }), + ); + let (fetched, ()) = block_on(join(port.fetch(LightingNodeId(1), 0), async { + let request = port.receive().await; + assert_ne!(request.id, id); + port.reply( + request.id, + Some(RemoteFrame { + page: frame_page(0, 2), + age_ms: 3, + }), + ); + })); + assert_eq!( + fetched, + Some(RemoteFrame { + page: frame_page(0, 2), + age_ms: 3 + }), + ); + } + #[test] fn replacement_rejects_duplicate_stable_ids_before_reaching_the_core() { let protocol = RynkLightingMailbox::new(); diff --git a/rmk/src/host/rynk/mod.rs b/rmk/src/host/rynk/mod.rs index 5c47b6046..b08d9089f 100644 --- a/rmk/src/host/rynk/mod.rs +++ b/rmk/src/host/rynk/mod.rs @@ -13,9 +13,10 @@ use embassy_futures::select::{Either, select}; use embedded_io_async::{Read, Write}; #[cfg(feature = "lighting")] pub use lighting::{ - RYNK_LIGHTING_TRANSACTION_CAPACITY, RynkLightingController, RynkLightingDescriptor, RynkLightingMailbox, - RynkLightingReadback, StandardRynkLightingAdapter, install_lighting_runtime_conditional_scenes, - install_lighting_scenes, + LightingReplicationStatus, PeripheralReplicaStatus, RYNK_LIGHTING_TRANSACTION_CAPACITY, RemoteFrame, + RemoteFramePort, RemoteFrameRequest, ReplicaDigests, ReplicationHealth, ReplicationMachineState, + RynkLightingController, RynkLightingDescriptor, RynkLightingMailbox, RynkLightingReadback, + StandardRynkLightingAdapter, install_lighting_runtime_conditional_scenes, install_lighting_scenes, }; use postcard::experimental::max_size::MaxSize; use rmk_types::constants::RYNK_BUFFER_SIZE; @@ -150,6 +151,10 @@ impl<'a> RynkService<'a> { fn requires_unlock(&self, cmd: Cmd) -> bool { match cmd { Cmd::BootloaderJump | Cmd::PeripheralBootloaderJump => self.lock_config.bootloader_requires_unlock, + // Reading raw LED colors can expose reactive per-key effects, and + // therefore keystrokes, exactly like the matrix snapshot does. + #[cfg(feature = "lighting")] + Cmd::GetLightingFrame => true, Cmd::StorageReset | Cmd::GetMatrixState => true, // Deleting a bond opens a re-pair hijack window; BLE-only command. #[cfg(feature = "_ble")] @@ -384,6 +389,10 @@ impl<'a> RynkService<'a> { serve::(self, msg).await } #[cfg(feature = "lighting")] + Cmd::GetLightingFrame => serve::(self, msg).await, + #[cfg(feature = "lighting")] + Cmd::GetLightingReplicaStatus => serve::(self, msg).await, + #[cfg(feature = "lighting")] Cmd::BeginLightingExtendedRuntimeConditionalSceneReplace => { serve::(self, msg).await } diff --git a/rmk/src/lighting/compositor.rs b/rmk/src/lighting/compositor.rs index 2f12be923..7f8072b76 100644 --- a/rmk/src/lighting/compositor.rs +++ b/rmk/src/lighting/compositor.rs @@ -253,6 +253,14 @@ impl Compositor { pub fn has_committed(&self) -> bool { self.has_committed } + + /// The last frame the output accepted — post-[`OutputTransform`], because + /// [`RenderTransaction::finish_with`] writes the transformed colors back + /// into the frame before it is presented and committed. It is therefore + /// what the driver was handed, not the pre-brightness composition. + pub const fn committed(&self) -> &LogicalFrame { + &self.committed + } } pub struct RenderTransaction<'a, 'context, C, Context, const N: usize> { diff --git a/rmk/src/lighting/mod.rs b/rmk/src/lighting/mod.rs index f91173b4e..fb09a46a1 100644 --- a/rmk/src/lighting/mod.rs +++ b/rmk/src/lighting/mod.rs @@ -43,12 +43,12 @@ pub use source::{ OutputMode, OutputModeIndicator, OverlayError, OverlayUpdate, PoweredOnlyScope, SceneCell, SparseScene, TtlOverlay, }; pub use standard::{ - BackgroundMode, BackgroundPatch, BackgroundState, CompiledScenePage, EmptySource, OVERLAY_CHUNK_SIZE, OverlayBatch, - OverlayCell, OverlayPage, ReplicaSlotError, RuntimeConditionalSceneCell, RuntimeConditionalSceneChunk, - RuntimeConditionalScenePage, RuntimeConditionalSceneTable, SCENE_CHUNK_SIZE, SCENE_TRANSACTION_TIMEOUT_MS, - SceneChunk, ScenePage, SceneTable, SceneTableCell, StandardCommand, StandardError, StandardInput, - StandardLightingEngine, StandardMutableState, StandardReplicaSlot, StandardReplicaState, StandardReply, - StandardState, UniformBackground, + BackgroundMode, BackgroundPatch, BackgroundState, CompiledScenePage, EmptySource, FRAME_CHUNK_SIZE, FramePage, + OVERLAY_CHUNK_SIZE, OverlayBatch, OverlayCell, OverlayPage, PresentedFrame, ReplicaSlotError, + RuntimeConditionalSceneCell, RuntimeConditionalSceneChunk, RuntimeConditionalScenePage, + RuntimeConditionalSceneTable, SCENE_CHUNK_SIZE, SCENE_TRANSACTION_TIMEOUT_MS, SceneChunk, ScenePage, SceneTable, + SceneTableCell, StandardCommand, StandardError, StandardInput, StandardLightingEngine, StandardMutableState, + StandardReplicaSlot, StandardReplicaState, StandardReply, StandardState, UniformBackground, }; pub use topology::*; diff --git a/rmk/src/lighting/standard/command.rs b/rmk/src/lighting/standard/command.rs index 589b1b89c..e809cb7ee 100644 --- a/rmk/src/lighting/standard/command.rs +++ b/rmk/src/lighting/standard/command.rs @@ -6,6 +6,7 @@ use embassy_sync::blocking_mutex::Mutex as BlockingMutex; #[allow(unused_imports)] use super::*; use crate::RawMutex; +use crate::lighting::Rgb8; use crate::lighting::compositor::{ ExtensionDescriptor, ExtensionLayerState, ExtensionParamSpec, ExtensionState, LightingSource, RenderError, }; @@ -109,6 +110,11 @@ pub enum StandardCommand { ReadOverlay { offset: u16, }, + /// One page of the last frame the output presented. Paged because a whole + /// frame is `N` RGB triples, far past any single reply's size budget. + ReadFrame { + offset: u16, + }, SetSceneCellIfRevision { expected_revision: u32, cell: SceneTableCell, @@ -189,6 +195,53 @@ pub struct StandardState { pub scene_len: usize, pub scene_policy: LayerPolicy, pub runtime_conditional_scene_len: usize, + /// What the LEDs are currently showing. `None` until the output accepts + /// its first frame. + pub presented: Option, +} + +/// Provenance of one frame the output presented: the engine revision it was +/// rendered at and the lighting context it was rendered from. +/// +/// Recorded at render time and promoted when the output acknowledges the +/// write, so it describes what the LEDs show rather than what the engine now +/// holds. On a split renderer replica the context is the *replicated* one, so +/// comparing it against the authority's is how a stale replica becomes +/// visible instead of merely suspected. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct PresentedFrame { + pub revision: u32, + pub context: LightingContext, +} + +/// Frame cells carried by one [`FramePage`]. Kept equal to the wire chunk +/// size so protocol adapters forward pages without re-batching. +pub const FRAME_CHUNK_SIZE: usize = 24; + +/// One page of the last presented frame's logical slots. +/// +/// Slot order is the compositor's, which is meaningful only against a +/// validated topology; routing readback maps each slot to its node, output, +/// and physical index. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct FramePage { + /// Engine revision the presented frame was rendered at. `None` before the + /// output has presented anything, when `cells` is still the fill color. + pub revision: Option, + /// Slots in the engine's logical frame. + pub total: u16, + /// Index of `cells[0]` within that frame. + pub start: u16, + /// Populated entries of `cells`; the rest are padding. + pub len: u8, + pub cells: [Rgb8; FRAME_CHUNK_SIZE], +} + +impl FramePage { + /// The populated cells, in slot order from [`Self::start`]. + pub fn cells(&self) -> &[Rgb8] { + &self.cells[..(self.len as usize).min(FRAME_CHUNK_SIZE)] + } } /// One page of transient overlay cells with remaining TTLs. @@ -355,6 +408,7 @@ pub struct RuntimeConditionalScenePage { pub enum StandardReply { State(StandardState), OverlayPage(OverlayPage), + FramePage(FramePage), ScenesPage(ScenePage), CompiledScenesPage(CompiledScenePage), SceneTransaction { id: u32, cell_count: u16 }, diff --git a/rmk/src/lighting/standard/engine.rs b/rmk/src/lighting/standard/engine.rs index c13d1fa9f..402c3cc19 100644 --- a/rmk/src/lighting/standard/engine.rs +++ b/rmk/src/lighting/standard/engine.rs @@ -6,7 +6,7 @@ use rmk_types::action::LightAction; use super::*; use crate::lighting::Rgb8; use crate::lighting::compositor::{Compositor, LightingSource, LogicalFrame}; -use crate::lighting::context::LightingContextProvider; +use crate::lighting::context::{LightingContext, LightingContextProvider}; use crate::lighting::effect::BuiltinEffect; use crate::lighting::output::BrightnessTransform; use crate::lighting::service::{CommandResult, Invalidation, LightingEngine, RenderInput, RenderOutcome}; @@ -91,6 +91,12 @@ pub struct StandardLightingEngine< wake_active: bool, effective_output_enabled: bool, output_brightness: u8, + /// Provenance of the frame currently on the LEDs. + presented: Option, + /// Provenance of the most recent render, promoted to `presented` once the + /// output acknowledges the write. A render that never reaches the driver + /// therefore never claims to be on the LEDs. + rendered: PresentedFrame, } impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize, const SCENE_CAP: usize> @@ -128,6 +134,11 @@ impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize, const wake_active: false, effective_output_enabled: true, output_brightness: u8::MAX, + presented: None, + rendered: PresentedFrame { + revision: 0, + context: LightingContext::default(), + }, } } @@ -177,6 +188,7 @@ impl<'scenes, Extension, Status, const N: usize, const OVERLAY_CAP: usize, const scene_len: self.scenes.len(), scene_policy: self.scenes.policy(), runtime_conditional_scene_len: self.runtime_conditional_scenes.len(), + presented: self.presented, } } @@ -734,6 +746,19 @@ where }, )); } + StandardCommand::ReadFrame { offset } => { + let start = (offset as usize).min(N); + let end = (start + FRAME_CHUNK_SIZE).min(N); + let mut cells = [Rgb8::BLACK; FRAME_CHUNK_SIZE]; + cells[..end - start].copy_from_slice(&self.compositor.committed().as_slice()[start..end]); + return Ok(CommandResult::unchanged(StandardReply::FramePage(FramePage { + revision: self.presented.map(|presented| presented.revision), + total: N.min(u16::MAX as usize) as u16, + start: start as u16, + len: (end - start) as u8, + cells, + }))); + } StandardCommand::ReadScenes { offset } => { return Ok(CommandResult::unchanged(StandardReply::ScenesPage(ScenePage { revision: self.revision, @@ -1193,6 +1218,7 @@ where } StandardCommand::ReadState => (Invalidation::None, false), StandardCommand::ReadOverlay { .. } + | StandardCommand::ReadFrame { .. } | StandardCommand::ReadScenes { .. } | StandardCommand::ReadCompiledScenes { .. } | StandardCommand::ReadRuntimeConditionalScenes { .. } @@ -1241,6 +1267,10 @@ where self.effective_output_enabled = effective_output_enabled; state_changed = true; } + self.rendered = PresentedFrame { + revision: self.revision, + context: *context, + }; let indicator_cell = self.controls.output_mode_indicator.map(|indicator| SceneCell { slot: indicator.slot, effect: indicator.effect(self.output_mode), @@ -1277,6 +1307,8 @@ where runtime_conditional_scene_expired_transaction: _, runtime_conditional_scene_committed_transaction: _, revision: _, + presented: _, + rendered: _, } = self; let mut transaction = compositor.begin(effect_now_ms, input.snapshot, Rgb8::BLACK, frame); transaction.apply(priority::BACKGROUND, background)?; @@ -1321,6 +1353,7 @@ where fn on_presented(&mut self, frame: &Self::Frame) { self.compositor.commit(frame); + self.presented = Some(self.rendered); } } diff --git a/rmk/src/lighting/standard/mod.rs b/rmk/src/lighting/standard/mod.rs index 6833ab5fb..94662da15 100644 --- a/rmk/src/lighting/standard/mod.rs +++ b/rmk/src/lighting/standard/mod.rs @@ -26,6 +26,11 @@ const _: () = core::assert!( "engine conditional page size must equal the wire chunk size" ); +const _: () = core::assert!( + command::FRAME_CHUNK_SIZE == rmk_types::protocol::rynk::LIGHTING_FRAME_CHUNK_SIZE, + "engine frame page size must equal the wire chunk size" +); + /// A staged scene replacement expires after this much command inactivity. pub const SCENE_TRANSACTION_TIMEOUT_MS: u64 = 5_000; diff --git a/rmk/src/lighting/standard/tests.rs b/rmk/src/lighting/standard/tests.rs index 818102374..d53d1b741 100644 --- a/rmk/src/lighting/standard/tests.rs +++ b/rmk/src/lighting/standard/tests.rs @@ -919,6 +919,109 @@ fn disabling_background_does_not_disable_layer_or_status_sources() { assert_eq!(frame.as_slice(), &[RED, GREEN]); } +#[test] +fn read_frame_reports_the_presented_frame_and_its_provenance() { + let mut engine = engine(); + let snapshot = context(1); + + // Nothing presented yet: the page is the fill color with no provenance. + let StandardReply::FramePage(page) = engine + .handle_command(0, StandardCommand::ReadFrame { offset: 0 }, &snapshot) + .unwrap() + .reply + else { + panic!("ReadFrame answers with a frame page"); + }; + assert_eq!(page.revision, None); + assert_eq!((page.total, page.start), (2, 0)); + assert_eq!(page.cells(), &[Rgb8::BLACK, Rgb8::BLACK]); + assert_eq!(engine.state().presented, None); + + let mut frame = LogicalFrame::new(Rgb8::BLACK); + engine + .render( + RenderInput { + now_ms: 0, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + let rendered_revision = engine.state().revision; + + // A render the output never acknowledged is not on the LEDs yet. + let StandardReply::FramePage(page) = engine + .handle_command(0, StandardCommand::ReadFrame { offset: 0 }, &snapshot) + .unwrap() + .reply + else { + panic!("ReadFrame answers with a frame page"); + }; + assert_eq!(page.revision, None); + assert_eq!(page.cells(), &[Rgb8::BLACK, Rgb8::BLACK]); + + >::on_presented(&mut engine, &frame); + let StandardReply::FramePage(page) = engine + .handle_command(0, StandardCommand::ReadFrame { offset: 0 }, &snapshot) + .unwrap() + .reply + else { + panic!("ReadFrame answers with a frame page"); + }; + assert_eq!(page.revision, Some(rendered_revision)); + assert_eq!(page.cells(), frame.as_slice()); + assert_eq!( + engine.state().presented, + Some(PresentedFrame { + revision: rendered_revision, + context: snapshot, + }), + ); + + // Offsets past the frame are empty rather than an error, so a host can + // page to exhaustion without knowing the chunk size. + let StandardReply::FramePage(page) = engine + .handle_command(0, StandardCommand::ReadFrame { offset: 9 }, &snapshot) + .unwrap() + .reply + else { + panic!("ReadFrame answers with a frame page"); + }; + assert_eq!((page.start, page.len), (2, 0)); + assert!(page.cells().is_empty()); +} + +/// Brightness is an `OutputTransform`, so the committed frame is what the +/// driver was handed. Dimming the output must therefore change the readback. +#[test] +fn presented_frame_readback_carries_the_brightness_transform() { + let mut engine = engine(); + let snapshot = context(1); + engine + .handle_command(0, StandardCommand::SetOutputBrightness(128), &snapshot) + .unwrap(); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + engine + .render( + RenderInput { + now_ms: 0, + snapshot: &snapshot, + }, + &mut frame, + ) + .unwrap(); + >::on_presented(&mut engine, &frame); + + let StandardReply::FramePage(page) = engine + .handle_command(0, StandardCommand::ReadFrame { offset: 0 }, &snapshot) + .unwrap() + .reply + else { + panic!("ReadFrame answers with a frame page"); + }; + assert_eq!(page.cells(), &[Rgb8::new(100, 0, 0), Rgb8::new(5, 5, 5)]); +} + #[test] fn standard_engine_composes_background_layer_and_expiring_overlay() { let mut engine = engine(); diff --git a/rmk/tests/integration/rynk_lighting.rs b/rmk/tests/integration/rynk_lighting.rs index e08cdb653..11f4fa2e1 100644 --- a/rmk/tests/integration/rynk_lighting.rs +++ b/rmk/tests/integration/rynk_lighting.rs @@ -12,9 +12,16 @@ use embassy_sync::blocking_mutex::raw::NoopRawMutex; use embassy_sync::pipe::Pipe; use rmk::config::{BehaviorConfig, PositionalConfig, RmkConfig}; use rmk::event::publish_event; -use rmk::host::{HostService as RynkService, RynkLightingController, RynkLightingDescriptor, RynkLightingMailbox}; +use rmk::host::{ + HostService as RynkService, LightingReplicationStatus, PeripheralReplicaStatus, RemoteFrame, RemoteFramePort, + ReplicaDigests, ReplicationHealth, ReplicationMachineState, RynkLightingController, RynkLightingDescriptor, + RynkLightingMailbox, +}; use rmk::keymap::{KeyMap, KeymapData}; -use rmk::lighting::{LedId, LedMetadata, LightingRouting, LightingTopology, MatrixSize, ZoneSpan}; +use rmk::lighting::{ + LedId, LedMetadata, LightingRouting, LightingTopology, MatrixSize, OutputCapabilities, OutputCoverage, OutputId, + OutputMetadata, ZoneSpan, +}; use rmk::physical_layout::{Coordinate, KeyPosition, KeySize, PhysicalKey, PhysicalLayout, Point3, Rotation}; use rmk::test_support::test_block_on; use rmk::types::action::KeyAction; @@ -454,3 +461,264 @@ fn extension_endpoints_are_unsupported_until_a_board_advertises_them() { assert_eq!(unsupported, Err(LightingError::Unsupported)); }); } + +/// The right half's frame and the replication handshake reach a host over the +/// same loopback, so a stale replica is observable rather than inferred. +/// +/// Node 1 has no engine here — the split round trip is the board's, and RMK +/// only owns the rendezvous — so a fake responder stands in for it. +struct FakeReplication; + +static REPLICATION_REFRESHES: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); + +const FAKE_DIGESTS: ReplicaDigests = ReplicaDigests { + schema: 1, + revision: 4, + settings: 11, + overlay: 12, + scenes: 13, + conditional_scenes: 14, +}; + +impl LightingReplicationStatus for FakeReplication { + fn request_refresh(&self) { + REPLICATION_REFRESHES.fetch_add(1, core::sync::atomic::Ordering::Relaxed); + } + + fn central(&self) -> ReplicationMachineState { + ReplicationMachineState { + last_acked_revision: Some(4), + awaiting_ack: true, + generation: 2, + link_up: true, + durable_dirty: true, + context_dirty: false, + health: ReplicationHealth::Resynchronizing, + expected_digests: Some(FAKE_DIGESTS), + last_attested_age_ms: Some(125), + mismatch_count: 1, + } + } + + fn peripheral(&self, node: rmk::lighting::LightingNodeId) -> Option { + (node == rmk::lighting::LightingNodeId(1)).then_some(PeripheralReplicaStatus { + applied_revision: Some(4), + engine_revision: 11, + layers: rmk::lighting::LayerState::new(3, 1, 0b1010), + powered: false, + wake_active: true, + effective_output_enabled: false, + age_ms: 250, + digests: Some(FAKE_DIGESTS), + }) + } +} + +static REPLICATION: FakeReplication = FakeReplication; +static REMOTE_FRAMES: RemoteFramePort = RemoteFramePort::new(); +static OBSERVABILITY_OUTPUTS: [OutputMetadata; 2] = [ + OutputMetadata { + node: rmk::lighting::LightingNodeId(0), + id: OutputId(0), + pixel_count: 1, + capabilities: OutputCapabilities::RGB, + coverage: OutputCoverage::Complete, + }, + OutputMetadata { + node: rmk::lighting::LightingNodeId(1), + id: OutputId(0), + pixel_count: 1, + capabilities: OutputCapabilities::RGB, + coverage: OutputCoverage::Complete, + }, +]; + +#[test] +fn lighting_observability_endpoints_cross_the_full_loopback() { + use rmk::host::StandardRynkLightingAdapter; + use rmk::lighting::service::RenderInput; + use rmk::lighting::{ + BackgroundState, EmptySource, FRAME_CHUNK_SIZE, FramePage, LayerPolicy, LayerScenes, LightingContext, + LightingEngine, LightingMailbox, LogicalFrame, Rgb8, StandardCommand, StandardError, StandardLightingEngine, + StandardReply, + }; + use rmk_types::protocol::rynk::{ + LightingFramePageResult, LightingFrameRequest, LightingNodeId, LightingReplicaStatusResult, + LightingReplicationHealth, LightingRgb8, + }; + + REPLICATION_REFRESHES.store(0, core::sync::atomic::Ordering::Relaxed); + let descriptor = RynkLightingDescriptor { + routing: LightingRouting { + outputs: &OBSERVABILITY_OUTPUTS, + routes: &[], + }, + ..descriptor() + }; + let mailbox: &'static RynkLightingMailbox = Box::leak(Box::new(RynkLightingMailbox::new())); + let service = service_with( + RynkLightingController::new(mailbox, descriptor, 8) + .with_remote_frames(&REMOTE_FRAMES) + .with_replication_status(&REPLICATION), + ); + + let core = LightingMailbox::, StandardReply, StandardError, 1>::new(); + let mut adapter = StandardRynkLightingAdapter::<2, 1>::new(mailbox, &core, descriptor.topology); + let mut engine: StandardLightingEngine<'static, EmptySource, EmptySource, 1, 2, 0> = StandardLightingEngine::new( + BackgroundState { + value: 40, + ..BackgroundState::default() + }, + LayerScenes { + scenes: &[], + policy: LayerPolicy::EffectiveOnly, + }, + EmptySource, + EmptySource, + ); + + // Present one frame up front: the endpoint reports what the output + // accepted, so without this there is nothing on the LEDs to report. + let context = LightingContext::default(); + let mut frame = LogicalFrame::new(Rgb8::BLACK); + engine + .render( + RenderInput { + now_ms: 0, + snapshot: &context, + }, + &mut frame, + ) + .expect("render the initial frame"); + as LightingEngine>:: + on_presented(&mut engine, &frame); + + let background = async { + let adapter_loop = async { + loop { + adapter.process_next().await; + } + }; + let engine_loop = async { + loop { + let (id, command) = core.receive_request().await; + let result = engine.handle_command(0, command, &context).map(|outcome| outcome.reply); + core.publish_reply(id, result); + } + }; + // Stand-in for the board's split round trip to the right half. + let remote_loop = async { + loop { + let request = REMOTE_FRAMES.receive().await; + let mut cells = [Rgb8::BLACK; FRAME_CHUNK_SIZE]; + cells[0] = Rgb8::new(1, 2, 3); + REMOTE_FRAMES.reply( + request.id, + Some(RemoteFrame { + page: FramePage { + revision: Some(11), + total: 1, + start: request.offset, + len: 1, + cells, + }, + age_ms: 250, + }), + ); + } + }; + select(select(adapter_loop, engine_loop), remote_loop).await; + }; + + loopback(&service, background, async |host| { + let local = host + .request::<_, LightingFramePageResult>( + Cmd::GetLightingFrame, + 0x7B, + &LightingFrameRequest { + node: LightingNodeId(0), + offset: 0, + }, + ) + .await + .expect("outer local frame envelope") + .expect("local frame page"); + assert_eq!(local.node, LightingNodeId(0)); + assert_eq!(local.revision, Some(0)); + assert_eq!((local.total_leds, local.start), (1, 0)); + assert_eq!(local.age_ms, 0, "the local half has no round trip to age"); + assert_eq!(local.cells.as_slice(), &[LightingRgb8 { r: 40, g: 40, b: 40 }]); + + let remote = host + .request::<_, LightingFramePageResult>( + Cmd::GetLightingFrame, + 0x7C, + &LightingFrameRequest { + node: LightingNodeId(1), + offset: 0, + }, + ) + .await + .expect("outer remote frame envelope") + .expect("remote frame page"); + assert_eq!(remote.node, LightingNodeId(1)); + assert_eq!(remote.revision, Some(11)); + assert_eq!(remote.age_ms, 250); + assert_eq!(remote.cells.as_slice(), &[LightingRgb8 { r: 1, g: 2, b: 3 }]); + + // A node the board never routed is rejected without a round trip. + let unknown = host + .request::<_, LightingFramePageResult>( + Cmd::GetLightingFrame, + 0x7D, + &LightingFrameRequest { + node: LightingNodeId(7), + offset: 0, + }, + ) + .await + .expect("outer unknown-node envelope"); + assert_eq!( + unknown, + Err(LightingError::UnknownNode { + node: LightingNodeId(7) + }) + ); + + let status = host + .request::<(), LightingReplicaStatusResult>(Cmd::GetLightingReplicaStatus, 0x6F, &()) + .await + .expect("outer replica-status envelope") + .expect("replica status"); + assert_eq!(REPLICATION_REFRESHES.load(core::sync::atomic::Ordering::Relaxed), 1,); + assert_eq!(status.central.revision, 0); + assert_eq!(status.central.presented_revision, Some(0)); + assert!(!status.central.wake_active); + assert!(status.central.effective_output_enabled); + let replication = status.replication.expect("the board wired a replication machine"); + assert_eq!(replication.last_acked_revision, Some(4)); + assert!(replication.awaiting_ack && replication.link_up); + assert_eq!(replication.generation, 2); + assert!(replication.durable_dirty && !replication.context_dirty); + assert_eq!(replication.health, LightingReplicationHealth::Resynchronizing); + assert_eq!(replication.last_attested_age_ms, Some(125)); + assert_eq!(replication.mismatch_count, 1); + let expected = replication.expected_digests.expect("central digest set"); + assert_eq!((expected.settings, expected.overlay), (11, 12)); + let peripheral = status.peripheral.expect("the board heard from the peripheral"); + assert_eq!(peripheral.node, LightingNodeId(1)); + assert_eq!(peripheral.applied_revision, Some(4)); + assert_eq!(peripheral.engine_revision, 11); + assert_eq!( + ( + peripheral.effective_layer, + peripheral.default_layer, + peripheral.active_bits + ), + (3, 1, 0b1010), + ); + assert!(peripheral.wake_active && !peripheral.effective_output_enabled); + assert_eq!(peripheral.age_ms, 250); + assert_eq!(peripheral.digests.expect("peripheral digest set").revision, 4); + }); +} diff --git a/rynk/src/api.rs b/rynk/src/api.rs index 869125591..123fdaacb 100644 --- a/rynk/src/api.rs +++ b/rynk/src/api.rs @@ -32,20 +32,21 @@ use rmk_types::protocol::rynk::{ LightingCapabilities, LightingCompiledSceneStatus, LightingCompiledScenesPage, LightingConditionalSceneStatus, LightingConditionalScenesPage, LightingExtendedRuntimeConditionalScenesPage, LightingExtension, LightingExtensionLayers, LightingExtensionNameKind, LightingExtensionNamesPage, LightingExtensionNamesRequest, - LightingExtensionParamsPage, LightingExtensionParamsRequest, LightingKeysPage, LightingLedsPage, - LightingOutputModeState, LightingOutputsPage, LightingOverlayPage, LightingOverlayPageRequest, - LightingOverlayTransaction, LightingPageRequest, LightingPhysicalKeysPage, LightingResult, LightingRoutesPage, - LightingRuntimeConditionalScenePageRequest, LightingRuntimeConditionalSceneStatus, - LightingRuntimeConditionalSceneTransaction, LightingRuntimeConditionalScenesPage, LightingScenePageRequest, - LightingSceneStatus, LightingSceneTransaction, LightingScenesPage, LightingState, LightingZoneMembershipsPage, - LightingZonesPage, LockStatus, MacroData, MatrixState, PeripheralStatus, ProtocolVersion, - PutLightingExtendedRuntimeConditionalSceneChunkRequest, PutLightingOverlayChunkRequest, - PutLightingRuntimeConditionalSceneChunkRequest, PutLightingSceneChunkRequest, SetComboBulkRequest, SetComboRequest, - SetEncoderRequest, SetForkRequest, SetKeyRequest, SetKeymapBulkRequest, SetLightingExtensionLayersRequest, - SetLightingExtensionParamRequest, SetLightingExtensionStateRequest, SetLightingLayerPolicyRequest, - SetLightingOutputModeRequest, SetLightingOverlayRequest, SetLightingSceneCellRequest, SetLightingStateRequest, - SetMacroRequest, SetMorseBulkRequest, SetMorseRequest, SplitCentralLatencyPolicy, SplitCentralLatencyState, - StorageResetMode, UnsetLightingOverlayRequest, UnsetLightingSceneCellRequest, command, + LightingExtensionParamsPage, LightingExtensionParamsRequest, LightingFramePage, LightingFrameRequest, + LightingKeysPage, LightingLedsPage, LightingOutputModeState, LightingOutputsPage, LightingOverlayPage, + LightingOverlayPageRequest, LightingOverlayTransaction, LightingPageRequest, LightingPhysicalKeysPage, + LightingReplicaStatus, LightingResult, LightingRoutesPage, LightingRuntimeConditionalScenePageRequest, + LightingRuntimeConditionalSceneStatus, LightingRuntimeConditionalSceneTransaction, + LightingRuntimeConditionalScenesPage, LightingScenePageRequest, LightingSceneStatus, LightingSceneTransaction, + LightingScenesPage, LightingState, LightingZoneMembershipsPage, LightingZonesPage, LockStatus, MacroData, + MatrixState, PeripheralStatus, ProtocolVersion, PutLightingExtendedRuntimeConditionalSceneChunkRequest, + PutLightingOverlayChunkRequest, PutLightingRuntimeConditionalSceneChunkRequest, PutLightingSceneChunkRequest, + SetComboBulkRequest, SetComboRequest, SetEncoderRequest, SetForkRequest, SetKeyRequest, SetKeymapBulkRequest, + SetLightingExtensionLayersRequest, SetLightingExtensionParamRequest, SetLightingExtensionStateRequest, + SetLightingLayerPolicyRequest, SetLightingOutputModeRequest, SetLightingOverlayRequest, + SetLightingSceneCellRequest, SetLightingStateRequest, SetMacroRequest, SetMorseBulkRequest, SetMorseRequest, + SplitCentralLatencyPolicy, SplitCentralLatencyState, StorageResetMode, UnsetLightingOverlayRequest, + UnsetLightingSceneCellRequest, command, }; #[cfg(feature = "alloc")] use rmk_types::protocol::rynk::{RYNK_HEADER_SIZE, RynkError, max_wire_size}; @@ -438,6 +439,18 @@ impl Client { Self::flatten_lighting(self.request::(&()).await?) } + /// Read one page of the frame a lighting output most recently accepted. + pub async fn get_lighting_frame(&self, request: LightingFrameRequest) -> Result { + self.require_lighting(Cmd::GetLightingFrame)?; + Self::flatten_lighting(self.request::(&request).await?) + } + + /// Read the board's cached view of split lighting replication. + pub async fn get_lighting_replica_status(&self) -> Result { + self.require_lighting(Cmd::GetLightingReplicaStatus)?; + Self::flatten_lighting(self.request::(&()).await?) + } + pub async fn set_lighting_output_mode( &self, request: SetLightingOutputModeRequest,