Skip to content

macOS: tell two identical radios apart by where they are plugged in - #109

Open
on8st wants to merge 1 commit into
kd9taw:mainfrom
on8st:up/usb-topology
Open

macOS: tell two identical radios apart by where they are plugged in#109
on8st wants to merge 1 commit into
kd9taw:mainfrom
on8st:up/usb-topology

Conversation

@on8st

@on8st on8st commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes #93.

Sent in the shape we agreed: optional topology fields on SerialPortInfo, string matching stays
the 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" that disambiguate_names assigns from
enumeration 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:

/dev/cu.SLAB_USBtoUART14       loc=0x2410000  hub=0x2400000  iface=0   ┐ one physical interface,
/dev/cu.usbserial-01AF7FED0    loc=0x2410000  hub=0x2400000  iface=0   ┘ two unrelated names
/dev/cu.SLAB_USBtoUART13       loc=0x2410000  hub=0x2400000  iface=1   ┐ the other half of the
/dev/cu.usbserial-01AF7FED1    loc=0x2410000  hub=0x2400000  iface=1   ┘ same bridge
USB Audio Device               loc=0x2420000  hub=0x2400000            ← same hub = same radio
/dev/cu.usbmodem601NTGYJF9992  loc=0x0130000  hub=0x0100000  iface=2   ← an LG monitor, no rig

collapse_tty_twins cannot pair SLAB_USBtoUART14 with usbserial-01AF7FED0 — nothing in the
strings relates them. But the topology does: same location, same interface number.

What this adds

crates/tempo-audio/src/usbtopo.rs reads locationID from the IO registry (serial) and out of
kAudioDevicePropertyDeviceUID (audio, free — no IOKit call), 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:

  1. ports::collapse_usb_siblings — runs behind collapse_tty_twins and drops a port only
    when the same (device, interface) pair is already in the list, preferring the self-identifying
    usbserial-<serial> name over the positional SLAB_ one. This is the auto-detect and
    CAT-probe path (detect_rigs, probe_cat_ports), so Detect stops listing each rig twice and
    the 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.
  2. SerialPortInfo / AudioDeviceDto gain interfaceIndex, siblingPorts, pairedAudio
    and usbHubOption, additive, None off macOS. Names and labels are untouched, so a
    picker that ignores them behaves exactly as before.
  3. checkRigForm gains 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 carries
    no 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 > 0 alone, and the live dump above shows why
that 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 same locationID
(one physical USB device on macOS), and the check requires siblingPorts > 1. Advice to pick a
different 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 siblingPorts uses the exact location while pairedAudio has to use the parent
hub: 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

  • Tie-break, never replacement. Every consumer runs the existing string rule first and asks
    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.
  • Both new checks are warnings, and neither can refuse a save. A heuristic must not be able to
    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.
  • Nothing is dropped unproven. collapse_usb_siblings keeps every port it cannot key: no
    location, no interface number, or an empty map because IOKit said nothing. Losing a real port
    looks exactly like a rig that stopped existing.
  • Never persisted. A locationID describes a physical socket, so it is recomputed at every
    enumeration; settings continue to store the device name. Both DTO doc comments say so.
  • The port list is unchanged. Every port you could pick before, you can still pick.

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.toml comment names it separately. All three are GPL-3.0-only
compatible and all three are already on deny.toml's allowlist today: cargo-deny reads the whole
lockfile with no target filter and these have been in it as transitive deps of cpal and serialport
all along. Cargo.lock gains three lines in tempo-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, the
    self-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 no
    topology 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.
  • usbtopo keeps only what it can honestly prove: the hub arithmetic and the UID parse. Its third
    test 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 failures
  • cargo test --manifest-path src-tauri/Cargo.toml --lib --features radio — 109 passed
  • cargo test -p tempo-audio --features device,serial — 531 + 4 + 7 + 1 + 5, 0 failures
  • cargo clippy -p tempo-audio clean at featureless, serial, and device,serial — the
    featureless pass caught a real one: collapse_usb_siblings was dead code without serial, which
    the --workspace sweep would have failed on. It now carries the same gate as collapse_tty_twins.
  • tsc -b clean · npm test — 264 files, 3298 passed
  • Live on the FT-710: the maps above are the real output, and the end-to-end collapse was run
    against the actual hardware.

Local clippy is 1.97.1 against CI's pin, so it also reports pre-existing findings in
propagation, tempo-core and service.rs; I checked line-by-line that none fall inside the
lines this branch adds.

Not in this PR

Rewriting the displayed label, so the audio picker reads USB Audio Device (FT-710) instead of a
bare " #2". That is the operator-visible payoff and I deliberately left it out: it changes what
every 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.md line 20 in 1.6.1 has a duplicated fragment —
**Windows: USB rig interfaces work again- **Windows: USB rig interfaces work again — …. Left
alone here rather than folded into this branch.

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]>
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.

Identify which radio a sound card or serial port belongs to, from USB topology (macOS)

1 participant