Skip to content

Add local ADS-B (readsb) snapshot parser (M2.1a)#5

Merged
kninetimmy merged 1 commit into
mainfrom
m2.1a-readsb-parser
Jun 16, 2026
Merged

Add local ADS-B (readsb) snapshot parser (M2.1a)#5
kninetimmy merged 1 commit into
mainfrom
m2.1a-readsb-parser

Conversation

@kninetimmy

Copy link
Copy Markdown
Owner

First slice of M2 — Local RF baseline: a pure, hardware-free parser that normalizes a readsb/dump1090 aircraft.json snapshot into schema-v2 TrackRecords (PRD §18.1, §17.2 adapter rules, §34 DoD items 2/3/5/6).

What's in it

  • src/aether/adapters/readsb.pyparse_aircraft_snapshot() / aircraft_to_track()
    • SI unit normalization (ft→m, kt→m/s, ft/min→m/s)
    • impossible coordinates drop geometry but keep the identified track (§17.2)
    • emergency-squawk (7500/7600/7700) / on_ground / non_icao / bad_position tagging
    • native fields (rssi, registration, type, category…) preserved in attributes
    • identity aircraft:icao:<hex> as id + correlation_key (M3 fusion hook)
    • locally_received + local_rf provenance; source=local_adsb
    • one malformed aircraft entry is skipped, not the whole snapshot
  • tests/fixtures/readsb/aircraft.json + 14 unit tests

Scope / not yet

Parser only. The adapter runner (connect, jittered backoff, source-status, throttle, immediate emergency publish), file/URL poller, fake feeder, and lifespan wiring — the full no-hardware path — are the next slice (M2.1b).

Verify

scripts/check.sh green locally — ruff, mypy (strict), 47 tests pass. No secrets, no live Internet calls.

🤖 Generated with Claude Code

First slice of M2 (local RF baseline): a pure, hardware-free parser
that normalizes a readsb/dump1090 aircraft.json snapshot into schema-v2
TrackRecords (PRD §18.1, §17.2 adapter rules, §34 DoD 2/3/5/6).

- SI unit normalization (ft→m, kt→m/s, ft/min→m/s)
- impossible coordinates drop geometry but keep the identified track
- emergency-squawk / on_ground / non_icao / bad_position tagging
- native fields (rssi, registration, type, category…) kept in attributes
- identity aircraft:icao:<hex> as id + correlation_key (M3 fusion hook)
- locally_received + local_rf provenance; source=local_adsb
- one malformed aircraft entry is skipped, not the whole snapshot

Adds tests/fixtures/readsb/aircraft.json and 14 unit tests. The runner,
throttle, source-health, fake feeder, and lifespan wiring (full
no-hardware path) are the next slice (M2.1b).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kninetimmy kninetimmy merged commit daaeaf9 into main Jun 16, 2026
5 checks passed
@kninetimmy kninetimmy deleted the m2.1a-readsb-parser branch June 16, 2026 21:57

@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 introduces a local ADS-B (readsb) snapshot parser to normalize aircraft.json snapshots into schema v2 TrackRecord objects, along with corresponding unit tests and test fixtures. The reviewer's feedback focuses on improving the robustness of the parser against malformed or extreme input data. Key recommendations include checking for finite float values in _num to prevent serialization issues, handling potential overflow or platform-specific exceptions when parsing timestamps and timedeltas, incorporating the g field for more reliable ground-status detection, utilizing type narrowing to eliminate a type-ignore comment, and defensively verifying that the input snapshot data is a dictionary before parsing.

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 +45 to +51
def _num(value: object) -> float | None:
"""Coerce a JSON number to float, rejecting bools and non-numbers."""
if isinstance(value, bool): # bool is an int subclass; never an altitude/speed
return None
if isinstance(value, (int, float)):
return float(value)
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The _num function coerces values to float, but it does not check if the resulting float is finite. If the input contains NaN or Infinity (which can occur in malformed or edge-case JSON payloads), these non-finite values will propagate into the TrackRecord fields. Standard JSON serialization of NaN or Infinity is invalid and will cause serialization failures downstream when publishing to the MQTT bus. Checking math.isfinite ensures only valid, serializable numbers are returned.

Suggested change
def _num(value: object) -> float | None:
"""Coerce a JSON number to float, rejecting bools and non-numbers."""
if isinstance(value, bool): # bool is an int subclass; never an altitude/speed
return None
if isinstance(value, (int, float)):
return float(value)
return None
def _num(value: object) -> float | None:
"""Coerce a JSON number to float, rejecting bools and non-numbers."""
if isinstance(value, bool): # bool is an int subclass; never an altitude/speed
return None
if isinstance(value, (int, float)):
import math
fval = float(value)
if math.isfinite(fval):
return fval
return None

Comment on lines +54 to +59
def _epoch_to_dt(value: object) -> datetime | None:
"""Convert a Unix epoch-seconds number to an aware UTC datetime."""
secs = _num(value)
if secs is None:
return None
return datetime.fromtimestamp(secs, tz=UTC)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using datetime.fromtimestamp(secs, tz=UTC) can raise platform-specific exceptions (such as OSError on Windows) for negative epoch values or values before 1970. Additionally, extremely large values can raise OverflowError or ValueError. A more robust, cross-platform approach is to add a timedelta to the epoch start datetime, wrapped in a try-except block to handle any overflow or value errors gracefully.

