Skip to content

Add topology-aware lighting with Rynk control - #987

Draft
colonelpanic8 wants to merge 31 commits into
rmk-rs:feat/rynkfrom
colonelpanic8:glove80-rmk/lighting
Draft

Add topology-aware lighting with Rynk control#987
colonelpanic8 wants to merge 31 commits into
rmk-rs:feat/rynkfrom
colonelpanic8:glove80-rmk/lighting

Conversation

@colonelpanic8

Copy link
Copy Markdown
Contributor

What

  • add physical key topology and generated lighting configuration
  • add a composable lighting engine with selectors, sources, effects, layers, output mapping, and RMK state integration
  • add native Rynk lighting discovery, query, mutation, and topic support
  • add Vial RGB Matrix compatibility backed by the same authoritative lighting state
  • add protocol snapshots, loopback coverage, and focused unit tests

Why

RMK currently has no general model for per-key lighting that can span irregular and split layouts while keeping firmware, Rynk, and Vial state consistent. This implementation makes one lighting service authoritative, so changes made by either host protocol are reflected by later state queries and rendered output.

Dependency

This branch is stacked on #984 because split keyboards use the bounded application-message channel to propagate lighting state. Once #984 lands, this PR can be rebased to contain only the lighting commits.

This is the complete implementation that supersedes the smaller, closed abstraction draft in #982.

Checks

  • repository formatter
  • full RMK feature-matrix tests, including Rynk lighting loopback and Vial RGB Matrix tests
  • Rynk native tests and doctests
  • WASM target checks and generated package TypeScript validation
  • workspace and WASM clippy with warnings denied
  • physical Glove80 lighting and host-state round trips exercised on the composed integration branch

HaoboGu and others added 27 commits July 19, 2026 21:34
Replace the placeholder rmk protocol module with the Rynk wire format: command, endpoint, and topic tables, error and message types, layout and system payloads, plus wire snapshots. Add tsify-based TypeScript type generation for host reuse.
…[keymap.layer]]

Separate the physical key map from the keymap: drop the legacy matrix_map/keymap path and its codegen, and add the macro codegen for the new schema.
… handler

Route USB and BLE host access through one handler and replace the vial_lock feature with host_lock.
Includes the lock gate, device-info and layout-blob endpoints, and keymap bulk transfer across layer boundaries.
Generates the TypeScript type bindings consumed by the web demo.
… examples

Add rp2040_rynk and qemu-riscv-rynk, adapt example sources, and regenerate Cargo.lock files.
Signed-off-by: Haobo Gu <haobogu@outlook.com>
Store each tap-hold's timing profile as a u8 index into a small,
deduplicated `taphold_profiles` table rather than an inline 4-byte
`MorseProfile`. `KeyAction::TapHold` shrinks from 12 to 7 bytes, which
also drops the u32's 4-byte alignment padding across every
KeyAction-sized RAM buffer (keymap array, held-key buffer, combos,
forks, morse cache).

Measured on the nrf52840 example at a 5x14x5 (350-key) board:
.bss -2408 bytes, .text -4 bytes (no flash cost). Savings scale with
board size; the table cost (CAP*4, default CAP=16 -> 64 B) is fixed.

- keyboard.toml: named profiles are interned automatically by the
  proc-macro; users keep referencing them by name.
- pure-Rust: default macros (th!/mt!/lt!/tt!) are unchanged; the
  custom-profile macros (thp!/mtp!/ltp!/ttp!) now take a table index
  and the user populates `behavior.morse.taphold_profiles`.
