Add the CBOR snapshot channel, expand the control model, ship MetersApp, and take the discovery port exclusively - #2
Merged
Conversation
The device speaks a native CBOR control channel alongside MIDI3. This implements exactly one route on it: the state-dump snapshot that reports the current bank and rig index. The channel's wider management grammar is documented but deliberately not implemented. The snapshot matters because the device never volunteers its rig position over MIDI3, so a client that wants to show where the device actually is has to ask for it once at connect. Reading it needs a second, short-lived TCP connection, which surfaced a device constraint worth encoding: it refuses to greet — or resets — a session opened too soon after a previous socket closed. `CONNECTION_COOLDOWN` (from the shared spec) is the minimum quiet gap, so every implementation spaces the CBOR fetch and the MIDI3 session identically and never overlaps them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
Three related additions across the spec and all three implementations. Rig navigation. CC 48/49 and CC 50-54 are momentary buttons, and the release is not optional: a press alone loads the target rig, but without the matching release the device abandons the change and reloads the previous rig about two seconds later — which reads as the device spontaneously undoing the navigation. `Control` now renders up, down and load_slot as a press immediately followed by its release in one message. Parameter registry. Adds the volume parameters (main/monitor/headphone, plus the read-only master), the bank-preview extended-string page, and effect-type categories. Appendix B allocates type values in 16-value blocks, one per group of the device's type knob, so the registry carries those blocks as a second lookup and a UI can name a slot's family without knowing the individual type. Declaring ranges rather than rows keeps the spec small and lets the generator expand them. State tree. Tracks the device's own position reports and the bank name preview, so a client can follow rig changes made anywhere — including at the front panel — rather than only the ones it made itself. Vectors and generated output are regenerated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
The `meters` terminal example rebuilt as a macOS app: rig header, amp and cabinet, the eight effect blocks with their on/off state and type, the tuner strobe and the level bars. Unlike the terminal example it is not read-only — clicking a signal-chain block toggles that effect — but everything else it sends is a value request. Two things shape the design. The high-rate lane is deliberately not published: status events fold into a private frame and a 33 ms tick publishes it once, so a 300 Hz stream does not drive 300 view updates a second. And every restart bumps an epoch counter that captured tasks check before writing, so a connection still unwinding can never write into the state of its successor. Rig position comes from the device wherever possible — seeded from the CBOR snapshot at connect, then kept live by the reports the device sends on every rig change — falling back to matching the loaded rig name against the bank preview. The app's own optimistic slot hint clears when a change arrives that it did not make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
Wires the new CBOR and registry surfaces into each package's public API, updates the overview, credits and per-language READMEs to describe them, and bumps the shared spec version — every implementation asserts it in its conformance suite and CI cross-checks that all three agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
Discovery bound UDP 5727 with SO_REUSEPORT, so it shared the port happily with anything else already listening. That is the wrong default here. The device answers a poll only on 5727 — it ignores the poll's source port — and when several sockets are bound to one UDP port the kernel delivers each datagram to exactly one of them. A second listener does not see a copy of the reply; it takes it. The practical failure: with Kemper's Rig Manager running, discovery returned no devices while the network and the Profiler were both healthy, and the app reported "No Profiler found on the network" — which sends you looking at the network rather than at the other program holding the port. So bind without SO_REUSEADDR or SO_REUSEPORT. If the port is free the bind succeeds and nothing else can take it while the socket is open, so replies cannot be stolen mid-session. If it is already held the bind fails with EADDRINUSE, which is reported as a port conflict naming the likely holder rather than being worked around. Each implementation gains a `DiscoveryPort` handle, acquired before a session and held for its lifetime, that can be polled repeatedly — a long-running client re-polls to notice devices appearing and disappearing without ever letting go of the port. `discover` and `find_first` still acquire, poll once and release, so one-shot callers are unchanged. Swift needed a transport change to allow this at all: it used one NWListener plus one NWConnection per target, all co-bound to 5727, which requires allowLocalEndpointReuse — precisely the SO_REUSEPORT that causes the bug. Network.framework cannot decline to share a port, so discovery is now a single exclusively-bound BSD socket. That also removes a latent mislabel, since recvfrom reports the true source address where the sender connection reported the broadcast address it was created with. MetersApp holds the port across its session and surfaces the conflict message. Manual mode connects straight over TCP and never polls, so it does not claim the port. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S
gotwalt
added a commit
that referenced
this pull request
Aug 24, 2026
…d-meters-app Add the CBOR snapshot channel, expand the control model, ship MetersApp, and take the discovery port exclusively
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.
Lands the work that had accumulated in the working tree — five themed commits, reviewable in order.
What's here
1. The CBOR state-snapshot channel — the device speaks a native CBOR control channel alongside MIDI3. This implements exactly one route on it: the state-dump snapshot reporting the current bank and rig index, which MIDI3 never volunteers. The channel's wider management grammar is documented but deliberately not implemented.
Reading it needs a second, short-lived TCP connection, which surfaced a device constraint worth encoding: it refuses to greet — or resets — a session opened too soon after a previous socket closed.
CONNECTION_COOLDOWNis now a shared spec constant, so all three implementations space the CBOR fetch and the MIDI3 session identically.2. Control model, parameter registry and state tree — three related additions:
Controlnow renders up/down/load_slot as a press immediately followed by its release.3. MetersApp — the
metersterminal example rebuilt as a native SwiftUI macOS app. The high-rate lane is deliberately not published: status events fold into a private frame that a 33 ms tick publishes once, so a 300 Hz stream doesn't drive 300 view updates a second. Every restart bumps an epoch counter that captured tasks check before writing, so a connection still unwinding can't write into its successor's state.4. Spec 0.5.0 — wires the new surfaces into each package's public API and bumps the shared version.
5. Exclusive ownership of the discovery port — the one behavioural fix, and worth reading closely.
The discovery bug
Discovery bound UDP 5727 with
SO_REUSEPORT, sharing the port with anything already listening. That's the wrong default here. The device answers a poll only on 5727 — it ignores the poll's source port — and when several sockets are bound to one UDP port the kernel delivers each datagram to exactly one of them. A second listener doesn't see a copy of the reply; it takes it.In practice: with Kemper's Rig Manager running, discovery returned no devices while the network and the Profiler were both healthy, and the app reported "No Profiler found on the network" — sending you to debug the network instead of the other program holding the port.
The fix is to bind without
SO_REUSEADDRorSO_REUSEPORT. Verified on macOS in both directions:SO_REUSEPORT. Replies can't be stolen mid-session.EADDRINUSE, reported as a port conflict naming the likely holder rather than worked around.Each implementation gains a
DiscoveryPorthandle, acquired before a session and held for its lifetime, pollable repeatedly so a long-running client can notice devices appearing and disappearing without releasing the port.discover/find_firststill acquire-poll-release, so one-shot callers are unchanged.I checked whether polling from an ephemeral source port could sidestep the clash entirely — it can't, the device replies only to 5727 (0 replies from an ephemeral port on an otherwise-idle machine, verified twice). Contention is unavoidable; exclusivity is the only real fix.
Swift needed a transport change to allow this at all. It used one
NWListenerplus oneNWConnectionper target, all co-bound to 5727 — a design that requiresallowLocalEndpointReuse, which is precisely theSO_REUSEPORTcausing the bug.Network.frameworkoffers no way to decline to share, so discovery is now a single exclusively-bound BSD socket. That incidentally removes a latent mislabel:recvfromreports the true source address, where the sender connection reported the broadcast address it was constructed with, so a reply could have been recorded as a device at255.255.255.255.Verification
Against a real Profiler ("Mini", 192.168.7.220):
acquired UDP 5727 exclusively→discovered 192.168.7.220 Mini→ connected, rig streaming.lsofconfirms it holds the port.Full CI-equivalent run is green locally: codegen drift clean, spec version consistent across all three generated modules, Rust (fmt + clippy
-D warnings+ 101 tests), Python (ruff check + format + 366 passed), Swift (format lint--strict+ 95 tests + both products).Notes for review
rust/src/control.rs,python/src/libkp/state.py); both predated this work and would have failed CI on arrival.🤖 Generated with Claude Code
https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S