Suggested change
def _epoch_to_dt(value: object) -> datetime | None:
"""Convert a Unix epoch-seconds number to an aware UTC datetime."""
secs = _num(value)
if secs is None:
return None
return datetime.fromtimestamp(secs, tz=UTC)
def _epoch_to_dt(value: object) -> datetime | None:
"""Convert a Unix epoch-seconds number to an aware UTC datetime."""
secs = _num(value)
if secs is None:
return None
try:
return datetime(1970, 1, 1, tzinfo=UTC) + timedelta(seconds=secs)
except (OverflowError, ValueError):
return None

Comment on lines +101 to +104
age_s = _num(ac.get("seen_pos"))
if age_s is None:
age_s = _num(ac.get("seen")) or 0.0
observed_at = snapshot_now - timedelta(seconds=age_s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If age_s is extremely large (e.g., due to malformed or malicious input), timedelta(seconds=age_s) can raise an OverflowError or ValueError. Wrapping this calculation in a try-except block and falling back to snapshot_now ensures that a single out-of-bounds age value does not crash the parser or drop the entire track.

Suggested change
age_s = _num(ac.get("seen_pos"))
if age_s is None:
age_s = _num(ac.get("seen")) or 0.0
observed_at = snapshot_now - timedelta(seconds=age_s)
age_s = _num(ac.get("seen_pos"))
if age_s is None:
age_s = _num(ac.get("seen")) or 0.0
try:
observed_at = snapshot_now - timedelta(seconds=age_s)
except (OverflowError, ValueError):
observed_at = snapshot_now

Comment on lines +111 to +112
raw_baro = ac.get("alt_baro")
on_ground = raw_baro == "ground"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In readsb/dump1090, the ground status is often indicated by the "g" field (a boolean or integer flag) in addition to "alt_baro" being "ground". Checking both fields ensures that the ground status is correctly identified even if "alt_baro" is missing or contains a numeric value (e.g., for ground vehicles or aircraft with barometric sensors active on the ground).

Suggested change
raw_baro = ac.get("alt_baro")
on_ground = raw_baro == "ground"
raw_baro = ac.get("alt_baro")
on_ground = (raw_baro == "ground") or ac.get("g") in (True, 1, "true", "1")

Comment on lines +121 to +126
geometry: Point | None = None
if has_pos:
coords = [float(lon), float(lat)] # type: ignore[arg-type]
if altitude_m is not None:
coords.append(altitude_m)
geometry = Point(coordinates=coords)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using explicit type narrowing for lon and lat allows us to avoid the # type: ignore[arg-type] comment, making the code cleaner and fully type-safe.

Suggested change
geometry: Point | None = None
if has_pos:
coords = [float(lon), float(lat)] # type: ignore[arg-type]
if altitude_m is not None:
coords.append(altitude_m)
geometry = Point(coordinates=coords)
geometry: Point | None = None
if has_pos and lon is not None and lat is not None:
coords = [lon, lat]
if altitude_m is not None:
coords.append(altitude_m)
geometry = Point(coordinates=coords)

is logged and skipped so one bad record never drops the whole snapshot
(PRD §17.2, §37 failure isolation).
"""
snapshot_now = _epoch_to_dt(data.get("now")) or received_at

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To enforce defensive programming, we should verify that the input data is actually a dictionary before calling .get() on it. If data is None or of another type (e.g., if the JSON payload was an array or a string), calling data.get() will raise an AttributeError and crash the parser.

    if not isinstance(data, dict):
        return []
    snapshot_now = _epoch_to_dt(data.get("now")) or received_at

kninetimmy added a commit that referenced this pull request Jun 17, 2026
Address the valid items from the Gemini reviews on the merged M1.2c and
M2.1a slices, now that readsb is live on the bus (M2.1b).

bus/client.apply_payload — guard the handle() (state apply) call. A bug or
bad-state error while applying one record must not propagate and tear down
the single subscriber ingest loop that feeds every source and client
(PRD §37 failure isolation). Logged and dropped like a malformed payload.

readsb parser robustness (PRD §17.2 "validate source responses"):
- _num rejects NaN/Infinity. Python's json accepts them by default, but
  they are invalid to re-emit and would crash a record's serialization on
  publish. Non-finite numeric fields normalize to None.
- _epoch_to_dt guards datetime.fromtimestamp against OverflowError/OSError/
  ValueError on out-of-range epochs (returns None -> snapshot falls back to
  received_at) instead of crashing the whole snapshot on a bad top-level
  `now`.
- absurd per-aircraft age no longer overflows timedelta; the track is kept,
  anchored at snapshot now.
- defensive isinstance(data, dict) guard at the parser entry.

Adds tests for each. scripts/check.sh green (67 passed).

Rejected from the review: inferring on-ground from a `g` field — not a
documented readsb aircraft.json field (guardrail: no invented API fields).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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