fix(hl2): pick the radio type when connecting by IP, so a Hermes-Lite 2 works over VPN - #4528
#4528fix(hl2): pick the radio type when connecting by IP, so a Hermes-Lite 2 works over VPN#4528jensenpat wants to merge 6 commits intoaethersdr:mainaethersdr/AetherSDR:mainfrom jensenpat:feat/hl2-connect-by-ipjensenpat/AetherSDR:feat/hl2-connect-by-ipCopy head branch name to clipboard
Conversation
…o Health
Seven quality-of-life items for the Hermes-Lite 2. Three of them share one
root cause: the GUI drove a Flex command plane this radio does not have, so
the widget moved, the setting persisted, and nothing reached the hardware.
Band switching (Band sidebar)
The band buttons resolved a Flex band-stack key and sent
"display pan set <pan> band=". The HL2 has no band stack and no VFO to read
back, so that command reached nothing. MainWindow now branches on
usesFlexCommandPlane(): for a non-Flex backend the app is authoritative and
tunes the slice directly (mode first, then frequency, then pan centre).
RadioCapabilities gains tuningMinHz/tuningMaxHz so the grid can be honest.
The HL2 reports 0.1-38.4 MHz (the AD9866's first Nyquist zone) and the 6 m
button is disabled with a tooltip rather than tuning somewhere it cannot
hear. Zero/zero means "not reported", so Flex is unaffected.
Companion filter board (J16 open collectors)
The HL2 has no switchable filters of its own -- it has seven open-collector
outputs at 0x00[23:17] that the gateware forwards to I2C 0x20. Setting the
config bits IS the whole mechanism. Selection is derived from the slice
frequency and auto-applies on connect (from the persisted frequency) and on
every band crossing.
The one-hot mapping is Quisk's Hermes_BandDict verbatim -- the grouping
(60+40 share a filter, 17+15 share one) is a property of the N2ADR board,
not something inferable from the band plan. Ours is the frequency ranges,
and the unit test checks them by asserting every amateur band lands on
Quisk's answer.
Two departures from "always engage the HPF": nothing below 1.6 MHz (it would
remove exactly what is being listened to), and no HPF on 160 m (the HL2's
switching supply couples spurs into those inductors -- wiki Options.md).
Logged at INFO on a new aether.hl2 category, not debug. There is no readback
anywhere in the protocol, so that line is the only evidence of what the
relays were told to do, and a support log has to already contain it.
RF gain -> AD9866 LNA
lnaGainDb was connect-time only. IRadioBackend::setPanRfGain (default no-op,
so Flex is untouched) carries the ANT panel's slider to the register at any
time, and panRfGainInfoChanged reports the real geometry: -12..+48 dB in
1 dB steps, against the model's Flex-shaped -8..+32 in 8s, which had made
two thirds of the available gain unreachable.
Hl2DbReference moves in the same call so the trace and S-meter do not slide
when gain changes. The persisted key is now family-scoped: a value last set
on a Flex was being restored onto the HL2 as an LNA gain the operator never
chose for that radio (observed live -- a Flex-era 16 was pushed at connect).
And the restore no longer writes a DEFAULT at all: with nothing saved, the
radio's own gain is the right answer. Seeding from the pan model does not
fix it -- the restore can run before the backend has published, so the model
still reads 0 and the same 0 goes to the hardware. Caught by Radio Health
showing "LNA gain 0 dB" on a fresh profile.
Forward / reverse power (uncalibrated)
Counts go through Quisk's power_meter_std_calibrations['HL2FilterE3'] curve.
That is a reference curve for an N2ADR rev E3 board, NOT a calibration of
this radio -- coupler, toroid and detector diode all vary. The meters are
labelled "(uncalibrated)" in their own MeterDef descriptions, because the
number itself looks exactly like a calibrated one. Raw counts still logged;
a per-unit calibration replaces the table and nothing else.
Radio Health dialog (Help menu, before Slice Troubleshooting)
New IRadioBackend::healthSnapshot() -- synchronous because every value is
already cached from unprompted telemetry, so there is no round-trip to
await. Shows firmware, ADC overload, LNA gain, TX inhibit (decoded from the
active-low bit), PTT/keyed/tune, TX FIFO depth and under/overflow, PA
temperature and bias, directional counts and watts, SWR, the J16 filter byte
and its meaning, tuning/DDC frequency, IQ rate and dropped EP6 packets.
A register the radio has not reported renders as a dash, never as zero.
Meter pacing
WDSP handed us ~47 S-meter readings a second and every one crossed to the
GUI thread. An EMA smooths every sample (so the published value represents
the whole interval rather than one arbitrary instant) and a 100 ms gate
decides how often one is published -- dropping without smoothing would
alias. 100 ms is the cadence MetisClient already paces telemetry at, and the
attack/decay constants are MeterModel's own Flex forward-power ballistics.
ATU button
TxApplet greys ATU and MEM when the backend reports hasTuner=false. A live
ATU button on a radio with no tuner is worse than a missing one: pressing it
keys the transmitter. updateAtuAvailability() is now the single owner of
that enabled state -- the radio-has-no-tuner and TGXL-in-Operate reasons
arrive from different models, and whichever fired last used to win.
Verification
192/192 tests pass, including 20 new assertions pinning the filter encoding
and the band map against Quisk's table.
tests/hl2_live_band_filter_probe.cpp (hardware-only, EXCLUDE_FROM_ALL)
answers "the unit test shares our reading of the register map, so what would
catch a convention error?". The filter byte rides the CONFIG register, so a
misplaced bit lands in the sample rate or receiver count and the failure is
a radio that still streams and still looks framed. Measured on a real HL2:
- EP6 packet rate held at 381/s (within 1.002x) across all nine filter
selections, with non-zero IQ. A bit in DATA[25:24] would have halved or
doubled it.
- Engaging the AM-blocking HPF at 0.6 MHz dropped the band 22-24 dB. That
is not the radio agreeing with our register map -- that is the antenna
path changing, and it confirms an N2ADR board is fitted. On a bare HL2
the writes are inert and harmless.
- A commanded 10 -> 30 dB LNA step moved the raw wire noise floor 19.8 dB,
measured before any of our DSP or dB referencing runs.
Driven on hardware through the automation bridge: 40 m button took the slice
9.98 USB -> 7.200 LSB with the pan following; the filter log went
0x48 (HPF + 30/20m) -> 0x44 (HPF + 60/40m); Radio Health populated with live
telemetry; RF Gain read -12..+48 at 20 dB; ATU and MEM disabled with "This
radio has no antenna tuner"; 6 m disabled as out of range.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Hermes-Lite 2 that is never told to stop keeps streaming at a host that is gone and then stops answering discovery -- alive at the network layer, invisible to every client, and needing a physical power cycle. Reproduced three times during aethersdr#4503: a plain `kill` on a connected AetherSDR wedged the radio every time. Hl2Backend's destructor already sends metis-stop, which covers a normal quit. It does not run on a signal -- Qt tears nothing down for SIGTERM, so the process simply vanishes mid-stream. The gateware watchdog is documented as the anti-wedge mechanism for exactly this case (MetisProtocol.h, kRunWatchdogDisable) and did NOT recover it in practice, so this is a belt alongside that brace rather than a replacement for it. NOT a self-pipe. The textbook Qt answer is to write to a pipe from the handler and do the real work back on the event loop. That would be useless here: an unresponsive event loop is one of the main reasons anyone reaches for kill in the first place, so the fix must not depend on the thing that may already be stuck. The handler does the whole job itself, calling nothing but sendto() on an address resolved in advance -- the descriptor, the sockaddr and the 64-byte stop datagram are all built by MetisClient::start(), on a normal thread, while the link is coming up. Arming happens BEFORE the first start datagram and disarming AFTER the normal stop has gone out. That asymmetry is deliberate: armed-but-not-streaming costs a stray 64-byte datagram to a radio that is not listening, while streaming-but-not-armed is a radio that has to be power-cycled. Termination signals only -- SIGTERM/SIGINT/SIGHUP/SIGQUIT. Deliberately NOT SIGSEGV/SIGABRT/SIGBUS: a crash leaves the radio in the same state and it is tempting to cover it, but those belong to whatever crash reporting the platform already has (on macOS the reporter uses Mach exception ports, and MacStartupAbortGuard owns SIGABRT during startup). SIGKILL cannot be caught, so `kill -9` still wedges the radio; nothing in a process can change that. An inherited SIG_IGN is left ignored. POSIX says a process should not take over a signal its parent deliberately neutralised, and this is not academic: nohup works by ignoring SIGHUP, so installing a handler over it turns a neutralised signal back into a fatal one. Caught in testing -- a nohup'd run died the moment its launching shell exited, which is the exact opposite of what nohup is for. Verification tests/hl2_signal_stop_test.py + hl2_signal_stop_child: a real MetisClient runs against a fake radio, the driver kills it with a real signal, and a metis-stop datagram must arrive. End-to-end on purpose -- calling fireEmergencyStop() directly would prove sendto() works, not that the handler is installed, that arming happened, or that the descriptor is still usable when the process dies. Covers SIGTERM, SIGINT and SIGHUP, checks the datagram is 64 bytes and leaves the gateware watchdog ENABLED (disabling it would tell the gateware to keep streaming forever at a host that is about to vanish -- the exact wedge this exists to prevent), and includes a NEGATIVE CONTROL: the same kill with the handlers not installed must send no stop, so the passes mean something. Confirmed on the real radio by the operator: pkill on a connected session, and the HL2 recovered and reconnected with no power cycle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Saved-radio autoconnect was wired to the Flex `RadioDiscovery::radioDiscovered` signal only. An HL2 arrives on a completely separate sweep — `hl2::Hl2Discovery` broadcasts HPSDR Protocol 1 discovery on UDP/1024 — and that signal was connected to the connect-list slots and nothing else. So an HL2 owner with `AutoConnectToLastRadio` on watched their radio appear in the picker and then had to click it by hand at every launch, with "Looking for your radio…" on screen the whole time. Extract the autoconnect lambda into `MainWindow::maybeAutoConnectToDiscoveredRadio()` and connect BOTH discovery sources to it. The slot is family-agnostic on purpose: wiring it to one discovery object is exactly what caused this, and any future backend that grows its own sweep gets autoconnect for free. Two behaviours the shared slot adds: - Fail closed on a busy non-Flex radio, matching the manual-connect gate in ConnectionPanel (aethersdr#4448). Protocol 1 is single-client, so connecting to an HL2 that is already streaming wedges both clients. Says so in the status line rather than silently doing nothing — this is the startup path and the operator is staring at "Looking for your radio…". - Also listen to `Hl2Discovery::radioUpdated`, not just `radioDiscovered`. A radio first seen as In_Use flips to Available via `radioUpdated` when the other client drops; without it the saved radio stays unconnected until it disappears from the sweep entirely and comes back. Flex behaviour is unchanged — same conditions, same order, same messages, now reached through a named method instead of an inline lambda. Verified on the HL2 at 00:1C:C0:A2:13:DD (gateware 74) with `AutoConnectToLastRadio=True` and that MAC saved as `LastConnectedRadioSerial`: the pre-fix build sat in "Looking for your radio…" for 45s with no autoconnect while the radio was answering discovery the whole time; the fixed build connects on its own at startup, confirmed over four consecutive launches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…orks over VPN The "Connect by IP" page had no way to say what it was dialing. It probed for a Hermes-Lite 2 first (a ~600 ms HPSDR Protocol 1 discovery datagram to UDP/1024) and fell through to the FlexRadio TCP/4992 command-plane probe if nothing answered. That made every Flex connect pay the HL2 timeout, and it gave an HL2 operator no signal about which protocol had actually been tried when the connect failed. Add a **Radio type** dropdown — FlexRadio or Hermes-Lite 2 — and probe exactly the family the operator picked. The two wire protocols are disjoint: a Hermes-Lite 2 never answers the Flex probe, and a FlexRadio never answers a Metis discovery datagram, so there is nothing to gain from trying both. A directed (unicast) Metis discovery is the whole reason connect-by-IP matters for an HL2 — Hl2Discovery's broadcast sweep never leaves the local subnet, so a radio across a VPN or a routed subnet was unreachable from the UI. Also: - Extract the probe into ConnectionPanel::probeHermesLite2() and reuse MetisProtocol's discoveryRequest()/parseDiscoveryReply()/isHermesLite2() instead of the hand-rolled byte poking the inline version used, and promote Hl2Discovery::macToSerial() so a directed probe produces byte-identical identity to a broadcast sweep. That serial IS the auto-reconnect key and the client-side nickname key, so the two paths must agree. - Honour the Advanced VPN source-path selection in the HL2 probe. It bound to AnyIPv4 unconditionally, which on a VPN exposing several adapters can send the request out the wrong interface. - Remember a successful HL2 address: it now calls rememberManualIp() and saveManualProfile() like the Flex path already did, so an HL2 lands in the recent-addresses dropdown, and the chosen family is persisted both globally (ConnectByIpRadioFamily) and per-address (routed profile identity.family) so picking a recent address restores its type. Profiles written before this carry no family and are read as flex, which is what they were. - Mark the HL2 result isRouted, matching what the Flex manual probe records. Bridge: `connect ip <host-or-ip> [flex|hl2]`. Omitting the type keeps the dialog's current selection, so existing scripts are unaffected; the response echoes "family". Rejects anything that is not flex or hl2 rather than silently probing the wrong protocol. Layout: adding a second row overflowed the dialog, and Qt resolved the shortfall by overlapping the labels with their own entry fields. The nested QFormLayout is replaced with explicit label+field rows, the wrapped result label now reserves its height instead of reflowing the page when a message appears, and the height floor comes from the panel's own content rather than the hardcoded 580/660 in MainWindow. Verified against the Hermes-Lite 2 at 192.168.1.21 over a VPN (broadcast discovery cannot see it): connected by IP from both the bridge verb and the GUI button, serial 00:1C:C0:A2:13:DD and gateware 74 matching a raw discovery probe exactly, live off-air RF on the panadapter (WWV at 10.000 MHz). Selecting FlexRadio for that same address correctly does not connect, and the radio was left answering discovery 0x02 idle. Full suite green, 192/192. Includes aethersdr#4503 and aethersdr#4507.
There was a problem hiding this comment.
Good change, and the reasoning in both the description and the code comments is the kind I wish more PRs had — the "the two protocols are disjoint, so stop paying the HL2 timeout on every Flex connect" framing is right, and reusing MetisProtocol's parse plus promoting macToSerial() so the directed probe and the broadcast sweep produce the same identity is exactly the correct instinct. Hardware verification over an actual VPN, including the negative case (selecting FlexRadio for that address does not connect), is convincing.
Three things I'd like fixed before merge, all in probeHermesLite2() and all in the same "the directed path should match the broadcast path" spirit that motivated macToSerial(): the nickname is not shared the way the serial now is, hostnames silently can't work on the HL2 path even though the bridge verb documents <host-or-ip>, and a bind failure is reported to the operator as "no radio answered".
Scope note: this stacks #4503 and #4507, so roughly 2,100 of the 2,548 changed lines belong to those PRs. I reviewed the feat(connect) commit as the new work here; the rest should land through its own PR first.
Would like fixed before merge
ConnectionPanel.cpp:1871— connect-by-IP shows "Hermes-Lite 2" instead of the operator's saved nickname; the broadcast sweep useseffectiveNickname().ConnectionPanel.cpp:1846— a hostname can never reach an HL2 (QHostAddress(ip)is null), and the failure looks like "radio didn't answer".ConnectionPanel.cpp:1840— bind failure is collapsed into the "no HL2 answered" message; the Flex probe distinguishes it.
Polish
ConnectionPanel.cpp:861— the hardcoded height floor moved rather than went away, and 700 px is tight on a 768 px-tall display.Hl2Discovery.h:51— the new doc block landed insidenicknameSettingsKey()'s comment.AutomationServer.cpp:5196— non-staticQRegularExpression, unlike the rest of the file.
Non-blocking notes
IConnectionAutomation::automationConnectByIp()gains a defaulted parameter on a virtual. Defaults bind statically, so a future implementer that defaults it differently would silently disagree with a call through the base pointer. OnlyConnectionPanelimplements it today, so this is harmless now — just worth knowing it's a trap if a second implementer (aetherd?) shows up.- The probe still blocks the GUI thread for up to 600 ms in
waitForReadyRead(), so the "Checking…" text likely never paints before the result replaces it. Pre-existing, not introduced here. - CodeGuard's six CG-PATH-001 hits are false positives and I've dropped them: the
MainWindow.cpplines are the FFTW-wisdom progress dialog and the macOS relaunch path derived fromQCoreApplication::applicationFilePath(), andhl2_live_band_filter_probe.cpp:98reads a host fromargv[1], not a path. No untrusted path input in any of them.
🤖 aethersdr-agent · cost: $6.1861 · model: claude-opus-5
| info.nickname = info.model; | ||
| info.serial = hl2::Hl2Discovery::macToSerial(reply->mac); |
There was a problem hiding this comment.
The directed probe hard-codes the nickname to the model string, while the broadcast sweep uses the operator's saved name (Hl2Discovery.cpp:168 — effectiveNickname(info.serial, info.model)). So an operator who named their HL2 in Radio Setup sees that name when it's discovered locally and "Hermes-Lite 2" when they reach the same radio over the VPN. That's the same class of divergence the macToSerial() promotion was meant to close, and effectiveNickname() is already a public static on Hl2Discovery.
Needs the serial computed first, so:
| info.nickname = info.model; | |
| info.serial = hl2::Hl2Discovery::macToSerial(reply->mac); | |
| info.serial = hl2::Hl2Discovery::macToSerial(reply->mac); | |
| // Same nickname the broadcast sweep shows for this MAC: an HL2 has | |
| // no on-radio name store, so the operator's custom name is kept | |
| // client-side keyed by serial. The two paths have to agree. | |
| info.nickname = hl2::Hl2Discovery::effectiveNickname(info.serial, info.model); |
| const auto request = hl2::discoveryRequest(); | ||
| hpsdr.writeDatagram(reinterpret_cast<const char*>(request.data()), | ||
| qint64(request.size()), | ||
| QHostAddress(ip), |
There was a problem hiding this comment.
QHostAddress(ip) returns a null address for anything that isn't a literal IP, so a hostname produces a writeDatagram() to a null destination — no request leaves, and 600 ms later the operator gets "No Hermes-Lite 2 answered at myradio.local. Check the address, and that the radio is powered…", which points at the radio for what is actually an unsupported input.
That matters because the Flex path does accept names (connectToHost() resolves), and docs/automation-bridge.md documents the verb as connect ip <host-or-ip> [flex|hl2] — so the doc promises something this path can't do.
Either resolve first (QHostInfo::fromName() is synchronous and this path already blocks) or detect the null address up front and say so explicitly:
const QHostAddress dest(ip);
if (dest.isNull()) { /* resolve, or report "enter an IP address for a Hermes-Lite 2" */ }Also worth checking writeDatagram()'s return value — right now a send failure of any kind is indistinguishable from a silent radio.
| && !bindSettings.bindAddress.isNull() | ||
| ? hpsdr.bind(bindSettings.bindAddress, 0) | ||
| : hpsdr.bind(QHostAddress(QHostAddress::AnyIPv4), 0); | ||
| if (!bound) |
There was a problem hiding this comment.
A failed bind returns false here, and the caller turns every false into "No Hermes-Lite 2 answered at %1. Check the address, and that the radio is powered, idle, and reachable on UDP port 1024." So an operator whose explicit VPN source path can no longer be bound is told to go check the radio.
probeFlexRadio() handles the same case properly (ConnectionPanel.cpp:1919): it sets m_manualSourceWarningLabel, reveals Advanced, and says "AetherSDR could not use that VPN source path. Try Auto or choose another path." Since this PR's whole point is that the operator should know which protocol was tried and why it failed, the HL2 path should reach the same message — e.g. set the source warning and message here before returning, or return a small enum/QString* reason so probeRadio() can pick the right text.
| // restored at the old 660 px height clipped the bottom of that page, so | ||
| // hold a floor that fits it. Everything above the stack is fixed-height, so | ||
| // this is the page height plus the surrounding chrome. | ||
| setMinimumHeight(700); |
There was a problem hiding this comment.
The magic number moved from MainWindow into the panel rather than being derived — MainWindow.cpp now calls minimumHeight(), which returns this constant, so it's still hand-maintained, just in one place instead of two. That's an improvement, but a hard 700 px floor is worth a second look: on a 1366×768 laptop, minus the menu bar and window chrome, a dialog that cannot be made shorter than 700 px is right at the edge of unusable, and a frameless dialog gives the user no OS-level escape.
If the reserved-height result label and the group's 240 px floor are doing the real work, layout()->minimumSize().height() (or just letting sizeHint() drive resize() with a lower setMinimumHeight) would keep the page from clipping without pinning a number the content no longer controls.
| // Canonical "AA:BB:CC:DD:EE:FF" rendering of a discovery reply's MAC, which | ||
| // IS RadioInfo::serial for this family. Shared so a directed (unicast) | ||
| // probe — ConnectionPanel's connect-by-IP path — produces byte-identical | ||
| // identity to a broadcast sweep. They must agree: the serial is the | ||
| // auto-reconnect key and the client-side nickname key. | ||
| static QString macToSerial(const std::array<std::uint8_t, 6>& mac); |
There was a problem hiding this comment.
This block landed inside nicknameSettingsKey()'s doc comment — lines 46–50 ("AppSettings key for a user-assigned nickname… Shared with RadioSetupDialog so both sides agree on the key") now read as the opening of macToSerial()'s documentation, and nicknameSettingsKey() two lines below is left undocumented. Worth moving the macToSerial() declaration + its comment below nicknameSettingsKey() (or above the nickname block with a blank line) so each comment sits on its own function.
| // The optional family picks which wire protocol to probe. Omitted keeps | ||
| // whatever the connect dialog's radio-type selector is set to, so every | ||
| // pre-existing `connect ip <addr>` script keeps working. | ||
| const QStringList ipTokens = arg.trimmed().split(QRegularExpression("\\s+"), |
There was a problem hiding this comment.
Non-static QRegularExpression constructed on every connect ip — the rest of this file uses static const QRegularExpression for exactly this (e.g. line 953). Not hot enough to matter for performance, but clazy flags the pattern and it's inconsistent with the neighbours:
| const QStringList ipTokens = arg.trimmed().split(QRegularExpression("\\s+"), | |
| static const QRegularExpression ipTokenSep(QStringLiteral("\\s+")); | |
| const QStringList ipTokens = arg.trimmed().split(ipTokenSep, |
|
Cisco CodeGuard — static analysis of this PR (6 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $6.3373 · model: claude-opus-5 |
|
Antares Detector — candidate vulnerable file(s)
The hl2 backend implementation likely contains the radio selection logic for connections by IP (VPN support). Hl2Backend.cpp implements backend behavior and would be responsible for choosing a radio type when connecting via IP, making it the primary location to fix this issue. Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting. 🤖 aethersdr-agent · cost: $6.3373 · model: claude-opus-5 |
What
The Connect by IP page had no way to say what it was dialing. It probed for a Hermes-Lite 2 first (a ~600 ms HPSDR Protocol 1 discovery datagram to UDP/1024) and fell through to the FlexRadio TCP/4992 command-plane probe if nothing answered. Every Flex connect paid the HL2 timeout, and an HL2 operator got no signal about which protocol had actually been tried when a connect failed.
This adds a Radio type dropdown — FlexRadio or Hermes-Lite 2 — and probes exactly the family the operator picked. The two wire protocols are disjoint: a Hermes-Lite 2 never answers the Flex probe, and a FlexRadio never answers a Metis discovery datagram, so there is nothing to gain from trying both.
A directed (unicast) Metis discovery is the whole reason connect-by-IP matters for an HL2:
Hl2Discovery's broadcast sweep never leaves the local subnet, so a radio across a VPN or a routed subnet was simply unreachable from the UI.Also in here
ConnectionPanel::probeHermesLite2(), reusingMetisProtocol'sdiscoveryRequest()/parseDiscoveryReply()/isHermesLite2()instead of the hand-rolled byte poking the inline version used, and promotedHl2Discovery::macToSerial()so a directed probe produces byte-identical identity to a broadcast sweep. That serial IS the auto-reconnect key and the client-side nickname key — the two paths have to agree.AnyIPv4unconditionally, which on a VPN exposing several adapters can send the request out the wrong interface.rememberManualIp()+saveManualProfile(), as the Flex path already did. HL2 addresses previously never reached the recent-addresses dropdown. The chosen family persists globally (ConnectByIpRadioFamily) and per-address (routed profileidentity.family), so picking a recent address restores its type. Profiles written before this carry no family and read as flex — which is what they were.isRouted, matching what the Flex manual probe records.Bridge
connect ip <host-or-ip> [flex|hl2]. Omitting the type keeps the dialog's current selection, so existingconnect ip <addr>scripts are unaffected; the response echoes"family". Anything that is notflexorhl2is rejected rather than silently probing the wrong protocol. Documented indocs/automation-bridge.md.Layout
Adding a second row overflowed the dialog and Qt resolved the shortfall by overlapping the labels with their own entry fields. The nested
QFormLayoutis replaced with explicit label+field rows, the wrapped result label reserves its height instead of reflowing the page when a message appears, and the height floor now comes from the panel's own content rather than the hardcoded580/660inMainWindow.Verification
Against the real Hermes-Lite 2 at
192.168.1.21over a VPN, where broadcast discovery cannot see it:serial 00:1C:C0:A2:13:DD, gateware74— matching a rawEF FE 02discovery probe exactly, which is what proves the sharedmacToSerial()path.connectedflag.hl2gives the HL2-specific failure message rather than falling through.0x02idle after a normal disconnect.Screenshots (connect dialog + live panadapter) attached in the thread.
Note
This branch stacks on and includes #4503 and #4507.
🤖 Generated with Claude Code