Repository navigation
fix(discovery): a blocked local network must not look like "no devices" - #8
Merged
Merged
Conversation
…off" The firmware now tells the truth: a device does not report hardware it does not have (link-devices ESP-030). Absent hardware is ABSENT from /config.json, never reported false. This app has to decode an HONEST document — until now KsConfig required every field, so it would have THROWN on one, meaning the firmware told the truth and the app called it broken. KsConfig.metronome / .led / .followBeatEnabled are now OPTIONAL, and nil is a different statement from false: nil = there is no speaker fitted false = there IS a speaker and it is switched off Conflating them is how a UI ends up drawing a volume slider for a speaker that does not exist. Same class of error as the other four this codebase has produced today (T-009, T-010, T-016, T-018): two situations collapsed into one signal. Sections render only for hardware the device reports. No speaker, no metronome controls. No strip, no LED controls. saveFormFields only sends what exists, too — posting metronome=0 to a board with no speaker would mean this app INVENTED a value for absent hardware, which is exactly the fabricated-defaults clobbering that /config.json exists to prevent. Absent stays absent, in both directions. And capability belongs to the BUILD, not the product. Solder a strip onto the Touch, flip ONE firmware flag, and the LED section appears in this app with NO app change at all. That is the whole point of declaring capability instead of hardcoding a product identity. KsConfig.outputCount becomes maxOutputCount — a MAXIMUM, never an assumption. The real count is clock.count, which the firmware sets to what is actually fitted: one DIN jack on a Touch, four on a P4. Padding would draw three dead output cards. (T-015.) The test fixture is the REAL Touch's /config.json, captured verbatim from kstouch-dfd0: no metronome keys, no led keys, no follow_beat, three WiFi slots with the migrated credential in slot 0, and ONE clock output. 103 tests, 0 failures. Verified against the real device: it decodes, displays 120.0 BPM with 1 peer, and is controllable. No UNSUPPORTED FW badge.
The device screen is one scroll mixing transport with cable/rate/LED/wifi. On stage you want transport and nothing you can fat-finger; at soundcheck you want the knobs. Split criterion is not advanced-vs-basic, it's WHAT YOU REACH FOR MID-SET. Transport, obviously. Nudge — the firmware's own comment says it 'slides the RC-505 into the pocket', live via /nudge, no reboot: a playing control, not a setup one. Swing, same. Everything else is set once and left alone. PERFORMANCE (the screen you land on): tempo, meter bridge, master transport, and per output the big transport button + NUDGE + SWING. Nothing on it changes your SOUND — only your timing and transport. You cannot knock a cable assignment loose mid-set because it isn't there to knock. SETTINGS (gear sheet): everything LIVE — cable, rate, follow-link, click, LED, diagnostics. DEVICE SETUP (sheet from Settings): everything that REBOOTS — wifi, metronome enable, follow-beat enable. THE INVARIANT SURVIVES: no surface ever mixes live and reboot controls. One merged Settings sheet would have re-created the exact adjacency this app exists to fix — the device's own web page puts metronome-enable and metro-accent 40px apart as the identical control, one of which reboots the box mid-set. Better labelling is not the same as impossible. Firmware update stays in the toolbar, not inside Settings: Settings needs /config.json, which 404s on old firmware, and an update is how you ESCAPE that state (T-009).
The device screen was one scroll mixing transport with cable, rate, LED, click and diagnostics. On stage you want transport and nothing you can fat-finger; at soundcheck you want the knobs. The split criterion is not advanced-vs-basic. It's WHAT DO YOU REACH FOR MID-SET. PERFORMANCE (the screen you land on): tempo glass, meter bridge, master PLAY/STOP, and per output the big transport button + NUDGE + SWING. Nothing else. Nothing on this screen changes your SOUND — only your timing and your transport. You cannot knock a cable assignment or a clock division loose mid-song, because they are not there to knock. Nudge earns its place on the firmware's own account: nudge_mbeats is live via /nudge, no reboot, and the comment says it "slides the RC-505 into the pocket". That's a playing control, not a setup one. Swing is the same kind of thing. SETTINGS (gear sheet): everything LIVE — per-output enable/cable/rate/follow-link, click vol/voice/accent, LED, diagnostics. It opens with the reassuring counterpart of the reboot warning: "Everything on this screen applies instantly. Nothing here reboots the device." DEVICE SETUP (a DOOR inside Settings): everything that REBOOTS — WiFi, metronome enable, follow-beat enable. Consequence band, REBOOTS tags, WRITE & REBOOT key. THE INVARIANT SURVIVES, AND IT'S THE WHOLE POINT: no surface ever mixes live and reboot controls. Merging everything into one Settings sheet would have re-created the exact adjacency this app exists to fix — the device's own web page puts the metronome ENABLE toggle and the metro-accent toggle forty pixels apart, as the identical component, in the same box, and one of them restarts the box mid-set. Better labelling is not the same as impossible. LiveRebootPartitionTests still enforces it in CI. Capability still drives visibility: no speaker, no click section; no strip, no LED section (link-devices ESP-030). Firmware update stays in the toolbar, NOT inside Settings: Settings needs /config.json, which 404s on old firmware, and an update is how you ESCAPE that state. Gating the escape behind the broken thing would strand the user (T-009). 103 tests, 0 failures. Verified against the real Touch: Performance shows 120.0 BPM, CLOCK OUT 1 PLAYING, with NUDGE and SWING under it and nothing else.
THE BUG THE USER REPORTED: "nudge and swing buttons not working."
They weren't working because the Touch had no /live route and returned 404. But
that is not why it was UNDIAGNOSABLE. `apply()` was:
try? await client.postLive(edit)
The 404 was caught and dropped. The stepper moved, no error appeared, and the
device never changed. The device said "I have never heard of that endpoint" as
loudly as HTTP can, and the app said nothing at all.
- apply() now reports failure, and keeps the three causes APART:
unsupportedByFirmware (404 — no /live at all; the way out is an OTA update),
rejectedByDevice, unreachable. Collapsing situations into one signal is the
mistake this codebase keeps making (T-009, T-010, T-016, T-018) and it sends
the user to fix the wrong thing.
- A failed edit leaves the displayed value at the DEVICE'S truth. Never leave a
stepper showing a number the device does not have.
- A SUCCEEDED edit now updates the local config (KsConfig.apply, pure and
tested). Without it even a WORKING /live looked broken: the POST succeeded,
the device obeyed, and the number on screen never moved.
- The Performance screen draws the failure. A failure the view model records and
the view never shows is still invisible.
An icon, drawn rather than pasted: Tools/make_app_icon.swift renders it from the
same palette as the device's own web UI. It's a MIDI clock pulse train in lime on
charcoal with a taller accented downbeat — literally what the box emits, and a
square wave still reads at 40px where a wordmark does not. Re-runnable, so the
art is source, not a binary nobody can regenerate.
7 new tests, full suite green.
Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
…th it
CLAUDE.md names this gap out loud: "Nothing has ever spoken to a real device. Every
network test runs against a stub." Every fixture in this suite was written by me from
a READING of the firmware, which makes them a test of my reading, not of the device.
These bytes are a verbatim capture from a Touch (a4:cb:8f:da:df:d0, fw 2.2.0) --
stopped and mid-song. Nothing prettified, reordered, or filled in.
They catch the reported bug immediately. `link_owns` was true while the device
cheerfully obeyed every play and stop; the app honours the ks_status.h contract
("when link_owns is true the manual buttons are ignored, so the UI greys them") and
so refused to even send the tap. Buttons dead, device fine.
test_the_app_can_start_a_stopped_touch FAILED against the device's real bytes. That
is the test that should have existed before I ever called the firmware "verified".
Also kept: the BROKEN bytes, as a fixture. A device that genuinely defers to Link (the
P4 does) SHOULD have its buttons greyed -- the app's contract-following is correct and
must not be "fixed" away. The fix belonged in whoever lied.
Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
`-derivedDataPath build/dd` (used to build for a physical device) writes derived data into the repo, and .gitignore only covered DerivedData/. 2121 build artifacts were committed on the previous commit. Ignored under both names now. Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
A fixture is a public artifact — it goes in the repo, the CI log, and the PR diff. The pre-push redaction hook caught the real SSID in two of them. Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
The user's KitchenSync Touch was powered, on the network, and audibly following the Link session -- its LED blinking in time -- and the app showed nothing at all. The device was advertising _http._tcp correctly the whole time; the SAME build found it instantly in the Simulator (which needs no Local Network permission). What was different on the phone was iOS's Local Network permission, reset by reinstalling the app. Denied, iOS does not FAIL the Bonjour browse. It returns nothing. Forever. And KitchenSyncDiscovery never set a stateUpdateHandler, so nobody was listening anyway: the browser was dead on arrival and stayed dead in silence, while the screen said "Looking for KitchenSync units on this network..." -- which was a lie. It was not looking. It never would be. So the user goes and debugs their WiFi, their router, their firmware: everything except the one switch that actually turns it back on. The recurring bug of this codebase, one more time: a failure with NO signal, sending someone to fix the wrong thing (T-009, T-010, T-016, T-018, and link_owns). iOS does tell us -- kDNSServiceErr_PolicyDenied. We just never listened. - DiscoveryStatus: a pure mapping from NWBrowser.State, so it can be asserted without standing up a browser. A denial ARRIVES AS A WAIT, which is the trap -- it looks like something that will resolve itself, and it never does. Only the DNS policy code tells a denial apart from a transient wait, and only a denial is the user's to fix. - KitchenSyncDiscovery gains a stateUpdateHandler and RESTARTS a failed browser. NWBrowser does not come back from .failed on its own. A denial is deliberately NOT retried: iOS would refuse the new browser exactly as it refused the last one, and a retry loop would only bury the one fact the user needs. - The list names the cause and puts them one tap from Settings. 7 new tests. Full suite green. Verified in the Simulator: the happy path still discovers the bench Touch (kstouch-dfd0.local, live BPM, 2 peers). Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6
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.
The user's Touch was powered, on the network, and audibly following the Link session — its LED blinking in time — and the app showed nothing at all.
It was never the network
The device was advertising
_http._tcpcorrectly the whole time (dns-sdconfirmed it:kstouch-dfd0, resolving to192.168.0.101). The same build found it instantly in the Simulator, which shares the Mac's network and needs no Local Network permission.What was different on the phone was iOS's Local Network permission, reset by reinstalling the app.
Denied, iOS does not fail the Bonjour browse. It returns nothing. Forever. And
KitchenSyncDiscoverynever set astateUpdateHandler, so nobody was listening anyway — the browser was dead on arrival and stayed dead in silence, while the screen said:Which was a lie. It was not looking. It never would be. So the user goes and debugs their WiFi, their router, their firmware — everything except the one switch that actually turns it back on.
The recurring bug of this codebase, one more time: a failure with no signal, sending someone to fix the wrong thing (T-009, T-010, T-016, T-018, and
link_ownsin link-devices#117). iOS does tell us —kDNSServiceErr_PolicyDenied. We just never listened.The fix
DiscoveryStatus— a pure mapping fromNWBrowser.State, so it can be asserted without standing up a browser. The trap is that a denial arrives as a.waiting: it looks like something that will resolve itself, and it never does. Only the DNS policy code tells a denial apart from a transient wait (WiFi coming up, interface bouncing) — and only a denial is the user's to fix. Putting a "change your privacy settings" banner on a transient wait would be the same crime in the other direction.KitchenSyncDiscoverygains astateUpdateHandler, and restarts a failed browser —NWBrowserdoes not come back from.failedon its own. A denial is deliberately not retried: iOS would refuse the new browser exactly as it refused the last one, and a retry loop would only bury the one fact the user needs.Verification
7 new tests, full suite green. Verified in the Simulator that the happy path still works — the bench Touch shows up as
kstouch-dfd0.localwith live BPM and 2 peers.The permission-denied path itself is asserted at the
DiscoveryStatuslevel (pure), not end-to-end — reproducing a real iOS permission denial in CI isn't something I can do honestly, so I haven't claimed it.https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6