Skip to content

feat(positions): station positions from Meshtastic, MeshCore and APRS on RF - #141

Merged
Xpiatio merged 2 commits into
masterfrom
feat/gps-positions
Aug 7, 2026
Merged

feat(positions): station positions from Meshtastic, MeshCore and APRS on RF#141
Xpiatio merged 2 commits into
masterfrom
feat/gps-positions

Conversation

@Xpiatio

@Xpiatio Xpiatio commented Aug 7, 2026

Copy link
Copy Markdown
Owner

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.pyPositionStore keyed (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.pyctx.report_position(...), a positions pump broadcasting {"type": "positions", ...}, and /tiles mounted from /data/tiles only when that directory exists.
  • backend/config.pystation_lat, station_lon, position_ttl_minutes, map_tiles_url.

PluginsPositionPoller is a component a plugin owns, not a base class: Meshtastic and MeshCore already extend MeshForwarderPlugin, and a second base would collide on _read_config / on_config_changed.

  • Meshtastic and MeshCore each gain position_rx_enabled (default off) and position_poll_seconds.
  • New examples/plugins/aprs_rf/ — KISS deframer, AX.25 UI-frame decode, aprslib position parse, TCP or serial transport, callsign allow/deny filter. No conflicts_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, no react-leaflet), PositionList, PositionsPanel, a MAP top-bar toggle that stays hidden until something has actually been heard, and DisplayPositions for the kiosk.
  • Station lat/lon, Map Tile URL and Position Expiry on the admin Station tab.

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 (Meshtastic last_heard, MeshCore last_advert) pass heard_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 + inert with Leaflet keyboard: false — a pan-and-zoom canvas conveys nothing to a screen reader, and focusable controls inside an aria-hidden subtree are exactly the trap inert exists to prevent. The List view is a first-class peer with real table semantics, not a fallback. Scanned with jest-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_rf under 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 passed
  • npm test → 80 files, 1221 tests passed
  • npx tsc -p tsconfig.build.json --noEmit → clean
  • No linter run: this repo has none (no lint script, no eslint/ruff/flake8).

Not yet done — manual smoke. Docker stack, a real tile pack, real Meshtastic/APRS hardware, and /display are 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.

Xpiatio added 2 commits August 6, 2026 21:59
…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.
@Xpiatio
Xpiatio merged commit caeece6 into master Aug 7, 2026
1 check passed
@Xpiatio
Xpiatio deleted the feat/gps-positions branch August 7, 2026 20:25
@Xpiatio Xpiatio mentioned this pull request Aug 7, 2026
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