Report runtime status so quiet water is distinguishable from a dead receiver - #16
Merged
Conversation
…eceiver
systemd can only tell you aiscot is "active (running)". It says that with
equal confidence when the antenna is disconnected, when the AIS receiver has
stopped feeding, when UNDERWAY_ONLY is silently dropping every moored hull in
the harbour, and when there is genuinely nothing on the water. All four look
identical from outside the process, and until now the only way to tell them
apart was to read journal text on the box.
This writes /run/aiscot/status.json (pytak.StatusWriter, added in pytak
7.4.0) so Cockpit can answer the questions systemd cannot:
rx AIS messages decoded
emitted messages that produced a CoT event
no_position Type 5/19/24 static & voyage data and partial multi-line
sentences -- no position, but this is where every ship
NAME comes from, and is normal traffic, not an error
no_cot positioned vessel that produced no CoT: UNDERWAY_ONLY
dropped a moored hull, IGNORE_ATON dropped a nav aid, or
the position failed validation
filtered_unknown dropped by the KNOWN_CRAFT allowlist
tracked vessels held in the static cache / in the current feed
plus a per-minute trend for a sparkline and a ring buffer of recent decodes
(mmsi, shipname, type, speed, placed).
The split between no_position, no_cot and filtered_unknown is the point. All
three previously presented as "no CoT came out", but each has a different
fix: nothing, a config knob, or a different config knob. Distinguishing them
is the difference between checking a checkbox and driving out to the site.
Static messages are shown in the feed even though they plot nothing. Hearing
a vessel name is proof the receiver is working, and on a quiet stretch of
water it may be the only traffic there is -- a feed showing only plotted
vessels would render that as an empty panel, which reads as a dead antenna.
The `placed` flag keeps both facts visible at once.
Both ingest paths are covered: AISNetworkClient (RF/NMEA over UDP, which is
what AryaOS boxes actually run) shares the owning AISWorker's status writer,
and AISWorker._process_message covers the AISHub/SeaVision HTTP feeds.
run() writes once with force=True before any traffic, because otherwise the
UI shows "no status from this gateway" until the first contact --
indistinguishable from failing to start, and AIS over RF can be silent for
many minutes on an inland or quiet-water site. _network_rx() re-writes once
the socket is bound so the panel can name the listener. A 5s heartbeat task
then keeps the file changing so an idle-but-healthy gateway is not reported
as wedged; it is a task rather than a timer in the feed loop because the RF
path parks on a datagram socket with no period at all.
Also fixes a real bug found while adding this: AISNetworkClient.handle_message
did not guard pyAISm.decod_ais(), which raises on a bad checksum or a
non-AIVDM line. Corrupt sentences are routine on RF AIS, so that exception
was escaping datagram_received() into asyncio's handler and logging a
traceback per corrupt burst. Now caught, logged at debug, and deliberately
NOT counted as `rx` -- so a mis-wired feed of pure garbage cannot look like
healthy traffic.
StatusWriter is acquired defensively via getattr() at import, with a _NoStatus
no-op stand-in. This is not hypothetical: fleet boxes run pytak 7.3.13, which
has no StatusWriter, and a hard pytak.StatusWriter reference would take the
gateway down at import over its own telemetry helper. Moving CoT is the job;
reporting on it is not. The degradation is visible rather than silent -- with
nothing writing the file, the Cockpit plugin says so explicitly instead of
rendering an empty feed as though the water were empty.
Adds aiscot.__version__, read from the VERSION file beside the module first
and importlib.metadata second. VERSION is what is actually executing;
importlib.metadata reports what is installed, which during development is a
different, older answer.
Tests use asyncio.run() rather than bare `async def`, because pytest-asyncio
is not installed everywhere and pytest SKIPS unmarked coroutine tests while
still counting them as a passing run -- tests that cannot fail. They are
guarded with skipif on hasattr(pytak, "StatusWriter") so they stay honest on
an old pytak rather than failing for the wrong reason.
Verified end-to-end against a real AIVDM sentence over a real UDP socket: the
startup write lands before any traffic, rx/emitted increment, the recent feed
carries the MMSI, a bad sentence is not counted, the listener is named, and
the heartbeat advances wall_t while idle. debian/aiscot.service already sets
RuntimeDirectory=aiscot.
Test counts (full suite, before -> after):
pytak 7.4.0 (StatusWriter present): 70 passed -> 86 passed
pytak 7.3.11 (no StatusWriter): 49 passed -> 50 passed, 15 skipped
The 21 pre-existing failures on 7.3.11 are unrelated: aiscot has required
pytak >= 7.3.12 for pytak.cot_event() since well before this change.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197da7dhvcPoHxYKamrYqyM
asyncio.Queue() and asyncio.Event() bind to the current event loop on Python
< 3.10, and asyncio.run() leaves no current loop behind when it returns -- so
constructing an AISWorker or AISNetworkClient in a test body works on 3.13
and fails on 3.9 with "There is no current event loop", but only in whichever
test happens to run after the first asyncio.run(). Order-dependent, and
invisible locally. Caught on the sibling adsbcot PR, where 3.9 CI failed eight
tests this way; fixed here before it could bite.
Construction now happens inside the loop that also runs the call under test,
via a small _in_loop() helper. No change to what is asserted; the suite still
fails 3/16 with count("no_position") and count("no_cot") stubbed out.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197da7dhvcPoHxYKamrYqyM
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.
Why
systemd can only tell you
aiscotis active (running). It says that with equal confidence when the antenna is disconnected, when the AIS receiver has stopped feeding, whenUNDERWAY_ONLYis silently dropping every moored hull in the harbour, and when there is genuinely nothing on the water. All four are indistinguishable from outside the process, and the only way to tell them apart was to read journal text on the box.This writes
/run/aiscot/status.jsonviapytak.StatusWriter(new in pytak 7.4.0) so Cockpit can answer what systemd cannot.Counters
rxemittedno_positionno_cotUNDERWAY_ONLYdropped a moored hull,IGNORE_ATONdropped a nav aid, or the position failed validationfiltered_unknownKNOWN_CRAFTallowlisttrackedPlus a per-minute trend for a sparkline and a ring buffer of recent decodes (
mmsi,shipname,type,speed,placed).The split between
no_position,no_cotandfiltered_unknownis the point. All three previously presented as "no CoT came out", but each has a different fix: nothing, a config knob, or a different config knob. Distinguishing them is the difference between checking a checkbox and driving out to the site.Static messages are shown in the feed even though they plot nothing. Hearing a vessel name is proof the receiver is working, and on a quiet stretch of water it may be the only traffic there is — a feed showing only plotted vessels would render that as an empty panel, which reads as a dead antenna. The
placedflag keeps both facts visible at once.Both ingest paths are covered:
AISNetworkClient(RF/NMEA over UDP, which is what AryaOS boxes actually run) shares the owningAISWorker's status writer, andAISWorker._process_messagecovers the AISHub/SeaVision HTTP feeds.Startup write and heartbeat
run()writes once withforce=Truebefore any traffic, because otherwise the UI shows "no status from this gateway" until the first contact — indistinguishable from failing to start, and AIS over RF can be silent for many minutes on an inland or quiet-water site._network_rx()re-writes once the socket is bound so the panel can name the listener.A 5s heartbeat task then keeps the file changing so an idle-but-healthy gateway is not reported as wedged. It is a task rather than a timer in the feed loop because the RF path parks on a datagram socket with no period at all.
Bug fixed along the way
AISNetworkClient.handle_message()did not guardpyAISm.decod_ais(), which raises on a bad checksum or a non-AIVDM line. Corrupt sentences are routine on RF AIS, so that exception was escapingdatagram_received()into asyncio's handler and logging a traceback per corrupt burst.Now caught, logged at debug, and deliberately not counted as
rx— so a mis-wired feed of pure garbage cannot look like healthy traffic. Two tests cover it, one with a non-AIVDM line and one with a genuinely bad checksum.Graceful and visible degradation
StatusWriteris acquired viagetattr(pytak, "StatusWriter", None)at import, with a_NoStatusno-op stand-in.This is not hypothetical: fleet boxes run pytak 7.3.13, which has no StatusWriter at all. A hard
pytak.StatusWriterreference would take the gateway down at import over its own telemetry helper. Moving CoT is the job; reporting on it is not.The degradation is visible rather than silent — with nothing writing the file, the Cockpit plugin says so explicitly instead of rendering an empty feed as though the water were empty.
Supporting change
aiscot.__version__, read from theVERSIONfile beside the module first andimportlib.metadatasecond.VERSIONis what is actually executing;importlib.metadatareports what is installed, which during development is a different, older answer.Tests
New
tests/test_status.pydrives coroutines withasyncio.run()rather than bareasync def. That is not a style choice: pytest SKIPS unmarked coroutine tests while still counting the run as passing — tests that cannot fail. (Verified by mutation: stubbing outcount("no_position")andcount("no_cot")fails 3 of them.)Status tests are guarded with
skipif(not hasattr(pytak, "StatusWriter"))so they stay honest on an old pytak rather than failing for the wrong reason.The 21 failures on 7.3.11 are pre-existing and unrelated —
aiscothas required pytak >= 7.3.12 forpytak.cot_event()since well before this change.Verified
End-to-end against a real AIVDM sentence over a real UDP socket: the startup write lands before any traffic,
rx/emittedincrement, the recent feed carries the MMSI, a garbage sentence is not counted, the listener is named (udp://0.0.0.0:15600), and the heartbeat advanceswall_twhile idle.debian/aiscot.servicealready setsRuntimeDirectory=aiscot, so no unit change was needed.🤖 Generated with Claude Code
https://claude.ai/code/session_0197da7dhvcPoHxYKamrYqyM