feat(lighting): runtime-configurable per-layer scenes over Rynk - #5
Merged
Merged
Conversation
Add the protocol types for runtime-configurable per-layer scenes: LightingSceneCell (layer + stable LED id + effect), LightingLayerPolicy, a revision-pinned scenes page, single-cell set/unset requests, an atomic Begin/Put/Commit/Abort replacement transaction, and SetLightingLayerPolicy. Discovery deliberately leaves LightingCapabilities and LightingState byte-identical: postcard is positional, so appending fields would break new-host/old-firmware decode. Scenes are advertised through a new LAYER_SCENES bit in the existing LightingFeatureFlags plus a dedicated GetLightingSceneStatus endpoint carrying capacity, occupancy, and policy. New LightingError variants (UnknownLayer, SceneFull) are appended, so existing encodings are unchanged. Protocol version bumps to v0.3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Give the standard engine an owned, fixed-capacity SceneTable (new SCENE_CAP const generic, default 0) holding per-layer, per-slot effects with the same EffectiveOnly/ActiveStack composition semantics as the static LayerScenes. The table composes in the layer band immediately after the board's static scenes — a runtime cell overrides a static default for the same slot — while the TTL overlay stays highest-priority. Scene mutations are revision-checked commands; whole-table replacement stages inside the engine via bounded chunks (Begin/Put/Commit/Abort) with inactivity expiry and an idempotent commit. Staging engine-side instead of reusing the host-side overlay staging keeps kilobyte-sized scene batches off the bounded mailbox channels, whose payloads are copied by value. Scene reads and transaction reservation need non-state readback, so the engine's reply type becomes the StandardReply enum and StandardState gains scene_len/scene_policy. The Rynk service exposes the table through nine endpoints for status, revision-pinned paged reads, single-cell set/unset, layer policy, and the chunked replacement transaction. It validates effects, layer bounds (against the live keymap), and stable LED ids (against the topology) before anything reaches the engine; boards opt in by advertising a capacity via RynkLightingController::with_scene_capacity, and scene endpoints reject with Unsupported when none is wired. Scene writes honor the same unlock gate as the other lighting writes. Scene configuration is durable config like the keymap: after every successful mutation the adapter reads the table back out of the engine and persists it as one header record (len + policy) plus chunk-sized shard records in wire (stable LED id) form. The storage task compares before writing so unchanged shards cost no flash traffic; a storage reset clears them. Boards load persisted scenes at boot with Storage::read_lighting_scenes + install_lighting_scenes, which skips cells whose LED id no longer resolves. Lighting featuresets join the CI test/clippy matrix. Composed with the split renderer replicas underneath: the runtime scene table travels inside StandardReplicaState, so replica renderers draw runtime scenes exactly like the authority without ever seeing the incremental scene mutation commands. StandardReplicaState/-Slot and StandardCommand gain a trailing SCENE_CAP const generic (default 0), ExportReplica snapshots the table and ApplyReplica installs it, and the replica round-trip test now covers a runtime scene cell. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndings Typed client methods for every scene endpoint, plus two alloc conveniences: read_all_lighting_scenes pages the whole table under one pinned revision and restarts on a concurrent-mutation conflict, and replace_all_lighting_scenes drives the begin/put/commit transaction with a best-effort abort on staging failure. The wasm client re-exports the same surface, so the generated TypeScript picks up the scene types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
colonelpanic8
force-pushed
the
glove80-rmk/scene-lighting
branch
from
July 21, 2026 04:46
f089143 to
c7c090c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on the topology-aware lighting topic (
glove80-rmk/lighting, upstream rmk-rs#987). The engine there already understands layer-aware sparse scenes (SceneCell/LayerScenes/LayerPolicy), but only as static board configuration. This PR makes per-layer, per-LED lighting a runtime-configurable, durable device setting: a host (Rynkbench) can store "layer 2 paints these keys blue, the nav layer breathes on the arrows" on the keyboard itself, where it survives reboots and renders with no host attached.Wire surface (protocol v0.2 → v0.3)
New endpoints, mirroring the overlay patterns:
0x0912GetLightingSceneStatus()→{ revision, capacity, scene_len, policy, chunk_capacity }0x0913GetLightingScenes{ revision, offset }→{ revision, total_count, items }0x0914SetLightingSceneCell{ expected_revision, cell }→LightingState0x0915UnsetLightingSceneCell{ expected_revision, layer, led_id }→LightingState0x0916BeginLightingSceneReplace{ expected_revision, cell_count }→{ id, cell_count }0x0917PutLightingSceneChunk{ transaction_id, offset, cells[≤8] }→()0x0918CommitLightingSceneReplace{ transaction_id }→LightingState0x0919AbortLightingSceneReplace{ transaction_id }→()0x091ASetLightingLayerPolicy{ expected_revision, policy }→LightingStatewith
LightingSceneCell { layer: u8, led_id: LightingLedId, effect: LightingEffect }andLightingLayerPolicy { EffectiveOnly | ActiveStack }mirroring the engine policy. All mutations use the same optimistic-concurrency handshake againstLightingState.revisionas the overlay ops; scene page reads are revision-pinned the way topology pages are topology-revision-pinned, so a multi-page read is self-consistent or conflicts.Struct-evolution decision (the
max_scene_cellsquestion): postcard is positional and non-self-describing, so appending fields toLightingCapabilities/LightingStatewould make a new host fail to decode an old firmware's replies — precisely the pairing capability negotiation must survive. Existing structs therefore stay byte-identical (locked by the wire-frame snapshots). Discovery instead uses a newLAYER_SCENESbit in the existingLightingFeatureFlagsfield (bitflag additions don't change encoding) plusGetLightingSceneStatus, whosecapacityfield plays themax_scene_cellsrole —0/Unsupportedmeans the feature is absent. NewLightingErrorvariants (UnknownLayer,SceneFull) are appended after the existing ones and can only be triggered by the new endpoints, so old hosts never see them.Service and engine semantics
SceneTable<SCENE_CAP>, a new const generic onStandardLightingEngine, default0; 256 is the suggested board value). Cells are unique per(layer, slot); policy semantics (EffectiveOnly/ActiveStackwith sparse fall-through) match the staticLayerScenessource exactly, and both are covered by engine tests.LightingEffect::validate), layer bounds against the live keymap (UnknownLayer), and stable LED ids against the topology (UnknownLed) before anything reaches the engine; the engine additionally bounds slots against its frame.Begin/Put/Commit/Abortmap 1:1 to the wire), with identical semantics: single active transaction (TransactionBusy), strict chunk ordering and count (InvalidRequest/TransactionIncomplete), 5 s inactivity expiry (TransactionExpired), revision check enforced atomically at commit, and idempotent commit for retries over a lossy link.LightingChangetopic; boards opt in viaRynkLightingController::with_scene_capacity(...), and scene writes honor the samewrite_requires_unlockgate as the other lighting writes.Persistence
Scene configuration is durable config like the keymap — the repo rule against persisting live lighting frames applies to overlay/host frames, not to this. After every successful scene mutation the Rynk adapter reads the table back out of the engine (keeping the engine the single source of truth rather than mirroring its insertion algorithm) and persists it in wire form (stable LED ids) as one header record
{ len, policy }plus 8-cell shard records. The storage task compares before writing, so a single-cell edit rewrites only the shards that changed. Boot loads viaStorage::read_lighting_scenes+install_lighting_scenes, which skips cells whose LED id no longer resolves against the current topology;StorageResetclears everything as usual. Gated onstoragelike the rest of durable config (arynk,lightingbuild without storage compiles to a no-op persist).Split
Nothing added to the split protocol itself, but this PR is now rebased onto the base branch's split renderer replicas (
aac695ad), and the two features compose at the engine boundary rather than coexisting by accident:StandardReplicaStatecarries the runtimeSceneTablealongside the mutable state, overlay, context, and animation anchor. A replica renderer is a full standard engine re-rendering centrally-configured sources locally, so host-configured per-layer scenes must reach it declaratively — replicas never see the incremental scene mutation commands (those stop at the central's Rynk adapter).ExportReplicasnapshots the table atomically with everything else;ApplyReplicainstalls it wholesale.StandardReplicaState,StandardReplicaSlot, andStandardCommandgain a trailingSCENE_CAPconst generic (default0— every existing single-parameter use keeps compiling and pairs with a scene-less engine). The engine'stype Commandis nowStandardCommand<OVERLAY_CAP, SCENE_CAP>, andStandardRynkLightingAdaptergrew the matching trailing parameter so its mailbox type-checks against a scene-capable engine.SceneTablegained a manualPartialEq/Eq(cells pastlenare stale storage, not state) so snapshots stay comparable.Scene transactions stay authority-only: staging, expiry, and idempotent-commit bookkeeping is not part of the replica snapshot, because a replica is a renderer, not a second authority.
Host client / wasm
rynk::Clientgains typed methods for all nine endpoints plus two alloc conveniences:read_all_lighting_scenes(pages under one pinned revision, restarts on concurrent-mutation conflict) andreplace_all_lighting_scenes(drives begin/put/commit with best-effort abort on failure).rynk-wasmmirrors the surface; the generated.d.tspicks up all scene types and typechecks undertsc --strict.Tests & verification
cargo nextest run(rmk): 689 passed withrynk,storage,lighting, 684 withrynk,lighting, plus the existing sets""/storage/vial,host_lock,storage/split,vial,storage/rynk,storage/rynk,_ble,split,storage,async_matrix— all green. New coverage: engine scene CRUD/revision conflicts/policy switching/priority ordering/chunked transaction incl. expiry and idempotent commit, plus a full-stack service→adapter→engine test asserting handler-side validation, revision-pinned paging, atomic replace, and the exact records that reach the flash channel.rynk,lightingandrynk,storage,lightingare added to the CI feature matrix (they were previously untested).cargo test(rynk host workspace): all green;cargo clippy --workspace --lib --tests --examples -- -D warningsclean.rmk-types: all green incl. regenerated wire snapshots and the protocol-reference doc (UPDATE_SNAPSHOTS=1), new round-trip/max-size tests for every scene type.cargo checkforrynk/rynk-wasmonwasm32-unknown-unknown,wasm-pack build --dev --target web, andtsc --strictover the generatedrynk_wasm.d.ts— all pass.cargo clippy -D warningsforrynk,lighting/rynk,storage,lighting/rynk,storage/vial,host_lock,storage: clean. (Pre-existing, unrelated:rynk,_ble,split,storage,async_matrixfails clippy on current stable insplit/peripheral.rscollapsible_match— reproduced on the base branch unchanged.)scripts/format_all.shapplied.Non-goals / follow-up for the firmware superproject
Firmware adoption is intentionally out of scope here. The follow-up (glove80 firmware + pin bump + Rynkbench UI vendor regen) needs to know:
StandardLightingEnginehas a new trailingSCENE_CAPconst generic (default0, suggest256for Glove80) and its mailbox reply type changed fromStandardStatetoStandardReply— boardLightingMailboxtype aliases must update.StandardCommand,StandardReplicaState, andStandardReplicaSlotcarry the same trailingSCENE_CAPparameter (default0), so a board using the split replica path must thread its scene capacity through its replica slot statics and mailbox alias too.RynkLightingController::new(...).with_scene_capacity(ENGINE::scene_capacity() as u16).storage.read_lighting_scenes::<CAP>(&mut cells).awaittheninstall_lighting_scenes(&mut engine, &topology, &cells, policy).LightingMailbox::receive_request/publish_replyare now public if the board drives the engine with a custom executor.LightingFeatureFlags::LAYER_SCENES+GetLightingSceneStatus.capacity > 0.🤖 Generated with Claude Code