feat(positions): station positions from Meshtastic, MeshCore and APRS on RF - #141
Merged
Conversation
…RS RF Hearthwave had no concept of a coordinate: every "location" was free text. This adds receive-only position ingest from three sources, a store with a staleness TTL, and a map for the operator UI and the kiosk. Backend - backend/geo.py: haversine distance, initial bearing, 16-point compass. - backend/positions/store.py: PositionStore keyed by (source, node_id), with range + null-island validation, a 500-entry cap, TTL expiry, and an atomic JSON round-trip so a restart doesn't blank the map. Age, distance, bearing and compass are resolved server-side — a wall kiosk's clock is not ours to trust, and an e-ink list has no way to compute geography. - config: station_lat/station_lon/station_origin, position_ttl_minutes, map_tiles_url. Unset coordinates are a first-class state. - server: the positions WS broadcast, debounced into one message per burst and refreshed on a slow cadence so the age counters keep moving; /tiles mounted only when an offline pack exists, so an install without one boots. Plugin SDK - PositionPoller: a component a plugin owns rather than a base class, so it composes with MeshForwarderPlugin instead of colliding with it. Owns the task lifecycle, floors the poll interval, and dedupes per node. - ctx.report_position(source, node_id, lat, lon, label="", **meta), where meta understands alt_m and heard_at. heard_at matters: a node database is a roster the radio keeps for days, so reading one is not hearing a station, and without it every stale node would read "now" and never age out. Sources - Meshtastic and MeshCore gain an optional, default-off position reader on the existing plugin — a second plugin id would contend for the same serial device. Both are inbound only and put nothing on the air. - examples/plugins/aprs_rf: new receive-only plugin. KISS deframer, AX.25 UI header decode, aprslib for the three position encodings, TCP or serial TNC, optional callsign allow/deny filter. No transmit path, by construction. Frontend - PositionList (station, distance, bearing, age) and MapPanel (Leaflet driven imperatively, offline tiles from /tiles with an optional remote fallback). - PositionsPanel pairs them as peer views. A map conveys nothing to a screen reader, so the canvas is aria-hidden and inert with Leaflet's keyboard handler off, and the list is the keyboard and screen-reader path. - Kiosk: e-ink panels get the distance-sorted list, everything else the map. - Admin: latitude, longitude, tile URL and position expiry fields. Legality - docs/legality.html gains a "Position reports" section and two rulemap rows. Every source here is receive-only, so Part 95E's data rules — which govern what a station sends — are not implicated. The section also documents why there is no "APRS on GMRS": Part 95E data is confined to hand-held portables with a non-removable antenna, one-second bursts no oftener than every thirty seconds, directed to one unit, on 462 MHz only, and § 95.1751(b) wants voice or Morse ID, which a data burst cannot supply. The 2023 Midland waiver (DA 23-633) is party-specific and stricter per burst, not a rule change.
Covers the position feature shipped in 9dcb8b5: - README: a Features bullet, the aprs_rf example plugin under "Examples and built-ins", report_position in the PluginContext list, and a receive-only paragraph under FCC compliance linking legality#positions. - USER_MANUAL: new section 34 (turning it on, reading the map/list panel, wall-display behaviour, tile packs, expiry and fix ageing), a new 22d for the APRS plugin with 22e renumbered, position settings on the two mesh plugin tables, station lat/lon in section 13, and the kiosk "Stations heard" block in section 32. - docs/index.html: a positions feature row, an APRS module card, and position bullets on the MeshCore and Meshtastic cards. Also corrects the stale test counts in README's Development section (2027/1035 -> 2026/1221, as measured on this branch). No version stamps touched; those belong to the release skill.
Merged
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.
Plots the stations Hearthwave hears on a map, with distance, bearing and age from a fixed station position. Three sources, all receive-only — no position source has a transmit path anywhere in it.
What's in it
Backend
backend/geo.py— haversine distance, initial great-circle bearing, 16-point compass. Pure functions, no deps.backend/positions/store.py—PositionStorekeyed(source, node_id), TTL expiry (default 1440 min), 500-entry cap evicting stalest first, null-island rejection, atomic write to/data/positions.json.backend/server.py—ctx.report_position(...), a positions pump broadcasting{"type": "positions", ...}, and/tilesmounted from/data/tilesonly when that directory exists.backend/config.py—station_lat,station_lon,position_ttl_minutes,map_tiles_url.Plugins —
PositionPolleris a component a plugin owns, not a base class: Meshtastic and MeshCore already extendMeshForwarderPlugin, and a second base would collide on_read_config/on_config_changed.position_rx_enabled(default off) andposition_poll_seconds.examples/plugins/aprs_rf/— KISS deframer, AX.25 UI-frame decode,aprslibposition parse, TCP or serial transport, callsign allow/deny filter. Noconflicts_with; it's a different receiver from the mesh radio. APRS-IS is deliberately excluded — RF only.Frontend
MapPanel(Leaflet driven imperatively from an effect;leaflet+@types/leaflet, noreact-leaflet),PositionList,PositionsPanel, a MAP top-bar toggle that stays hidden until something has actually been heard, andDisplayPositionsfor the kiosk.Two decisions worth flagging in review
heard_at. A node database is a roster the radio keeps for days, so reading a row is not hearing a station. Sources that timestamp (Meshtasticlast_heard, MeshCorelast_advert) passheard_at; APRS omits it, because the packet arriving is the hearing, and gets the wall clock. Without this every stale roster entry read "Heard now" and never hit the TTL.Server-side geography. Age, distance and bearing are resolved on the server — a wall kiosk's clock isn't trustworthy and an e-ink list can't compute geography. Consequence: the pump rebroadcasts every 30 s even when nothing changed, or a quiet channel freezes every station at "Heard now".
Accessibility
The map container is
aria-hidden+inertwith Leafletkeyboard: false— a pan-and-zoom canvas conveys nothing to a screen reader, and focusable controls inside anaria-hiddensubtree are exactly the trapinertexists to prevent. The List view is a first-class peer with real table semantics, not a fallback. Scanned withjest-axe; findings fixed before commit.Legality
Receive-only, so Part 95E's digital-data limits — which govern what a station sends — are not engaged. Separately, and contrary to a claim going around: "APRS on GMRS" is not newly allowed. GMRS location data dates to the 2017 Report and Order (WT Docket 10-119, FCC 17-57), and the 2021 order on reconsideration expressly declined to relax the duty cycle. Part 95E digital data is confined to certified hand-held portables with a non-removable integral antenna (§ 95.1731(d), § 95.1787(a)(4)), ≤ 1 s per transmission and ≤ 1 per 30 s (§ 95.1787(a)(2)-(3)), directed to one specific unit rather than broadcast (§ 95.1731(d)), on 462 MHz channels only (§ 95.1773(c), § 95.1787(a)(5)); and § 95.1751(b) requires ID by voice or Morse, which a data burst cannot supply. DA 23-633 (Midland, WT Docket 21-388) is a party-specific Bureau waiver, stricter per burst — a waiver is not a rule change. Verified against the CFR 2026-08-06 and written up in
docs/legality.html#positions.Docs
README (feature bullet,
aprs_rfunder Examples and built-ins,report_position, receive-only FCC paragraph), USER_MANUAL (new § 34; new § 22d for APRS with the old 22d renumbered to 22e; position settings on both mesh tables; station lat/lon in § 13; kiosk block in § 32),docs/index.html(feature row, APRS module card, mesh card bullets),docs/plugins.md,docs/legality.html. Also corrected README's stale test counts (2027/1035 → 2026/1221).No version stamps touched — those belong to the release skill at tag time, and the entries above must not be duplicated by that run.
Verification
python3 -m pytest tests/unit -q→ 2026 passednpm test→ 80 files, 1221 tests passednpx tsc -p tsconfig.build.json --noEmit→ cleanlintscript, no eslint/ruff/flake8).Not yet done — manual smoke. Docker stack, a real tile pack, real Meshtastic/APRS hardware, and
/displayare all unexercised. The Meshtastic node-record shape and the MeshCore contact fields were written from the library docs and have not been confirmed against installed versions on hardware.