- Table capacity is configurable via `[rmk] taphold_profile_max_num`.
- Vial wire format is unchanged (profile isn't carried); the stored
  KeyAction encoding changes but auto-migrates via the build-hash guard.

Regenerated the rmk_protocol keymap endpoint snapshots (KeyAction
schema change propagates to keymap/combo/encoder/fork endpoints).
* refactor: redesign core rynk traits, achieve full-duplex communication

Signed-off-by: Haobo Gu <haobogu@outlook.com>

* refactor: update rynk crates to use the new contract

Signed-off-by: Haobo Gu <haobogu@outlook.com>

* chore(rynk): trim comments

* refactor(rynk): third simplification pass

- rynk-wasm: drop the label round-trip (JS already owns the picker name)
  and the WebDevice wrapper — JsByteLink is a local wasm-bindgen type, so
  it implements RynkDevice directly; device.rs deleted
- qemu_behavior: drop six request_raw calls duplicating the typed calls
  right above them (request delegates to request_raw, same code path)
- driver/tests: drop three tests subsumed by supersets (cmd mismatch,
  topic-during-request, cancelled-request recovery)
- rynk-serial: declare the tokio feature actually used (io-util, not time)

Signed-off-by: Haobo Gu <haobogu@outlook.com>

---------

Signed-off-by: Haobo Gu <haobogu@outlook.com>
Signed-off-by: Haobo Gu <haobogu@outlook.com>
Rebasing onto origin/main linearized away the merge commit 9b8d64c,
which carried hand-made changes not present in either parent: the
nrf54lm20_ble example tweaks, the rynk_loopback fixture downsize, and
the rynk/Cargo.lock removal. Restore them to match the pre-rebase tree.
Signed-off-by: Haobo Gu <haobogu@outlook.com>
- features/rynk.md: drop the non-existent `bulk` Cargo feature (the
  `features = ["bulk"]` snippet failed to compile); bulk is always on with
  `rynk`, sized by `rynk_buffer_size`. Note the tool-author example needs
  `embassy-futures`.
- migration/v08_v09.md: fix the fabricated `bulk_transfer` -> `bulk` rename
  (removed, folded into `rynk`); document the `KeyAction::TapHold` u8-index
  break, the removed `[rmk].protocol_max_bulk_size` key, and the new
  unknown-key strictness.
- configuration/layout.md: remove the `PDF(n)` action (unsupported in
  keyboard.toml — the keymap grammar has no rule for it), document the `Trns`
  transparent form, and split two examples that shared one toml fence.
- rmk/CHANGELOG.md: add the Rynk feature entry and the BREAKING `keyboard.toml`
  layout-schema change.
A USB bulk-IN transfer completes on the host only once it sees a packet shorter than the max packet size. A device-to-host frame whose length is a multiple of that size (64 at Full-Speed) ends on a full packet, so the host read stays pending. Wrap the CDC Sender in a small Write adapter that appends a zero-length packet after such a frame.

Scoped to the USB transport; run_session, UART, and BLE are untouched. This is an independent robustness fix, not the root cause of the nRF52840 "CDC IN never completes" report — there even the short handshake response and raw libusb hung, which a ZLP cannot explain.

Signed-off-by: Haobo Gu <haobogu@outlook.com>
Add an opaque, bounded application payload that firmware can exchange
between the split central and peripheral alongside the normal split
traffic, without ever taking priority over key events.

* `split_app` module: `SplitAppData` (a small, `MaxSize`, postcard
  length-prefixed payload) plus four statics — `SPLIT_APP_TX`
  (central -> peripheral), `SPLIT_APP_PERIPH_TX` (peripheral ->
  central), the symmetric `SPLIT_APP_RX` inbox, and the `SPLIT_APP_LINK`
  watch that reports split-link state to the application.
* Producers use `try_send` only (bounded, drop-on-full) so the split
  read/write loops never block or get starved by application traffic;
  key events are always polled first.
* The split driver and peripheral drain the application queues as the
  lowest-priority arm of their outgoing selects and forward received
  `SplitMessage::Application` payloads into the inbox.
* `SPLIT_APP_LINK` is state-based (a `Watch`), so a late-subscribing
  application still observes the current link state; the `false -> true`
  edge is a resync trigger. Link-down edges are emitted from a drop
  guard so they survive async cancellation of the split session.
* On the peripheral the link is raised on the FIRST inbound message from
  the central rather than on bare connection: over BLE, notifications to
  a central that has not yet subscribed are silently dropped, so the
  connection alone is not proof the application channel is usable.

Developed for a split keyboard port.
@colonelpanic8
colonelpanic8 marked this pull request as draft July 20, 2026 22:48
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>
@HaoboGu
HaoboGu force-pushed the feat/rynk branch 5 times, most recently from c09015e to d2c97ab Compare July 31, 2026 14:10
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.

2 participants