Add local ADS-B (readsb) snapshot parser (M2.1a)#5
Conversation
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>
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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 |
| 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) |
There was a problem hiding this comment.
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.
| 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 |
| 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) |
There was a problem hiding this comment.
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.
| 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 |
| raw_baro = ac.get("alt_baro") | ||
| on_ground = raw_baro == "ground" |
There was a problem hiding this comment.
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).
| 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") |
| 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) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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_atAddress 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>
First slice of M2 — Local RF baseline: a pure, hardware-free parser that normalizes a readsb/dump1090
aircraft.jsonsnapshot into schema-v2TrackRecords (PRD §18.1, §17.2 adapter rules, §34 DoD items 2/3/5/6).What's in it
src/aether/adapters/readsb.py—parse_aircraft_snapshot()/aircraft_to_track()on_ground/non_icao/bad_positiontaggingrssi, registration, type, category…) preserved inattributesaircraft:icao:<hex>asid+correlation_key(M3 fusion hook)locally_received+local_rfprovenance;source=local_adsbtests/fixtures/readsb/aircraft.json+ 14 unit testsScope / 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.shgreen locally — ruff, mypy (strict), 47 tests pass. No secrets, no live Internet calls.🤖 Generated with Claude Code