Skip to content

Add local APRS adapter runner + Dire Wolf receive-only iGate (M2.2b)#11

Merged
kninetimmy merged 2 commits into
mainfrom
m2.2b-local-aprs
Jun 17, 2026
Merged

Add local APRS adapter runner + Dire Wolf receive-only iGate (M2.2b)#11
kninetimmy merged 2 commits into
mainfrom
m2.2b-local-aprs

Conversation

@kninetimmy

Copy link
Copy Markdown
Owner

What

The runtime around the M2.2a pure APRS parser (#10): read Dire Wolf's KISS TCP port, decode KISS + AX.25 → TNC2, and publish locally-heard APRS onto the bus with local_rf provenance and source health — the APRS twin of the M2.1b readsb runner (#6). Receive-only throughout; Dire Wolf owns the RX→APRS-IS gating, not us (APRS-FR-004).

This satisfies the remaining M2 exit criteria: both SDRs can run, valid RF APRS is gated to APRS-IS by Dire Wolf, there is no RF transmit path, and local APRS appears in the browser.

How it's built (mirrors local_adsb.py)

  • aprs_kiss.py — pure KISS de-framing + AX.25 UI → TNC2 decode. No sockets, fully fixture-tested. Correct bit math (callsign >>1, SSID bits 4..1, H-bit * only on digipeaters, SOURCE>DEST order), cross-read buffering, bounded remainder. Every malformation is contained as None.
  • local_aprs.pyAprsSource (async TCP reader that immediately half-closes its write side so nothing can ever push a frame toward the TNC), ThrottleGate with TTL + size eviction (a push stream has no per-poll snapshot to prune against, so memory is bounded by time/size instead), local_aprs_records() (per-line parse guarded), and run_local_aprs() with a fresh source + generator per bus reconnect.
  • aprs_fake_feeder.py — a fake TCP KISS server (the no-hardware stand-in for Dire Wolf); its frame builders are the exact inverse of the decoder and are reused by the unit tests.
  • config.py / main.pyAETHER_LOCAL_APRS* settings (loopback defaults, KISSPORT 8001) + lifespan wiring behind the toggle.
  • config/direwolf.conf.example + docs/local-aprs-igate.md — a provably receive-only iGate: no IGTXVIA/PTT/beacon/digipeat directives, placeholder callsign, no coordinates.

Receive-only guardrail (decision 1 / PRD §2.3 / §18.3)

No transmit path in code or config. To keep it that way, a receive-only tripwire in both scripts/check.sh and CI fails the build if any uncommented transmit/beacon/digipeat/IGTXVIA directive appears in the sample Dire Wolf config — the load-bearing property is now self-enforcing.

How this was produced

Built via a multi-agent (ultracode) workflow — adversarial spec verification (KISS/AX.25/iGate) → design → implement → a review panel (no-TX safety audit, framing-vs-spec, failure-isolation). Two findings were applied beyond the first pass: unbounded ThrottleGate memory (the prune() was never called on a push stream → switched to TTL+size eviction) and a per-line parser guard. A maintainer review pass then added a decoder bounds-check (a frame whose addresses consume every octet would IndexError past the "no exception escapes" contract — PRD §37) with a regression test, and verified the receive-only tripwire is comment-aware.

Tests

KISS/AX.25 decode + runner/throttle units, and an integration test driving fake-server → adapter → bus → state → /ws/v2 (skips without a broker; CI starts Mosquitto). Full local gate green: ruff + mypy --strict + 123 tests.

Scope

Mic-E / telemetry / message decoding stay deferred (the parser recognizes and skips them); emergency-bypass in the throttle is best-effort until Mic-E lands.

🤖 Generated with Claude Code

The runtime around the M2.2a pure parser: read Dire Wolf's KISS TCP port,
decode to TNC2, and publish locally-heard APRS onto the bus with local_rf
provenance and source health — the APRS twin of the M2.1b readsb runner.
Receive-only throughout; Dire Wolf owns the RX->APRS-IS gating.

- aprs_kiss.py: pure KISS de-framing + AX.25 UI -> TNC2 decode (no sockets,
  fully fixture-tested). Correct bit math (callsign >>1, SSID bits 4..1, H-bit
  '*' only on digipeaters, SOURCE>DEST order), cross-read buffering, bounded
  remainder. Malformation is contained as None — including a frame whose
  address block consumes every octet (no control/PID), which would otherwise
  IndexError past the decoder's "no exception escapes" contract (PRD §37).
- local_aprs.py: AprsSource (async TCP reader that half-closes its write side
  so nothing can push a frame toward the TNC), ThrottleGate with TTL+size
  eviction (a push stream has no snapshot to prune against, so memory is bound
  by time/size instead), local_aprs_records() (per-line parse guarded so one
  bad packet can't unwind the stream), and run_local_aprs() with a fresh
  source+generator per bus reconnect (the M2.1b closed-generator lesson).
- aprs_fake_feeder.py: a fake TCP KISS server (the no-hardware stand-in for
  Dire Wolf) whose frame builders are the exact inverse of the decoder.
- config.py/main.py: AETHER_LOCAL_APRS* settings (loopback defaults) + lifespan
  wiring behind the toggle.
- config/direwolf.conf.example + docs/local-aprs-igate.md: a provably
  receive-only iGate (no IGTXVIA/PTT/beacon/digipeat; placeholder callsign,
  no coordinates). A receive-only tripwire in scripts/check.sh and CI fails the
  build if any uncommented transmit directive appears in the sample config.
- Tests: KISS/AX.25 decode + runner/throttle units, and an integration test
  driving fake-server -> adapter -> bus -> state -> /ws/v2 (skips w/o a broker).
  Full gate green: ruff + mypy --strict + 123 tests.

Scope: Mic-E/telemetry/message decoding stay deferred (parser recognizes and
skips them); emergency-bypass in the throttle is best-effort until Mic-E lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a local APRS adapter that ingests 144.39 MHz APRS reception by reading decoded AX.25 frames from Dire Wolf's KISS TCP port. It includes a receive-only sample configuration, a fake KISS server for no-hardware verification, and robust de-framing, decoding, and throttling logic. The code review feedback highlights three critical issues in local_aprs.py: first, half-closing the write side of the TCP connection with write_eof() will cause real Dire Wolf instances to drop the connection; second, the local_aprs_records generator needs a try...finally block to prevent socket leaks upon cancellation; and third, the background task must catch OSError alongside MqttError to avoid crashing during connection or resolution failures.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +153 to +159
# Receive-only: we never write to the KISS socket. Half-close the write
# side immediately so nothing can ever push a frame toward the TNC.
try:
if writer.can_write_eof():
writer.write_eof()
except OSError: # some transports disallow half-close; just never write
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling writer.write_eof() to half-close the write side of the TCP connection will cause real Dire Wolf instances to immediately close the connection. Dire Wolf's KISS TCP server reads from the client to receive transmit frames; when it detects EOF (FIN) from the client, it interprets it as a connection closure and closes its side of the socket. This prevents the adapter from receiving any packets from a real Dire Wolf server. To maintain compatibility while remaining receive-only, simply avoid writing to the socket instead of half-closing it.

Suggested change
# Receive-only: we never write to the KISS socket. Half-close the write
# side immediately so nothing can ever push a frame toward the TNC.
try:
if writer.can_write_eof():
writer.write_eof()
except OSError: # some transports disallow half-close; just never write
pass
# Receive-only: we never write to the KISS socket. We keep the write side
# open to prevent Dire Wolf from closing the connection on EOF.

Comment on lines +282 to +347
while True:
try:
async for line in source.frames():
backoff = INITIAL_BACKOFF_S # a live read means we're connected
now = _now()
try:
track = parse_aprs_packet(line, received_at=now, source=SOURCE)
except Exception: # one bad packet must not drop the rest of the stream
# Mirrors parse_aprs_lines' per-line guard: a parser edge on one
# decoded line is contained as a rejected record, never unwinds
# the stream and crashes the adapter (PRD §17.2, §37).
log.warning("skipping malformed APRS packet", exc_info=True)
rejected += 1
yield _status(
"connected",
now,
records_received=received,
records_rejected=rejected,
attributes=attrs,
)
continue
if track is None:
# Deferred (Mic-E/telemetry/message) or junk: counted, not shown.
rejected += 1
yield _status(
"connected",
now,
records_received=received,
records_rejected=rejected,
attributes=attrs,
)
continue
if gate.admit(track.id, now, emergency="emergency" in track.tags):
received += 1
yield track
yield _status(
"connected",
now,
records_received=received,
records_rejected=rejected,
last_record_at=track.observed_at,
attributes=attrs,
)
except (TimeoutError, ConnectionError, OSError) as exc:
now = _now()
log.warning("local APRS socket error (%s); backing off", exc)
yield _status(
"degraded",
now,
records_received=received,
records_rejected=rejected,
error_code=type(exc).__name__,
error_summary=str(exc)[:200],
attributes=attrs,
)
await source.close()
sleep_for, backoff = _backoff(backoff)
await asyncio.sleep(sleep_for)
# Re-open the SAME source object (a fresh socket) and resume the stream.
continue
# frames() returned without an error (the async-for fell through): the
# socket closed cleanly. Treat like a drop and reconnect.
await source.close()
sleep_for, backoff = _backoff(backoff)
await asyncio.sleep(sleep_for)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The local_aprs_records async generator does not wrap its main loop in a try...finally block to ensure the source is closed upon cancellation or generator disposal. If the task running this generator is cancelled, the TCP socket remains open, leading to a resource leak. Wrapping the loop in try...finally ensures await source.close() is always executed.

    try:
        while True:
            try:
                async for line in source.frames():
                    backoff = INITIAL_BACKOFF_S  # a live read means we're connected
                    now = _now()
                    try:
                        track = parse_aprs_packet(line, received_at=now, source=SOURCE)
                    except Exception:  # one bad packet must not drop the rest of the stream
                        # Mirrors parse_aprs_lines' per-line guard: a parser edge on one
                        # decoded line is contained as a rejected record, never unwinds
                        # the stream and crashes the adapter (PRD §17.2, §37).
                        log.warning("skipping malformed APRS packet", exc_info=True)
                        rejected += 1
                        yield _status(
                            "connected",
                            now,
                            records_received=received,
                            records_rejected=rejected,
                            attributes=attrs,
                        )
                        continue
                    if track is None:
                        # Deferred (Mic-E/telemetry/message) or junk: counted, not shown.
                        rejected += 1
                        yield _status(
                            "connected",
                            now,
                            records_received=received,
                            records_rejected=rejected,
                            attributes=attrs,
                        )
                        continue
                    if gate.admit(track.id, now, emergency="emergency" in track.tags):
                        received += 1
                        yield track
                        yield _status(
                            "connected",
                            now,
                            records_received=received,
                            records_rejected=rejected,
                            last_record_at=track.observed_at,
                            attributes=attrs,
                        )
            except (TimeoutError, ConnectionError, OSError) as exc:
                now = _now()
                log.warning("local APRS socket error (%s); backing off", exc)
                yield _status(
                    "degraded",
                    now,
                    records_received=received,
                    records_rejected=rejected,
                    error_code=type(exc).__name__,
                    error_summary=str(exc)[:200],
                    attributes=attrs,
                )
                await source.close()
                sleep_for, backoff = _backoff(backoff)
                await asyncio.sleep(sleep_for)
                # Re-open the SAME source object (a fresh socket) and resume the stream.
                continue
            # frames() returned without an error (the async-for fell through): the
            # socket closed cleanly. Treat like a drop and reconnect.
            await source.close()
            sleep_for, backoff = _backoff(backoff)
            await asyncio.sleep(sleep_for)
    finally:
        await source.close()

Comment on lines +384 to +387
except aiomqtt.MqttError as exc:
sleep_for, backoff = _backoff(backoff)
log.warning("local APRS lost broker (%s); reconnecting in %.1fs", exc, sleep_for)
await asyncio.sleep(sleep_for)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The run_local_aprs background task only catches aiomqtt.MqttError. However, during the initial connection or reconnection, connect(cfg, ...) can raise OSError (such as ConnectionRefusedError or socket.gaierror if the broker host is temporarily unresolvable). If an OSError is raised, it will propagate out of the loop and crash the background task permanently, preventing any future reconnection attempts. Catching OSError along with aiomqtt.MqttError ensures the runner is resilient to temporary broker/network outages.

Suggested change
except aiomqtt.MqttError as exc:
sleep_for, backoff = _backoff(backoff)
log.warning("local APRS lost broker (%s); reconnecting in %.1fs", exc, sleep_for)
await asyncio.sleep(sleep_for)
except (aiomqtt.MqttError, OSError) as exc:
sleep_for, backoff = _backoff(backoff)
log.warning("local APRS lost broker (%s); reconnecting in %.1fs", exc, sleep_for)
await asyncio.sleep(sleep_for)

test_aprs_kiss.py imports `from tests.fixtures.aprs import kiss_frames`. That
only resolved under `python -m pytest` (which adds CWD to sys.path), as
scripts/check.sh runs — but CI invokes bare `pytest`, which does not, so
collection failed with ModuleNotFoundError: No module named 'tests'. Set
pythonpath = ["."] in the pytest config so both invocations are equivalent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kninetimmy kninetimmy merged commit 7346306 into main Jun 17, 2026
5 checks passed
@kninetimmy kninetimmy deleted the m2.2b-local-aprs branch June 17, 2026 22:05
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.

1 participant