fix(adapters): promote device-lost signalling into the base class (#41) - #42
Merged
Conversation
`device_lost` was a bare attribute on RadioAdapter that only soapy.py ever set, so core/engine.py's two guards — refuse an AE connection when the radio is gone (:1068), drop AE rather than serve a dead stream (:2193) — were dead code for every other adapter. Both read it with getattr(..., False), so they silently did nothing. The visible result, with a Radioberry powered off: the gate came up, logged "board=0x00", advertised a FLEX-6700, and AE connected and sat on "Connecting to radio..." with a black waterfall and a full TX surface — the exact failure soapy.py's own comment says the guard exists to prevent. Promoted rather than copied. base.py gains note_device_alive() and note_device_silent(reason), so an adapter signals health from its read loop instead of re-implementing the threshold logic. The clock runs from the last evidence of LIFE, not from the first silent call: a source alternating one good read with a burst of failures is not healthy, and resetting on each good read would hide that forever. note_device_silent() returns True only on the transition, so a hot loop can log exactly once. The HPSDR adapter now uses them, and its open() no longer trusts --radio-ip blindly. `ip = self.radio_ip or self._discover(s)` short-circuits the discovery check whenever an address is supplied — which every systemd unit in deploy/ does — so the "no HPSDR device found" RuntimeError was unreachable in the shipped configuration, and the follow-up _discover() result was discarded. It is now checked, and the board id prints as a reading rather than an `or 0` fallback that looked like one. Soapy is left alone: its own detection is richer than the helper (it also watches for unchanged buffers while the driver reports success) and it works. test_device_lost.py locks the contract and is registered in tests.yml — it is stdlib-only, so it joins the existing job rather than needing a new one. Both mutations were run: making note_device_silent() never fire, and resetting the clock on silence instead of tracking last-alive. Each fails the suite. Not covered: only the HPSDR adapter was audited against a powered-off radio. The same `self.<ip> or self._discover(...)` shape may exist elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nigelfenton
force-pushed
the
fix/device-lost-in-base-class
branch
from
September 2, 2026 04:32
e74c70a to
355c9a0
Compare
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.
Fixes #41.
The failure
With a Radioberry powered off, the gate came up anyway, advertised a
FLEX-6700, and AE connected and sat on "Connecting to radio…" with a black
waterfall, an S-meter at −127 dBm and a full TX surface — for hardware that was
not powered on.
Two causes, both fixed
1.
device_lostwas Soapy-only. It was declared onRadioAdapterbut setin exactly one file.
core/engine.py's two guards — refuse an AE connectionwhen the radio is gone (
:1068) and drop AE rather than serve a dead stream(
:2193) — both read it withgetattr(..., False), so for every other adapterthey silently did nothing. The comments at both sites describe a guarantee that
only ever held for one adapter.
2.
--radio-ipshort-circuited the board check.ip = self.radio_ip or self._discover(s)means theRuntimeError("no HPSDR device found…")isunreachable whenever an address is supplied — which every systemd unit in
deploy/does. The follow-up_discover()ran but its result was discarded, soboard=0x00(theor 0fallback) printed as though it were a reading.Promoted, not copied
base.pygainsnote_device_alive()andnote_device_silent(reason), so anadapter signals health from its read loop instead of re-implementing threshold
logic. Design points worth reviewing:
call. A source alternating one good read with a burst of failures is not
healthy; resetting on each good read would hide that forever.
note_device_silent()returns True only on the transition, so a hot loopcan log exactly once.
"it was never there" is
open()'s job, and it has a better error.device_lost_after_sdefaults to Soapy's measured 3 s and is overridable.Soapy is deliberately left alone. Its detection is richer than the helper
(it also watches for unchanged buffers while the driver reports success) and it
works. This PR makes the contract available to everyone else rather than
rewriting the one adapter that already had it.
The HPSDR adapter now uses the helpers in its EP6 read loop, verifies the board
even when
--radio-ipis given, and prints the board id as a reading.Verified
The original failure, reproduced fixed on the same hardware. Radioberry
still powered off, Pi 5, this branch:
Ports 7993/8797 never opened — nothing was advertised, so there is no
phantom radio for AE to find.
Tests.
test_device_lost.pylocks the contract; stdlib-only, so it joins theexisting CI job rather than needing a new one. Full suite 193 passed on
Windows; the CI list 20/20 on both x86-64 and the Pi's ARM64.
Both mutations run rather than assumed — making
note_device_silent()neverfire, and resetting the clock on silence instead of tracking last-alive. Each
fails the suite; neither is a green re-run.
Not covered
Only the HPSDR adapter was audited against a powered-off radio. The same
self.<ip> or self._discover(...)shape may exist in other adapters — I havenot checked. Nothing here was tested against a live Radioberry, so the healthy
path is exercised only by the unit tests and by the fact that the failure path
now refuses correctly.