Skip to content

feat(lighting): runtime-configurable per-layer scenes over Rynk - #5

Merged
colonelpanic8 merged 3 commits into
glove80-rmk/lightingfrom
glove80-rmk/scene-lighting
Jul 21, 2026
Merged

feat(lighting): runtime-configurable per-layer scenes over Rynk#5
colonelpanic8 merged 3 commits into
glove80-rmk/lightingfrom
glove80-rmk/scene-lighting

Conversation

@colonelpanic8

@colonelpanic8 colonelpanic8 commented Jul 21, 2026

Copy link
Copy Markdown
Owner

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:

Cmd Endpoint Shape
0x0912 GetLightingSceneStatus (){ revision, capacity, scene_len, policy, chunk_capacity }
0x0913 GetLightingScenes { revision, offset }{ revision, total_count, items }
0x0914 SetLightingSceneCell { expected_revision, cell }LightingState
0x0915 UnsetLightingSceneCell { expected_revision, layer, led_id }LightingState
0x0916 BeginLightingSceneReplace { expected_revision, cell_count }{ id, cell_count }
0x0917 PutLightingSceneChunk { transaction_id, offset, cells[≤8] }()
0x0918 CommitLightingSceneReplace { transaction_id }LightingState
0x0919 AbortLightingSceneReplace { transaction_id }()
0x091A SetLightingLayerPolicy { expected_revision, policy }LightingState

with LightingSceneCell { layer: u8, led_id: LightingLedId, effect: LightingEffect } and LightingLayerPolicy { EffectiveOnly | ActiveStack } mirroring the engine policy. All mutations use the same optimistic-concurrency handshake against LightingState.revision as 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_cells question): postcard is positional and non-self-describing, so appending fields to LightingCapabilities/LightingState would 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 new LAYER_SCENES bit in the existing LightingFeatureFlags field (bitflag additions don't change encoding) plus GetLightingSceneStatus, whose capacity field plays the max_scene_cells role — 0/Unsupported means the feature is absent. New LightingError variants (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

  • The engine owns the scene table (SceneTable<SCENE_CAP>, a new const generic on StandardLightingEngine, default 0; 256 is the suggested board value). Cells are unique per (layer, slot); policy semantics (EffectiveOnly/ActiveStack with sparse fall-through) match the static LayerScenes source exactly, and both are covered by engine tests.
  • Composition: runtime scenes apply in the layer priority band immediately after the static board scenes — a host-configured cell overrides a board default for the same slot — and below the TTL overlay, which stays highest-priority. Background/extension/status bands are untouched.
  • Validation: the service checks effects (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.
  • Replace transaction — one deliberate deviation from copy-pasting the overlay machinery: the overlay transaction stages host-side because a whole overlay batch (≤64 cells) fits one mailbox command. A scene table is 4× larger and mailbox payloads are copied by value into bounded channels, so a host-side stage would cost kilobytes of static RAM per channel slot. The scene transaction instead stages inside the engine via chunk-sized commands (Begin/Put/Commit/Abort map 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.
  • Scene mutations advance the lighting revision and surface through the existing coalesced LightingChange topic; boards opt in via RynkLightingController::with_scene_capacity(...), and scene writes honor the same write_requires_unlock gate 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 via Storage::read_lighting_scenes + install_lighting_scenes, which skips cells whose LED id no longer resolves against the current topology; StorageReset clears everything as usual. Gated on storage like the rest of durable config (a rynk,lighting build 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:

  • StandardReplicaState carries the runtime SceneTable alongside 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). ExportReplica snapshots the table atomically with everything else; ApplyReplica installs it wholesale.
  • That makes the snapshot's size scene-dependent, so StandardReplicaState, StandardReplicaSlot, and StandardCommand gain a trailing SCENE_CAP const generic (default 0 — every existing single-parameter use keeps compiling and pairs with a scene-less engine). The engine's type Command is now StandardCommand<OVERLAY_CAP, SCENE_CAP>, and StandardRynkLightingAdapter grew the matching trailing parameter so its mailbox type-checks against a scene-capable engine.
  • SceneTable gained a manual PartialEq/Eq (cells past len are stale storage, not state) so snapshots stay comparable.
  • The engine replica round-trip test now sets a runtime scene cell on the authority and asserts the replica renders a bit-identical frame from the snapshot alone.

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::Client gains typed methods for all nine endpoints plus two alloc conveniences: read_all_lighting_scenes (pages under one pinned revision, restarts on concurrent-mutation conflict) and replace_all_lighting_scenes (drives begin/put/commit with best-effort abort on failure). rynk-wasm mirrors the surface; the generated .d.ts picks up all scene types and typechecks under tsc --strict.

Tests & verification

  • cargo nextest run (rmk): 689 passed with rynk,storage,lighting, 684 with rynk,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,lighting and rynk,storage,lighting are added to the CI feature matrix (they were previously untested).
  • cargo test (rynk host workspace): all green; cargo clippy --workspace --lib --tests --examples -- -D warnings clean.
  • 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.
  • Wasm: cargo check for rynk/rynk-wasm on wasm32-unknown-unknown, wasm-pack build --dev --target web, and tsc --strict over the generated rynk_wasm.d.ts — all pass.
  • cargo clippy -D warnings for rynk,lighting / rynk,storage,lighting / rynk,storage / vial,host_lock,storage: clean. (Pre-existing, unrelated: rynk,_ble,split,storage,async_matrix fails clippy on current stable in split/peripheral.rs collapsible_match — reproduced on the base branch unchanged.)
  • scripts/format_all.sh applied.

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:

  • StandardLightingEngine has a new trailing SCENE_CAP const generic (default 0, suggest 256 for Glove80) and its mailbox reply type changed from StandardState to StandardReply — board LightingMailbox type aliases must update. StandardCommand, StandardReplicaState, and StandardReplicaSlot carry the same trailing SCENE_CAP parameter (default 0), so a board using the split replica path must thread its scene capacity through its replica slot statics and mailbox alias too.
  • Opt in with RynkLightingController::new(...).with_scene_capacity(ENGINE::scene_capacity() as u16).
  • At boot (before spawning tasks): storage.read_lighting_scenes::<CAP>(&mut cells).await then install_lighting_scenes(&mut engine, &topology, &cells, policy).
  • LightingMailbox::receive_request/publish_reply are now public if the board drives the engine with a custom executor.
  • Protocol version is v0.3; Rynkbench gates on LightingFeatureFlags::LAYER_SCENES + GetLightingSceneStatus.capacity > 0.

🤖 Generated with Claude Code

colonelpanic8 and others added 3 commits July 20, 2026 21:33
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
colonelpanic8 force-pushed the glove80-rmk/scene-lighting branch from f089143 to c7c090c Compare July 21, 2026 04:46
@colonelpanic8
colonelpanic8 merged commit c7c090c into glove80-rmk/lighting Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant