Skip to content

feat(app): Settings › Knob pane, USB setup sheet, knob settings - #228

Merged
tarakanof merged 8 commits into
mainfrom
feat/222-knob-pane
Oct 4, 2026
Merged

tarakanof merged 8 commits into
mainfrom
feat/222-knob-pane

Conversation

@tarakanof

Copy link
Copy Markdown
Owner

Closes #6

Phase 1 of knob provisioning: settings move from compiled-in Kconfig to NVS.

Changes

  • components/cinder_cfg (pure C, host-tested): cfg_t field table (NVS keys), validation (URL http(s)://host[:port], WPA2 password, header-safe token, device id), one-time seed from the build config, hardware ID from the MAC, log line without secrets. Also reset_gesture (factory-reset state machine).
  • main/config_store.c (ESP only): nvs_flash_init, load namespace cinder (wifi_ssid, wifi_pass, ember_url, dev_id, dev_tok, name; settings blob + settings_ver reserved for docs(unraid): data-dir chown (UID 65532) + :3627 preset in install steps #8), copies in PSRAM.
  • Dev seed: when the namespace has no keys and CONFIG_CINDER_WIFI_SSID is set, the Kconfig values are written once. The current EMBER_TOKEN becomes dev_tok until docs(unraid): data-dir chown (UID 65532) + :3627 preset in install steps #8. CINDER_EMBER_URL default is now empty, so release builds hold no secrets.
  • ember/pomo/weather clients read URL + token from config_store.
  • Setup face when no SSID: "Connect to a Mac with Ember" + ID 61FC8C; Wi-Fi not started.
  • Factory reset: hold 10 s (no turn) → overlay; still holding, turn right 24 detents → erase cinder + esp_wifi_restore() + reboot (core-1 task, no log in the LVGL task). Release / left detent / 5 s without a right detent cancels; that press is then no push, long push or page change. Normal input unchanged (push vs long push on release, push-and-turn = page).
  • Docs: docs/llm.md, docs/firmware-plan.md.

Verification

  • Host tests: firmware/test/host/run.sh all pass (new test_cfg.c: keys, URL/password/token validation, seed, IDs, no secrets in logs, gesture confirm/cancel/timeout, push/long push/page change untouched).
  • idf.py build: 0 warnings.
  • Hardware (knob, /dev/cu.usbmodem1101):
    I (826) config: NVS empty: seeded from the build config (ssid "…", password set, ember http://192.168.0.2:3627, token set, device (none))
    I (775) config: provisioned: ssid "…", password set, ember http://192.168.0.2:3627, token set, device (none)   # next boot: no reseed
    I (879) cinder: before display: free internal 199391 B, largest DMA block 126976 B
    I (2410) cinder: bot face ready; free internal 46 KB (largest 31 KB)
    I (6585) ember: Wi-Fi up, IP 192.168.0.39
    I (8237) pomo: state idle idle, 0/0 s, round 0
    I (8244) ember: Ember mood -> 2
    I (8266) ember: brightness -> 76
    I (9868) weather: open-meteo clouds/2 22.1 C -> partly-cloudy int 0 night
    I (62413) cinder: pose redraws 11.5/s | screen refreshes 379, avg 9.1 ms, max 19.7 ms | mood working | page 0
    
    Baseline (main) on the same knob: 47 KB free (largest 31 KB).
  • Found on the way: +1.8 KB of new static .bss made the second 56 KB LVGL draw buffer fail (alloc secondary buffer 56640 bytes failed, boot loop). Fixed by keeping config copies in PSRAM / on existing stacks; .bss is now +8 B vs main.
  • The log above is from the build with esp_wifi_set_storage(WIFI_STORAGE_RAM); there the first association failed (reason 203) and the IP came ~5 s late. The final commit drops that line (driver keeps its default flash copy, like main; factory reset clears it). The knob stopped answering on USB (no ROM output, esptool cannot connect) right after flashing that last one-line change, so the final build is not hardware-verified: needs a replug and a reflash.
  • Not tested on hardware: factory reset (would wipe the user's Wi-Fi) and the setup face (needs empty NVS).

Spec: Obsidian Superpowers Specs/cinder/2026-10-04-knob-provisioning-design.md. Deviation: Wi-Fi credentials live in namespace cinder (single source of truth), not only in the driver's storage.

Improv Serial and CINDER1 codecs pinned by shared test vectors, a byte
demuxer for frames, JSON lines and logs, a KnobLink transport seam with a
termios serial link that never toggles DTR/RTS, IOKit port discovery for
303a:1001, the setup state machine, and the /v1/devices client and model
with merge-PUT autosave.

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of #228 (Knob pane + USB setup). swift test --package-path macos: 722 tests pass. I didn't open the real serial port; the serial findings come from pty harnesses built against the PR's own SerialPortLink/KnobSession.

What checks out:

  • Improv frames: I rebuilt all 14 vectors in improv.json from the spec in Python (header, version 1, type, length, cmd,len,len-prefixed strings, low-byte sum checksum). All 14 match, and so do the 5 invalid cases.
  • Demuxer: I fuzzed 3000 streams cut at random byte boundaries, mixing ANSI log lines, printable garbage, Improv frames with \n inside their strings, text before a frame on the same line, and CINDER1 events. No frame or line was lost.
  • Merge PUT: the patch carries changed fields of nested objects only, sends pages whole and never adds unknown keys. That matches mergeKnobSettings + DisallowUnknownFields. normalized() matches validate().
  • The token and password are never logged. New UI strings are in the catalog.

Findings (ranked)

1. High: a replaced knob is almost never forgotten

KnobSetupSheet.swift:42-46,85-87. replaces is a computed property. It is read again inside didSetUp, when setup finishes, not when the user confirms. KnobPane's .task calls knob.load() every 15 s while the sheet is up. Once the mint has created the new record, KnobDevice.current (newest created_at) is the new knob, so replaces comes back nil and didSetUp(_, replacing: nil) forgets nothing. A setup takes 30-90 s, so this hits nearly every time. The old knob stays registered with a live token and is hidden by the one-knob UI.
Fix: capture the replaced knob once, when Set Up / Replace is pressed:

private func send() {
    let old = replaces
    model?.send { await env.knob.didSetUp($0, replacing: old) }
}

Do the same for remint.

2. High: "Re-mint Token" always fails with "The knob was disconnected"

KnobSetupModel.swift:173-183, 208-223; KnobProvisioner.swift:280-282, 303-314. emberUnauthorized only happens after join has followed the reboot and opened a new session. run stores next only on success. On failure, self.session is still the pre-reboot session, which is closed. remint → setEmber → KnobSession.send throws .closed → .disconnected. Even with a live session, remint doesn't drain() before waitForEmber, so a queued {"ev":"ember","state":"unauthorized"} from before set_ember matches at once and the re-mint fails again.
The session opened by the reconnect is also never closed on any error path, and SerialPortLink has no deinit. In the harness each dropped session leaked one fd (/dev/fd 6 → 7) for the life of the process.
Fix: let the provisioner report the live session on failure, e.g. struct KnobSetupFailure: Error { let error: KnobSetupError; let session: KnobSession? }, or an onSession callback. Have the model adopt it in run's catch. Add await session.drain() before waitForEmber in remint, and deinit { close() } on SerialPortLink.

3. High (dev workflow): probes open every 303a:1001 port with no exclusivity, and two readers split the byte stream

SerialPortLink.swift:22-30, KnobModel.swift:178-190, AppEnvironment.feedKnobPorts. Ember probes every Espressif USB-Serial/JTAG port at launch and on every re-enumeration. Each probe writes IMPROV… and a CINDER1 info line, then reads for up to 5 s. Nothing takes TIOCEXCL or flock. I opened two SerialPortLinks on one pty and wrote 40 lines: one got 1, the other 39. Concrete cases:

  • During idf.py monitor, every knob reboot re-enumerates the port, and Ember swallows the boot log.
  • The ROM download mode is also 303a:1001, so during idf.py flash the probe can eat esptool's SLIP replies.
  • If the user hits "Set Up Knob…" while the USB row still says "Checking the board…", the sheet and the in-flight probe split the replies. portBusy is only checked before a probe starts. The sheet's identify then times out and shows "isn't running cinder".

Fix: right after open:

if ioctl(fd, TIOCEXCL) != 0 || flock(fd, LOCK_EX | LOCK_NB) != 0 {
    Darwin.close(fd); throw KnobLinkError.openFailed("busy")   // → .unavailable
}

Pyserial's exclusive=True uses the same flock. Also have the sheet wait for any in-flight probe of the port before it connects. Consider probing only while Settings › Knob is open, or after the port has been stable for a few seconds.

4. Medium: a failed setup of a different board leaves an orphan record that becomes "the knob"

POST /v1/devices creates the record before set_ember and Wi-Fi. If either fails (wrong password, timeout), the new record stays. KnobDevice.current picks the newest created_at, so the pane switches to the board that never checked in. The working knob drops out of the UI and can't be forgotten there, but its token stays valid.
Fix: in provision, if the mint created a new record (minted.device.lastCheckin == nil and its hwID differs from the registered knob's) and a later step throws, DELETE it before rethrowing.
Separately, re-setting up the same knob revokes its working token at mint time. A set_ember failure then leaves the knob on 401 until setup succeeds. The server design allows nothing better, but the error text should say to run setup again.

5. Medium: the check-in test can report Done for a token that never worked

KnobProvisioner.swift:105-108, 187, 331. Re-provisioning keeps last_checkin, and the check is seenAt >= started - 5s, comparing the Mac clock with the server clock. A check-in on the old token in the 5 s before started (there's one every 60 s or on each epoch bump), or a server clock running ahead of the Mac, makes waitForEmber return at once. Example: a typo in the Ember URL still ends in "Done".
Fix: use the server's own clock as the baseline: let base = minted.device.lastCheckin?.seenAt, and succeed only when seenAt > base (or when base == nil and any check-in exists). Drop the -5.

6. Low: setup work ignores cancellation

KnobLink.swift:101-103 (withCheckedContinuation ignores cancellation) and SerialPortLink.swift:122-128 (reopens at once, without checking). If model.close() runs while .sending (app quit, Settings window closed), join sees .closed, calls reconnect, reopens the port and carries on. It can even reach didSetUp/forget.
Fix: wrap the waiter in withTaskCancellationHandler and resume it with nil on cancel. Call try Task.checkCancellation() at the top of reconnect and of the join and waitForEmber loops.

7. Low: probe results are single-shot and cached

KnobModel.swift:181. USB enumerates at ROM time, before the firmware's listener runs. One device-info RPC with a 2 s timeout at first-match can catch a knob that is still booting. .notCinder/.unavailable is then cached until replug, and no setup notification is posted. Fix: retry the RPC 2-3 times, treat an unsolicited CINDER1 {"ev":"boot"} as cinder, and re-probe non-cinder ports when the pane appears.

DTR/RTS (I can't verify this without hardware)

The link never issues TIOCMBIS/TIOCMBIC/TIOCMSET and clears HUPCL (tested on a pty). macOS asserts DTR/RTS on open, and userspace can't prevent that, so the "doesn't reset on open" item in the spec's hardware test plan is still required before merge. Minor: when configure fails, init closes the fd with HUPCL still set (SerialPortLink.swift:25-28), which drops the lines. Clear HUPCL before any other termios step, or leave the fd open until the clear succeeds.

Nits

  • Package.swift: SwiftPM warns about 2 unhandled files (testdata/knob/*.json). Add exclude: ["testdata"] to the test target. The vectors aren't actually shared with cinder yet (no firmware/test/vectors), so add a sync note to avoid drift.
  • ImprovCodec.State lacks the spec's 0x00 Stopped and includes the BLE-only 0x01; ErrorCode.notAuthorized 0x04 is also BLE-only. Both are harmless.
  • The sheet doesn't check WPA password length (8-63). A short password costs a reboot plus ~45 s before "Couldn't join".
  • Accessibility: progress rows in the setup sheet are an unlabeled SF Symbol plus text. Use .accessibilityElement(children: .combine) and .accessibilityValue("done/in progress/pending").

…tups

Review of #228: take the port exclusively, close dropped links, keep the
rebooted session for re-mint, delete records that never worked, judge
success by a server-side checkin baseline, honour cancellation, and retry
device info for a knob that is still booting.
The port is shared with idf.py monitor and esptool, so detection stays
passive and probes run only while Settings › Knob is visible. The setup
sheet keeps the knob it replaces from the moment the user confirms, waits
for a probe in flight, and checks WPA password length.
@tarakanof

Copy link
Copy Markdown
Owner Author

Review fixes (f5752f2, 9c8d9a9). I wrote the tests first; swift test passes 737/737. Unsigned Debug and Release builds succeed with no warnings, and strings.sh check passes.

  1. Replaced knob: the sheet now captures replaces when you press Set Up, Replace or Re-mint, and passes it to didSetUp. Test: setUpReplacingForgetsTheOldKnob.
  2. Re-mint: the provisioner hands each session it opens after a reboot to the caller (onSession). The model adopts the live one when a step fails and closes it on cancel. remint drains stale events before and after set_ember. SerialPortLink now has deinit { close() }. Tests: remintAfterRebootUsesTheLiveSessionAndIgnoresStaleEvents, setupModelAdoptsTheRebootedSessionSoReMintWorks, and droppedSerialLinkClosesItsDescriptor, which fails without the deinit.
  3. Ports: the link takes TIOCEXCL + flock(LOCK_EX|LOCK_NB) right after open; a busy port shows as "couldn't open". Detection is passive (IOKit VID:PID + serial), with no probing at launch or on enumeration and no plug-in notification. Probes run only while the Knob pane is visible: on appear (boards that weren't cinder get retried) and for new boards. Setup, factory reset and any other USB action wait for a probe in flight first. Tests: serialLinkTakesThePortExclusively, knobPortsAreDetectedWithoutOpeningThem.
  4. Orphans: if a step after the mint fails and the record never checked in, the record is deleted. For a knob that already had a token, the sheet says "The knob's old token no longer works. Run the setup again to finish." Tests: failedSetupDeletesARecordThatNeverWorked, failedResetupKeepsAWorkingRecord.
  5. Success check: needs a checkin strictly newer than minted.device.lastCheckin?.seenAt (server clock only). Tests: checkinMustBeNewerThanTheMintBaseline, provisionPassesTheMintBaselineToTheCheckinTest.
  6. Cancellation: expect uses withTaskCancellationHandler, and the join/waitForEmber/reconnect loops check for cancellation. Tests: expectStopsWhenCancelled, cancelledSetupDoesNotReconnect.
  7. Device info is retried 3 times, and a CINDER1 boot event also counts as cinder. Ports are probed again when the pane appears. Tests: identifyRetriesAKnobThatIsStillBooting, bootEventCountsAsCinder.
    Minor fixes:
  • HUPCL is cleared before any other termios step, and also on the busy path.
  • The test target excludes testdata; the vectors carry a note to keep them in sync with cinder.
  • Improv gains the 0x00 Stopped state; the BLE-only values are labelled.
  • WPA passwords must be 8–63 characters or 64 hex digits; an empty password is allowed only for open networks or an SSID typed by hand.
  • Progress rows now have accessibility labels and values (done / in progress / pending).

@tarakanof

Copy link
Copy Markdown
Owner Author

Re-check of f5752f2 and 9c8d9a9. Locally, swift test --package-path macos passes 737/737. The branch is mergeable and merges cleanly with main, 0 commits behind. CI fails, though: the macOS job fails serialLinkTakesThePortExclusively with Caught error: .busy. My guess is that the reopen after first.close() hits one of two things. Either TIOCEXCL stays on the tty because PtyKnob holds the slave fd open (my local OS doesn't seem to enforce TIOCEXCL on a pty, see #3), or the fd is closed asynchronously in the dispatch source's cancel handler. In 1500 open/close/reopen cycles on a pty, under CPU load, I couldn't reproduce it locally. Fix that before merging.

I checked each fix by mutating a scratch copy of the code and seeing whether a test fails:

  1. Replaced knob: fixed in the sheet. The capture itself has no test: setUpReplacingForgetsTheOldKnob only exercises KnobModel.didSetUp(_:replacing:), which was already there before the fix, so it passes on the old code too.
  2. Re-mint: removing onSession, the adopt-on-failure or the deinit each fails a test. Removing the drain()s in remint (before, after, or both) fails nothing. The fixture's stale unauthorized event is consumed by provision first, so the test never has a queued stale event.
  3. Ports: removing flock fails a test. Removing TIOCEXCL alone fails nothing locally. The passive policy holds at app level. AppEnvironment only calls ports.start() (IOKit matching, no open), and the notifier and feedKnobPorts are gone. Probes run only from KnobPane onAppear/onChange, and SettingsRootView.detail is a switch, so they run only when the pane is selected. The sheet and factory reset set portBusy, then waitForProbe.
  4. Orphans: dropping the DELETE and deleting every time both fail a test. Gap: if setup is cancelled after the mint, forget runs in the cancelled task, so URLSession drops the DELETE and the orphan stays. Run it in an unstructured Task.
  5. Baseline: changing > to >= or passing nil in provision fails a test. Passing nil in remint fails nothing.
  6. Cancellation: removing the expect cancellation handler fails a test. cancelledSetupDoesNotReconnect adds nothing, because it cancels before setEmber. With the reconnect/join checks and the join catch CancellationError removed together, every test still passes. The fix itself is correct and layered.
  7. Retry and boot event: OK.

Verdict: the fixes are correct. Make CI green first. The test gaps (drain, remint baseline, sheet capture, deeper cancellation) and the DELETE on cancel can go in a follow-up.

TIOCEXCL lives on the tty and outlasts our fd while anything else holds the
tty open, and the fd itself closed later on the dispatch queue, so an
immediate reopen could fail with EBUSY (seen on CI).
The orphan DELETE runs unstructured so a cancelled setup still sends it, a
cancelled setup never sends Wi-Fi, and the setup model captures the knob it
replaces at send time. Tests now fail if the remint drain, its baseline,
the capture or the join cancellation check go missing.
@tarakanof

Copy link
Copy Markdown
Owner Author

Re-verify fixes (3a1cd33, 8f12d62). CI is green and swift test passes 742/742.

  • CI failure: SerialPortLink.close() now drops TIOCEXCL (TIOCNXCL) and the flock right away, before the dispatch source closes the fd. Before, the exclusive flag stayed on the tty while the pty's slave was still open, and the fd closed later on the source's queue, so an immediate reopen got EBUSY.
  • Replaced knob: KnobSetupModel now owns replaces (through registered), captures it in send/remint, and passes it to finished. setupModelKeepsTheReplacedKnobFromSendTime fails without the capture.
  • Remint drain: remintDropsAStaleEventQueuedAfterTheFailure queues an unauthorized event after provision fails. It fails if either drain() is removed.
  • Remint baseline: remintPassesTheMintBaseline fails if nil is passed instead.
  • Cancel after set_ember: cancelAfterSetEmberNeitherSendsWiFiNorReconnects cancels as the set_ember reply arrives. join now checks for cancellation before it sends Wi-Fi. The test fails without that check.
  • Orphan DELETE: it runs in a detached task, so cancelling the setup no longer stops it. orphanDeleteSurvivesCancellation fails if forget runs in the cancelled task.

http-only lower-case URL with an IPv4 or [a-z0-9.-] host, 32-byte name,
self-restart after set_ember, Wi-Fi rejection and ember connecting.
The knob has no TLS and a 32-byte name, restarts itself when its Ember URL
changes, and reports rejected Wi-Fi settings separately from a failed join,
so the setup validates before minting and handles each case.
@tarakanof

Copy link
Copy Markdown
Owner Author

Firmware contract fixes from the cinder#16/#17 review (d1daac8 tests first, then 9564974). CI is green and swift test passes 753/753.

  • Ember URL: the setup now normalises the URL with CinderLineCodec.normalizedEmberURL and rejects anything outside the contract before minting a token, both in the sheet and in the provisioner.
    • Accepted: http only, sent as a lower-case scheme and host. The host is an IPv4 address or an ASCII [a-z0-9.-] name, with an optional port 1–65535.
    • Rejected: https, IPv6, _, IDN, any path, query or credentials, and anything over 128 characters.
    • cinder1.json now includes url_normalized cases and more host_rejected cases for the firmware tests to reuse.
  • Name: set_ember always sends the server record's name. It falls back to the typed name, then Knob XXXXXX, so it is never empty. Names are capped at 32 UTF-8 bytes on a character boundary, both in the field and on the wire; a longer name is too_long.
  • Knob restarts after set_ember (when its URL changed): re-mint reconnects by USB serial number. Setup also reconnects; if the knob comes back ready, Ember sends the Wi-Fi settings again (at most 3 sends in total).
  • Wi-Fi rejected: Improv 0x01 or CINDER1 {"ev":"wifi","state":"invalid"} now shows "The knob rejected the Wi-Fi settings. Check the network name and password." This is separate from "Couldn't join".
  • ember: connecting: treated as still in progress. Success is still ok, or a server check-in strictly newer than the mint baseline. Only unreachable produces the "can't reach" error; otherwise the step times out.

@tarakanof
tarakanof merged commit c4c9e5d into main Oct 4, 2026
3 checks passed
@tarakanof
tarakanof deleted the feat/222-knob-pane branch October 4, 2026 18:24
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