fix: re-enable display after encoder-only writes - #13
Conversation
The Twister firmware disables the LED display when it receives an encoder bulk-transfer write and only re-enables it via the confirmation animation fired by a global-settings write (or a USB replug). apply() previously only sent globals when a global tag changed, so encoder-only plans (colors/mappings) left the device dark until unplugged and replugged. Send a trailing no-op global write to trigger the re-enable whenever a plan touches any encoder. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fe10722cef
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // leave the display dark until the device is unplugged and replugged. | ||
| if (targets.some((target) => target !== "globals")) { | ||
| const policy = firmwarePolicy(device.identity.firmwareDate); | ||
| const [refreshFrame] = encodeGlobalDryRun(progressive.globals.rawTags, policy); |
There was a problem hiding this comment.
Preflight the refresh frame before applying encoder changes
When a 2026-07-02 device omits any expanded global tag 33–38, normalizeGlobals accepts the response with a warning and an encoder-only plan remains eligible, but encodeGlobalDryRun rejects that layout here. Because this runs after the target loop, the encoder changes have already been written and verified when the apply suddenly fails, leaving no complete journal entry or returned verified snapshot. Construct and validate the refresh frame before the first write so an unsupported global layout is rejected without partially applying the plan.
Useful? React with 👍 / 👎.
| frameHex: refreshFrame!.hex, | ||
| detail: "display re-enable after encoder write", | ||
| }); | ||
| connection.sendConfigurationWrite(refreshFrame!.bytes); |
There was a problem hiding this comment.
Record failures from the trailing refresh write
If the MIDI backend throws while sending this added global frame, the exception bypasses the write error handling used for planned frames. The journal is left with a final pending entry rather than unknown, even though all requested encoder changes may already be present and the refresh outcome is ambiguous, which undermines journal-based recovery and produces an unclassified transport error. Wrap this send in the same unknown-outcome handling as the target writes.
Useful? React with 👍 / 👎.
| // leave the display dark until the device is unplugged and replugged. | ||
| if (targets.some((target) => target !== "globals")) { | ||
| const policy = firmwarePolicy(device.identity.firmwareDate); | ||
| const [refreshFrame] = encodeGlobalDryRun(progressive.globals.rawTags, policy); |
There was a problem hiding this comment.
A cosmetic refresh can fail an apply that already fully succeeded.
encodeGlobalDryRun calls assertExactTags, which throws Global record tag layout is not allowlisted whenever the device's global tag set is not exactly [0..9, 31, 32, 33..38]. That layout is not guaranteed here: normalizeGlobals only pushes a warning (Firmware did not return 2026 global tag N) when a 2026 tag is missing, so an export can legitimately carry a short/extra tag set and still produce a valid plan for encoder targets.
Scenario: a 2026-07-02 unit that omits (or adds) one global tag. All encoder frames are written and full-snapshot verified, then this line throws. applyPatchPlan rejects, complete is never journaled, the CLI never writes last-verified.json, and the user sees apply fail — for changes that already landed correctly — and may restore from the backup unnecessarily.
The display refresh is best-effort polish; it should not be able to turn a verified apply into a failure. Wrap the whole block (encode + send) in try/catch, journal the failure, and continue to complete.
| frameHex: refreshFrame!.hex, | ||
| detail: "display re-enable after encoder write", | ||
| }); | ||
| connection.sendConfigurationWrite(refreshFrame!.bytes); |
There was a problem hiding this comment.
Unguarded send: unlike every other write in this function, a transport failure here is never journaled as unknown.
The main loop wraps sendConfigurationWrite in try/catch and records outcome: "unknown" before rethrowing (applier.ts:63-66). This send has no such guard, so if the MIDI output errors (port closed, rtmidi throw), the journal is left with a pending globals entry, no terminal outcome, no complete, and the caller gets a raw rtmidi error message rather than the write-outcome language the rest of the applier uses. docs/write-safety.md invariant 6 states every pending is followed by verified, failed, or unknown.
| if (targets.some((target) => target !== "globals")) { | ||
| const policy = firmwarePolicy(device.identity.firmwareDate); | ||
| const [refreshFrame] = encodeGlobalDryRun(progressive.globals.rawTags, policy); | ||
| await appendJournal(options.journalPath, { |
There was a problem hiding this comment.
This pending entry never gets a terminal outcome, even on the happy path.
docs/write-safety.md invariant 6: "Before each send, an append-only journal records pending. Outcomes are verified, failed, or unknown." After this change, every successful encoder apply leaves a pending globals record with no matching outcome, immediately followed by complete. Journal-recovery tooling (explicitly listed as required future coverage in the same doc) and any human auditing the file cannot distinguish this normal, intentional no-op from a global write whose fate is genuinely unknown — i.e. exactly the ambiguous case the journal exists to flag. Emit a terminal entry (verified, or a distinct non-pending record) for this frame.
| // write and only re-enables it from the confirmation animation fired by a | ||
| // global-settings write (or a USB replug). Without this, encoder-only plans | ||
| // leave the display dark until the device is unplugged and replugged. | ||
| if (targets.some((target) => target !== "globals")) { |
There was a problem hiding this comment.
A global write now happens after the final verification, so postSnapshot describes a state that was never read back.
The frame is sent after the last exportConfiguration, and progressive is returned (and written to last-verified.json) unchanged. The no-op claim rests entirely on the assumption that every global tag round-trips read→write identically. The codebase already documents one asymmetry of exactly this kind on the encoder side (tag 24 shiftedMidiChannel, semanticRaw/desiredRaw), and the frame includes identity/topology globals — tag 0 is the device MIDI channel, tags 2-7 the side-button actions — which docs/write-safety.md invariant 8 says must be isolated, ordered last, and gated behind a fresh discovery/confirmation cycle. If any tag does not round-trip, apply silently changes global device state and reports a verified snapshot that does not match the hardware. Re-export after the refresh and compare against progressive, so a non-no-op is caught rather than assumed away.
| class FakeWritableTwister implements ConfigurationWriteConnection { | ||
| private readonly handlers = new Set<MessageHandler>(); | ||
| private readonly encoders = new Map<number, number[]>(); | ||
| private globals: number[] = [...GLOBALS]; |
There was a problem hiding this comment.
Nothing here asserts the behavior the PR adds.
The fake now accepts and merges global writes, but the test makes no assertion that a trailing global frame was sent, that it carried the device's current values, or that this.globals is byte-identical to GLOBALS afterwards. Both regressions the change is meant to prevent pass silently: deleting the whole new block, or having it write mutated global values. Given the PR explicitly ships unverified on hardware, the fake is the only line of defense — assert (a) exactly one bytes[4] === 1 write was received, and (b) this.globals deep-equals GLOBALS at the end.
| progressive = observed; | ||
| } | ||
|
|
||
| // Firmware disables the LED display while receiving an encoder bulk-transfer |
There was a problem hiding this comment.
The display stays dark on exactly the paths where the user most needs to see the device.
The refresh only runs after every target verifies. If an encoder write fails verification or the read-back times out (applier.ts:68-75), the function throws before reaching this block, so the display remains disabled — and the CLI is at that moment printing a manual-restore instruction that the user has to act on with a dark device. The unknown/failed paths are the ones that most warrant the extra frame, not the least.
Claude Code review — 6 findings (inline)Reviewed The change is correct in intent — the firmware really does gate
Suggested shapePreflight the refresh frame (encode + Still unverified on hardwareWorth repeating from the PR body: the fake device in the test suite models the firmware behavior this PR assumes. If the 🤖 Generated with Claude Code |
Review of the previous commit found the refresh write was placed after final verification and outside the error handling guarding every other write in applyPatchPlan. Three consequences, all addressed here: - encodeGlobalDryRun's tag-layout check ran after every encoder frame had landed and verified, so a device whose globals omit an expanded tag (normalizeGlobals only warns) failed the apply for changes that actually succeeded. Validate the frame before the first write. - A transport error on the refresh send bypassed the journal, leaving a dangling `pending` entry and an unclassified error. Wrap it in the same unknown-outcome handling as the planned writes. - The frame rewrites every global tag verbatim, including the identity and topology tags invariant 8 isolates, and nothing confirmed it was a no-op. Read back and compare, and journal a terminal outcome so the entry resolves on every path (invariant 6). Tests now assert the refresh is sent, is the final frame, leaves every global tag byte-identical, journals `unknown` on transport failure, and writes nothing at all when the layout is unsupported. Each fails if the corresponding behavior is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes pushed —
|
| Finding | Raised by | Fix |
|---|---|---|
applier.ts:88 — preflight the refresh frame (P1) |
Codex + Claude | encodeGlobalDryRun now validates the global tag layout before the first write, using before.globals.rawTags. An encoder write doesn't change the tag layout, only values, so validating early is sound while the actual frame is still built from the latest snapshot. Unsupported layout now fails with the device untouched. |
applier.ts:96 — journal refresh transport failure (P2) |
Codex + Claude | Send wrapped in the same journal-unknown-then-rethrow guard as the planned writes. Error message states the requested changes did verify, so the failure isn't mistaken for a lost patch. |
applier.ts:89 — pending never resolves (P2) |
Claude | Terminal outcome journaled on every path — verified on success, unknown on transport/read-back failure, failed on state change. Satisfies invariant 6. |
applier.ts:86 — "no-op" assumed, never checked (P2) |
Claude | Refresh is now followed by a read-back and full snapshot comparison against the last verified state. If the frame alters any global — including the identity/topology tags invariant 8 isolates — the apply fails loudly and points at the backup. postSnapshot is the re-read snapshot, so it describes state actually observed. |
test/applier.test.ts:20 — nothing asserted the new behavior |
Claude | Three new tests, below. |
Tests
Previously the entire fake-device global-write block could be deleted with all 15 tests still green. Now:
encoder-only writes end with a global frame that leaves globals byte-identical— asserts exactly one global frame, that it's the final frame, and that every global tag is unchanged afterward.a failed display re-enable is journaled as unknown after the changes verified— simulated transport failure; asserts theunknownentry and the absence ofcomplete.an unsupported global tag layout is refused before any encoder frame is sent— device with a truncated global layout; assertsconnection.writesis empty.
Mutation-checked, since "tests pass" was exactly the weak claim last time:
- removing the preflight → the layout test fails
- removing the refresh block → the other two fail
Not fixed, deliberately: applier.ts:82 (P3)
The display stays dark when apply throws on verification failure or read-back timeout. I'm leaving this. Those are precisely the ambiguous-outcome paths, and pushing an additional global frame when the device state is already unknown cuts against invariant 7's rule that nothing is auto-corrected after an ambiguous failure. A dark display is a legitimate signal that the apply did not complete. Better addressed in CLI guidance than by writing more frames into an unknown state — happy to be overruled.
Still unverified on hardware
Unchanged from the original PR body, and the reason I'm not self-merging: the tests validate this against a fake device encoding the assumption under test. The new read-back does convert the invariant-8 risk from silent to loud — if command 0x01 round-trips any global tag asymmetrically, apply now fails instead of quietly rewriting device identity. But whether the display actually re-enables on a real Twister still needs a physical check.
🤖 Generated with Claude Code
Problem
The Twister firmware disables the LED display while it receives an encoder bulk-transfer write, and only re-enables it via the confirmation animation fired by a global-settings write (
command 0x01) — or by a USB replug.applyPatchPlanonly sent a global-settings frame when a global tag actually changed. So any encoder-only plan (colors, mappings — the common case) finished with the device left dark until the user unplugged and replugged it.Fix
After a plan containing any encoder write completes, send one extra no-op global-settings write built from the device's own current, unchanged global tag values. That triggers the firmware's
setting_confirmation_animation()→display_enable()path without altering any global state.Tests
test/applier.test.ts's fake device previously only modelled encoder writes and would have thrown on the new trailing frame. It now handles global writes and tracks its own global-tag state.npm run checkpasses: build clean, 15/15 tests.Not yet verified on hardware
This was verified by build + tests only. The behavioral claim — that the display comes back on immediately after a color/mapping
apply— still needs a check against a real Twister before this is trusted in practice.🤖 Generated with Claude Code