macOS: tell two identical radios apart by where they are plugged in - #109
Open
on8st wants to merge 1 commit into
Open
macOS: tell two identical radios apart by where they are plugged in#109on8st wants to merge 1 commit into
on8st wants to merge 1 commit into
Conversation
Two rigs with the same bridge and codec chips make every distinguishing string identical: eight serial rows labelled "CP2105 Dual USB to UART Bridge Controller", two sound cards both called "USB Audio Device", and only a positional " kd9taw#2" between them that comes from enumeration order. Move one rig to another socket and that ordinal swaps, so every saved profile silently points at the other radio — observed here 2026-08-13, with nothing to warn about it because both names still resolve. A CP2105 is also claimed by two drivers at once, giving one physical interface two unrelated names (cu.SLAB_USBtoUART14 and cu.usbserial-01AF7FED0). No name rule can pair those, so Detect listed each rig twice and the CAT probe spent a full baud ladder on a port it had already tried. usbtopo reads locationID from the IO registry and out of CoreAudio's device UID, and reduces both to a parent hub: a rig carrying CAT and audio down one cable is internally a hub, so its interfaces are siblings. Three consumers, and each of them runs the existing string rule FIRST and asks topology only about what the string could not settle — string matching works on every platform and has field evidence behind it, this reads an undocumented Apple convention on one OS. - ports::collapse_usb_siblings drops a port only when the same (device, interface) pair is already listed, keeping the self-identifying usbserial- name over the positional SLAB_ one. Anything it cannot key is KEPT: losing a real port looks exactly like a rig that stopped existing. - SerialPortInfo and AudioDeviceDto gain interfaceIndex, siblingPorts, pairedAudio and usbHub. Option, additive, None off macOS, never persisted — they describe where hardware is plugged in at this instant. - checkRigForm gains the two checks held out of kd9taw#88: the half of a dual bridge that carries no CAT (the port that makes a working radio look dead), and a codec inside the other radio. Both WARNINGS; neither may ever refuse a save, because a rig whose audio is not on its own CAT device is a normal station. The port list itself is unchanged: every port pickable before is still pickable. siblingPorts exists because the live data falsified the first version of the dual-bridge check. It fired on interfaceIndex > 0 alone, and an LG monitor on this desk is interface 2 of a device with exactly ONE interface — so it would have said "is port 3 of this device, CAT is normally on port 1" about the only port there is. The count is taken over ports sharing the EXACT same locationID, which on macOS is one physical USB device; advice to pick a different port is only meaningful when a different port exists. That exactness is also the difference between the two relations, and it is recorded because it bounds what each may claim. Two serial interfaces of one bridge share the same locationID, so counting them is exact. A rig's CAT bridge and its codec are SEPARATE USB devices behind the rig's internal hub, so only their parent relates them — and two unrelated things in one external hub share a parent too. A USB headset beside a rig's CAT adapter could read as "inside" it. Hence the interface advice can be precise while the paired-audio reading may only ever raise a doubt. CI has no Mac with two radios on it, so the registry walk is compile-checked and nothing more. That shaped the design rather than the test list: everything that decides anything is a pure function taking the maps as parameters, and the tests inject locations measured on real hardware. Both new checks were verified by neutering them and confirming the suite goes red. The featureless clippy pass caught collapse_usb_siblings as dead code without `serial`; it now carries the same gate as collapse_tty_twins. Licences read per crate, not assumed alike: io-kit-sys "MIT / Apache-2.0", core-foundation-sys "MIT OR Apache-2.0", coreaudio-sys "MIT" only. All GPL-3.0-only compatible, all already on deny.toml's allowlist as transitive deps of cpal and serialport; Cargo.lock gains no new [[package]] entries. NOTICE entry filed. Refs kd9taw#93. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
on8st
added a commit
to on8st/Nexus
that referenced
this pull request
Aug 18, 2026
Two rows in this file claimed usbtopo had not been proposed. It has: kd9taw#93 asked the question, the maintainer answered with a shape, and kd9taw#109 is that shape — off upstream/main, 9/9 CI green, awaiting review. A reader acting on either row would have redone work sitting in an open PR, which is the exact failure this file exists to prevent. - The fork-only row now scopes itself to what is ACTUALLY still fork-only: the three label-rewriting helpers (label_by_rig, label_serial_ports, devices_sharing_usb_device). They were removed from the PR rather than shipped unwired — offering code with no caller invites the reviewer to design it for you. - The "Serial dedup" upstreamability row is resolved, and in the order it recommended: the name-based half landed as collapse_tty_twins via PR kd9taw#92 (checked with git log -S, not from memory), and the topology half went on top of it as collapse_usb_siblings in kd9taw#109. A new section records the split between what went and what stayed, plus three things that were NOT in the design and would otherwise be lost: - siblingPorts exists because live data falsified the first version of the dual-bridge check. It fired on interfaceIndex > 0 alone, and the LG monitor on this desk is interface 2 of a device with exactly ONE interface — it would have told the operator to pick "port 1" of a device that has no port 1. - The two relations are not equally strong, and that bounds what each may claim. Two serial interfaces of one bridge share a locationID, so counting them is exact. A rig's CAT bridge and its codec are separate USB devices behind the rig's internal hub, so only parent_hub relates them — and two unrelated things in one EXTERNAL hub share a parent too. Hence the interface advice can be precise while the paired-audio reading may only ever raise a doubt. - Featureless clippy caught collapse_usb_siblings as dead code without `serial`, and the first local run reported clean only because -D warnings aborted on pre-existing findings in propagation before reaching tempo-audio. Use --no-deps for one crate, and do not read a clean sweep as clean until a positive control says it reached your file. Fork-local file — never upstreamed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
on8st
added a commit
to on8st/Nexus
that referenced
this pull request
Aug 18, 2026
This file listed nine PRs as "open upstream, awaiting review". All nine merged, some of them two days ago. A list that rots this fast is worse than no list: it is the reason someone would think work still needs offering when it has already landed. Counted from the API rather than from this file's memory — 14 of 16 fork PRs merged (kd9taw#69-kd9taw#74, kd9taw#77-kd9taw#79, kd9taw#88-kd9taw#92), two open: kd9taw#85 (restore, three review points addressed, awaiting re-review) and kd9taw#109 (usbtopo, 9/9 CI green, awaiting review). A new 2026-08-18 status section carries that, plus the numbers worth knowing before starting anything: upstream is 1.6.1 — five releases in four days — and macos-support sits on the 1.6.0 manifests, 13 commits behind upstream/main and 93 ahead. Merge forward first, fork-radar before that. The 08-15 section is demoted to "(previous)" per this file's own convention, but its stale heading is rewritten rather than merely dated: a heading that asserts a present state still misleads a skimmer inside a section marked historical. Two claims retired with it: - The standing-policy question. Never answered as a written policy and it no longer needs to be — generic fixes, macOS fixes and test-quality work have all gone in, and on kd9taw#93 the maintainer answered a design question with a concrete shape to build to. Treat the categories as settled by behaviour and stop gating work on a policy statement. The dual radio-config question IS still open; do not "fix" it. - The upstreamability assessment is marked consumed, not abandoned: every one of its four rows has been offered, and its "three separate PRs, not one" call proved right — the confirm change did take the longest review. Issue kd9taw#76 is closed; kd9taw#110 was withdrawn and scrubbed as filed too early. Fork-local file — never upstreamed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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.
Closes #93.
Sent in the shape we agreed: optional topology fields on
SerialPortInfo, string matching staysthe first pass, macOS-gated with empty-map stubs. The two checks held out of #88 ride in behind it.
The problem, measured
ON8ST runs an FT-710 and an FTX-1. Both use a Silicon Labs CP2105 for CAT and a C-Media codec for
audio, so every string that could tell them apart is byte-identical: eight serial rows all
labelled "CP2105 Dual USB to UART Bridge Controller", and two sound cards both called
USB Audio Device, separated only by a positional" #2"thatdisambiguate_namesassigns fromenumeration order. Move one rig to a different USB socket and that ordinal swaps — every saved
profile silently starts pointing at the other radio, and nothing warns, because both names still
resolve to a real device. That happened here on 2026-08-13.
There is also a duplicate no name rule can pair: a CP2105 is claimed by two drivers at once.
Live on this machine right now:
collapse_tty_twinscannot pairSLAB_USBtoUART14withusbserial-01AF7FED0— nothing in thestrings relates them. But the topology does: same location, same interface number.
What this adds
crates/tempo-audio/src/usbtopo.rsreadslocationIDfrom the IO registry (serial) and out ofkAudioDevicePropertyDeviceUID(audio, free — no IOKit call), and reduces both to a parent hub. Arig carrying CAT and audio down one cable is internally a hub, so its interfaces are siblings.
Three consumers:
ports::collapse_usb_siblings— runs behindcollapse_tty_twinsand drops a port onlywhen the same (device, interface) pair is already in the list, preferring the self-identifying
usbserial-<serial>name over the positionalSLAB_one. This is the auto-detect andCAT-probe path (
detect_rigs,probe_cat_ports), so Detect stops listing each rig twice andthe baud sweep stops spending a full ladder on a port it already tried. Verified live: 5 rows
in, 3 out, the two
SLAB_duplicates gone and both halves of the bridge kept.SerialPortInfo/AudioDeviceDtogaininterfaceIndex,siblingPorts,pairedAudioand
usbHub—Option, additive,Noneoff macOS. Names and labels are untouched, so apicker that ignores them behaves exactly as before.
checkRigFormgains the two checks held out of Check the rig form before saving it, not after it behaves like broken hardware #88: the half of a dual bridge that carriesno CAT (the port that makes a working radio look dead — it opens, the writes succeed, nothing
answers), and a codec that is inside the other radio.
One false positive the live data caught
The first version of check 3 fired on
interfaceIndex > 0alone, and the live dump above shows whythat is wrong: the LG monitor's control port is interface 2 of a device that has exactly one
interface. Configuring a rig on it would have produced "is port 3 of this device. CAT is normally
on port 1" — advice to pick a port that does not exist, about the only port there is.
So the DTO also carries
siblingPorts, counted over ports sharing the exact samelocationID(one physical USB device on macOS), and the check requires
siblingPorts > 1. Advice to pick adifferent port is only meaningful when a different port exists. Verified live — the FT-710's bridge
reports 2, the monitor reports 1 — and the regression test was written failing-first against the
ungated version.
That is also why
siblingPortsuses the exact location whilepairedAudiohas to use the parenthub: a rig's CAT bridge and its codec are separate USB devices behind the rig's internal hub, so
only the parent relates them — and two unrelated things in one external hub share a parent too. A
USB headset beside a rig's CAT adapter could look "inside" it. Both DTO doc comments and the check
comment say so, and it is the second reason that check can only ever warn.
The rules I held to
topology only about what the string could not settle. Not politeness: string matching works on
every platform and has field evidence behind it, while this reads an undocumented Apple
convention on one OS. Where they could disagree, the string wins.
lock an operator out of their own station. A rig whose audio genuinely is not on its own CAT
device — a separate interface box, an analogue card — is a normal setup, not a mistake.
collapse_usb_siblingskeeps every port it cannot key: nolocation, no interface number, or an empty map because IOKit said nothing. Losing a real port
looks exactly like a rig that stopped existing.
locationIDdescribes a physical socket, so it is recomputed at everyenumeration; settings continue to store the device name. Both DTO doc comments say so.
Licences
Read per crate from each one's own
Cargo.toml, not assumed alike:io-kit-sys"MIT / Apache-2.0",
core-foundation-sys"MIT OR Apache-2.0",coreaudio-sys"MIT" only —not dual, which is why the
Cargo.tomlcomment names it separately. All three are GPL-3.0-onlycompatible and all three are already on
deny.toml's allowlist today: cargo-deny reads the wholelockfile with no target filter and these have been in it as transitive deps of cpal and serialport
all along.
Cargo.lockgains three lines intempo-audio's dependency list and no new[[package]]entries — nothing new enters the tree. NOTICE entry filed for all three.Tests — written to be read, because CI cannot run this
CI has no Mac with two radios on it, so the IOKit walk is compile-checked and nothing more. That
shaped the design rather than the test list: everything that decides anything is a pure function
taking the maps as parameters, and the walk itself decides nothing. So the tests inject the
locations measured on the hardware above:
ports::usb_siblings_collapse_to_the_name_that_identifies_itself— the collapse, theself-identifying-name preference, that a different device is not a duplicate, that an unkeyable
port is kept, that both halves of one bridge survive (this is why the key is (device, interface)
and not device alone), and that empty maps return the list untouched.
rigFormChecks.test.ts— eight cases including the two that matter most: a caller passing notopology gets precisely the checks it got before (asserted by comparing the two result sets,
not by eyeballing), and empty topology is indistinguishable from no topology.
usbtopokeeps only what it can honestly prove: the hub arithmetic and the UID parse. Its thirdtest asserts a consistency property of whatever the walk returns (an interface number implies a
location, or the pair key would drop the port) and says in its own doc comment that it is vacuous
on a machine with no USB serial ports — the alternative being a test that only passes on one desk.
Each new check was verified by neutering it and confirming the suite goes red — the two topology
checks take three tests down with them.
Verification
cargo test --workspace— 77 suites, 2565 passed, 0 failurescargo test --manifest-path src-tauri/Cargo.toml --lib --features radio— 109 passedcargo test -p tempo-audio --features device,serial— 531 + 4 + 7 + 1 + 5, 0 failurescargo clippy -p tempo-audioclean at featureless,serial, anddevice,serial— thefeatureless pass caught a real one:
collapse_usb_siblingswas dead code withoutserial, whichthe
--workspacesweep would have failed on. It now carries the same gate ascollapse_tty_twins.tsc -bclean ·npm test— 264 files, 3298 passedagainst the actual hardware.
Local clippy is 1.97.1 against CI's pin, so it also reports pre-existing findings in
propagation,tempo-coreandservice.rs; I checked line-by-line that none fall inside thelines this branch adds.
Not in this PR
Rewriting the displayed label, so the audio picker reads
USB Audio Device (FT-710)instead of abare
" #2". That is the operator-visible payoff and I deliberately left it out: it changes whatevery picker shows and wants its own review of what happens when the topology reading is wrong.
This PR only adds facts beside the existing name and label; nothing displayed changes. The module
header names it as the follow-up.
Unrelated, spotted while editing:
CHANGELOG.mdline 20 in 1.6.1 has a duplicated fragment —**Windows: USB rig interfaces work again- **Windows: USB rig interfaces work again — …. Leftalone here rather than folded into this branch.