From 9dcb8b5dd34852dcfd06786b89dbbce1aed8dd89 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Thu, 6 Aug 2026 21:59:10 -0400 Subject: [PATCH 1/2] feat(positions): plot stations heard over Meshtastic, MeshCore and APRS RF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/config.py | 46 ++ backend/geo.py | 50 ++ backend/plugins/context.py | 14 + backend/plugins/position_source.py | 212 ++++++++ backend/plugins/sdk.py | 8 +- backend/positions/__init__.py | 4 + backend/positions/store.py | 334 ++++++++++++ backend/requirements.txt | 4 + backend/server.py | 184 ++++++- backend/tests/unit/plugins/_helpers.py | 47 ++ backend/tests/unit/plugins/test_aprs_rf.py | 486 ++++++++++++++++++ .../unit/plugins/test_example_plugins.py | 40 +- backend/tests/unit/plugins/test_loader.py | 1 + .../unit/plugins/test_meshcore_positions.py | 297 +++++++++++ .../unit/plugins/test_meshtastic_positions.py | 355 +++++++++++++ .../unit/plugins/test_position_source.py | 251 +++++++++ backend/tests/unit/positions/__init__.py | 0 backend/tests/unit/positions/test_store.py | 282 ++++++++++ backend/tests/unit/test_geo.py | 74 +++ backend/tests/unit/test_plugin_endpoints.py | 3 +- backend/tests/unit/test_server_positions.py | 175 +++++++ docs/legality.html | 58 ++- docs/plugins.md | 63 ++- examples/plugins/aprs_rf/ax25.py | 107 ++++ examples/plugins/aprs_rf/kiss.py | 84 +++ examples/plugins/aprs_rf/parser.py | 93 ++++ examples/plugins/aprs_rf/plugin.py | 191 +++++++ examples/plugins/aprs_rf/transport.py | 119 +++++ examples/plugins/meshcore/plugin.py | 115 ++++- examples/plugins/meshtastic/plugin.py | 139 ++++- frontend/package-lock.json | 22 + frontend/package.json | 2 + frontend/src/App.tsx | 29 ++ .../src/components/AdminPanel/AdminPanel.tsx | 108 ++++ .../AdminPanel/__tests__/AdminPanel.test.tsx | 152 +++++- .../src/components/DesktopApp/DesktopApp.tsx | 28 + .../__tests__/DesktopApp.kid.test.tsx | 3 + .../__tests__/DesktopApp.tier.test.tsx | 3 + .../components/DisplayApp/DisplayApp.test.tsx | 93 ++++ .../src/components/DisplayApp/DisplayApp.tsx | 15 +- .../DisplayApp/DisplayPositions.tsx | 56 ++ .../src/components/MapPanel/MapPanel.test.tsx | 213 ++++++++ frontend/src/components/MapPanel/MapPanel.tsx | 209 ++++++++ .../PositionList/PositionList.test.tsx | 83 +++ .../components/PositionList/PositionList.tsx | 95 ++++ .../components/PositionList/format.test.ts | 41 ++ .../src/components/PositionList/format.ts | 41 ++ .../PositionsPanel/PositionsPanel.test.tsx | 101 ++++ .../PositionsPanel/PositionsPanel.tsx | 93 ++++ frontend/src/components/TopBar/TopBar.tsx | 20 + frontend/src/hooks/useDisplaySocket.ts | 12 +- frontend/src/types/appTypes.ts | 9 + frontend/src/types/ws.ts | 42 ++ frontend/src/ui/a11y.ts | 16 + 54 files changed, 5260 insertions(+), 62 deletions(-) create mode 100644 backend/geo.py create mode 100644 backend/plugins/position_source.py create mode 100644 backend/positions/__init__.py create mode 100644 backend/positions/store.py create mode 100644 backend/tests/unit/plugins/_helpers.py create mode 100644 backend/tests/unit/plugins/test_aprs_rf.py create mode 100644 backend/tests/unit/plugins/test_meshcore_positions.py create mode 100644 backend/tests/unit/plugins/test_meshtastic_positions.py create mode 100644 backend/tests/unit/plugins/test_position_source.py create mode 100644 backend/tests/unit/positions/__init__.py create mode 100644 backend/tests/unit/positions/test_store.py create mode 100644 backend/tests/unit/test_geo.py create mode 100644 backend/tests/unit/test_server_positions.py create mode 100644 examples/plugins/aprs_rf/ax25.py create mode 100644 examples/plugins/aprs_rf/kiss.py create mode 100644 examples/plugins/aprs_rf/parser.py create mode 100644 examples/plugins/aprs_rf/plugin.py create mode 100644 examples/plugins/aprs_rf/transport.py create mode 100644 frontend/src/components/DisplayApp/DisplayPositions.tsx create mode 100644 frontend/src/components/MapPanel/MapPanel.test.tsx create mode 100644 frontend/src/components/MapPanel/MapPanel.tsx create mode 100644 frontend/src/components/PositionList/PositionList.test.tsx create mode 100644 frontend/src/components/PositionList/PositionList.tsx create mode 100644 frontend/src/components/PositionList/format.test.ts create mode 100644 frontend/src/components/PositionList/format.ts create mode 100644 frontend/src/components/PositionsPanel/PositionsPanel.test.tsx create mode 100644 frontend/src/components/PositionsPanel/PositionsPanel.tsx create mode 100644 frontend/src/ui/a11y.ts diff --git a/backend/config.py b/backend/config.py index f3a598e..6bb6e6e 100644 --- a/backend/config.py +++ b/backend/config.py @@ -23,6 +23,21 @@ CONFIG_FILE = Path(os.environ.get("RADIO_TTY_CONFIG", "/data/config.json")) +def coerce_latlon(value: object, limit: float) -> float | None: + """Return *value* as a float within ±*limit*, or None if unset/unusable. + + Empty string is treated as unset because that is what an admin clearing + the field in Settings sends. + """ + if value is None or value == "": + return None + try: + number = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + return number if -limit <= number <= limit else None + + class ServerConfig(dict): """Typed wrapper around the JSON config dict. @@ -45,6 +60,37 @@ def name(self) -> str: def location(self) -> str: return self.get("location", "") + # ---- station position / map ------------------------------------------ + + # Free-text `location` above is what goes over the air; these are the + # machine-readable fix used to centre the map and to work out how far away + # everything else is. Unset (None) is a first-class state: without it the + # map still renders, it just has no origin to measure from. + + @property + def station_lat(self) -> float | None: + return coerce_latlon(self.get("station_lat"), 90.0) + + @property + def station_lon(self) -> float | None: + return coerce_latlon(self.get("station_lon"), 180.0) + + @property + def station_origin(self) -> tuple[float, float] | None: + """(lat, lon) when both are set and valid, else None.""" + lat, lon = self.station_lat, self.station_lon + return None if lat is None or lon is None else (lat, lon) + + @property + def position_ttl_minutes(self) -> int: + """How long a heard position stays on the map (default 24 h).""" + return max(1, int(self.get("position_ttl_minutes", 1440))) + + @property + def map_tiles_url(self) -> str: + """Remote XYZ tile template used only when no local pack is installed.""" + return self.get("map_tiles_url", "") + # ---- audio / STT ----------------------------------------------------- # Devices are stored by name, not by PortAudio index: a card that is busy diff --git a/backend/geo.py b/backend/geo.py new file mode 100644 index 0000000..9374775 --- /dev/null +++ b/backend/geo.py @@ -0,0 +1,50 @@ +"""Great-circle helpers for position display. + +Pure functions, no dependencies. Distances are small enough here (a +neighbourhood net, a mesh, an APRS receive footprint) that the spherical +earth model is well inside the error of a consumer GPS fix. +""" +from __future__ import annotations + +import math + +EARTH_RADIUS_KM = 6371.0088 # IUGG mean radius + +KM_PER_MILE = 1.609344 + +#: 16-point compass, indexed by round(bearing / 22.5) % 16. +COMPASS_POINTS: tuple[str, ...] = ( + "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", +) + + +def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Great-circle distance between two points, in kilometres.""" + phi1, phi2 = math.radians(lat1), math.radians(lat2) + d_phi = phi2 - phi1 + d_lambda = math.radians(lon2 - lon1) + a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2 + return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(min(1.0, a))) + + +def bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Initial great-circle bearing from point 1 to point 2, in degrees true. + + Returned in [0, 360). This is the *initial* bearing; over the distances + this app deals with it does not measurably diverge from the rhumb line. + """ + phi1, phi2 = math.radians(lat1), math.radians(lat2) + d_lambda = math.radians(lon2 - lon1) + y = math.sin(d_lambda) * math.cos(phi2) + x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(d_lambda) + return math.degrees(math.atan2(y, x)) % 360.0 + + +def compass_point(deg: float) -> str: + """Nearest 16-point compass abbreviation for a bearing in degrees.""" + return COMPASS_POINTS[int(round(deg / 22.5)) % 16] + + +def km_to_miles(km: float) -> float: + return km / KM_PER_MILE diff --git a/backend/plugins/context.py b/backend/plugins/context.py index e2887ea..82a976d 100644 --- a/backend/plugins/context.py +++ b/backend/plugins/context.py @@ -23,6 +23,18 @@ class PluginContext: get_config() — the live ServerConfig (dict-like). Read tunables here; a plugin's own settings live under config["plugins"][id]. channel_clear() — True when the channel is idle (safe to transmit). + report_position(...)— hand a heard station position to the core (async). + Signature: (source, node_id, lat, lon, label="", **meta). + Returns False for a fix that failed validation rather + than raising: these arrive in bulk from node databases + and a dead GPS is routine, not exceptional. Extra + keyword arguments are stored as display metadata, + except `alt_m` (altitude in metres) and `heard_at` + (epoch seconds the station was actually heard — + pass it when reading a node database, whose rows + are days old; omit it when the packet arriving is + itself the hearing, and the host uses the clock). + Broadcasting to clients is debounced by the host. data_dir — the writable data directory (e.g. for plugin state files). logger — a logger namespaced to the plugin. """ @@ -31,6 +43,7 @@ class PluginContext: enqueue_tx: Callable[[dict], Awaitable[None]] get_config: Callable[[], object] channel_clear: Callable[[], bool] + report_position: Callable[..., Awaitable[bool]] data_dir: Path logger: logging.Logger @@ -41,6 +54,7 @@ def for_plugin(self, plugin_id: str) -> "PluginContext": enqueue_tx=self.enqueue_tx, get_config=self.get_config, channel_clear=self.channel_clear, + report_position=self.report_position, data_dir=self.data_dir, logger=logging.getLogger(f"hearthwave.plugin.{plugin_id}"), ) diff --git a/backend/plugins/position_source.py b/backend/plugins/position_source.py new file mode 100644 index 0000000..5d2347e --- /dev/null +++ b/backend/plugins/position_source.py @@ -0,0 +1,212 @@ +"""Shared machinery for plugins that receive station positions. + +The inbound counterpart to ``mesh_forwarder.py``. Where a forwarder pushes +accepted TX out to a mesh, a position source pulls fixes in — from a mesh +radio's node database, an APRS TNC, or anything else that knows where other +stations are — and hands them to the core via ``ctx.report_position``. + +This is a component, not a base class, deliberately. The Meshtastic plugin is +already a ``MeshForwarderPlugin``; a second base class would collide with it on +``_read_config`` and ``on_config_changed``. A plugin instead *owns* a poller and +drives it from its own lifecycle hooks: + + self._poller = PositionPoller("meshtastic", self._poll_nodes, ctx_getter=lambda: self.ctx) + ... + async def on_config_changed(self, config): + await super().on_config_changed(config) + await self._poller.configure(enabled=..., poll_seconds=...) + +Polling rather than callbacks is deliberate too. Mesh libraries deliver events +on their own serial reader thread, which would need a +``loop.call_soon_threadsafe`` hop to reach the event loop safely; a node +database that already accumulates positions can just be read on a timer. A +genuinely push-based source (a socket) can still use this: block inside +``poll_once`` for as long as the link is up. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Awaitable, Callable, NamedTuple + +_log = logging.getLogger(__name__) + +#: Floor on the poll interval. A plugin's config may ask for less; it doesn't +#: get it. Serial mesh reads are not free and nothing on a map moves this fast. +MIN_POLL_SECONDS = 5.0 + +#: Ignore an unchanged fix for the same node inside this window. Guards against +#: a digipeater echoing the same beacon three times in as many seconds. +DEFAULT_MIN_REPORT_INTERVAL_S = 10.0 + +#: Bound on the dedupe table so a busy band can't grow it without limit. Sized +#: above PositionStore.MAX_ENTRIES so it never evicts a node the store holds. +MAX_TRACKED_NODES = 1000 + + +class _Seen(NamedTuple): + """Last accepted fix for one node. ``heard_at`` is None for sources that + don't timestamp — see :meth:`PositionPoller.report`.""" + + at: float # monotonic, ours + lat: float + lon: float + heard_at: float | None # epoch, the source's + + +class PositionPoller: + """Runs a plugin's position read on a timer and reports what it finds. + + Owns the task lifecycle, the poll interval floor, and per-node dedupe. The + plugin supplies ``poll_once`` (and optionally ``on_start``/``on_stop`` to + open and close a link) and calls :meth:`report` from inside it. + """ + + def __init__( + self, + source_name: str, + poll_once: Callable[[], Awaitable[None]], + *, + ctx_getter: Callable[[], object], + on_start: Callable[[], Awaitable[None]] | None = None, + on_stop: Callable[[], Awaitable[None]] | None = None, + min_report_interval_s: float = DEFAULT_MIN_REPORT_INTERVAL_S, + ) -> None: + self.source_name = source_name + self.min_report_interval_s = min_report_interval_s + self._poll_once = poll_once + self._ctx_getter = ctx_getter + self._on_start = on_start + self._on_stop = on_stop + self._poll_seconds = MIN_POLL_SECONDS + self._task: asyncio.Task | None = None + self._last_seen: dict[str, _Seen] = {} + + @property + def is_running(self) -> bool: + return self._task is not None and not self._task.done() + + # -- lifecycle ------------------------------------------------------- + async def configure(self, *, enabled: bool, poll_seconds: float) -> None: + """Start, stop, or re-tune the poll loop. Safe to call on every config change.""" + self._poll_seconds = max(MIN_POLL_SECONDS, float(poll_seconds or 0)) + if enabled: + self._start() + else: + await self.stop() + + def _start(self) -> None: + if self.is_running: + return # interval is re-read each pass, so a running loop needs no restart + self._task = asyncio.create_task( + self._run_loop(), name=f"position-poller-{self.source_name}" + ) + + async def stop(self) -> None: + task, self._task = self._task, None + if task is not None: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception: + _log.exception("%s: position poller stop failed", self.source_name) + self._last_seen.clear() + + async def _run_loop(self) -> None: + """Poll until cancelled. One bad poll never ends the loop. + + The interval is re-read each pass, so an admin changing it in Settings + takes effect on the next tick without a reconnect. + """ + if self._on_start is not None: + try: + await self._on_start() + except asyncio.CancelledError: + raise + except Exception: + _log.exception("%s: position source failed to open", self.source_name) + return + try: + while True: + try: + await self._poll_once() + except asyncio.CancelledError: + raise + except Exception: + _log.exception("%s: position poll failed", self.source_name) + await asyncio.sleep(self._poll_seconds) + except asyncio.CancelledError: + pass + finally: + if self._on_stop is not None: + try: + await self._on_stop() + except Exception: + _log.exception("%s: position source teardown failed", self.source_name) + + # -- reporting ------------------------------------------------------- + async def report( + self, + node_id: str, + lat: float, + lon: float, + label: str = "", + **meta, + ) -> bool: + """Report one position, suppressing unchanged repeats. + + A node that has actually moved is always reported, however recently it + was last heard. An unchanged fix is suppressed, but what counts as + "unchanged" depends on the source: + + * A source that passes ``heard_at`` (a node database, which remembers + nodes for days) is only telling us something new when that timestamp + advances. Re-reading the same row every minute is not a new hearing, + and treating it as one would keep a node that went off the air three + days ago pinned to the map as if it were live. + * A source that doesn't (a live APRS feed, where every packet *is* a + hearing) gets the rate limit instead: same coordinates inside + ``min_report_interval_s`` are dropped, so a digipeater echo doesn't + count three times. + """ + node_id = str(node_id or "").strip() + if not node_id: + return False + now = asyncio.get_running_loop().time() + heard_at = _as_epoch(meta.get("heard_at")) + previous = self._last_seen.get(node_id) + if previous is not None and previous.lat == lat and previous.lon == lon: + if heard_at is not None and previous.heard_at is not None: + if heard_at <= previous.heard_at: + return False + elif (now - previous.at) < self.min_report_interval_s: + return False + + ctx = self._ctx_getter() + if ctx is None: + return False + reported = await ctx.report_position( # type: ignore[attr-defined] + self.source_name, node_id, lat, lon, label=label, **meta + ) + if reported: + self._remember(_Seen(now, lat, lon, heard_at), node_id) + return bool(reported) + + def _remember(self, seen: _Seen, node_id: str) -> None: + if node_id not in self._last_seen and len(self._last_seen) >= MAX_TRACKED_NODES: + oldest = min(self._last_seen, key=lambda key: self._last_seen[key].at) + del self._last_seen[oldest] + self._last_seen[node_id] = seen + + +def _as_epoch(raw: object) -> float | None: + """Coerce a source-supplied heard timestamp, or None if it isn't one.""" + if raw is None: + return None + try: + value = float(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + return value if value > 0 else None diff --git a/backend/plugins/sdk.py b/backend/plugins/sdk.py index 68d7e3e..77c84fc 100644 --- a/backend/plugins/sdk.py +++ b/backend/plugins/sdk.py @@ -4,8 +4,9 @@ from backend.plugins.sdk import BasePlugin, PluginManifest, ConfigField -Internal modules (base, context, mesh_forwarder) may be reorganised; this module -is the contract that stays stable. See docs/plugins.md for the authoring guide. +Internal modules (base, context, mesh_forwarder, position_source) may be +reorganised; this module is the contract that stays stable. See docs/plugins.md +for the authoring guide. A plugin lives at /data/plugins//plugin.py and exposes a BasePlugin subclass (or a module-level `PLUGIN` instance / `get_plugin()` factory). The loader binds a @@ -22,6 +23,7 @@ MeshForwarderPlugin, MeshTransport, ) +from backend.plugins.position_source import MIN_POLL_SECONDS, PositionPoller __all__ = [ "BasePlugin", @@ -31,4 +33,6 @@ "MeshForwarderPlugin", "MeshTransport", "MeshForwardConfig", + "PositionPoller", + "MIN_POLL_SECONDS", ] diff --git a/backend/positions/__init__.py b/backend/positions/__init__.py new file mode 100644 index 0000000..e1731f7 --- /dev/null +++ b/backend/positions/__init__.py @@ -0,0 +1,4 @@ +"""Received station positions (mesh, APRS) and their store.""" +from backend.positions.store import PositionRecord, PositionStore + +__all__ = ["PositionRecord", "PositionStore"] diff --git a/backend/positions/store.py b/backend/positions/store.py new file mode 100644 index 0000000..b6d9f92 --- /dev/null +++ b/backend/positions/store.py @@ -0,0 +1,334 @@ +"""Store of positions heard from other stations. + +Positions arrive from plugins (a mesh radio's node database, an APRS TNC) and +are held keyed by ``(source, node_id)`` — the same callsign heard on two +different bearers is two rows, because they are two different radios telling +us two different things. + +Everything here is best-effort, ephemeral data: a position is only as good as +its age, so reads expire anything older than the configured TTL and the file +on disk exists purely so a restart does not blank the map. That is why writes +are deliberately not durable-per-update; the server flushes on a timer (see +``flush``), and losing the last few seconds of positions costs nothing. + +The entry cap is the same reasoning as ``_OUTBOUND_QUEUE_MAX`` in +``plugins/mesh_forwarder.py``: a busy APRS band is an unbounded input, and an +unbounded input needs a bound somewhere. +""" +from __future__ import annotations + +import json +import logging +import os +import time +from dataclasses import dataclass, field +from pathlib import Path + +from backend.geo import bearing_deg, compass_point, haversine_km +from backend.persistence._utils import atomic_json_write + +_log = logging.getLogger(__name__) + +_DEFAULT_PATH = Path(os.environ.get("RADIO_TTY_POSITIONS", "/data/positions.json")) + +MAX_LABEL_LEN = 64 +MAX_SOURCE_LEN = 32 +MAX_NODE_ID_LEN = 64 +MAX_EXTRA_KEYS = 12 +MAX_EXTRA_VALUE_LEN = 120 + +#: Hard ceiling on stored stations. Well above any plausible neighbourhood +#: net or mesh; low enough that a chatty APRS band cannot grow the file +#: without limit. Eviction is oldest-heard-first. +MAX_ENTRIES = 500 + +DEFAULT_TTL_MINUTES = 1440 + + +class InvalidPosition(ValueError): + """A reported position failed validation and was not stored.""" + + +@dataclass(frozen=True) +class PositionRecord: + source: str + node_id: str + label: str + lat: float + lon: float + heard_at: float # epoch seconds + alt_m: float | None = None + extra: dict = field(default_factory=dict) + + def to_payload(self, now: float, origin: tuple[float, float] | None) -> dict: + """Wire form for the WS/state payload. + + Age is resolved server-side rather than shipping a timestamp because a + wall kiosk's clock is not to be trusted, and distance/bearing likewise + because the e-ink list has no way to compute them. + """ + payload = { + "source": self.source, + "node_id": self.node_id, + "label": self.label, + "lat": self.lat, + "lon": self.lon, + "alt_m": self.alt_m, + "age_s": max(0, int(now - self.heard_at)), + "extra": self.extra, + "distance_km": None, + "bearing_deg": None, + "compass": None, + } + if origin is not None: + o_lat, o_lon = origin + bearing = bearing_deg(o_lat, o_lon, self.lat, self.lon) + payload["distance_km"] = round(haversine_km(o_lat, o_lon, self.lat, self.lon), 3) + payload["bearing_deg"] = round(bearing, 1) + payload["compass"] = compass_point(bearing) + return payload + + +def _clamp(text: object, limit: int) -> str: + return str(text or "").strip()[:limit] + + +def validate_coords(lat: object, lon: object) -> tuple[float, float]: + """Coerce and range-check a coordinate pair. + + Rejects exactly (0, 0) — "null island" is what a GPS-less node reports far + more often than it is a real fix in the Gulf of Guinea. + """ + try: + lat_f, lon_f = float(lat), float(lon) # type: ignore[arg-type] + except (TypeError, ValueError) as exc: + raise InvalidPosition(f"Non-numeric coordinates: {lat!r}, {lon!r}") from exc + if not (-90.0 <= lat_f <= 90.0) or not (-180.0 <= lon_f <= 180.0): + raise InvalidPosition(f"Coordinates out of range: {lat_f}, {lon_f}") + if lat_f == 0.0 and lon_f == 0.0: + raise InvalidPosition("Null-island coordinates (0, 0) rejected") + return lat_f, lon_f + + +class PositionStore: + def __init__( + self, + path: Path | None = None, + *, + ttl_minutes: int = DEFAULT_TTL_MINUTES, + max_entries: int = MAX_ENTRIES, + ) -> None: + self._path = path or _DEFAULT_PATH + self._max_entries = max(1, int(max_entries)) + self._ttl_s = max(60, int(ttl_minutes) * 60) + self._records: dict[tuple[str, str], PositionRecord] = {} + self._dirty = False + self._load() + + # ---- configuration --------------------------------------------------- + + @property + def ttl_minutes(self) -> int: + return self._ttl_s // 60 + + def set_ttl_minutes(self, minutes: int) -> None: + self._ttl_s = max(60, int(minutes) * 60) + + # ---- persistence ----------------------------------------------------- + + def _load(self) -> None: + if not self._path.exists(): + return + try: + with open(self._path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (json.JSONDecodeError, OSError) as exc: + _log.warning("Could not load %s: %s; starting empty", self._path, exc) + return + if not isinstance(data, dict): + return + for raw in data.get("positions", []): + try: + record = PositionRecord( + source=_clamp(raw["source"], MAX_SOURCE_LEN), + node_id=_clamp(raw["node_id"], MAX_NODE_ID_LEN), + label=_clamp(raw.get("label"), MAX_LABEL_LEN), + lat=float(raw["lat"]), + lon=float(raw["lon"]), + heard_at=float(raw["heard_at"]), + alt_m=None if raw.get("alt_m") is None else float(raw["alt_m"]), + extra=dict(raw.get("extra") or {}), + ) + except (KeyError, TypeError, ValueError): + continue + if record.source and record.node_id: + self._records[(record.source, record.node_id)] = record + + def take_pending(self) -> dict | None: + """Snapshot for disk, or None if nothing changed. Clears the dirty flag. + + Split from :meth:`write` so the caller can build the snapshot on the + event loop and do the file I/O in a worker thread: iterating + ``_records`` off-thread while a plugin reports a new fix would raise + "dictionary changed size during iteration". + """ + if not self._dirty: + return None + self._dirty = False + return { + "positions": [ + { + "source": r.source, + "node_id": r.node_id, + "label": r.label, + "lat": r.lat, + "lon": r.lon, + "heard_at": r.heard_at, + "alt_m": r.alt_m, + "extra": r.extra, + } + for r in self._records.values() + ] + } + + def write(self, payload: dict) -> bool: + """Write a snapshot from :meth:`take_pending`. Safe to call off-thread.""" + try: + atomic_json_write(self._path, payload) + except OSError as exc: + # A read-only or full /data must not take the radio down; the + # positions themselves are still live in memory. Re-arm the dirty + # flag so the next pass retries instead of dropping the write. + _log.warning("Could not persist positions to %s: %s", self._path, exc) + self._dirty = True + return False + return True + + def flush(self) -> bool: + """Persist if anything changed since the last flush. Returns True if written.""" + payload = self.take_pending() + if payload is None: + return False + return self.write(payload) + + # ---- mutation -------------------------------------------------------- + + def upsert( + self, + source: str, + node_id: str, + lat: float, + lon: float, + *, + label: str = "", + alt_m: float | None = None, + extra: dict | None = None, + now: float | None = None, + ) -> PositionRecord: + """Record a heard position. Raises InvalidPosition on bad input.""" + source = _clamp(source, MAX_SOURCE_LEN) + node_id = _clamp(node_id, MAX_NODE_ID_LEN) + if not source or not node_id: + raise InvalidPosition("source and node_id are required") + lat_f, lon_f = validate_coords(lat, lon) + + alt: float | None = None + if alt_m is not None: + try: + alt = float(alt_m) + except (TypeError, ValueError): + alt = None + + key = (source, node_id) + previous = self._records.get(key) + # An update that omits the label keeps the one we already knew: mesh + # node databases routinely hand back a position before the node's name. + resolved_label = _clamp(label, MAX_LABEL_LEN) or (previous.label if previous else "") + + record = PositionRecord( + source=source, + node_id=node_id, + label=resolved_label, + lat=lat_f, + lon=lon_f, + heard_at=float(now if now is not None else time.time()), + alt_m=alt, + extra=self._clean_extra(extra), + ) + self._records[key] = record + self._dirty = True + self._enforce_cap() + return record + + @staticmethod + def _clean_extra(extra: dict | None) -> dict: + if not isinstance(extra, dict): + return {} + cleaned: dict[str, str] = {} + for name, value in extra.items(): + if len(cleaned) >= MAX_EXTRA_KEYS: + break + key = _clamp(name, MAX_LABEL_LEN) + if key: + cleaned[key] = _clamp(value, MAX_EXTRA_VALUE_LEN) + return cleaned + + def _enforce_cap(self) -> None: + overflow = len(self._records) - self._max_entries + if overflow <= 0: + return + stalest = sorted(self._records.items(), key=lambda kv: kv[1].heard_at)[:overflow] + for key, _ in stalest: + del self._records[key] + _log.info("Position store over cap; evicted %d stalest entries", overflow) + + def remove(self, source: str, node_id: str) -> bool: + if self._records.pop((source, node_id), None) is None: + return False + self._dirty = True + return True + + def clear(self) -> None: + if self._records: + self._records.clear() + self._dirty = True + + # ---- reads ----------------------------------------------------------- + + def purge_expired(self, now: float | None = None) -> int: + """Drop records older than the TTL. Returns the number removed.""" + cutoff = (now if now is not None else time.time()) - self._ttl_s + expired = [key for key, rec in self._records.items() if rec.heard_at < cutoff] + for key in expired: + del self._records[key] + if expired: + self._dirty = True + return len(expired) + + def active(self, now: float | None = None) -> list[PositionRecord]: + """Non-expired records, freshest first. Does not mutate the store.""" + moment = now if now is not None else time.time() + cutoff = moment - self._ttl_s + live = [rec for rec in self._records.values() if rec.heard_at >= cutoff] + live.sort(key=lambda rec: rec.heard_at, reverse=True) + return live + + def snapshot( + self, + origin: tuple[float, float] | None = None, + now: float | None = None, + ) -> list[dict]: + """Wire payload for the state/display messages. + + Sorted nearest-first when we know where we are, freshest-first when we + do not — the distance-sorted e-ink list depends on this ordering, and + doing it here keeps the two renderers from disagreeing. + """ + moment = now if now is not None else time.time() + payloads = [rec.to_payload(moment, origin) for rec in self.active(moment)] + if origin is not None: + payloads.sort(key=lambda p: p["distance_km"]) + return payloads + + def __len__(self) -> int: + return len(self._records) diff --git a/backend/requirements.txt b/backend/requirements.txt index 2de29b5..d793e8c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -29,3 +29,7 @@ meshcore # Optional: enables the Meshtastic plugin (outbound LoRa mesh bridge). Imported # lazily and degrades gracefully when absent; mutually exclusive with MeshCore. meshtastic +# Optional: enables the APRS RF plugin (receive-only position ingest from a KISS +# TNC). Pure Python, imported lazily; the plugin reports a clear error when it is +# enabled without this installed. +aprslib diff --git a/backend/server.py b/backend/server.py index 420b5ac..60b2b11 100644 --- a/backend/server.py +++ b/backend/server.py @@ -127,6 +127,7 @@ import logging import os import re +import time import uuid from contextlib import asynccontextmanager from pathlib import Path @@ -144,7 +145,7 @@ from backend.audio.capture import enumerate_monitor_sources from backend.audio.spectro_task import SpectroTask from backend.audio.vad import load_vad_model, make_vad_iterator -from backend.config import ServerConfig +from backend.config import ServerConfig, coerce_latlon from backend.constants import ( GAIN_MODES, VALID_FINAL_MODELS, @@ -170,6 +171,7 @@ from backend.auth_routes import router as _auth_router from backend.plugins import loader as plugin_loader from backend.plugins import plugin_registry +from backend.positions.store import InvalidPosition, PositionStore from backend.persistence.attendance import AttendanceTracker, build_attendance_rows from backend.persistence.contacts import ( ContactsStore, @@ -232,6 +234,16 @@ _config: ServerConfig | None = None # Directory scanned for installable plugins (each subdir with a plugin.py). _PLUGINS_DIR = Path(os.environ.get("RADIO_TTY_PLUGINS_DIR", "/data/plugins")) +# Optional offline XYZ tile pack. Mounted at /tiles only when it exists, so an +# install without a pack still boots — the map just has no basemap. +_TILES_DIR = Path(os.environ.get("RADIO_TTY_TILES_DIR", "/data/tiles")) +# Set when a plugin reports a position; drained by _positions_pump. +_positions_dirty = False +_POSITIONS_PUMP_INTERVAL_S = 2.0 +# Rebroadcast on this cadence even when nothing changed. Age is resolved +# server-side (a wall kiosk's clock is not ours to trust), so on a quiet +# channel every station would otherwise sit at "Heard now" indefinitely. +_POSITIONS_REFRESH_S = 30.0 # PluginContext bound at startup; reused by the install/reload/uninstall endpoints. _plugin_ctx = None _contacts_store: ContactsStore | None = None @@ -241,6 +253,7 @@ _presence_store: PresenceStore | None = None _family_store: FamilyStore | None = None _incidents_store: IncidentsStore | None = None +_position_store: PositionStore | None = None _neighborhood: NeighborhoodNet | None = None _audit_log: AuditLog | None = None _stt_worker: STTWorker | None = None @@ -1450,6 +1463,13 @@ def _build_status() -> dict: "station_callsign": (_config.callsign if _config else "N0CALL"), "station_name": (_config.name if _config else ""), "station_location": (_config.location if _config else ""), + # Machine-readable station fix. Null when unset — the map then has no + # origin and the list falls back to freshest-first (see PositionStore). + "station_lat": (_config.station_lat if _config else None), + "station_lon": (_config.station_lon if _config else None), + "map_tiles_url": (_config.map_tiles_url if _config else ""), + "map_tiles_local": _TILES_DIR.is_dir(), + "position_ttl_minutes": (_config.position_ttl_minutes if _config else 1440), "station_voice": (_config.voice if _config else ""), "station_length_scale": float(_config.tts_length_scale) if _config else 1.0, "gemini_api_key_set": bool(_config and _config.gemini_api_key), @@ -1526,6 +1546,109 @@ def _sync_live_state_for_user(user_id: str, updated_profile: dict) -> None: live_state.prefs = new_prefs +def _build_positions_msg() -> dict: + """Snapshot of every non-expired heard position, nearest-first.""" + if _position_store is None: + return {"type": "positions", "stations": []} + origin = _config.station_origin if _config else None + return {"type": "positions", "stations": _position_store.snapshot(origin)} + + +def _resolve_heard_at(raw: object) -> float | None: + """When a source says it heard a station, or None for "just now". + + Node databases hand back rows the radio heard days ago, so the moment we + read one is not the moment it was heard — without this, a node that went + off the air last week would sit on the map reading "now" and would never + reach its TTL. Sources with no timestamp (a live APRS feed, where the + packet arriving *is* the hearing) pass nothing and get the wall clock. + + A timestamp ahead of us is clamped to now: it's another radio's clock, and + a future one would otherwise outlive its TTL. One implausibly far in the + past is kept as-is — the store will expire it, which is the honest answer + when our own mesh radio's clock is unset. + """ + if raw is None: + return None + try: + heard = float(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + if heard <= 0: + return None + return min(heard, time.time()) + + +async def _report_position( + source: str, + node_id: str, + lat: float, + lon: float, + label: str = "", + **meta, +) -> bool: + """PluginContext.report_position — record a position heard by a plugin. + + Returns False (rather than raising) on a bad fix: a mesh node with a dead + GPS should cost the plugin author nothing, and these arrive in bulk. + Broadcasting is deferred to _positions_pump so a node-database sweep of + fifty nodes produces one message, not fifty. + """ + global _positions_dirty + if _position_store is None: + return False + alt_m = meta.pop("alt_m", None) + heard_at = _resolve_heard_at(meta.pop("heard_at", None)) + try: + _position_store.upsert( + source, node_id, lat, lon, + label=label, alt_m=alt_m, extra=meta or None, now=heard_at, + ) + except InvalidPosition as exc: + _log.debug("Rejected position from %s/%s: %s", source, node_id, exc) + return False + _positions_dirty = True + return True + + +async def _positions_pump() -> None: + """Debounce position changes into one broadcast, and persist on a timer. + + Positions arrive in bursts (a node-DB poll, a busy APRS band); coalescing + them here keeps a hundred kiosks from being woken a hundred times. The TTL + purge shares this loop so an expiring station also disappears from the map + without needing its own timer. A slow unconditional refresh keeps the age + counters honest when nothing is arriving at all. + """ + global _positions_dirty + last_sent = 0.0 + while True: + try: + await asyncio.sleep(_POSITIONS_PUMP_INTERVAL_S) + if _position_store is None: + continue + if _config is not None: + _position_store.set_ttl_minutes(_config.position_ttl_minutes) + if _position_store.purge_expired(): + _positions_dirty = True + now = asyncio.get_running_loop().time() + due = len(_position_store) > 0 and (now - last_sent) >= _POSITIONS_REFRESH_S + if not _positions_dirty and not due: + continue + _positions_dirty = False + last_sent = now + await _manager.broadcast(_build_positions_msg()) + # Snapshot here, write off-thread: the store's dict must not be + # iterated in a worker while a plugin is reporting into it. + pending = _position_store.take_pending() + if pending is not None: + await asyncio.to_thread(_position_store.write, pending) + except asyncio.CancelledError: + break + except Exception as exc: + _log.error("_positions_pump error: %s", exc) + + async def _status_pump() -> None: """Broadcast live signal-quality status to all clients every 5 seconds.""" while True: @@ -1687,7 +1810,7 @@ async def _lifespan(app: FastAPI): global _audio_level, _radio_error, _channel_clear, _last_id_time, _has_transmitted, _last_beacon_time, _ncs_plugin global _level_window, _attendance, _spectro, _monitor_chunk_cb global _pending_stations, _auto_add_tasks, _event_loop, _audit_log - global _calibration_capture + global _calibration_capture, _position_store, _positions_dirty # --- startup ----------------------------------------------------------- # Surface the app's own INFO logs (TX playback, station ID, monitor state, @@ -1726,6 +1849,10 @@ async def _lifespan(app: FastAPI): _presence_store = PresenceStore(_config.presence_file) _family_store = FamilyStore(_config.family_file) _incidents_store = IncidentsStore(_config.incidents_file) + _position_store = PositionStore(ttl_minutes=_config.position_ttl_minutes) + _positions_dirty = False + if len(_position_store): + _log.info("Positions loaded: %d stations", len(_position_store)) _neighborhood = NeighborhoodNet() purged = _token_store.purge_expired() if purged: @@ -1850,6 +1977,7 @@ async def _enqueue_tx(payload: dict) -> None: enqueue_tx=_enqueue_tx, get_config=lambda: _config, channel_clear=lambda: _channel_clear, + report_position=_report_position, data_dir=_PLUGINS_DIR.parent, logger=logging.getLogger("hearthwave.plugin"), ) @@ -1878,6 +2006,7 @@ async def _enqueue_tx(payload: dict) -> None: asyncio.create_task(_online_status_pump(), name="online-status-pump"), asyncio.create_task(_voices_watcher_pump(), name="voices-watcher"), asyncio.create_task(_family_reminder_pump(), name="family-reminder-pump"), + asyncio.create_task(_positions_pump(), name="positions-pump"), } _log.info("Hearthwave server ready.") @@ -1898,6 +2027,11 @@ async def _enqueue_tx(payload: dict) -> None: _monitor.stop() _monitor = None + # The pump owns the write timer, and it is already cancelled — flush here + # so a clean shutdown keeps whatever arrived in the last couple of seconds. + if _position_store is not None: + _position_store.flush() + _level_window.clear() _log.info("Hearthwave server stopped.") @@ -1909,6 +2043,15 @@ async def _enqueue_tx(payload: dict) -> None: app = FastAPI(title="Hearthwave", lifespan=_lifespan) app.include_router(_auth_router, prefix="/auth") +# Offline basemap. Mounting is conditional and happens at import time: an +# install with no tile pack must still boot, and StaticFiles refuses a missing +# directory. Operators who add a pack later restart the container anyway. +if _TILES_DIR.is_dir(): + from fastapi.staticfiles import StaticFiles + + app.mount("/tiles", StaticFiles(directory=str(_TILES_DIR)), name="tiles") + _log.info("Offline map tiles mounted from %s", _TILES_DIR) + # --------------------------------------------------------------------------- # HTTP endpoints @@ -2104,6 +2247,37 @@ async def _ws_handle_set_admin_config(ws: WebSocket, data: dict, state: "Connect _config["name"] = str(data["name"]).strip() if "location" in data: _config["location"] = str(data["location"]).strip() + positions_changed = False + for key, limit in (("station_lat", 90.0), ("station_lon", 180.0)): + if key not in data: + continue + raw = data[key] + # Empty clears the fix; anything unparseable or out of range is ignored + # rather than stored, so a typo can't silently move the map origin. + if raw is None or str(raw).strip() == "": + if _config.get(key) is not None: + _config[key] = None + positions_changed = True + continue + coerced = coerce_latlon(raw, limit) + if coerced is None: + await _manager.send_to(ws, {"type": "error", + "detail": f"{key} must be a number between -{limit:g} and {limit:g}."}) + elif _config.get(key) != coerced: + _config[key] = coerced + positions_changed = True + if "position_ttl_minutes" in data: + try: + ttl = int(float(data["position_ttl_minutes"])) + except (TypeError, ValueError): + ttl = 0 + if 1 <= ttl <= 43200: # 30 days + _config["position_ttl_minutes"] = ttl + if _position_store is not None: + _position_store.set_ttl_minutes(ttl) + positions_changed = True + if "map_tiles_url" in data: + _config["map_tiles_url"] = str(data["map_tiles_url"]).strip()[:512] if "gemini_api_key" in data: key = str(data["gemini_api_key"]).strip() if key: @@ -2156,6 +2330,10 @@ async def _ws_handle_set_admin_config(ws: WebSocket, data: dict, state: "Connect await _manager.broadcast(_build_status()) if "neighborhood_net_day" in data or "neighborhood_net_time" in data: await _manager.broadcast(_build_neighborhood_state_msg()) + if positions_changed: + # Distance and bearing are resolved server-side against the origin, so + # moving the origin invalidates every row already on the clients. + await _manager.broadcast(_build_positions_msg()) if rx_mode_changed and _stt_worker is not None and _stt_listening: _stt_worker.stop() await _stt_worker.join() @@ -2683,6 +2861,7 @@ async def websocket_endpoint( }) await _manager.send_to(ws, _build_family_presence_msg()) await _manager.send_to(ws, _build_neighborhood_state_msg()) + await _manager.send_to(ws, _build_positions_msg()) await _manager.send_to(ws, {"type": "chat_history", "messages": history_msgs}) else: role = profile.get("role") or ("admin" if profile.get("is_admin") else "adult") @@ -2722,6 +2901,7 @@ async def websocket_endpoint( await _manager.send_to(ws, _build_family_reminders_msg()) await _manager.send_to(ws, _build_neighborhood_state_msg()) await _manager.send_to(ws, _build_neighborhood_incidents_msg()) + await _manager.send_to(ws, _build_positions_msg()) await _manager.send_to(ws, {"type": "voices_list", "voices": _list_voices()}) # Backfill the shared message stream accumulated since the last clear # (snapshotted above, before this socket joined the broadcast set). diff --git a/backend/tests/unit/plugins/_helpers.py b/backend/tests/unit/plugins/_helpers.py new file mode 100644 index 0000000..e2ae6f4 --- /dev/null +++ b/backend/tests/unit/plugins/_helpers.py @@ -0,0 +1,47 @@ +"""Shared scaffolding for the example-plugin tests. + +Not a conftest: these are plain callables the tests invoke with arguments +(a plugin id, a config), which fixtures would only make more indirect. +""" +from __future__ import annotations + +import logging +from pathlib import Path + +from backend.config import ServerConfig +from backend.plugins import loader +from backend.plugins.context import PluginContext +from backend.plugins.registry import PluginRegistry + +EXAMPLES = Path(__file__).resolve().parents[4] / "examples" / "plugins" + + +def make_ctx(config: ServerConfig | None = None, *, report_position=None) -> PluginContext: + async def _noop(*_a, **_k): + return None + + return PluginContext( + broadcast=_noop, + enqueue_tx=_noop, + get_config=(lambda: config) if config is not None else dict, + channel_clear=lambda: True, + report_position=report_position or _noop, + data_dir=Path("/tmp"), + logger=logging.getLogger("test.plugin"), + ) + + +async def load_example(plugin_id: str, config: ServerConfig | None = None, *, ctx=None): + """Load an example plugin from examples/plugins the way a real install does.""" + inst = await loader.load_plugin( + EXAMPLES / plugin_id, ctx or make_ctx(config), PluginRegistry() + ) + assert inst is not None, f"example plugin {plugin_id} failed to load" + return inst + + +def make_config(plugin_id: str, **values) -> ServerConfig: + cfg = ServerConfig() + if values: + cfg.set_plugin_config(plugin_id, values) + return cfg diff --git a/backend/tests/unit/plugins/test_aprs_rf.py b/backend/tests/unit/plugins/test_aprs_rf.py new file mode 100644 index 0000000..377a362 --- /dev/null +++ b/backend/tests/unit/plugins/test_aprs_rf.py @@ -0,0 +1,486 @@ +"""Unit tests for the APRS RF example plugin (examples/plugins/aprs_rf). + +No TNC and no aprslib needed. The KISS deframer and the AX.25 decoder are +exercised against frames this module encodes byte by byte — the plugin only +ever decodes, so the encoders live here in the tests. aprslib is faked, with +canned returns copied from a real aprslib 0.7.2 parse of these exact TNC2 +strings, so the mapping is checked without pinning an optional dependency into +the test environment. +""" +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +from pathlib import Path + +import pytest + +from backend.plugins.base import BasePlugin +from backend.tests.unit.plugins._helpers import EXAMPLES, load_example, make_config, make_ctx + +APRS_DIR = EXAMPLES / "aprs_rf" + + +def _load_standalone(name: str): + """Import one of the plugin's dependency-free helper modules directly.""" + spec = importlib.util.spec_from_file_location(f"aprs_rf_test_{name}", APRS_DIR / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +kiss = _load_standalone("kiss") +ax25 = _load_standalone("ax25") +parser = _load_standalone("parser") + + +# --------------------------------------------------------------------------- +# Frame encoders — test-side only +# --------------------------------------------------------------------------- + +def encode_address(call: str, *, last: bool = False, repeated: bool = False) -> bytes: + base, _, ssid = call.partition("-") + out = bytearray((ord(char) << 1) & 0xFE for char in base.ljust(6)) + ssid_byte = 0x60 | (int(ssid or 0) << 1) + if last: + ssid_byte |= 0x01 + if repeated: + ssid_byte |= 0x80 + out.append(ssid_byte) + return bytes(out) + + +def ui_frame(src: str, dest: str, digis=(), info: str = "", control=0x03, pid=0xF0) -> bytes: + """Build an AX.25 UI frame. A digi suffixed with '*' has been repeated.""" + addresses = [encode_address(dest), encode_address(src)] + for digi in digis: + addresses.append(encode_address(digi.rstrip("*"), repeated=digi.endswith("*"))) + frame = bytearray(b"".join(addresses)) + frame[-1] |= 0x01 # end-of-address on the last one + frame += bytes([control, pid]) + info.encode("latin-1") + return bytes(frame) + + +def kiss_wrap(frame: bytes, command: int = 0x00) -> bytes: + body = bytearray([command]) + for byte in frame: + if byte == kiss.FEND: + body += bytes([kiss.FESC, kiss.TFEND]) + elif byte == kiss.FESC: + body += bytes([kiss.FESC, kiss.TFESC]) + else: + body.append(byte) + return bytes([kiss.FEND]) + bytes(body) + bytes([kiss.FEND]) + + +BEACON_INFO = "!4254.00N/08548.00W>Mobile unit" +BEACON_FRAME = ui_frame("W8ABC-9", "APRS", ["WIDE1-1*", "WIDE2-1"], BEACON_INFO) +BEACON_TNC2 = "W8ABC-9>APRS,WIDE1-1*,WIDE2-1:" + BEACON_INFO + + +# --------------------------------------------------------------------------- +# KISS deframer +# --------------------------------------------------------------------------- + +class TestKissDeframer: + def test_extracts_one_frame(self): + assert kiss.KissDeframer().feed(kiss_wrap(BEACON_FRAME)) == [BEACON_FRAME] + + def test_extracts_several_frames_from_one_read(self): + stream = kiss_wrap(BEACON_FRAME) + kiss_wrap(b"\x01\x02\x03") + assert kiss.KissDeframer().feed(stream) == [BEACON_FRAME, b"\x01\x02\x03"] + + def test_reassembles_a_frame_split_across_reads(self): + stream = kiss_wrap(BEACON_FRAME) + deframer = kiss.KissDeframer() + got = [] + for start in range(0, len(stream), 7): + got += deframer.feed(stream[start:start + 7]) + assert got == [BEACON_FRAME] + + def test_unescapes_fend_and_fesc_in_the_payload(self): + payload = bytes([0x11, kiss.FEND, 0x22, kiss.FESC, 0x33]) + assert kiss.KissDeframer().feed(kiss_wrap(payload)) == [payload] + + def test_drops_non_data_command_frames(self): + """TXDELAY and friends share the stream and are not AX.25.""" + assert kiss.KissDeframer().feed(kiss_wrap(b"\x20", command=0x01)) == [] + + def test_honours_the_port_nibble(self): + """Command byte 0x10 is port 1, data — still a frame.""" + assert kiss.KissDeframer().feed(kiss_wrap(BEACON_FRAME, command=0x10)) == [BEACON_FRAME] + + def test_ignores_noise_before_the_first_fend(self): + assert kiss.KissDeframer().feed(b"junk" + kiss_wrap(BEACON_FRAME)) == [BEACON_FRAME] + + def test_ignores_idle_padding_between_frames(self): + stream = bytes([kiss.FEND] * 4) + kiss_wrap(BEACON_FRAME) + bytes([kiss.FEND] * 3) + assert kiss.KissDeframer().feed(stream) == [BEACON_FRAME] + + def test_drops_an_invalid_escape_sequence(self): + corrupt = bytes([kiss.FEND, 0x00, 0x11, kiss.FESC, 0x99, 0x22, kiss.FEND]) + assert kiss.KissDeframer().feed(corrupt) == [] + + def test_oversized_frames_are_dropped_not_buffered(self): + deframer = kiss.KissDeframer(max_frame_bytes=16) + assert deframer.feed(kiss_wrap(b"x" * 64)) == [] + assert len(deframer._buf) <= 16 + # Recovers on the next frame that fits. + assert deframer.feed(kiss_wrap(b"short")) == [b"short"] + + def test_reset_drops_a_partial_frame(self): + deframer = kiss.KissDeframer() + deframer.feed(kiss_wrap(BEACON_FRAME)[:20]) + deframer.reset() + assert deframer.feed(kiss_wrap(BEACON_FRAME)) == [BEACON_FRAME] + + +# --------------------------------------------------------------------------- +# AX.25 +# --------------------------------------------------------------------------- + +class TestAx25Decoder: + def test_renders_tnc2_text(self): + assert ax25.decode_ui_frame(BEACON_FRAME) == BEACON_TNC2 + + def test_renders_a_pathless_frame(self): + frame = ui_frame("W8XYZ", "APDR16", [], "=4254.00N/08548.00W$Home") + assert ax25.decode_ui_frame(frame) == "W8XYZ>APDR16:=4254.00N/08548.00W$Home" + + def test_ssid_zero_is_not_suffixed(self): + call, last, repeated = ax25.decode_address(encode_address("W8XYZ", last=True)) + assert (call, last, repeated) == ("W8XYZ", True, False) + + def test_repeated_digis_are_starred(self): + frame = ui_frame("W8ABC", "APRS", ["W8REP-1*", "WIDE2-1"], "!0000.00N/00000.00W-") + assert ax25.decode_ui_frame(frame).startswith("W8ABC>APRS,W8REP-1*,WIDE2-1:") + + def test_accepts_the_poll_variant_of_the_ui_control_byte(self): + assert ax25.decode_ui_frame(ui_frame("W8ABC", "APRS", [], "!x", control=0x13)) + + @pytest.mark.parametrize("kwargs", [{"control": 0x00}, {"pid": 0xCF}]) + def test_rejects_non_ui_traffic(self, kwargs): + """Connected-mode AX.25 shares the channel and carries no APRS.""" + with pytest.raises(ax25.FrameError): + ax25.decode_ui_frame(ui_frame("W8ABC", "APRS", [], "data", **kwargs)) + + def test_rejects_an_empty_info_field(self): + with pytest.raises(ax25.FrameError): + ax25.decode_ui_frame(ui_frame("W8ABC", "APRS", [], "")) + + @pytest.mark.parametrize("frame", [b"", b"\x00" * 6, BEACON_FRAME[:10], BEACON_FRAME[:15]]) + def test_rejects_truncated_frames(self, frame): + with pytest.raises(ax25.FrameError): + ax25.decode_ui_frame(frame) + + def test_rejects_an_address_block_with_no_end_bit(self): + with pytest.raises(ax25.FrameError): + ax25.decode_ui_frame(encode_address("W8ABC") * 12) + + def test_rejects_a_non_alphanumeric_callsign(self): + with pytest.raises(ax25.FrameError): + ax25.decode_address(bytes([0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x61])) + + def test_info_field_survives_high_bytes(self): + """Mic-E and comment text are not always valid UTF-8.""" + frame = ui_frame("W8ABC", "APRS", [], "") + b"\xff\xfe" + assert ax25.decode_ui_frame(frame).endswith("\xff\xfe") + + +# --------------------------------------------------------------------------- +# aprslib mapping +# --------------------------------------------------------------------------- + +class _GenericError(Exception): + pass + + +def fake_aprslib(**packets) -> types.ModuleType: + """An aprslib stand-in returning canned parses, keyed by TNC2 text.""" + module = types.ModuleType("aprslib") + exceptions = types.ModuleType("aprslib.exceptions") + exceptions.GenericError = _GenericError + module.exceptions = exceptions + + def parse(text): + if text not in packets: + raise _GenericError(f"unknown format: {text}") + result = packets[text] + if isinstance(result, Exception): + raise result + return result + + module.parse = parse + return module + + +# Verified against aprslib 0.7.2 by parsing these exact strings. +BEACON_PACKET = { + "from": "W8ABC-9", "latitude": 42.9, "longitude": -85.8, "altitude": None, + "comment": "Mobile unit", "format": "uncompressed", "symbol": ">", "symbol_table": "/", + "path": ["WIDE1-1*", "WIDE2-1"], +} +MICE_TNC2 = 'W8ABC-9>T2SY1U,WIDE1-1:`(_fn"Oj/`"4)}147.030MHz' +MICE_PACKET = { + "from": "W8ABC-9", "latitude": 42.6525, "longitude": -12.129, "altitude": 18, + "speed": 37.04, "course": 251, "comment": "`147.030MHz", "format": "mic-e", + "symbol": "j", "symbol_table": "/", +} + + +@pytest.fixture +def aprs_parser(monkeypatch): + monkeypatch.setattr( + parser, "_aprslib", + fake_aprslib(**{BEACON_TNC2: BEACON_PACKET, MICE_TNC2: MICE_PACKET}), + ) + return parser + + +class TestParser: + def test_maps_a_position_beacon(self, aprs_parser): + fix = aprs_parser.parse_position(BEACON_TNC2) + assert (fix["node_id"], fix["lat"], fix["lon"]) == ("W8ABC-9", 42.9, -85.8) + assert fix["alt_m"] is None + assert fix["extra"]["comment"] == "Mobile unit" + assert fix["extra"]["symbol"] == "/>" + assert fix["extra"]["path"] == "WIDE1-1*,WIDE2-1" + assert "speed" not in fix["extra"] + + def test_maps_mic_e_altitude_speed_and_course(self, aprs_parser): + fix = aprs_parser.parse_position(MICE_TNC2) + assert fix["alt_m"] == 18.0 + assert fix["extra"]["speed"] == "37 km/h" + assert fix["extra"]["course"] == "251°" + + def test_unparseable_packets_are_skipped(self, aprs_parser): + assert aprs_parser.parse_position("nonsense") is None + + def test_a_library_crash_is_skipped_not_raised(self, monkeypatch): + """RF hands aprslib bytes no sender intended; the read loop must survive.""" + monkeypatch.setattr( + parser, "_aprslib", fake_aprslib(**{BEACON_TNC2: IndexError("string index")}) + ) + assert parser.parse_position(BEACON_TNC2) is None + + @pytest.mark.parametrize("packet", [ + {"from": "W8ABC", "latitude": None, "longitude": None}, # status / message + {"from": "", "latitude": 42.9, "longitude": -85.8}, # no source callsign + {"from": "W8ABC", "latitude": "north", "longitude": 1.0}, # non-numeric + ]) + def test_packets_without_a_usable_fix_are_skipped(self, monkeypatch, packet): + monkeypatch.setattr(parser, "_aprslib", fake_aprslib(**{"p": packet})) + assert parser.parse_position("p") is None + + +# --------------------------------------------------------------------------- +# Plugin +# --------------------------------------------------------------------------- + +class FakeLink: + """Yields canned reads, then drops the link the way a real TNC would.""" + + def __init__(self, chunks) -> None: + self.description = "fake" + self.chunks = list(chunks) + self.opened = False + self.closed = False + + async def open(self) -> None: + self.opened = True + + async def read(self) -> bytes: + if not self.chunks: + raise ConnectionError("TNC went away") + return self.chunks.pop(0) + + def close(self) -> None: + self.closed = True + + +async def load_aprs(config=None, *, reports=None): + """Load the plugin from disk with a context that records reported positions.""" + async def _report(source, node_id, lat, lon, label="", **meta): + if reports is not None: + reports.append((source, node_id, lat, lon, label, meta)) + return True + + ctx = make_ctx(config, report_position=_report) + inst = await load_example("aprs_rf", config, ctx=ctx) + # The plugin's own copy of parser.py, loaded under its package name. + plugin_parser = sys.modules[f"{type(inst).__module__}.parser"] + plugin_parser._aprslib = fake_aprslib(**{BEACON_TNC2: BEACON_PACKET}) + return inst + + +class TestManifest: + async def test_starts_disabled(self): + inst = await load_aprs() + assert inst.manifest.default_enabled is False + assert inst.is_enabled(make_config("aprs_rf")) is False + + async def test_defaults_target_direwolfs_kiss_port(self): + inst = await load_aprs() + fields = {f.key: f.default for f in inst.manifest.config_schema} + assert (fields["transport"], fields["host"], fields["port"]) == ("tcp", "127.0.0.1", 8001) + + async def test_has_no_transmit_path(self): + """Receive-only is a legal constraint, not a preference — see the module + docstring and docs/legality.html. Guard it with a test.""" + inst = await load_aprs() + assert type(inst).on_audio_tx_pre_queue is BasePlugin.on_audio_tx_pre_queue + assert inst.manifest.tx_composition is None + link = inst._make_tcp_link() + assert not hasattr(link, "write") and not hasattr(link, "send") + + +class TestConfigMapping: + async def test_reads_the_namespaced_section(self): + cfg = make_config("aprs_rf", host="10.0.0.5", port="8010", callsign_filter="w8abc") + inst = await load_aprs(cfg) + await inst.on_config_changed(cfg) + assert (inst._host, inst._port) == ("10.0.0.5", 8010) + assert inst._filter == frozenset({"W8ABC"}) + assert inst._poller.is_running is False # still disabled + + async def test_serial_transport_builds_a_serial_link(self): + cfg = make_config("aprs_rf", transport="serial", serial_port="/dev/ttyS3", baud="19200") + inst = await load_aprs(cfg) + await inst.on_config_changed(cfg) + link = inst._link_factory() + assert (link.port, link.baud) == ("/dev/ttyS3", 19200) + + @pytest.mark.parametrize("mode,filt,callsign,expected", [ + ("allow", "", "W8ABC-9", True), + ("allow", "W8ABC", "W8ABC-9", True), # bare callsign covers every SSID + ("allow", "W8ABC-7", "W8ABC-9", False), # a specific SSID does not + ("allow", "W8XYZ", "W8ABC-9", False), + ("deny", "W8ABC", "W8ABC-9", False), + ("deny", "W8XYZ", "W8ABC-9", True), + ("deny", "", "W8ABC-9", True), + ]) + async def test_callsign_filter(self, mode, filt, callsign, expected): + cfg = make_config("aprs_rf", filter_mode=mode, callsign_filter=filt) + inst = await load_aprs(cfg) + await inst.on_config_changed(cfg) + assert inst._passes_filter(callsign) is expected + + +class TestReadLoop: + async def test_reports_a_heard_station(self): + reports = [] + inst = await load_aprs(reports=reports) + link = FakeLink([kiss_wrap(BEACON_FRAME)]) + inst._link_factory = lambda: link + + with pytest.raises(ConnectionError): + await inst._pump() + + assert link.opened and link.closed + source, node_id, lat, lon, label, meta = reports[0] + assert (source, node_id, lat, lon, label) == ("aprs_rf", "W8ABC-9", 42.9, -85.8, "W8ABC-9") + assert meta["comment"] == "Mobile unit" + + async def test_a_frame_split_across_reads_still_reports(self): + reports = [] + inst = await load_aprs(reports=reports) + stream = kiss_wrap(BEACON_FRAME) + inst._link_factory = lambda: FakeLink([stream[:11], stream[11:]]) + + with pytest.raises(ConnectionError): + await inst._pump() + assert len(reports) == 1 + + async def test_undecodable_frames_do_not_stop_the_loop(self): + reports = [] + inst = await load_aprs(reports=reports) + junk = kiss_wrap(ui_frame("W8ABC", "APRS", [], "data", control=0x00)) + inst._link_factory = lambda: FakeLink([junk, kiss_wrap(BEACON_FRAME)]) + + with pytest.raises(ConnectionError): + await inst._pump() + assert len(reports) == 1 + + async def test_a_filtered_station_is_not_reported(self): + reports = [] + cfg = make_config("aprs_rf", callsign_filter="W8XYZ") + inst = await load_aprs(cfg, reports=reports) + await inst.on_config_changed(cfg) + inst._link_factory = lambda: FakeLink([kiss_wrap(BEACON_FRAME)]) + + with pytest.raises(ConnectionError): + await inst._pump() + assert reports == [] + + async def test_a_stale_partial_frame_is_dropped_on_reconnect(self): + reports = [] + inst = await load_aprs(reports=reports) + stream = kiss_wrap(BEACON_FRAME) + inst._link_factory = lambda: FakeLink([stream[:11]]) + with pytest.raises(ConnectionError): + await inst._pump() + + # The second session must not splice the truncated frame onto its first read. + inst._link_factory = lambda: FakeLink([stream]) + with pytest.raises(ConnectionError): + await inst._pump() + assert len(reports) == 1 + + async def test_missing_aprslib_fails_before_the_link_is_opened(self, monkeypatch): + inst = await load_aprs() + plugin_parser = sys.modules[f"{type(inst).__module__}.parser"] + monkeypatch.setattr(plugin_parser, "_aprslib", None) + monkeypatch.setitem(sys.modules, "aprslib", None) # forces ImportError + link = FakeLink([]) + inst._link_factory = lambda: link + + with pytest.raises(RuntimeError, match="aprslib not installed"): + await inst._pump() + assert link.opened is False + + +class TestLifecycle: + """on_config_changed rebuilds the link factory from config, so tests that + want a fake link install it immediately after — the poll task it starts has + not reached its first await yet.""" + + async def test_enabling_starts_the_reader_and_unload_stops_it(self): + cfg = make_config("aprs_rf", enabled=True) + inst = await load_aprs(cfg) + + await inst.on_config_changed(cfg) + inst._link_factory = lambda: FakeLink([]) + assert inst._poller.is_running is True + await inst.on_unload() + assert inst._poller.is_running is False + + async def test_disabling_stops_the_reader(self): + cfg = make_config("aprs_rf", enabled=True) + inst = await load_aprs(cfg) + await inst.on_config_changed(cfg) + inst._link_factory = lambda: FakeLink([]) + + off = make_config("aprs_rf", enabled=False) + await inst.on_config_changed(off) + assert inst._poller.is_running is False + + async def test_the_link_is_closed_when_the_reader_is_cancelled(self): + cfg = make_config("aprs_rf", enabled=True) + inst = await load_aprs(cfg) + link = FakeLink([]) + + async def _blocking_read(): + await asyncio.Event().wait() + + link.read = _blocking_read + + await inst.on_config_changed(cfg) + inst._link_factory = lambda: link + for _ in range(5): + await asyncio.sleep(0) + assert link.opened is True + + await inst.on_unload() + assert link.closed is True diff --git a/backend/tests/unit/plugins/test_example_plugins.py b/backend/tests/unit/plugins/test_example_plugins.py index f7cd8ec..0363c6f 100644 --- a/backend/tests/unit/plugins/test_example_plugins.py +++ b/backend/tests/unit/plugins/test_example_plugins.py @@ -1,5 +1,8 @@ """Unit tests for the shipped example plugins (examples/plugins/meshcore, meshtastic). +The APRS RF example has its own module (test_aprs_rf) — it is a position source +rather than a mesh forwarder, so it shares none of the parametrisation here. + These are the reference third-party plugins, so they are exercised the way a real install runs them: loaded from disk through the public loader, then poked at their own surface — manifest defaults, the namespaced config mapping, the transport @@ -12,48 +15,13 @@ """ from __future__ import annotations -import logging import sys import types -from pathlib import Path import pytest -from backend.config import ServerConfig -from backend.plugins import loader -from backend.plugins.context import PluginContext -from backend.plugins.registry import PluginRegistry from backend.plugins.sdk import MeshForwardConfig, MeshForwarderPlugin, MeshTransport - -EXAMPLES = Path(__file__).resolve().parents[4] / "examples" / "plugins" - - -def make_ctx(config: ServerConfig | None = None) -> PluginContext: - async def _noop(*_a, **_k): - return None - - return PluginContext( - broadcast=_noop, - enqueue_tx=_noop, - get_config=(lambda: config) if config is not None else dict, - channel_clear=lambda: True, - data_dir=Path("/tmp"), - logger=logging.getLogger("test.plugin"), - ) - - -async def load_example(plugin_id: str, config: ServerConfig | None = None): - """Load an example plugin from examples/plugins the way a real install does.""" - inst = await loader.load_plugin(EXAMPLES / plugin_id, make_ctx(config), PluginRegistry()) - assert inst is not None, f"example plugin {plugin_id} failed to load" - return inst - - -def make_config(plugin_id: str, **values) -> ServerConfig: - cfg = ServerConfig() - if values: - cfg.set_plugin_config(plugin_id, values) - return cfg +from backend.tests.unit.plugins._helpers import load_example, make_config # Per-example expectations: id, default packet length, transport class name, and the diff --git a/backend/tests/unit/plugins/test_loader.py b/backend/tests/unit/plugins/test_loader.py index 407f7e0..79f6b81 100644 --- a/backend/tests/unit/plugins/test_loader.py +++ b/backend/tests/unit/plugins/test_loader.py @@ -24,6 +24,7 @@ async def _noop(*_a, **_k): enqueue_tx=_noop, get_config=dict, channel_clear=lambda: True, + report_position=_noop, data_dir=Path("/tmp"), logger=logging.getLogger("test.plugin"), ) diff --git a/backend/tests/unit/plugins/test_meshcore_positions.py b/backend/tests/unit/plugins/test_meshcore_positions.py new file mode 100644 index 0000000..a2f7017 --- /dev/null +++ b/backend/tests/unit/plugins/test_meshcore_positions.py @@ -0,0 +1,297 @@ +"""Unit tests for the MeshCore example's position-RX half. + +The forwarder half is covered by test_example_plugins; this module is only about +the contact-list read: the snapshot taken off the library's live mapping, the +0.0/0.0 "never advertised" case, the mapping onto ctx.report_position, and the +fact that positions stay off until both the plugin and position RX are enabled. + +Contact records here are shaped the way meshcore 2.3.8 shapes them — the reader +decodes a CONTACT frame into public_key / adv_name / adv_lat / adv_lon / +last_advert, with the coordinates already divided down from raw int32 by 1e6, +and MeshCore.contacts keyed by public key. + +No radio and no meshcore install is needed: the interface is a plain stub. +""" +from __future__ import annotations + +import sys + +import pytest + +from backend.tests.unit.plugins._helpers import load_example, make_config, make_ctx + + +def plugin_module(inst): + """The loaded plugin's own module (loader names it hw_plugin_meshcore).""" + return sys.modules[type(inst).__module__] + + +class FakeCommands: + def __init__(self, owner): + self._owner = owner + + async def get_contacts(self): + self._owner.refreshes += 1 + + +class FakeMeshCore: + """Stands in for meshcore.MeshCore.""" + + def __init__(self, contacts=None): + self.contacts = contacts + self.commands = FakeCommands(self) + self.refreshes = 0 + + +class Reports: + def __init__(self, accept=True): + self.calls: list[tuple] = [] + self._accept = accept + + async def __call__(self, source, node_id, lat, lon, label="", **meta): + self.calls.append((source, node_id, lat, lon, label, meta)) + return self._accept + + @property + def by_id(self) -> dict: + return {call[1]: call for call in self.calls} + + +async def make_plugin(reports=None, **values): + cfg = make_config("meshcore", **values) + ctx = make_ctx(cfg, report_position=reports) + return await load_example("meshcore", cfg, ctx=ctx), cfg + + +def contact(key="ab" * 32, name="Repeater", lat=42.9, lon=-85.8, last_advert=1700000000): + return { + "public_key": key, + "type": 2, + "flags": 0, + "out_path": "", + "adv_name": name, + "last_advert": last_advert, + "adv_lat": lat, + "adv_lon": lon, + "lastmod": last_advert, + } + + +class TestContactCoords: + async def test_reads_the_advertised_floats(self): + inst, _ = await make_plugin() + assert plugin_module(inst)._contact_coords(contact()) == (42.9, -85.8) + + async def test_a_contact_that_never_advertised_a_location_has_no_fix(self): + """The firmware sends a raw int32 zero in both fields for "unset".""" + inst, _ = await make_plugin() + assert plugin_module(inst)._contact_coords(contact(lat=0.0, lon=0.0)) == (None, None) + + @pytest.mark.parametrize("row", [{}, {"adv_lat": None, "adv_lon": None}, + {"adv_lat": "north", "adv_lon": 1.0}]) + async def test_a_missing_or_unparsable_coordinate_has_no_fix(self, row): + inst, _ = await make_plugin() + assert plugin_module(inst)._contact_coords(row) == (None, None) + + async def test_a_zero_on_one_axis_only_is_still_a_fix(self): + """0° longitude with a real latitude is Greenwich, not an unset field.""" + inst, _ = await make_plugin() + assert plugin_module(inst)._contact_coords(contact(lat=51.5, lon=0.0)) == (51.5, 0.0) + + +class TestReadContacts: + async def make_client(self, contacts, connected=True): + inst, cfg = await make_plugin() + client = inst._make_transport(cfg) + client._mc = FakeMeshCore(contacts) + client._connected = connected + return client + + async def test_flattens_the_contact_mapping(self): + key = "ab" * 32 + client = await self.make_client({key: contact(key)}) + assert await client.read_contacts() == [{ + "public_key": key, + "adv_name": "Repeater", + "adv_lat": 42.9, + "adv_lon": -85.8, + "last_advert": 1700000000, + }] + + async def test_refreshes_the_list_before_reading_it(self): + client = await self.make_client({}) + await client.read_contacts() + assert client._mc.refreshes == 1 + + async def test_falls_back_to_the_mapping_key(self): + """The library keys on public_key, so the row is authoritative but optional.""" + row = contact() + del row["public_key"] + client = await self.make_client({"cd" * 32: row}) + assert (await client.read_contacts())[0]["public_key"] == "cd" * 32 + + async def test_skips_entries_that_are_not_records(self): + client = await self.make_client({"a": contact("a"), "b": "junk", "c": None}) + assert [row["public_key"] for row in await client.read_contacts()] == ["a"] + + async def test_copies_the_row_instead_of_aliasing_it(self): + live = contact() + client = await self.make_client({live["public_key"]: live}) + rows = await client.read_contacts() + live["adv_lat"] = 0.0 + assert rows[0]["adv_lat"] == 42.9 + + async def test_reads_nothing_while_disconnected(self): + client = await self.make_client({"a": contact("a")}, connected=False) + assert await client.read_contacts() == [] + + async def test_a_radio_with_no_contact_list_yields_no_rows(self): + client = await self.make_client(None) + assert await client.read_contacts() == [] + + +class TestReportContact: + async def test_reports_the_key_coordinates_and_name(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_contact(contact("ab" * 32, "Hilltop")) + source, node_id, lat, lon, label, meta = reports.calls[0] + assert (source, node_id, lat, lon, label) == ( + "meshcore", "ab" * 32, 42.9, -85.8, "Hilltop" + ) + assert meta == {"heard_at": 1700000000} + + async def test_the_fix_ages_from_the_advert_not_the_contact_list_read(self): + # The contact list is a roster the radio keeps; re-reading it is not a + # new hearing, and treating it as one would never let a node age off. + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_contact(contact(last_advert=1_699_999_000)) + assert reports.calls[0][5]["heard_at"] == 1_699_999_000 + + async def test_an_unnamed_contact_reports_with_an_empty_label(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_contact(contact(name="")) + assert reports.calls[0][4] == "" + + async def test_a_contact_without_a_location_is_not_reported(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_contact(contact(lat=0.0, lon=0.0)) + assert reports.calls == [] + + async def test_a_contact_with_no_key_is_not_reported(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_contact(contact(key="")) + assert reports.calls == [] + + +class TestPollContacts: + async def test_reports_every_contact_that_advertises_a_location(self): + reports = Reports() + inst, cfg = await make_plugin(reports) + client = inst._make_transport(cfg) + client._mc = FakeMeshCore({ + "a": contact("a", "Alpha", 42.9, -85.8), + "b": contact("b", "Bravo", 0.0, 0.0), # advertising, no location + "c": contact("c", "Charlie", 42.8, -85.7), + }) + client._connected = True + inst._transport = client + + await inst._poll_contacts() + assert set(reports.by_id) == {"a", "c"} + + async def test_polling_without_a_transport_is_a_no_op(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._poll_contacts() # must not raise + assert reports.calls == [] + + async def test_polling_a_disconnected_radio_is_a_no_op(self): + reports = Reports() + inst, cfg = await make_plugin(reports) + client = inst._make_transport(cfg) + client._mc = FakeMeshCore({"a": contact("a")}) + inst._transport = client # built but never connected + await inst._poll_contacts() + assert reports.calls == [] + + async def test_an_unchanged_advert_is_reported_once(self): + """Dedupe lives in the poller; this confirms the plugin routes through it.""" + reports = Reports() + inst, cfg = await make_plugin(reports) + client = inst._make_transport(cfg) + client._mc = FakeMeshCore({"a": contact("a")}) + client._connected = True + inst._transport = client + + await inst._poll_contacts() + await inst._poll_contacts() + assert len(reports.calls) == 1 + + client._mc.contacts["a"]["adv_lat"] = 43.0 + await inst._poll_contacts() + assert len(reports.calls) == 2 + + +class TestPositionRxLifecycle: + """Position RX must stay off until the plugin *and* the toggle are on.""" + + async def make_enabled_plugin(self, **values): + inst, cfg = await make_plugin(Reports(), **values) + + class _Stub: + is_connected = True + + async def connect(self): ... + async def disconnect(self): ... + async def send_text(self, text, channel): ... + + # Keeps on_config_changed off the (absent) meshcore library and the serial port. + inst._make_transport = lambda config: _Stub() + return inst, cfg + + async def test_manifest_defaults_position_rx_off(self): + inst, _ = await make_plugin() + fields = {f.key: f for f in inst.manifest.config_schema} + assert fields["position_rx_enabled"].default is False + assert fields["position_poll_seconds"].default == 60 + + async def test_enabling_the_plugin_alone_does_not_start_the_poller(self): + inst, cfg = await self.make_enabled_plugin(enabled=True) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is False + await inst.on_unload() + + async def test_the_toggle_alone_does_not_start_the_poller(self): + inst, cfg = await self.make_enabled_plugin(position_rx_enabled=True) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is False + await inst.on_unload() + + async def test_both_enabled_starts_the_poller(self): + inst, cfg = await self.make_enabled_plugin(enabled=True, position_rx_enabled=True) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is True + await inst.on_unload() + assert inst._poller.is_running is False + + async def test_turning_the_toggle_back_off_stops_the_poller(self): + inst, cfg = await self.make_enabled_plugin(enabled=True, position_rx_enabled=True) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is True + + cfg.set_plugin_config("meshcore", {"enabled": True, "position_rx_enabled": False}) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is False + + async def test_the_poll_interval_honours_the_floor(self): + inst, cfg = await self.make_enabled_plugin( + enabled=True, position_rx_enabled=True, position_poll_seconds=1 + ) + await inst.on_config_changed(cfg) + assert inst._poller._poll_seconds == 5.0 + await inst.on_unload() diff --git a/backend/tests/unit/plugins/test_meshtastic_positions.py b/backend/tests/unit/plugins/test_meshtastic_positions.py new file mode 100644 index 0000000..9218b01 --- /dev/null +++ b/backend/tests/unit/plugins/test_meshtastic_positions.py @@ -0,0 +1,355 @@ +"""Unit tests for the Meshtastic example's position-RX half. + +The forwarder half of that plugin is covered by test_example_plugins; this +module is only about the node-database read: the snapshot the client takes off +the library's live mapping, the coordinate extraction, the mapping onto +ctx.report_position, and the fact that positions stay off until *both* the +plugin and position RX are enabled. + +Node records here are shaped the way meshtastic 2.7.11 shapes them — `nodes` +keyed by node-ID string, values a MessageToDict of NodeInfo, so camelCase keys, +derived float latitude/longitude alongside the raw latitudeI/longitudeI, and +zero-valued fields omitted entirely. + +No radio and no meshtastic install is needed: the interface is a plain stub. +""" +from __future__ import annotations + +import sys + +import pytest + +from backend.tests.unit.plugins._helpers import load_example, make_config, make_ctx + + +def plugin_module(inst): + """The loaded plugin's own module (loader names it hw_plugin_meshtastic).""" + return sys.modules[type(inst).__module__] + + +class FakeInterface: + """Stands in for meshtastic.serial_interface.SerialInterface.""" + + def __init__(self, nodes=None): + self.nodes = nodes + self.closed = False + + def close(self): + self.closed = True + + +class Reports: + """Collects ctx.report_position calls the way the server would receive them.""" + + def __init__(self, accept=True): + self.calls: list[tuple] = [] + self._accept = accept + + async def __call__(self, source, node_id, lat, lon, label="", **meta): + self.calls.append((source, node_id, lat, lon, label, meta)) + return self._accept + + @property + def by_id(self) -> dict: + return {call[1]: call for call in self.calls} + + +async def make_plugin(reports=None, **values): + """Load the example with a ctx whose report_position we can inspect.""" + cfg = make_config("meshtastic", **values) + ctx = make_ctx(cfg, report_position=reports) + return await load_example("meshtastic", cfg, ctx=ctx), cfg + + +class TestNodeCoords: + """The float keys the library derives, with the raw integers as a fallback.""" + + async def test_prefers_the_derived_float_keys(self): + inst, _ = await make_plugin() + coords = plugin_module(inst)._node_coords + assert coords({"latitude": 42.9, "longitude": -85.8}) == (42.9, -85.8) + + async def test_falls_back_to_the_1e7_degree_integers(self): + inst, _ = await make_plugin() + coords = plugin_module(inst)._node_coords + lat, lon = coords({"latitudeI": 429000000, "longitudeI": -858000000}) + assert lat == pytest.approx(42.9) + assert lon == pytest.approx(-85.8) + + async def test_mixes_a_derived_key_with_an_integer_one(self): + inst, _ = await make_plugin() + coords = plugin_module(inst)._node_coords + lat, lon = coords({"latitude": 42.9, "longitudeI": -858000000}) + assert (lat, lon) == (42.9, pytest.approx(-85.8)) + + @pytest.mark.parametrize( + "position", + [ + None, + "not a dict", + {}, # no fix at all + {"time": 1700000000}, # position packet with no coordinates + {"latitude": "north", "longitude": 1.0}, + ], + ) + async def test_no_usable_fix_yields_no_coordinates(self, position): + inst, _ = await make_plugin() + assert plugin_module(inst)._node_coords(position) == (None, None) + + @pytest.mark.parametrize( + "position", [{"latitude": 42.9}, {"longitude": -85.8}] + ) + async def test_a_half_fix_is_left_incomplete_for_the_caller_to_drop(self, position): + """MessageToDict omits a zero-valued coordinate, so exactly 0° looks like this.""" + inst, _ = await make_plugin() + assert None in plugin_module(inst)._node_coords(position) + + +class TestSnapshotNodes: + async def make_client(self, nodes, connected=True): + inst, cfg = await make_plugin() + client = inst._make_transport(cfg) + client._iface = FakeInterface(nodes) + client._connected = connected + return client + + async def test_maps_the_library_record_onto_a_flat_row(self): + client = await self.make_client({ + "!a1b2c3d4": { + "num": 2712847316, + "user": {"id": "!a1b2c3d4", "longName": "Base Station", "shortName": "BASE"}, + "position": {"latitudeI": 429000000, "longitudeI": -858000000, + "latitude": 42.9, "longitude": -85.8, "altitude": 218}, + "lastHeard": 1700000000, + "snr": 6.25, + }, + }) + assert await client.read_nodes() == [{ + "id": "!a1b2c3d4", + "long_name": "Base Station", + "short_name": "BASE", + "position": {"latitudeI": 429000000, "longitudeI": -858000000, + "latitude": 42.9, "longitude": -85.8, "altitude": 218}, + "last_heard": 1700000000, + "snr": 6.25, + }] + + async def test_tolerates_a_node_with_no_user_or_position(self): + """A node heard once but never identified still has a record in the DB.""" + client = await self.make_client({"!deadbeef": {"num": 1}}) + assert await client.read_nodes() == [{ + "id": "!deadbeef", + "long_name": "", + "short_name": "", + "position": None, + "last_heard": None, + "snr": None, + }] + + async def test_skips_entries_that_are_not_records(self): + client = await self.make_client({"!a": {"num": 1}, "!b": "junk", "!c": None}) + assert [row["id"] for row in await client.read_nodes()] == ["!a"] + + async def test_copies_the_position_instead_of_aliasing_it(self): + """The serial reader thread mutates these dicts as packets arrive.""" + live = {"latitude": 42.9, "longitude": -85.8} + client = await self.make_client({"!a": {"position": live}}) + rows = await client.read_nodes() + live["latitude"] = 0.0 + assert rows[0]["position"]["latitude"] == 42.9 + + async def test_reads_nothing_while_disconnected(self): + client = await self.make_client({"!a": {"position": {"latitude": 1.0, + "longitude": 2.0}}}, + connected=False) + assert await client.read_nodes() == [] + + async def test_a_radio_with_no_node_database_yields_no_rows(self): + client = await self.make_client(None) + assert await client.read_nodes() == [] + + +class TestReportNode: + async def test_reports_coordinates_label_and_altitude(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_node({ + "id": "!a1b2c3d4", + "long_name": "Base Station", + "short_name": "BASE", + "position": {"latitude": 42.9, "longitude": -85.8, "altitude": 218}, + "last_heard": 1_700_000_000, + "snr": 6.25, + }) + source, node_id, lat, lon, label, meta = reports.calls[0] + assert (source, node_id, lat, lon, label) == ( + "meshtastic", "!a1b2c3d4", 42.9, -85.8, "Base Station" + ) + assert meta == { + "alt_m": 218, "snr": "6.2 dB", "short_name": "BASE", + "heard_at": 1_700_000_000, + } + + async def test_the_fix_carries_when_the_radio_heard_it_not_when_we_read_it(self): + # The node DB remembers nodes for days; without this every poll would + # re-stamp a long-gone node as live. + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_node({ + "id": "!a", + "position": {"latitude": 42.9, "longitude": -85.8}, + "last_heard": 1_699_999_000, + }) + assert reports.calls[0][5]["heard_at"] == 1_699_999_000 + + async def test_falls_back_to_the_short_name_for_the_label(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_node({ + "id": "!a", "short_name": "BASE", + "position": {"latitude": 42.9, "longitude": -85.8}, + }) + assert reports.calls[0][4] == "BASE" + + async def test_an_unnamed_node_reports_with_an_empty_label(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_node({"id": "!a", "position": {"latitude": 42.9, "longitude": -85.8}}) + source, node_id, lat, lon, label, meta = reports.calls[0] + assert (node_id, label) == ("!a", "") + assert meta == {"alt_m": None, "heard_at": None} + + async def test_a_node_without_a_fix_is_not_reported(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_node({"id": "!a", "long_name": "No GPS", "position": None}) + assert reports.calls == [] + + async def test_a_node_sitting_at_exactly_zero_degrees_is_not_reported(self): + """MessageToDict drops the zero coordinate, leaving half a fix — unplottable.""" + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_node({"id": "!a", "position": {"latitude": 42.9}}) + assert reports.calls == [] + + async def test_a_node_with_no_id_is_not_reported(self): + """The poller drops it rather than keying the store on an empty string.""" + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._report_node({"id": "", "position": {"latitude": 42.9, "longitude": -85.8}}) + assert reports.calls == [] + + +class TestPollNodes: + async def test_reports_every_node_that_has_a_fix(self): + reports = Reports() + inst, cfg = await make_plugin(reports) + client = inst._make_transport(cfg) + client._iface = FakeInterface({ + "!a": {"user": {"longName": "Alpha"}, + "position": {"latitude": 42.9, "longitude": -85.8}}, + "!b": {"user": {"longName": "Bravo"}}, # heard, no GPS + "!c": {"user": {"longName": "Charlie"}, + "position": {"latitudeI": 428000000, "longitudeI": -857000000}}, + }) + client._connected = True + inst._transport = client + + await inst._poll_nodes() + + assert set(reports.by_id) == {"!a", "!c"} + assert reports.by_id["!c"][2] == pytest.approx(42.8) + + async def test_polling_without_a_transport_is_a_no_op(self): + reports = Reports() + inst, _ = await make_plugin(reports) + await inst._poll_nodes() # must not raise + assert reports.calls == [] + + async def test_polling_a_disconnected_radio_is_a_no_op(self): + reports = Reports() + inst, cfg = await make_plugin(reports) + client = inst._make_transport(cfg) + client._iface = FakeInterface({"!a": {"position": {"latitude": 1.0, "longitude": 2.0}}}) + inst._transport = client # built but never connected + await inst._poll_nodes() + assert reports.calls == [] + + async def test_an_unchanged_fix_is_reported_once(self): + """Dedupe lives in the poller; this confirms the plugin routes through it.""" + reports = Reports() + inst, cfg = await make_plugin(reports) + client = inst._make_transport(cfg) + client._iface = FakeInterface( + {"!a": {"position": {"latitude": 42.9, "longitude": -85.8}}} + ) + client._connected = True + inst._transport = client + + await inst._poll_nodes() + await inst._poll_nodes() + assert len(reports.calls) == 1 + + client._iface.nodes["!a"]["position"]["latitude"] = 43.0 + await inst._poll_nodes() + assert len(reports.calls) == 2 + + +class TestPositionRxLifecycle: + """Position RX must stay off until the plugin *and* the toggle are on.""" + + async def make_enabled_plugin(self, **values): + inst, cfg = await make_plugin(Reports(), **values) + + class _Stub: + is_connected = True + + async def connect(self): ... + async def disconnect(self): ... + async def send_text(self, text, channel): ... + + # Keeps on_config_changed off the (absent) meshtastic library and the serial port. + inst._make_transport = lambda config: _Stub() + return inst, cfg + + async def test_manifest_defaults_position_rx_off(self): + inst, _ = await make_plugin() + fields = {f.key: f for f in inst.manifest.config_schema} + assert fields["position_rx_enabled"].default is False + assert fields["position_poll_seconds"].default == 60 + + async def test_enabling_the_plugin_alone_does_not_start_the_poller(self): + inst, cfg = await self.make_enabled_plugin(enabled=True) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is False + await inst.on_unload() + + async def test_the_toggle_alone_does_not_start_the_poller(self): + inst, cfg = await self.make_enabled_plugin(position_rx_enabled=True) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is False + await inst.on_unload() + + async def test_both_enabled_starts_the_poller(self): + inst, cfg = await self.make_enabled_plugin(enabled=True, position_rx_enabled=True) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is True + await inst.on_unload() + assert inst._poller.is_running is False + + async def test_turning_the_toggle_back_off_stops_the_poller(self): + inst, cfg = await self.make_enabled_plugin(enabled=True, position_rx_enabled=True) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is True + + cfg.set_plugin_config("meshtastic", {"enabled": True, "position_rx_enabled": False}) + await inst.on_config_changed(cfg) + assert inst._poller.is_running is False + + async def test_the_poll_interval_honours_the_floor(self): + inst, cfg = await self.make_enabled_plugin( + enabled=True, position_rx_enabled=True, position_poll_seconds=1 + ) + await inst.on_config_changed(cfg) + assert inst._poller._poll_seconds == 5.0 + await inst.on_unload() diff --git a/backend/tests/unit/plugins/test_position_source.py b/backend/tests/unit/plugins/test_position_source.py new file mode 100644 index 0000000..31b2a47 --- /dev/null +++ b/backend/tests/unit/plugins/test_position_source.py @@ -0,0 +1,251 @@ +"""Unit tests for backend.plugins.position_source. + +PositionPoller owns the poll-task lifecycle and the per-node dedupe so that +concrete sources (Meshtastic node DB, APRS TNC) only have to say what a poll +reads. These tests drive it with a fake read; the real sources need hardware. +""" +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +import pytest + +from backend.plugins.context import PluginContext +from backend.plugins.position_source import MIN_POLL_SECONDS, PositionPoller + + +class FakeSource: + """Stands in for the plugin that owns a poller.""" + + def __init__(self, **poller_kwargs) -> None: + self.reported: list[tuple] = [] + self.polls = 0 + self.opens = 0 + self.closes = 0 + self.poll_error: Exception | None = None + self.open_error: Exception | None = None + self.polled = asyncio.Event() + self.report_result = True + self.ctx = self._make_ctx() + self.poller = PositionPoller( + "fake", + self._poll_once, + ctx_getter=lambda: self.ctx, + on_start=self._open, + on_stop=self._close, + **poller_kwargs, + ) + + def _make_ctx(self) -> PluginContext: + async def _noop(*_a, **_k): + return None + + async def _report(source, node_id, lat, lon, label="", **meta): + self.reported.append((source, node_id, lat, lon, label, meta)) + return self.report_result + + return PluginContext( + broadcast=_noop, + enqueue_tx=_noop, + get_config=dict, + channel_clear=lambda: True, + report_position=_report, + data_dir=Path("/tmp"), + logger=logging.getLogger("test.position_source"), + ) + + async def _open(self) -> None: + if self.open_error is not None: + raise self.open_error + self.opens += 1 + + async def _close(self) -> None: + self.closes += 1 + + async def _poll_once(self) -> None: + self.polls += 1 + self.polled.set() + if self.poll_error is not None: + raise self.poll_error + + +@pytest.fixture +def source(): + return FakeSource() + + +async def _settle(): + """Let the poll task reach its first await without wall-clock sleeping.""" + for _ in range(5): + await asyncio.sleep(0) + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_enabling_starts_a_single_poll_task(source): + await source.poller.configure(enabled=True, poll_seconds=60) + await source.poller.configure(enabled=True, poll_seconds=60) # idempotent + await source.polled.wait() + assert source.opens == 1 + assert source.polls == 1 + await source.poller.stop() + + +@pytest.mark.asyncio +async def test_disabling_stops_and_closes(source): + await source.poller.configure(enabled=True, poll_seconds=60) + await source.polled.wait() + await source.poller.configure(enabled=False, poll_seconds=60) + assert source.poller.is_running is False + assert source.closes == 1 + + +@pytest.mark.asyncio +async def test_starting_disabled_never_polls(source): + await source.poller.configure(enabled=False, poll_seconds=60) + await _settle() + assert source.polls == 0 + assert source.poller.is_running is False + + +@pytest.mark.asyncio +async def test_stop_is_safe_when_never_started(source): + await source.poller.stop() + assert source.poller.is_running is False + + +@pytest.mark.asyncio +async def test_a_failed_open_does_not_leave_a_running_loop(source): + source.open_error = RuntimeError("no serial port") + await source.poller.configure(enabled=True, poll_seconds=60) + await _settle() + assert source.polls == 0 + assert source.poller.is_running is False + + +@pytest.mark.asyncio +async def test_a_failing_poll_does_not_kill_the_loop(source): + source.poll_error = RuntimeError("radio unplugged") + await source.poller.configure(enabled=True, poll_seconds=0) + # Interval is floored, so drive the retries by hand rather than waiting. + while source.polls < 1: + await asyncio.sleep(0) + assert source.poller.is_running is True + await source.poller.stop() + + +@pytest.mark.asyncio +async def test_poll_interval_is_floored(source): + await source.poller.configure(enabled=False, poll_seconds=0.1) + assert source.poller._poll_seconds == MIN_POLL_SECONDS + + +@pytest.mark.asyncio +async def test_reconfiguring_a_running_poller_retunes_without_restart(source): + await source.poller.configure(enabled=True, poll_seconds=60) + await source.polled.wait() + await source.poller.configure(enabled=True, poll_seconds=120) + assert source.poller._poll_seconds == 120 + assert source.opens == 1 # not torn down and reopened + await source.poller.stop() + + +# --------------------------------------------------------------------------- +# Reporting / dedupe +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_report_forwards_to_the_context(source): + assert await source.poller.report("n1", 42.9, -85.8, label="Barn", alt_m=240) is True + assert source.reported == [("fake", "n1", 42.9, -85.8, "Barn", {"alt_m": 240})] + + +@pytest.mark.asyncio +async def test_repeat_of_the_same_fix_is_suppressed(source): + await source.poller.report("n1", 42.9, -85.8) + assert await source.poller.report("n1", 42.9, -85.8) is False + assert len(source.reported) == 1 + + +@pytest.mark.asyncio +async def test_a_node_that_moved_is_always_reported(source): + await source.poller.report("n1", 42.9, -85.8) + assert await source.poller.report("n1", 42.91, -85.8) is True + assert len(source.reported) == 2 + + +@pytest.mark.asyncio +async def test_the_dedupe_window_can_be_disabled(): + src = FakeSource(min_report_interval_s=0.0) + await src.poller.report("n1", 42.9, -85.8) + assert await src.poller.report("n1", 42.9, -85.8) is True + + +@pytest.mark.asyncio +async def test_dedupe_is_per_node(source): + await source.poller.report("n1", 42.9, -85.8) + assert await source.poller.report("n2", 42.9, -85.8) is True + + +@pytest.mark.asyncio +async def test_blank_node_id_is_refused(source): + assert await source.poller.report(" ", 42.9, -85.8) is False + assert source.reported == [] + + +@pytest.mark.asyncio +async def test_a_rejected_fix_is_not_remembered(source): + source.report_result = False + assert await source.poller.report("n1", 42.9, -85.8) is False + assert source.poller._last_seen == {} + + +@pytest.mark.asyncio +async def test_report_without_a_context_is_a_no_op(source): + source.ctx = None + assert await source.poller.report("n1", 42.9, -85.8) is False + + +@pytest.mark.asyncio +async def test_dedupe_table_is_bounded(source, monkeypatch): + monkeypatch.setattr("backend.plugins.position_source.MAX_TRACKED_NODES", 3) + for i in range(6): + await source.poller.report(f"n{i}", 42.9 + i / 1000, -85.8) + assert len(source.poller._last_seen) == 3 + + +@pytest.mark.asyncio +async def test_a_re_read_of_the_same_node_db_row_is_not_a_new_hearing(source): + # Polling a node database every minute must not keep re-stamping a node + # the radio last actually heard days ago. + await source.poller.report("n1", 42.9, -85.8, heard_at=1_700_000_000) + assert await source.poller.report("n1", 42.9, -85.8, heard_at=1_700_000_000) is False + assert len(source.reported) == 1 + + +@pytest.mark.asyncio +async def test_an_advancing_heard_at_reports_even_inside_the_rate_limit(source): + # The station really was heard again, so its age must reset — the 10 s + # window is for sources that can't tell us, not for ones that can. + await source.poller.report("n1", 42.9, -85.8, heard_at=1_700_000_000) + assert await source.poller.report("n1", 42.9, -85.8, heard_at=1_700_000_060) is True + assert len(source.reported) == 2 + + +@pytest.mark.asyncio +async def test_a_useless_heard_at_falls_back_to_the_rate_limit(source): + # A radio with no clock reports 0; that is not a timestamp. + await source.poller.report("n1", 42.9, -85.8, heard_at=0) + assert await source.poller.report("n1", 42.9, -85.8, heard_at=0) is False + + +@pytest.mark.asyncio +async def test_stopping_clears_the_dedupe_table(source): + await source.poller.report("n1", 42.9, -85.8) + await source.poller.stop() + assert source.poller._last_seen == {} diff --git a/backend/tests/unit/positions/__init__.py b/backend/tests/unit/positions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/positions/test_store.py b/backend/tests/unit/positions/test_store.py new file mode 100644 index 0000000..94a1179 --- /dev/null +++ b/backend/tests/unit/positions/test_store.py @@ -0,0 +1,282 @@ +"""Unit tests for backend.positions.store. + +Covers the parts that decide whether a bad or hostile input can hurt us: +range validation, the null-island reject, the entry cap, TTL expiry, and the +persistence round-trip. Time is injected everywhere so nothing sleeps. +""" +from __future__ import annotations + +import json + +import pytest + +from backend.positions.store import ( + MAX_EXTRA_KEYS, + MAX_LABEL_LEN, + InvalidPosition, + PositionStore, + validate_coords, +) + +T0 = 1_700_000_000.0 + + +@pytest.fixture +def store(tmp_path): + return PositionStore(tmp_path / "positions.json", ttl_minutes=60, max_entries=5) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +def test_validate_coords_accepts_ordinary_fix(): + assert validate_coords(42.9, -85.8) == (42.9, -85.8) + + +def test_validate_coords_accepts_strings(): + assert validate_coords("42.9", "-85.8") == (42.9, -85.8) + + +@pytest.mark.parametrize("lat,lon", [(91.0, 0.0), (-91.0, 0.0), (0.0, 181.0), (0.0, -181.0)]) +def test_validate_coords_rejects_out_of_range(lat, lon): + with pytest.raises(InvalidPosition): + validate_coords(lat, lon) + + +def test_validate_coords_rejects_null_island(): + with pytest.raises(InvalidPosition): + validate_coords(0.0, 0.0) + + +def test_validate_coords_allows_a_zero_on_one_axis_only(): + assert validate_coords(0.0, -85.8) == (0.0, -85.8) + + +@pytest.mark.parametrize("lat,lon", [("north", 0.0), (None, 1.0), (1.0, object())]) +def test_validate_coords_rejects_non_numeric(lat, lon): + with pytest.raises(InvalidPosition): + validate_coords(lat, lon) + + +def test_upsert_requires_source_and_node_id(store): + with pytest.raises(InvalidPosition): + store.upsert("", "node", 42.9, -85.8) + with pytest.raises(InvalidPosition): + store.upsert("aprs", " ", 42.9, -85.8) + + +# --------------------------------------------------------------------------- +# Upsert semantics +# --------------------------------------------------------------------------- + +def test_upsert_keys_on_source_and_node(store): + store.upsert("aprs", "W8ABC-9", 42.9, -85.8, now=T0) + store.upsert("meshtastic", "W8ABC-9", 43.0, -85.7, now=T0) + assert len(store) == 2 + + +def test_upsert_replaces_same_key(store): + store.upsert("aprs", "W8ABC-9", 42.9, -85.8, now=T0) + record = store.upsert("aprs", "W8ABC-9", 43.0, -85.7, now=T0 + 10) + assert len(store) == 1 + assert (record.lat, record.lon) == (43.0, -85.7) + + +def test_update_without_label_keeps_the_known_one(store): + store.upsert("meshtastic", "!abcd", 42.9, -85.8, label="Barn", now=T0) + record = store.upsert("meshtastic", "!abcd", 42.91, -85.81, now=T0 + 10) + assert record.label == "Barn" + + +def test_label_is_clamped(store): + record = store.upsert("aprs", "n1", 42.9, -85.8, label="x" * 500, now=T0) + assert len(record.label) == MAX_LABEL_LEN + + +def test_extra_metadata_is_bounded(store): + record = store.upsert( + "aprs", "n1", 42.9, -85.8, + extra={f"k{i}": "v" * 400 for i in range(50)}, + now=T0, + ) + assert len(record.extra) == MAX_EXTRA_KEYS + assert all(len(v) <= 120 for v in record.extra.values()) + + +def test_altitude_is_optional_and_tolerant(store): + assert store.upsert("aprs", "n1", 42.9, -85.8, now=T0).alt_m is None + assert store.upsert("aprs", "n2", 42.9, -85.8, alt_m="240", now=T0).alt_m == 240.0 + assert store.upsert("aprs", "n3", 42.9, -85.8, alt_m="high", now=T0).alt_m is None + + +# --------------------------------------------------------------------------- +# Cap + TTL +# --------------------------------------------------------------------------- + +def test_cap_evicts_the_stalest_entries(store): + for i in range(8): + store.upsert("aprs", f"n{i}", 42.9, -85.8, now=T0 + i) + assert len(store) == 5 + remaining = {r.node_id for r in store.active(T0 + 100)} + assert remaining == {"n3", "n4", "n5", "n6", "n7"} + + +def test_active_excludes_expired(store): + store.upsert("aprs", "old", 42.9, -85.8, now=T0) + store.upsert("aprs", "new", 43.0, -85.7, now=T0 + 3500) + live = store.active(now=T0 + 3601) + assert [r.node_id for r in live] == ["new"] + + +def test_active_does_not_mutate(store): + store.upsert("aprs", "old", 42.9, -85.8, now=T0) + store.active(now=T0 + 999_999) + assert len(store) == 1 + + +def test_purge_expired_removes_and_counts(store): + store.upsert("aprs", "old", 42.9, -85.8, now=T0) + store.upsert("aprs", "new", 43.0, -85.7, now=T0 + 3500) + assert store.purge_expired(now=T0 + 3601) == 1 + assert len(store) == 1 + + +def test_set_ttl_minutes_has_a_floor(store): + store.set_ttl_minutes(0) + assert store.ttl_minutes == 1 + + +def test_active_is_freshest_first(store): + store.upsert("aprs", "a", 42.9, -85.8, now=T0) + store.upsert("aprs", "b", 42.9, -85.8, now=T0 + 5) + assert [r.node_id for r in store.active(T0 + 6)] == ["b", "a"] + + +# --------------------------------------------------------------------------- +# Snapshot payload +# --------------------------------------------------------------------------- + +def test_snapshot_without_origin_has_no_distance(store): + store.upsert("aprs", "n1", 42.9, -85.8, now=T0) + row = store.snapshot(origin=None, now=T0)[0] + assert row["distance_km"] is None + assert row["bearing_deg"] is None + assert row["compass"] is None + + +def test_snapshot_with_origin_resolves_distance_and_bearing(store): + store.upsert("aprs", "north", 43.9, -85.8, now=T0) + row = store.snapshot(origin=(42.9, -85.8), now=T0)[0] + assert row["distance_km"] == pytest.approx(111.2, rel=0.01) + assert row["compass"] == "N" + + +def test_snapshot_sorts_nearest_first_when_origin_known(store): + store.upsert("aprs", "far", 45.0, -85.8, now=T0) + store.upsert("aprs", "near", 42.95, -85.8, now=T0) + assert [r["node_id"] for r in store.snapshot(origin=(42.9, -85.8), now=T0)] == ["near", "far"] + + +def test_snapshot_age_is_server_resolved_and_never_negative(store): + store.upsert("aprs", "n1", 42.9, -85.8, now=T0) + assert store.snapshot(now=T0 + 90)[0]["age_s"] == 90 + # A node whose clock ran ahead of ours must not render as "-4 s ago". + assert store.snapshot(now=T0 - 4)[0]["age_s"] == 0 + + +# --------------------------------------------------------------------------- +# Persistence +# --------------------------------------------------------------------------- + +def test_flush_is_a_no_op_when_clean(tmp_path): + store = PositionStore(tmp_path / "p.json") + assert store.flush() is False + assert not (tmp_path / "p.json").exists() + + +def test_round_trip(tmp_path): + path = tmp_path / "p.json" + first = PositionStore(path, ttl_minutes=60) + first.upsert("aprs", "W8ABC-9", 42.9, -85.8, label="Truck", alt_m=240.0, + extra={"comment": "mobile"}, now=T0) + assert first.flush() is True + + second = PositionStore(path, ttl_minutes=60) + record = second.active(now=T0)[0] + assert (record.source, record.node_id, record.label) == ("aprs", "W8ABC-9", "Truck") + assert (record.lat, record.lon, record.alt_m) == (42.9, -85.8, 240.0) + assert record.extra == {"comment": "mobile"} + + +def test_load_skips_corrupt_rows(tmp_path): + path = tmp_path / "p.json" + path.write_text(json.dumps({"positions": [ + {"source": "aprs", "node_id": "good", "lat": 42.9, "lon": -85.8, "heard_at": T0}, + {"source": "aprs", "node_id": "no-coords", "heard_at": T0}, + {"lat": 1.0, "lon": 2.0, "heard_at": T0}, + "not-a-dict-at-all", + ]}), encoding="utf-8") + store = PositionStore(path, ttl_minutes=60) + assert [r.node_id for r in store.active(T0)] == ["good"] + + +def test_load_tolerates_garbage_file(tmp_path): + path = tmp_path / "p.json" + path.write_text("{not json", encoding="utf-8") + assert len(PositionStore(path)) == 0 + + +def test_remove_and_clear(store): + store.upsert("aprs", "n1", 42.9, -85.8, now=T0) + assert store.remove("aprs", "nope") is False + assert store.remove("aprs", "n1") is True + store.upsert("aprs", "n2", 42.9, -85.8, now=T0) + store.clear() + assert len(store) == 0 + + +def test_flush_survives_an_unwritable_path(tmp_path): + # /data going read-only must not take the radio down. + store = PositionStore(tmp_path / "missing-dir" / "p.json", ttl_minutes=60) + store.upsert("aprs", "n1", 42.9, -85.8, now=T0) + (tmp_path / "missing-dir").write_text("I am a file, not a directory", encoding="utf-8") + assert store.flush() is False + assert len(store) == 1 + + +def test_take_pending_snapshots_so_the_write_can_go_off_thread(tmp_path): + # The server builds the payload on the event loop and writes it in a + # worker; the snapshot must not alias the live dict, or a position + # arriving mid-write would change size during iteration. + store = PositionStore(tmp_path / "p.json", ttl_minutes=60) + store.upsert("aprs", "n1", 42.9, -85.8, now=T0) + pending = store.take_pending() + assert pending is not None and len(pending["positions"]) == 1 + + store.upsert("aprs", "n2", 43.0, -85.9, now=T0) + assert len(pending["positions"]) == 1 # unaffected by the later upsert + assert store.write(pending) is True + + reloaded = PositionStore(tmp_path / "p.json", ttl_minutes=60) + assert [r.node_id for r in reloaded.active(T0)] == ["n1"] + + +def test_take_pending_returns_none_when_clean(tmp_path): + store = PositionStore(tmp_path / "p.json", ttl_minutes=60) + assert store.take_pending() is None + store.upsert("aprs", "n1", 42.9, -85.8, now=T0) + assert store.take_pending() is not None + assert store.take_pending() is None # dirty flag cleared by the first call + + +def test_a_failed_write_re_arms_the_dirty_flag(tmp_path): + # Otherwise a transient full disk would silently drop the positions until + # the next station happened to be heard. + store = PositionStore(tmp_path / "missing-dir" / "p.json", ttl_minutes=60) + store.upsert("aprs", "n1", 42.9, -85.8, now=T0) + (tmp_path / "missing-dir").write_text("I am a file, not a directory", encoding="utf-8") + pending = store.take_pending() + assert pending is not None + assert store.write(pending) is False + assert store.take_pending() is not None # retried on the next pass diff --git a/backend/tests/unit/test_geo.py b/backend/tests/unit/test_geo.py new file mode 100644 index 0000000..9ec975f --- /dev/null +++ b/backend/tests/unit/test_geo.py @@ -0,0 +1,74 @@ +"""Unit tests for backend.geo — great-circle distance, bearing, compass. + +Reference values are the standard worked examples for the haversine and +initial-bearing formulae, checked to a tolerance far tighter than any GPS fix +this app will ever see. +""" +from __future__ import annotations + +import pytest + +from backend.geo import bearing_deg, compass_point, haversine_km, km_to_miles + + +def test_zero_distance_to_self(): + assert haversine_km(42.9, -85.8, 42.9, -85.8) == pytest.approx(0.0, abs=1e-9) + + +def test_known_distance_jfk_to_lax(): + # JFK (40.6413, -73.7781) to LAX (33.9416, -118.4085): ~3974 km great circle. + km = haversine_km(40.6413, -73.7781, 33.9416, -118.4085) + assert km == pytest.approx(3974, rel=0.002) + + +def test_one_degree_of_latitude_is_about_111_km(): + assert haversine_km(0.0, 0.0, 1.0, 0.0) == pytest.approx(111.19, rel=0.001) + + +def test_antipodal_is_half_the_circumference(): + km = haversine_km(0.0, 0.0, 0.0, 180.0) + assert km == pytest.approx(20015, rel=0.001) + + +def test_distance_is_symmetric(): + a = haversine_km(42.9, -85.8, 43.1, -85.5) + b = haversine_km(43.1, -85.5, 42.9, -85.8) + assert a == pytest.approx(b) + + +@pytest.mark.parametrize( + "lat2,lon2,expected", + [ + (43.9, -85.8, 0.0), # due north + (42.9, -84.8, 90.0), # due east (short hop, so convergence is negligible) + (41.9, -85.8, 180.0), # due south + (42.9, -86.8, 270.0), # due west + ], +) +def test_cardinal_bearings(lat2, lon2, expected): + assert bearing_deg(42.9, -85.8, lat2, lon2) == pytest.approx(expected, abs=0.5) + + +def test_bearing_is_always_in_range(): + for lon in range(-180, 181, 15): + assert 0.0 <= bearing_deg(42.9, -85.8, 10.0, float(lon)) < 360.0 + + +@pytest.mark.parametrize( + "deg,expected", + [ + (0, "N"), (11, "N"), (12, "NNE"), (45, "NE"), (90, "E"), + (180, "S"), (270, "W"), (348, "NNW"), (349, "N"), (359.9, "N"), + ], +) +def test_compass_point(deg, expected): + assert compass_point(deg) == expected + + +def test_compass_point_wraps_past_360(): + assert compass_point(360.0) == "N" + assert compass_point(405.0) == "NE" + + +def test_km_to_miles(): + assert km_to_miles(1.609344) == pytest.approx(1.0) diff --git a/backend/tests/unit/test_plugin_endpoints.py b/backend/tests/unit/test_plugin_endpoints.py index 5094430..646a8aa 100644 --- a/backend/tests/unit/test_plugin_endpoints.py +++ b/backend/tests/unit/test_plugin_endpoints.py @@ -56,7 +56,8 @@ async def _noop(*_a, **_k): srv._plugin_ctx = PluginContext( broadcast=_noop, enqueue_tx=_noop, get_config=lambda: srv._config, - channel_clear=lambda: True, data_dir=tmp_path, logger=logging.getLogger("t"), + channel_clear=lambda: True, report_position=_noop, + data_dir=tmp_path, logger=logging.getLogger("t"), ) monkeypatch.setattr(srv, "_build_status", lambda: {"type": "status"}) monkeypatch.setattr(srv._manager, "broadcast", _noop) diff --git a/backend/tests/unit/test_server_positions.py b/backend/tests/unit/test_server_positions.py new file mode 100644 index 0000000..ae1c6c4 --- /dev/null +++ b/backend/tests/unit/test_server_positions.py @@ -0,0 +1,175 @@ +"""The host half of position reporting: backend.server._report_position. + +Covers the bit a plugin can't cover for itself — deciding *when* a fix was +heard. Everything about validation and storage lives in the store's own tests. +""" +import asyncio +import contextlib +import sys +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# backend.server transitively imports sounddevice (and other audio/ML deps) at +# module load time. Stub them out so tests run in environments without audio hardware. +for _stub in ("sounddevice", "faster_whisper", "silero_vad", "piper"): + sys.modules.setdefault(_stub, MagicMock()) +_piper_config_stub = MagicMock() +_piper_config_stub.SynthesisConfig = MagicMock +sys.modules.setdefault("piper.config", _piper_config_stub) + +import backend.server as server +from backend.positions.store import PositionStore + + +@pytest.fixture +def store(tmp_path, monkeypatch): + store = PositionStore(tmp_path / "positions.json", ttl_minutes=60) + monkeypatch.setattr(server, "_position_store", store) + monkeypatch.setattr(server, "_positions_dirty", False) + return store + + +def _only(store): + return next(iter(store._records.values())) + + +# --------------------------------------------------------------------------- +# heard_at resolution +# --------------------------------------------------------------------------- + +def test_no_heard_at_means_now(): + assert server._resolve_heard_at(None) is None + + +@pytest.mark.parametrize("raw", ["not-a-time", 0, -1, object()]) +def test_a_useless_heard_at_means_now(raw): + assert server._resolve_heard_at(raw) is None + + +def test_a_real_heard_at_is_kept(): + heard = time.time() - 3600 + assert server._resolve_heard_at(heard) == pytest.approx(heard) + + +def test_a_string_heard_at_is_coerced(): + heard = time.time() - 60 + assert server._resolve_heard_at(str(heard)) == pytest.approx(heard) + + +def test_a_future_heard_at_is_clamped_to_now(): + # Another radio's clock running fast would otherwise outlive its TTL. + resolved = server._resolve_heard_at(time.time() + 86400) + assert resolved <= time.time() + 1 + + +def test_an_ancient_heard_at_is_kept_so_the_ttl_can_expire_it(): + # Honest and invisible beats fresh-looking and wrong. + assert server._resolve_heard_at(1.0) == 1.0 + + +# --------------------------------------------------------------------------- +# _report_position +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_report_stores_the_fix_and_marks_it_dirty(store): + assert await server._report_position("aprs_rf", "W8ABC-9", 42.9, -85.8, label="Truck") is True + assert _only(store).label == "Truck" + assert server._positions_dirty is True + + +@pytest.mark.asyncio +async def test_a_node_db_row_ages_from_when_it_was_heard(store): + # The whole point: reading a roster is not hearing a station. + heard = time.time() - 7200 + await server._report_position("meshtastic", "!abcd", 42.9, -85.8, heard_at=heard) + assert _only(store).heard_at == pytest.approx(heard) + + +@pytest.mark.asyncio +async def test_a_source_with_no_timestamp_is_stamped_on_arrival(store): + await server._report_position("aprs_rf", "W8ABC-9", 42.9, -85.8) + assert _only(store).heard_at == pytest.approx(time.time(), abs=5) + + +@pytest.mark.asyncio +async def test_heard_at_does_not_leak_into_the_display_metadata(store): + await server._report_position("meshtastic", "!abcd", 42.9, -85.8, + heard_at=time.time(), alt_m=240, snr="6.2 dB") + record = _only(store) + assert record.extra == {"snr": "6.2 dB"} + assert record.alt_m == 240.0 + + +@pytest.mark.asyncio +async def test_a_bad_fix_is_refused_without_raising(store): + assert await server._report_position("meshtastic", "!abcd", 0.0, 0.0) is False + assert len(store) == 0 + assert server._positions_dirty is False + + +@pytest.mark.asyncio +async def test_reporting_before_the_store_exists_is_a_no_op(monkeypatch): + monkeypatch.setattr(server, "_position_store", None) + assert await server._report_position("aprs_rf", "W8ABC-9", 42.9, -85.8) is False + + +# --------------------------------------------------------------------------- +# _positions_pump +# --------------------------------------------------------------------------- + +@pytest.fixture +def pump(store, monkeypatch): + """The pump with its sleep collapsed, plus a recording broadcast.""" + monkeypatch.setattr(server, "_POSITIONS_PUMP_INTERVAL_S", 0) + monkeypatch.setattr(server, "_config", None) + manager = MagicMock() + manager.broadcast = AsyncMock() + monkeypatch.setattr(server, "_manager", manager) + return manager + + +async def _run_pump_briefly(seconds: float = 0.05): + """Let the pump spin for a moment. Its interval is patched to 0, so this + is many iterations, not one — enough for the refresh cadence to fire.""" + task = asyncio.create_task(server._positions_pump()) + await asyncio.sleep(seconds) + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_pump_broadcasts_a_new_fix(pump, store): + await server._report_position("aprs_rf", "W8ABC-9", 42.9, -85.8) + await _run_pump_briefly() + assert pump.broadcast.await_count >= 1 + assert pump.broadcast.await_args[0][0]["type"] == "positions" + + +@pytest.mark.asyncio +async def test_pump_stays_quiet_when_nothing_changed(pump, store, monkeypatch): + # A hundred kiosks must not be woken every two seconds for no reason. + monkeypatch.setattr(server, "_POSITIONS_REFRESH_S", 10_000) + await server._report_position("aprs_rf", "W8ABC-9", 42.9, -85.8) + await _run_pump_briefly() + assert pump.broadcast.await_count == 1 + + +@pytest.mark.asyncio +async def test_pump_refreshes_so_the_age_counters_keep_moving(pump, store, monkeypatch): + # Age is resolved server-side, so silence must not freeze every station + # at "Heard now". + monkeypatch.setattr(server, "_POSITIONS_REFRESH_S", 0) + await server._report_position("aprs_rf", "W8ABC-9", 42.9, -85.8) + await _run_pump_briefly() + assert pump.broadcast.await_count > 1 + + +@pytest.mark.asyncio +async def test_pump_says_nothing_at_all_with_no_stations(pump, store, monkeypatch): + monkeypatch.setattr(server, "_POSITIONS_REFRESH_S", 0) + await _run_pump_briefly() + pump.broadcast.assert_not_awaited() diff --git a/docs/legality.html b/docs/legality.html index 2758ff3..ff11fe0 100644 --- a/docs/legality.html +++ b/docs/legality.html @@ -309,6 +309,7 @@ Rule map Automated functions Mesh bridge + Position reports Responsibilities + +
+
+
+ Receive-only by construction +

Position reports are heard, never transmitted.

+

Hearthwave can plot nearby stations on a map: GPS positions read from a Meshtastic or MeshCore mesh radio, and APRS positions decoded from a KISS TNC. All three are optional, off by default, and receive-only — no position source in the codebase has a transmit path, and none of them touches the GMRS radio. That matters, because "APRS on GMRS" is a thing people ask about, and the honest answer is narrower than the rumour.

+
+
+
+ Listening is unregulated +

Receiving needs no license

+

Nothing in Part 95E, Part 97, or Part 15 restricts receiving a transmission that someone else chose to broadcast. The APRS plugin drives a TNC in KISS mode and only ever reads frames from it; the mesh plugins poll the mesh radio's own node database. No PTT line, no transmit call, no keying of anything.

+
+
+ 47 U.S.C. § 605 +

What we hear stays here

+

Positions land in a local store on your hub with a staleness timer, and are shown on your own screens and wall displays. They are not republished, not uploaded to APRS-IS or any other aggregator, and not forwarded onto the mesh. Same rule the transcript path already follows: received traffic is displayed to the licensee's household, nowhere else.

+
+
+ 47 CFR §§ 95.1731(d) · 95.1787 +

GMRS data exists, but it is tiny

+

Part 95E does permit some digital data — and it is not a general packet channel. It is confined to certified hand-held portables with a non-removable antenna, one-second bursts, at most one every thirty seconds, on 462 MHz only, carrying a location or a brief message to one named unit. Hearthwave sends none of it.

+
+
+ +
+

Why there is no "APRS on GMRS" here

+

GMRS data is often described as new. It isn't: the FCC added it in the 2017 Report and Order in WT Docket No. 10-119 (FCC 17-57), and its 2021 order on reconsideration expressly declined to relax the duty-cycle limit. What the rule authorizes is a short location or text exchange between handhelds — not amateur-style APRS. Five limits do the work:

+
+
    +
  • Hand-held portables only§ 95.1731(d) limits digital data to hand-held portable units, and § 95.1787(a)(4) requires the antenna to be "a non-removable integral part" of the unit. A base station wired to an outdoor antenna — which is what a Hearthwave station is — is outside the allowance before the first byte is sent.
  • +
  • One second, once every thirty§ 95.1787(a)(2) caps each data transmission at one second; (a)(3) permits no more than one transmission in any thirty-second window. Periodic beaconing at APRS rates does not fit, and neither does digipeating someone else's packet.
  • +
  • Directed, not broadcast§ 95.1731(d) allows location data, a request for location data, or a brief text message "to another specific GMRS or FRS unit." An APRS position beacon is addressed to everyone listening — the opposite shape.
  • +
  • 462 MHz only§ 95.1773(c) limits digital data to the 462 MHz main and interstitial channels; § 95.1787(a)(5) separately forbids it on the 467 MHz main channels.
  • +
  • A data burst cannot identify itself§ 95.1751(b) requires the call sign by voice in English or by international Morse as an audible tone. A packet carries neither, so an unattended data-only station has no lawful way to meet its identification duty.
  • +
+ +
+ About the Midland waiver. This is the order people are usually thinking of when they say GMRS data rules have loosened. On July 24, 2023 the Mobility Division of the Wireless Telecommunications Bureau granted Midland Radio Corporation a waiver of §§ 95.1731(d), 95.1787(a)(3)–(4) and 95.1767(a) (DA 23-633, WT Docket No. 21-388), so that vehicle-mounted off-road radios with an external antenna could send GPS position at 50 watts. Read the conditions: 462 MHz only; at most one 50-millisecond burst every ten seconds; only while two or more users are actively linked in Midland's app; only when the radio confirms the channel is clear; and Midland had to file a petition for rulemaking within sixty days. That is stricter per burst than the rule it waives, and it runs to one company and its named equipment — a waiver is not a rule change, and no rule change has followed. Anyone citing it as "GMRS APRS is legal now" is over-reading it. If you want to run APRS properly, that is a Part 97 amateur privilege needing an amateur license and an amateur radio — Hearthwave will happily receive those packets and will never transmit one. +
+
+
+
@@ -630,6 +680,7 @@

What the software can't do for you.

  • Keep accounts inside the licenseCreate transmit-capable accounts only for immediate family covered by your license; give everyone else listen-only. You are responsible for every operator, including minors (§ 95.1743).
  • Treat display devices like household keysA wall-display device token can send "I'm OK" and your quick-message list on the air. Issue one per trusted device, and revoke it if the tablet leaves the house or is lost (§ 95.1705).
  • Keep the hub off the public internetHearthwave ships no relay, tunnel, or port-forwarding service on purpose. Away from home, reach it over your own VPN (WireGuard/Tailscale) — the control link stays yours, exactly as § 95.1749 intends.
  • +
  • Keep received positions to yourselfThe map plots stations that happened to be heard — neighbours, passing APRS mobiles, mesh nodes. Show it on your own screens; don't republish it, and don't feed it to an aggregator. If you want to beacon your own position, do it on a service that permits it, from a radio certified for it (why →).
  • Stay present for automated functionsEnable the beacon only when the station is attended; run nets from the chair, not from memory.
  • Transmit permissible contentPlain-language personal communications (§ 95.1731). No music, ads, coded messages, or false traffic (§ 95.1733(a)) — software can frame your callsign, but what you say is on you.
  • @@ -649,6 +700,9 @@

    Primary sources

  • FCC — General Mobile Radio Service overview (licensing, permitted uses, and the Commission's guidance on network connections and repeater linking)
  • 47 CFR Part 15 — Radio frequency devices (eCFR) — the unlicensed side of the mesh bridge, in particular § 15.5 (conditions of operation), § 15.23 (home-built devices), and § 15.201 (equipment authorization)
  • 47 U.S.C. § 605 — Unauthorized publication or use of communications (Cornell LII) — why received traffic is never bridged anywhere
  • +
  • The GMRS digital-data rules, in full (Cornell LII): § 95.1731 (permissible communications, incl. (d) data), § 95.1773 (channels and bandwidth), § 95.1787 (hand-held data limits), and § 95.1751 (identification by voice or Morse)
  • +
  • FCC 17-57 — Report and Order, WT Docket No. 10-119 (May 2017) — the proceeding that created the limited GMRS data allowance
  • +
  • DA 23-633 — Midland Radio waiver Order, WT Docket No. 21-388 (July 24, 2023) — the party-specific waiver often mis-cited as a rule change
  • Hearthwave source code — every claim on this page about behavior is verifiable in the repository
  • @@ -689,7 +743,7 @@

    Primary sources

    The family airwaves, open to everyone. Self-hosted, accessible GMRS — built so no one is left off the channel.

    diff --git a/docs/plugins.md b/docs/plugins.md index 87fc1ce..16ee3bc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -148,6 +148,7 @@ Bound before `setup()`. Your only door to core services: | `await ctx.enqueue_tx(payload)` | queue a transmission, e.g. `{"text": "...", "_pre_formatted": True}` | | `ctx.get_config()` | the live config; your settings are at `ctx.get_config().plugin_config(id)` | | `ctx.channel_clear()` | `True` when the channel is idle (safe to transmit) | +| `await ctx.report_position(source, node_id, lat, lon, label="", **meta)` | hand a heard station position to the core; returns `False` if it was rejected as invalid. `alt_m` and `heard_at` in `**meta` are understood, the rest is display metadata | | `ctx.data_dir` | the writable data directory (for plugin state files) | | `ctx.logger` | a logger namespaced to your plugin | @@ -195,11 +196,68 @@ tx_composition={"max_len_key": "max_packet_length", "separator_key": "prefix_sep ``` The keys reference fields in your own `config_schema`. +### `PositionPoller` (reporting heard positions) +For plugins that *receive* where other stations are — a mesh radio's node +database, an APRS TNC, anything similar. It is a component you own, not a base +class, so it composes with `MeshForwarderPlugin` instead of fighting it: + +```python +from backend.plugins.sdk import PositionPoller + +class MyPlugin(BasePlugin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._poller = PositionPoller("my_plugin", self._poll, ctx_getter=lambda: self.ctx) + + async def on_config_changed(self, config): + cfg = config.plugin_config("my_plugin") + await self._poller.configure( + enabled=bool(cfg.get("position_rx_enabled")), + poll_seconds=float(cfg.get("position_poll_seconds", 60)), + ) + + async def on_unload(self): + await self._poller.stop() + + async def _poll(self): + for node in self._read_nodes(): + await self._poller.report(node.id, node.lat, node.lon, label=node.name, snr=node.snr) +``` + +The poller owns the task lifecycle, floors the interval at `MIN_POLL_SECONDS` +(5 s), and suppresses an unchanged fix for the same node within 10 s — a node +that actually moved is always reported. One failing poll logs and continues; it +never ends the loop. Pass `on_start`/`on_stop` callbacks to open and close a +link. A push-based source can block inside its poll callback for as long as the +link is up. Keyword `**meta` becomes the `extra` map shown in the map popup and +list, so keep it short and stringifiable. + +**Pass `heard_at` if your source knows it.** A node database is a roster the +radio keeps for days, so reading it is not the same as hearing the station — +without a timestamp every row you read looks like it arrived this second, and +a node that went off the air last week never ages off the map: + +```python +await self._poller.report(node.id, node.lat, node.lon, heard_at=node.last_heard) +``` + +It is epoch seconds; a value ahead of the server's clock is clamped to now. +Omit it when the packet arriving *is* the hearing (an APRS TNC, a live socket) +— then the 10-second rate limit applies instead, and the host stamps the fix +on arrival. + +Distance, bearing, staleness and the TTL are the core's job — report raw lat/lon +and nothing else. + ### Dependencies Bundling pip dependencies isn't automatic. **Import optional libraries lazily** inside the method that needs them and raise a clear error if absent (see the mesh examples) — that way your plugin still loads and lists, and the failure is obvious only when actually used. Required libraries must be present in the server image. +Shipped in the image for the example plugins: `pyserial`, `meshcore`, +`meshtastic`, and `aprslib` (APRS packet parsing). All four are still imported +lazily, so a slimmed-down image degrades to a clear error rather than a failed +load. --- @@ -238,4 +296,7 @@ Plugins**, and toggle "Echo to all clients" — no rebuild, no restart. - `examples/plugins/meshcore/plugin.py` — serial transport + `MeshForwarderPlugin` + `tx_composition` + a full `config_schema`. - `examples/plugins/meshtastic/plugin.py` — same shape, wrapping a blocking library - in a thread executor; mutually exclusive with MeshCore via `conflicts_with`. + in a thread executor; mutually exclusive with MeshCore via `conflicts_with`. Also + shows a `PositionPoller` composed onto a `MeshForwarderPlugin`. +- `examples/plugins/aprs_rf/` — receive-only position source: KISS deframing, an + AX.25 UI-frame decoder, and `aprslib` parsing, with no transmit path at all. diff --git a/examples/plugins/aprs_rf/ax25.py b/examples/plugins/aprs_rf/ax25.py new file mode 100644 index 0000000..0cd6338 --- /dev/null +++ b/examples/plugins/aprs_rf/ax25.py @@ -0,0 +1,107 @@ +"""Minimal AX.25 UI-frame decoder — just enough to reach the APRS payload. + +APRS rides in the info field of an AX.25 UI frame. This decodes the address +block (destination, source, up to eight digipeaters) and re-renders the frame +as the TNC2 monitor text that `aprslib.parse` expects: + + SRC>DEST,DIGI1*,DIGI2:info + +Receive-only: nothing here builds a frame. + +Address encoding, for reference — each address is seven bytes. The first six +are the callsign, space-padded, each character shifted left one bit. The +seventh byte carries the SSID in bits 1-4, the "has been repeated" H bit in +bit 7 (only meaningful for digipeaters), and the end-of-address extension bit +in bit 0, which is set on the last address. +""" +from __future__ import annotations + +ADDRESS_LEN = 7 +MAX_ADDRESSES = 10 # destination + source + 8 digipeaters +UI_CONTROL = 0x03 +UI_CONTROL_POLL = 0x13 # UI with the P/F bit set — still a UI frame +PID_NO_LAYER3 = 0xF0 + + +class FrameError(ValueError): + """The bytes are not a decodable AX.25 UI frame.""" + + +def decode_address(block: bytes) -> tuple[str, bool, bool]: + """Decode one seven-byte address. + + Returns ``(callsign, is_last, was_repeated)`` where *callsign* carries the + ``-SSID`` suffix when the SSID is non-zero. + """ + if len(block) != ADDRESS_LEN: + raise FrameError("truncated address") + chars = [] + for byte in block[:6]: + char = chr(byte >> 1) + if char == " ": + continue + if not (char.isalnum() and char.isascii()): + raise FrameError(f"invalid callsign character {char!r}") + chars.append(char) + if not chars: + raise FrameError("empty callsign") + ssid_byte = block[6] + ssid = (ssid_byte >> 1) & 0x0F + callsign = "".join(chars) + if ssid: + callsign = f"{callsign}-{ssid}" + return callsign, bool(ssid_byte & 0x01), bool(ssid_byte & 0x80) + + +def decode_ui_frame(frame: bytes) -> str: + """Render an AX.25 UI frame as TNC2 monitor text. + + Raises :class:`FrameError` for anything that isn't a UI/no-layer-3 frame — + a shared RF channel carries connected-mode traffic too, and a half-decoded + frame is worse than a skipped one. + """ + addresses: list[tuple[str, bool]] = [] + offset = 0 + while True: + if offset + ADDRESS_LEN > len(frame): + raise FrameError("address block ran off the end of the frame") + callsign, is_last, repeated = decode_address(frame[offset:offset + ADDRESS_LEN]) + addresses.append((callsign, repeated)) + offset += ADDRESS_LEN + if is_last: + break + if len(addresses) >= MAX_ADDRESSES: + raise FrameError("address block has no end-of-address bit") + if len(addresses) < 2: + raise FrameError("frame has no source address") + if offset + 2 > len(frame): + raise FrameError("frame ends before its control/PID bytes") + + control, pid = frame[offset], frame[offset + 1] + if control not in (UI_CONTROL, UI_CONTROL_POLL): + raise FrameError(f"not a UI frame (control 0x{control:02X})") + if pid != PID_NO_LAYER3: + raise FrameError(f"unexpected PID 0x{pid:02X}") + + info = _decode_info(frame[offset + 2:]) + if not info: + raise FrameError("UI frame has an empty info field") + + (dest, _), (src, _) = addresses[0], addresses[1] + path = "".join( + f",{call}*" if repeated else f",{call}" + for call, repeated in addresses[2:] + ) + return f"{src}>{dest}{path}:{info}" + + +def _decode_info(raw: bytes) -> str: + """Info fields are usually ASCII, but Mic-E and comments carry raw bytes. + + UTF-8 first (some clients send it), latin-1 as the byte-preserving fallback + so a stray high byte can't corrupt the coordinates that follow it. + """ + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("latin-1") diff --git a/examples/plugins/aprs_rf/kiss.py b/examples/plugins/aprs_rf/kiss.py new file mode 100644 index 0000000..9704aa8 --- /dev/null +++ b/examples/plugins/aprs_rf/kiss.py @@ -0,0 +1,84 @@ +"""KISS deframer — turns a TNC byte stream into AX.25 frames. + +KISS (Kantronics/Chepponis-Karn) wraps each frame between FEND bytes, escaping +any FEND or FESC that occurs inside it. The first byte of a frame is a command +byte: high nibble = port, low nibble = command, where 0 means "data frame". +Only data frames carry AX.25; everything else (TXDELAY, persistence, ...) is a +host-to-TNC control byte and is dropped. + +The deframer is fed arbitrary chunks — a frame may span several reads, and a +read may hold several frames — so it keeps its state between calls. +""" +from __future__ import annotations + +FEND = 0xC0 +FESC = 0xDB +TFEND = 0xDC +TFESC = 0xDD + +CMD_DATA = 0x00 + +#: A frame longer than this is a stuck stream, not AX.25 (the AX.25 spec caps +#: the info field at 256 bytes; direwolf and friends never exceed ~1 kB). The +#: overrun is dropped rather than buffered, so a noisy link can't eat memory. +MAX_FRAME_BYTES = 1024 + + +class KissDeframer: + """Incremental KISS deframer. Feed bytes, get complete AX.25 frames back.""" + + def __init__(self, max_frame_bytes: int = MAX_FRAME_BYTES) -> None: + self._max_frame_bytes = max_frame_bytes + self._buf = bytearray() + self._in_frame = False + self._escaped = False + self._overrun = False + + def reset(self) -> None: + """Drop any partial frame. Call after a reconnect.""" + self._buf.clear() + self._in_frame = False + self._escaped = False + self._overrun = False + + def feed(self, chunk: bytes) -> list[bytes]: + """Return every complete data frame contained in *chunk*, unescaped.""" + frames: list[bytes] = [] + for byte in chunk: + if byte == FEND: + # FEND both ends the frame in progress and opens the next one; + # back-to-back FENDs (idle padding) yield an empty frame we skip. + frame = self._finish() + if frame is not None: + frames.append(frame) + self.reset() + self._in_frame = True + continue + if not self._in_frame: + continue # noise before the first FEND + if self._escaped: + self._escaped = False + if byte == TFEND: + byte = FEND + elif byte == TFESC: + byte = FESC + else: + # Invalid escape: the frame is corrupt, so drop it rather + # than hand a mangled one to the AX.25 decoder. + self._overrun = True + continue + elif byte == FESC: + self._escaped = True + continue + if len(self._buf) >= self._max_frame_bytes: + self._overrun = True + continue + self._buf.append(byte) + return frames + + def _finish(self) -> bytes | None: + if not self._in_frame or self._overrun or len(self._buf) < 2: + return None + if self._buf[0] & 0x0F != CMD_DATA: + return None + return bytes(self._buf[1:]) diff --git a/examples/plugins/aprs_rf/parser.py b/examples/plugins/aprs_rf/parser.py new file mode 100644 index 0000000..9327abd --- /dev/null +++ b/examples/plugins/aprs_rf/parser.py @@ -0,0 +1,93 @@ +"""TNC2 text -> normalised position, via aprslib. + +APRS encodes coordinates three different ways (uncompressed, Base-91 +compressed, and Mic-E, which hides half the latitude in the destination +callsign). `aprslib` already implements all three, so this module only maps its +output onto what `ctx.report_position` wants and drops everything that carries +no fix — status, messages, telemetry, bulletins. + +aprslib is imported lazily so the plugin still loads (and reports a clear error +when enabled) on installs that never pip-installed it. +""" +from __future__ import annotations + +_aprslib = None + + +def load_aprslib(): + """Import aprslib, raising a message an operator can act on.""" + global _aprslib + if _aprslib is None: + try: + import aprslib # optional dependency + except ImportError as exc: # pragma: no cover - env-dependent + raise RuntimeError( + "aprslib not installed — `pip install aprslib` to enable the APRS RF plugin" + ) from exc + _aprslib = aprslib + return _aprslib + + +def parse_position(tnc2: str) -> dict | None: + """Return ``{node_id, lat, lon, alt_m, extra}``, or None if there's no fix. + + Never raises for bad input: a shared RF channel carries plenty of packets + that aren't positions, and one malformed beacon must not end the read loop. + """ + aprslib = load_aprslib() + try: + packet = aprslib.parse(tnc2) + except aprslib.exceptions.GenericError: + return None + except Exception: + # aprslib is strict about well-formed input but not exhaustively + # defensive; RF gives it bytes no sender intended. + return None + + source = str(packet.get("from") or "").strip() + lat, lon = packet.get("latitude"), packet.get("longitude") + if not source or lat is None or lon is None: + return None + try: + lat, lon = float(lat), float(lon) + except (TypeError, ValueError): + return None + + return { + "node_id": source, + "lat": lat, + "lon": lon, + "alt_m": _as_float(packet.get("altitude")), + "extra": _extra(packet), + } + + +def _extra(packet: dict) -> dict: + """The handful of fields worth showing in a marker popup. The store clamps + key count and value length, so this only has to pick, not police.""" + extra: dict[str, str] = {} + comment = str(packet.get("comment") or "").strip() + if comment: + extra["comment"] = comment + symbol = f"{packet.get('symbol_table') or ''}{packet.get('symbol') or ''}".strip() + if symbol: + extra["symbol"] = symbol + speed = _as_float(packet.get("speed")) + if speed is not None and speed > 0: + extra["speed"] = f"{speed:.0f} km/h" + course = _as_float(packet.get("course")) + if course is not None: + extra["course"] = f"{course:.0f}°" + path = packet.get("path") + if isinstance(path, (list, tuple)) and path: + extra["path"] = ",".join(str(hop) for hop in path) + return extra + + +def _as_float(value) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/examples/plugins/aprs_rf/plugin.py b/examples/plugins/aprs_rf/plugin.py new file mode 100644 index 0000000..6d7dbb2 --- /dev/null +++ b/examples/plugins/aprs_rf/plugin.py @@ -0,0 +1,191 @@ +"""APRS over RF — example Hearthwave plugin (position receive only). + +Reads a KISS TNC (direwolf over TCP, or a serial TNC), decodes the AX.25 UI +frames it hears, and plots any station that beacons a position. Third example +plugin alongside meshcore and meshtastic, and the only inbound-only one — a +useful template for "listen to a radio, report what you hear". + +RECEIVE ONLY, DELIBERATELY. This plugin has no transmit path and must not grow +one. Two independent reasons: + + * APRS on 144.390 MHz is the amateur service; transmitting there needs an + amateur licence and amateur-certified equipment, and a GMRS station may not + key it (47 CFR 95.1761 — GMRS transmitters must be certified for Part 95 + Subpart E). + * GMRS *does* permit short digital data bursts carrying GPS location, and has + since the 2017 Report and Order — but only from a hand-held portable unit + (95.1731(d)) whose antenna is "a non-removable integral part" of it + (95.1787(a)(4)), at most one one-second transmission per thirty seconds + (95.1787(a)(2)-(3)), addressed to a specific GMRS or FRS unit rather than + broadcast (95.1731(d)), and only on 462 MHz channels (95.1773(c), + 95.1787(a)(5)). Identification still has to be by voice or Morse + (95.1751(b)), which a data burst cannot do. A PC-driven TNC bolted to a + mobile or base radio satisfies none of that. See docs/legality.html. + +Listening is unrestricted, so receive is all this does. + +The link is driven by the SDK's PositionPoller. Its poll interval is the +*reconnect* delay here rather than a scan period: one "poll" opens the link and +stays inside the read loop until the TNC drops, at which point the poller waits +and re-opens. That is what the poller docs mean by a push-based source. +""" +from __future__ import annotations + +import logging + +from backend.plugins.sdk import ( + MIN_POLL_SECONDS, + BasePlugin, + ConfigField, + PluginManifest, + PositionPoller, +) + +from . import parser +from .ax25 import FrameError, decode_ui_frame +from .kiss import KissDeframer +from .transport import KissSerialLink, KissTcpLink + +_log = logging.getLogger(__name__) + +PLUGIN_ID = "aprs_rf" + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_TCP_PORT = 8001 # direwolf's default KISSPORT +DEFAULT_SERIAL_PORT = "/dev/ttyUSB1" +DEFAULT_BAUD = 9600 +DEFAULT_RECONNECT_SECONDS = 15 + + +class AprsRfPlugin(BasePlugin): + """Plot stations heard on an APRS RF channel (see module docstring).""" + + manifest = PluginManifest( + id=PLUGIN_ID, + name="APRS (RF receive)", + description="Listen to a KISS TNC and plot the position of every APRS station " + "heard on the air. Receive only — never transmits.", + config_schema=( + ConfigField("transport", "TNC connection", "select", "tcp", + options=(("tcp", "KISS over TCP (direwolf)"), + ("serial", "KISS over serial TNC")), + help="How to reach the TNC. Direwolf listens on TCP by default."), + ConfigField("host", "TNC host", "text", DEFAULT_HOST, + help="KISS TCP host. 127.0.0.1 when direwolf runs beside Hearthwave."), + ConfigField("port", "TNC port", "number", DEFAULT_TCP_PORT, minimum=1, maximum=65535, + help="KISS TCP port — direwolf's KISSPORT, 8001 unless changed."), + ConfigField("serial_port", "TNC serial device", "text", DEFAULT_SERIAL_PORT, + help="Used only when the connection is set to serial."), + ConfigField("baud", "Serial baud rate", "number", DEFAULT_BAUD, minimum=1), + ConfigField("reconnect_seconds", "Reconnect delay", "number", + DEFAULT_RECONNECT_SECONDS, minimum=MIN_POLL_SECONDS, + help="Seconds to wait before re-opening a dropped TNC link."), + ConfigField("callsign_filter", "Callsign filter", "text", "", + help="Comma-separated callsigns. Empty plots every station heard. " + "W8ABC matches every SSID; W8ABC-9 matches just that one."), + ConfigField("filter_mode", "Filter mode", "select", "allow", + options=(("allow", "Plot only these callsigns"), + ("deny", "Plot everything except these")), + help="Applied only when a callsign filter is set."), + ), + ) + + def __init__(self) -> None: + super().__init__() + self._deframer = KissDeframer() + self._link = None + self._filter: frozenset[str] = frozenset() + self._filter_mode = "allow" + self._link_factory = self._make_tcp_link + self._host = DEFAULT_HOST + self._port = DEFAULT_TCP_PORT + self._serial_port = DEFAULT_SERIAL_PORT + self._baud = DEFAULT_BAUD + self._poller = PositionPoller( + PLUGIN_ID, + self._pump, + ctx_getter=lambda: self.ctx, + ) + + # -- lifecycle ------------------------------------------------------- + async def on_config_changed(self, config) -> None: + c = config.plugin_config(PLUGIN_ID) + self._filter = _parse_filter(c.get("callsign_filter", "")) + self._filter_mode = "deny" if c.get("filter_mode") == "deny" else "allow" + self._link_factory = ( + self._make_serial_link if c.get("transport") == "serial" else self._make_tcp_link + ) + self._host = str(c.get("host", DEFAULT_HOST) or DEFAULT_HOST) + self._port = int(c.get("port", DEFAULT_TCP_PORT) or DEFAULT_TCP_PORT) + self._serial_port = str(c.get("serial_port", DEFAULT_SERIAL_PORT) or DEFAULT_SERIAL_PORT) + self._baud = int(c.get("baud", DEFAULT_BAUD) or DEFAULT_BAUD) + await self._poller.configure( + enabled=bool(c.get("enabled", False)), + poll_seconds=float(c.get("reconnect_seconds", DEFAULT_RECONNECT_SECONDS)), + ) + + async def on_unload(self) -> None: + await self._poller.stop() + + def _make_tcp_link(self) -> KissTcpLink: + return KissTcpLink(self._host, self._port) + + def _make_serial_link(self) -> KissSerialLink: + return KissSerialLink(self._serial_port, self._baud) + + # -- read loop ------------------------------------------------------- + async def _pump(self) -> None: + """Hold the TNC link open and decode until it drops. + + Returning (or raising) hands control back to the poller, which waits the + reconnect delay and calls this again. + """ + parser.load_aprslib() # fail before opening the link, not per packet + link = self._link_factory() + self._deframer.reset() + await link.open() + self._link = link + try: + while True: + chunk = await link.read() + for frame in self._deframer.feed(chunk): + await self._handle_frame(frame) + finally: + self._link = None + link.close() + + async def _handle_frame(self, frame: bytes) -> None: + try: + tnc2 = decode_ui_frame(frame) + except FrameError as exc: + _log.debug("APRS RF: skipped frame (%s)", exc) + return + fix = parser.parse_position(tnc2) + if fix is None or not self._passes_filter(fix["node_id"]): + return + await self._poller.report( + fix["node_id"], + fix["lat"], + fix["lon"], + label=fix["node_id"], + alt_m=fix["alt_m"], + **fix["extra"], + ) + + def _passes_filter(self, callsign: str) -> bool: + if not self._filter: + return True + listed = _matches(callsign, self._filter) + return listed if self._filter_mode == "allow" else not listed + + +def _parse_filter(raw) -> frozenset[str]: + return frozenset( + entry.strip().upper() for entry in str(raw or "").split(",") if entry.strip() + ) + + +def _matches(callsign: str, entries: frozenset[str]) -> bool: + """A bare callsign in the list matches every SSID of that station.""" + callsign = callsign.upper() + return callsign in entries or callsign.split("-")[0] in entries diff --git a/examples/plugins/aprs_rf/transport.py b/examples/plugins/aprs_rf/transport.py new file mode 100644 index 0000000..26d0d49 --- /dev/null +++ b/examples/plugins/aprs_rf/transport.py @@ -0,0 +1,119 @@ +"""KISS links — where the TNC byte stream comes from. + +Two ways in, both receive-only as far as this plugin is concerned: a KISS TCP +port (direwolf, `KISSPORT 8001` by default) or a serial TNC. Both present the +same three calls so the plugin doesn't care which is configured. + +``close()`` is deliberately synchronous. It runs from the ``finally`` of a read +loop that is usually being cancelled, and awaiting anything there risks a second +CancelledError before the socket is released. +""" +from __future__ import annotations + +import asyncio +import logging + +_log = logging.getLogger(__name__) + +CONNECT_TIMEOUT_S = 10.0 +READ_BYTES = 4096 + +#: Serial read timeout. Short enough that a cancelled poll loop's worker thread +#: retires promptly, long enough not to spin on an idle TNC. +SERIAL_READ_TIMEOUT_S = 0.5 + + +class KissLink: + """Byte source for the deframer.""" + + description = "kiss" + + async def open(self) -> None: + raise NotImplementedError + + async def read(self) -> bytes: + """Return the next bytes read, possibly empty. Raises on a dead link.""" + raise NotImplementedError + + def close(self) -> None: + raise NotImplementedError + + +class KissTcpLink(KissLink): + """KISS over TCP — direwolf, soundmodem, or any KISS-over-network TNC.""" + + def __init__(self, host: str, port: int) -> None: + self.host = host + self.port = port + self.description = f"tcp {host}:{port}" + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + + async def open(self) -> None: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.port), timeout=CONNECT_TIMEOUT_S + ) + _log.info("APRS RF: KISS TCP connected to %s:%d", self.host, self.port) + + async def read(self) -> bytes: + if self._reader is None: + raise ConnectionError("KISS TCP link is not open") + chunk = await self._reader.read(READ_BYTES) + if not chunk: + raise ConnectionError(f"KISS TCP {self.host}:{self.port} closed the connection") + return chunk + + def close(self) -> None: + writer, self._writer, self._reader = self._writer, None, None + if writer is not None: + try: + writer.close() + except Exception: # pragma: no cover - best-effort teardown + _log.exception("APRS RF: KISS TCP close failed") + + +class KissSerialLink(KissLink): + """KISS over a serial TNC. Blocking reads run in a worker thread.""" + + def __init__(self, port: str, baud: int) -> None: + self.port = port + self.baud = baud + self.description = f"serial {port} @ {baud}" + self._ser = None + + async def open(self) -> None: + try: + import serial # optional dependency (pyserial) + except ImportError as exc: # pragma: no cover - env-dependent + raise RuntimeError( + "pyserial not installed — `pip install pyserial` to use a serial TNC" + ) from exc + self._ser = await asyncio.to_thread( + serial.Serial, self.port, self.baud, timeout=SERIAL_READ_TIMEOUT_S + ) + _log.info("APRS RF: KISS serial opened on %s @ %d", self.port, self.baud) + + async def read(self) -> bytes: + if self._ser is None: + raise ConnectionError("KISS serial link is not open") + return await asyncio.to_thread(self._read_blocking) + + def _read_blocking(self) -> bytes: + ser = self._ser + if ser is None: + raise ConnectionError("KISS serial link closed mid-read") + # One blocking byte (bounded by the port timeout), then drain whatever + # else arrived with it, so a burst costs one thread hop rather than N. + data = ser.read(1) + waiting = getattr(ser, "in_waiting", 0) + if waiting: + data += ser.read(waiting) + return data + + def close(self) -> None: + ser, self._ser = self._ser, None + if ser is not None: + try: + ser.close() + except Exception: # pragma: no cover - best-effort teardown + _log.exception("APRS RF: KISS serial close failed") diff --git a/examples/plugins/meshcore/plugin.py b/examples/plugins/meshcore/plugin.py index 1123678..46a17f7 100644 --- a/examples/plugins/meshcore/plugin.py +++ b/examples/plugins/meshcore/plugin.py @@ -1,12 +1,12 @@ -"""MeshCore — example Hearthwave plugin (outbound LoRa mesh bridge). +"""MeshCore — example Hearthwave plugin (LoRa mesh bridge + position source). This is a reference plugin: it shows how to write a real, installable Hearthwave plugin against the public SDK. Drop this directory into /data/plugins/ and it loads. What it does: mirrors every accepted radio transmission onto a MeshCore mesh, prefixed with the sender's name, clamped to the mesh packet limit, forwarded -without ever delaying the radio TX path. Outbound only — received radio traffic -is never forwarded. +without ever delaying the radio TX path. Received mesh *messages* are never +forwarded into the app; the only inbound path is optional position RX, below. Everything mechanical (prefix build, length clamp, non-blocking queue, sender task, connect/disconnect lifecycle) comes from the SDK's MeshForwarderPlugin; this @@ -14,19 +14,29 @@ The MeshCore Companion serial protocol is spoken via the optional `meshcore` Python package, imported lazily so the plugin loads even when it's absent (the -error surfaces only when you enable it). Verify the two library calls (create + -send) against your installed meshcore version / firmware. +error surfaces only when you enable it). Verify the library calls (create, send, +get_contacts) against your installed meshcore version / firmware. + +Position RX refreshes the contact list and reads each contact's advertised +coordinates. The record shape below was read from meshcore 2.3.8: the reader +decodes a CONTACT frame into `adv_lat` / `adv_lon` floats (raw int32 / 1e6) +alongside `public_key`, `adv_name` and `last_advert`, and MeshCore.contacts is +the accumulated mapping keyed by public key. A contact that has never advertised +a location decodes to exactly 0.0 / 0.0, which is indistinguishable from a node +genuinely sitting at null island — both are dropped. """ from __future__ import annotations import logging from backend.plugins.sdk import ( + MIN_POLL_SECONDS, ConfigField, MeshForwardConfig, MeshForwarderPlugin, MeshTransport, PluginManifest, + PositionPoller, ) _log = logging.getLogger(__name__) @@ -75,6 +85,31 @@ async def send_text(self, text: str, channel: int) -> None: # NOTE: verify against the installed meshcore-py API / firmware. await self._mc.commands.send_chan_msg(channel, text) + async def read_contacts(self) -> list[dict]: + """Refresh the radio's contact list and snapshot what it advertises. + + get_contacts() drives the refresh; MeshCore.contacts is the mapping the + library accumulates from the resulting frames. The rows are copied + rather than handed out by reference — the reader task keeps mutating + the live dicts as adverts arrive. + """ + if not self._connected or self._mc is None: + return [] + # NOTE: verify against the installed meshcore-py API / firmware. + await self._mc.commands.get_contacts() + contacts = getattr(self._mc, "contacts", None) or {} + return [ + { + "public_key": contact.get("public_key") or key, + "adv_name": contact.get("adv_name", ""), + "adv_lat": contact.get("adv_lat"), + "adv_lon": contact.get("adv_lon"), + "last_advert": contact.get("last_advert"), + } + for key, contact in list(contacts.items()) + if isinstance(contact, dict) + ] + class MeshCorePlugin(MeshForwarderPlugin): """Forward accepted TX onto a MeshCore mesh (see module docstring).""" @@ -83,7 +118,8 @@ class MeshCorePlugin(MeshForwarderPlugin): id=PLUGIN_ID, name="MeshCore", description="Mirror every accepted transmission onto a MeshCore LoRa mesh, " - "prefixed with the sender's name. Serial-connected Companion radio.", + "prefixed with the sender's name. Serial-connected Companion radio. Can also " + "read contacts' advertised positions for the map.", conflicts_with=("meshtastic",), config_schema=( ConfigField("serial_port", "MeshCore device", "text", "/dev/ttyUSB0", @@ -94,6 +130,12 @@ class MeshCorePlugin(MeshForwarderPlugin): ConfigField("channel_idx", "Channel index", "number", 0, minimum=0), ConfigField("prefix_separator", "Name separator", "text", ": ", help='Joins the sender name and message, e.g. ": " → "Ben: hello"'), + ConfigField("position_rx_enabled", "Show contact positions on the map", "bool", False, + help="Read the radio's contact list and plot contacts that " + "advertise a location."), + ConfigField("position_poll_seconds", "Position poll interval", "number", 60, + minimum=MIN_POLL_SECONDS, + help="Seconds between contact-list reads."), ), tx_composition={ "max_len_key": "max_packet_length", @@ -102,6 +144,14 @@ class MeshCorePlugin(MeshForwarderPlugin): }, ) + def __init__(self) -> None: + super().__init__() + self._poller = PositionPoller( + PLUGIN_ID, + self._poll_contacts, + ctx_getter=lambda: getattr(self, "ctx", None), + ) + def _read_config(self, config) -> MeshForwardConfig: c = config.plugin_config(PLUGIN_ID) return MeshForwardConfig( @@ -121,3 +171,56 @@ def _make_transport(self, config) -> MeshTransport: def _transport_key(self, config): c = config.plugin_config(PLUGIN_ID) return (c.get("serial_port", "/dev/ttyUSB0"), int(c.get("baud", 115200))) + + # -- position RX ---------------------------------------------------- + async def on_config_changed(self, config) -> None: + # The forwarder half owns the serial link, so it runs first — the + # poller has nothing to read until the transport is up. + await super().on_config_changed(config) + c = config.plugin_config(PLUGIN_ID) + await self._poller.configure( + enabled=bool(c.get("enabled", False)) and bool(c.get("position_rx_enabled", False)), + poll_seconds=float(c.get("position_poll_seconds", 60)), + ) + + async def on_unload(self) -> None: + await self._poller.stop() + await super().on_unload() + + async def _poll_contacts(self) -> None: + transport = self._transport + if not isinstance(transport, MeshCoreClient) or not transport.is_connected: + return + for contact in await transport.read_contacts(): + await self._report_contact(contact) + + async def _report_contact(self, contact: dict) -> None: + lat, lon = _contact_coords(contact) + if lat is None or lon is None: + return + await self._poller.report( + contact.get("public_key") or "", + lat, + lon, + label=contact.get("adv_name") or "", + # The advert's own time, not the contact-list read: the list is a + # roster the radio keeps, and re-reading it is not a new hearing. + heard_at=contact.get("last_advert"), + ) + + +def _contact_coords(contact: dict) -> tuple[float | None, float | None]: + """Pull advertised coordinates out of a MeshCore contact record. + + A contact that has never advertised a location carries a raw int32 zero in + both fields, so 0.0/0.0 means "no location", not "on the equator off the + coast of Africa". + """ + try: + lat = float(contact.get("adv_lat")) + lon = float(contact.get("adv_lon")) + except (TypeError, ValueError): + return None, None + if lat == 0.0 and lon == 0.0: + return None, None + return lat, lon diff --git a/examples/plugins/meshtastic/plugin.py b/examples/plugins/meshtastic/plugin.py index 450366d..ba32129 100644 --- a/examples/plugins/meshtastic/plugin.py +++ b/examples/plugins/meshtastic/plugin.py @@ -1,15 +1,27 @@ -"""Meshtastic — example Hearthwave plugin (outbound LoRa mesh bridge). +"""Meshtastic — example Hearthwave plugin (LoRa mesh bridge + position source). Reference plugin, sibling to the MeshCore example. Mirrors every accepted radio -transmission onto a Meshtastic mesh, prefixed with the sender's name. Serial-only. -Mutually exclusive with MeshCore (one serial mesh radio at a time) — declared via -the manifest's `conflicts_with`, enforced by the host. +transmission onto a Meshtastic mesh, prefixed with the sender's name, and +optionally reads the radio's node database for other nodes' GPS positions. +Serial-only. Mutually exclusive with MeshCore (one serial mesh radio at a time) +— declared via the manifest's `conflicts_with`, enforced by the host. The `meshtastic` Python API is synchronous/blocking (pubsub + a blocking serial -reader), so interface construction and every send run in a thread executor to keep -the event loop responsive. Imported lazily. Verify the two library calls -(SerialInterface ctor + sendText channel arg) and the true max text length against -your installed meshtastic version / firmware. +reader), so interface construction, every send, and the node-database read run +in a thread executor to keep the event loop responsive. Imported lazily. Verify +the two library calls (SerialInterface ctor + sendText channel arg) and the true +max text length against your installed meshtastic version / firmware. + +Position RX polls `iface.nodes` rather than subscribing to +`meshtastic.receive.position`: the node database already accumulates every +position the radio has heard, and polling keeps the callback off the library's +serial reader thread. The record shape below was read from meshtastic 2.7.11 — +`nodes` is keyed by node ID string and each value is a `MessageToDict` of the +NodeInfo protobuf, hence the camelCase keys and the float `latitude`/`longitude` +that `_fixupPosition` derives from the integer `latitudeI`/`longitudeI`. +Because MessageToDict omits zero-valued fields, a node sitting at exactly 0° +has no coordinate key at all — which is indistinguishable from no fix, and is +handled the same way. """ from __future__ import annotations @@ -17,11 +29,13 @@ import logging from backend.plugins.sdk import ( + MIN_POLL_SECONDS, ConfigField, MeshForwardConfig, MeshForwarderPlugin, MeshTransport, PluginManifest, + PositionPoller, ) _log = logging.getLogger(__name__) @@ -76,6 +90,36 @@ async def send_text(self, text: str, channel: int) -> None: None, lambda: self._iface.sendText(text, channelIndex=channel) ) + async def read_nodes(self) -> list[dict]: + """Snapshot the radio's node database. + + Copied in the executor rather than handed out by reference: the + library's serial reader thread mutates these dicts as packets arrive, + so iterating the live mapping from the event loop would race. + """ + if not self._connected or self._iface is None: + return [] + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._snapshot_nodes) + + def _snapshot_nodes(self) -> list[dict]: + nodes = getattr(self._iface, "nodes", None) or {} + snapshot = [] + for node_id, node in list(nodes.items()): + if not isinstance(node, dict): + continue + position = node.get("position") + user = node.get("user") + snapshot.append({ + "id": node_id, + "long_name": (user or {}).get("longName", ""), + "short_name": (user or {}).get("shortName", ""), + "position": dict(position) if isinstance(position, dict) else None, + "last_heard": node.get("lastHeard"), + "snr": node.get("snr"), + }) + return snapshot + class MeshtasticPlugin(MeshForwarderPlugin): """Forward accepted TX onto a Meshtastic mesh (see module docstring).""" @@ -84,7 +128,8 @@ class MeshtasticPlugin(MeshForwarderPlugin): id=PLUGIN_ID, name="Meshtastic", description="Mirror every accepted transmission onto a Meshtastic LoRa mesh, " - "prefixed with the sender's name. Serial-connected radio.", + "prefixed with the sender's name. Serial-connected radio. Can also read " + "other nodes' GPS positions for the map.", conflicts_with=("meshcore",), config_schema=( ConfigField("serial_port", "Meshtastic device", "text", "/dev/ttyUSB0", @@ -95,6 +140,11 @@ class MeshtasticPlugin(MeshForwarderPlugin): help="0 is the primary channel."), ConfigField("prefix_separator", "Name separator", "text", ": ", help='Joins the sender name and message, e.g. ": " → "Ben: hello"'), + ConfigField("position_rx_enabled", "Show node positions on the map", "bool", False, + help="Read the radio's node database and plot nodes that have a GPS fix."), + ConfigField("position_poll_seconds", "Position poll interval", "number", 60, + minimum=MIN_POLL_SECONDS, + help="Seconds between node-database reads."), ), tx_composition={ "max_len_key": "max_packet_length", @@ -103,6 +153,14 @@ class MeshtasticPlugin(MeshForwarderPlugin): }, ) + def __init__(self) -> None: + super().__init__() + self._poller = PositionPoller( + PLUGIN_ID, + self._poll_nodes, + ctx_getter=lambda: getattr(self, "ctx", None), + ) + def _read_config(self, config) -> MeshForwardConfig: c = config.plugin_config(PLUGIN_ID) return MeshForwardConfig( @@ -119,3 +177,66 @@ def _make_transport(self, config) -> MeshTransport: def _transport_key(self, config): c = config.plugin_config(PLUGIN_ID) return (c.get("serial_port", "/dev/ttyUSB0"),) + + # -- position RX ---------------------------------------------------- + async def on_config_changed(self, config) -> None: + # The forwarder half owns the serial link, so it runs first — the + # poller has nothing to read until the transport is up. + await super().on_config_changed(config) + c = config.plugin_config(PLUGIN_ID) + await self._poller.configure( + enabled=bool(c.get("enabled", False)) and bool(c.get("position_rx_enabled", False)), + poll_seconds=float(c.get("position_poll_seconds", 60)), + ) + + async def on_unload(self) -> None: + await self._poller.stop() + await super().on_unload() + + async def _poll_nodes(self) -> None: + transport = self._transport + if not isinstance(transport, MeshtasticClient) or not transport.is_connected: + return + for node in await transport.read_nodes(): + await self._report_node(node) + + async def _report_node(self, node: dict) -> None: + lat, lon = _node_coords(node.get("position")) + if lat is None or lon is None: + return + position = node.get("position") or {} + extra = {} + if node.get("snr") is not None: + extra["snr"] = f"{float(node['snr']):.1f} dB" + if node.get("short_name"): + extra["short_name"] = node["short_name"] + await self._poller.report( + node.get("id") or "", + lat, + lon, + label=node.get("long_name") or node.get("short_name") or "", + alt_m=position.get("altitude"), + # When our radio heard it, not when we read the database — the node + # DB keeps nodes for days, so polling it is not a hearing. + heard_at=node.get("last_heard"), + **extra, + ) + + +def _node_coords(position) -> tuple[float | None, float | None]: + """Pull float coordinates out of a Meshtastic position dict. + + Prefers the float keys the library derives, falling back to the raw + 1e-7-degree integers in case a future version stops deriving them. + """ + if not isinstance(position, dict): + return None, None + lat, lon = position.get("latitude"), position.get("longitude") + if lat is None and position.get("latitudeI") is not None: + lat = position["latitudeI"] * 1e-7 + if lon is None and position.get("longitudeI") is not None: + lon = position["longitudeI"] * 1e-7 + try: + return (None if lat is None else float(lat), None if lon is None else float(lon)) + except (TypeError, ValueError): + return None, None diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9ae17b7..41c820d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.1", "@mui/material": "^9.0.1", + "leaflet": "^1.9.4", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -22,6 +23,7 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/leaflet": "^1.9.22", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", @@ -1293,6 +1295,21 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -2207,6 +2224,11 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a130311..c9971ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.1", "@mui/material": "^9.0.1", + "leaflet": "^1.9.4", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -29,6 +30,7 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/leaflet": "^1.9.22", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ecb3d68..7f1009a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import type { ChatMessagePayload, Contact, AttendanceStation, + StationPosition, JournalEntry, NetSessionSummary, AttendanceStatRow, @@ -193,6 +194,7 @@ export default function App() { // Panel visibility const [showAttendance, setShowAttendance] = useState(false); + const [showPositions, setShowPositions] = useState(false); const [showJournal, setShowJournal] = useState(false); const [showContacts, setShowContacts] = useState(false); const [showSettings, setShowSettings] = useState(false); @@ -341,6 +343,9 @@ export default function App() { // Attendance const [attendanceStations, setAttendanceStations] = useState([]); + // Station positions heard by the mesh/APRS plugins (server-sorted, nearest first) + const [positions, setPositions] = useState([]); + // Journals const [journals, setJournals] = useState([]); const [journalResult, setJournalResult] = useState(null); @@ -413,6 +418,11 @@ export default function App() { ncsPreambleText: '', ncsClosingText: '', rxMode: 'voice', + stationLat: null as number | null, + stationLon: null as number | null, + mapTilesLocal: false, + mapTilesUrl: '', + positionTtlMinutes: 1440, display_quick_messages: [] as string[], }); @@ -558,6 +568,13 @@ export default function App() { ncsPreambleText: msg.ncs_preamble_text ?? prev.ncsPreambleText, ncsClosingText: msg.ncs_closing_text ?? prev.ncsClosingText, rxMode: msg.rx_mode ?? prev.rxMode, + // Coordinates are nullable, so `??` would keep a stale value after + // an admin clears them — take the field whenever the key is present. + stationLat: msg.station_lat !== undefined ? msg.station_lat : prev.stationLat, + stationLon: msg.station_lon !== undefined ? msg.station_lon : prev.stationLon, + mapTilesLocal: msg.map_tiles_local ?? prev.mapTilesLocal, + mapTilesUrl: msg.map_tiles_url ?? prev.mapTilesUrl, + positionTtlMinutes: msg.position_ttl_minutes ?? prev.positionTtlMinutes, display_quick_messages: msg.display_quick_messages ?? prev.display_quick_messages, })); setServerConfig((prev) => ({ @@ -748,6 +765,10 @@ export default function App() { }); break; + case 'positions': + setPositions(msg.stations); + break; + case 'session_attendance': setAttendanceStations(msg.stations); break; @@ -1156,6 +1177,10 @@ export default function App() { journals_dir: string; ncs_zone: string; rx_mode: string; + station_lat: number | null; + station_lon: number | null; + map_tiles_url: string; + position_ttl_minutes: number; display_quick_messages: string[]; }) { send({ type: 'set_admin_config', ...values }); @@ -1544,6 +1569,7 @@ export default function App() { const showCallsignChips = serviceMode === 'GMRS'; function handleToggleAttendance() { setShowAttendance((v) => !v); } + function handleTogglePositions() { setShowPositions((v) => !v); } function handleToggleJournal() { setShowJournal((v) => !v); } function handleToggleContacts() { if (showContacts) handleContactsClose(); @@ -1700,6 +1726,7 @@ export default function App() { channelClear, attendanceStations, onClearAttendance: handleClearAttendance, + positions, journals, journalResult, journalGenerating, @@ -1904,6 +1931,8 @@ export default function App() { showLevelMeter={showLevelMeter} onToggleLevelMeter={handleToggleLevelMeter} showAttendance={showAttendance} + showPositions={showPositions} + onTogglePositions={handleTogglePositions} showJournal={showJournal} showNcs={showNcs} ncsEnabled={isPluginEnabled(plugins, 'ncs')} diff --git a/frontend/src/components/AdminPanel/AdminPanel.tsx b/frontend/src/components/AdminPanel/AdminPanel.tsx index 351feee..fa11139 100644 --- a/frontend/src/components/AdminPanel/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel/AdminPanel.tsx @@ -61,6 +61,13 @@ interface AdminConfig { netDay: string; /** "HH:MM" 24-hour time, or "" when unset. */ netTime: string; + /** Own station coordinates, null when unset. */ + stationLat: number | null; + stationLon: number | null; + /** True when the server is serving an offline tile pack at /tiles. */ + mapTilesLocal: boolean; + mapTilesUrl: string; + positionTtlMinutes: number; /** Quick-message shortcuts offered on the kiosk display's "I'm OK" screen. */ display_quick_messages: string[]; } @@ -85,6 +92,10 @@ interface Props { rx_mode: string; neighborhood_net_day: string; neighborhood_net_time: string; + station_lat: number | null; + station_lon: number | null; + map_tiles_url: string; + position_ttl_minutes: number; display_quick_messages: string[]; }) => void; onPreviewVoice: (voiceId: string) => void; @@ -115,6 +126,27 @@ export interface AdminPanelHandle { save(): void; } +/** A coordinate for the text field: empty when unset, full precision otherwise. */ +function coordText(value: number | null | undefined): string { + return typeof value === 'number' ? String(value) : ''; +} + +/** Parse a typed coordinate. Empty or unparsable means "unset", not zero. */ +export function parseCoord(text: string): number | null { + const trimmed = text.trim(); + if (!trimmed) return null; + const value = Number(trimmed); + return Number.isFinite(value) ? value : null; +} + +/** Range check for the field's own error state; the server re-checks. */ +export function coordError(text: string, limit: number): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + const value = Number(trimmed); + return !Number.isFinite(value) || Math.abs(value) > limit; +} + /** Build the seed JSON object from a config snapshot, mirroring buildValues(). */ function seedFromConfig(config: AdminConfig): string { return JSON.stringify({ @@ -131,6 +163,12 @@ function seedFromConfig(config: AdminConfig): string { rx_mode: config.rxMode || 'voice', neighborhood_net_day: config.netDay || '', neighborhood_net_time: config.netTime || '', + // Normalised the same way the fields are seeded and read back, so a + // config that omits these keys does not read as dirty on open. + station_lat: parseCoord(coordText(config.stationLat)), + station_lon: parseCoord(coordText(config.stationLon)), + map_tiles_url: config.mapTilesUrl || '', + position_ttl_minutes: Number(config.positionTtlMinutes ?? 1440) || 1440, display_quick_messages: config.display_quick_messages || [], }); } @@ -155,6 +193,12 @@ export const AdminPanel = forwardRef(function AdminPane const [rxMode, setRxMode] = useState('voice'); const [netDay, setNetDay] = useState(''); const [netTime, setNetTime] = useState(''); + // Coordinates are held as text so a half-typed "-85." isn't snapped to a + // number mid-keystroke, and so "" can mean "unset" rather than 0. + const [stationLat, setStationLat] = useState(''); + const [stationLon, setStationLon] = useState(''); + const [mapTilesUrl, setMapTilesUrl] = useState(''); + const [positionTtl, setPositionTtl] = useState('1440'); const [showKey, setShowKey] = useState(false); const [quickMessagesText, setQuickMessagesText] = useState(''); const [newDisplayLabel, setNewDisplayLabel] = useState(''); @@ -179,6 +223,10 @@ export const AdminPanel = forwardRef(function AdminPane setRxMode(config.rxMode || 'voice'); setNetDay(config.netDay || ''); setNetTime(config.netTime || ''); + setStationLat(coordText(config.stationLat)); + setStationLon(coordText(config.stationLon)); + setMapTilesUrl(config.mapTilesUrl || ''); + setPositionTtl(String(config.positionTtlMinutes ?? 1440)); setShowKey(false); setQuickMessagesText((config.display_quick_messages || []).join('\n')); // Compute seed from config directly (state setters are async), mirroring @@ -210,6 +258,10 @@ export const AdminPanel = forwardRef(function AdminPane rx_mode: rxMode, neighborhood_net_day: netDay, neighborhood_net_time: netTime, + station_lat: parseCoord(stationLat), + station_lon: parseCoord(stationLon), + map_tiles_url: mapTilesUrl.trim(), + position_ttl_minutes: Number(positionTtl) || 1440, display_quick_messages: quickMessagesText .split('\n') .map((line) => line.trim()) @@ -277,6 +329,62 @@ export const AdminPanel = forwardRef(function AdminPane fullWidth /> + + setStationLat(e.target.value)} + error={coordError(stationLat, 90)} + helperText={ + coordError(stationLat, 90) + ? 'Must be between -90 and 90' + : 'Decimal degrees. Leave blank if unknown.' + } + placeholder="e.g. 42.9634" + fullWidth + /> + setStationLon(e.target.value)} + error={coordError(stationLon, 180)} + helperText={ + coordError(stationLon, 180) + ? 'Must be between -180 and 180' + : 'Negative is west. Used to centre the map.' + } + placeholder="e.g. -85.6681" + fullWidth + /> + + + setMapTilesUrl(e.target.value)} + placeholder="https://tiles.example.org/{z}/{x}/{y}.png" + helperText={ + config.mapTilesLocal + ? 'An offline tile pack is installed in /data/tiles and is used instead.' + : 'Optional. Needs internet; leave blank to run map-less until a tile pack is installed.' + } + fullWidth + /> + + setPositionTtl(e.target.value)} + helperText="Stations stop being plotted once their last fix is this old." + slotProps={{ htmlInput: { min: 1, step: 1 } }} + fullWidth + /> + {voices.length > 0 && ( diff --git a/frontend/src/components/AdminPanel/__tests__/AdminPanel.test.tsx b/frontend/src/components/AdminPanel/__tests__/AdminPanel.test.tsx index 0f7df42..a4feb64 100644 --- a/frontend/src/components/AdminPanel/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/AdminPanel/__tests__/AdminPanel.test.tsx @@ -4,7 +4,7 @@ import { ThemeProvider } from '@mui/material/styles' import { makeTheme } from '../../../theme' import { describe, it, expect, vi, beforeEach } from 'vitest' import { createRef } from 'react' -import { AdminPanel } from '../AdminPanel' +import { AdminPanel, parseCoord, coordError } from '../AdminPanel' import type { AdminPanelHandle } from '../AdminPanel' import type { VoiceOption, DeviceTokenRecord } from '../../../types/ws' @@ -33,6 +33,11 @@ function makeConfig(overrides: Partial<{ rxMode: string; netDay: string; netTime: string; + stationLat: number | null; + stationLon: number | null; + mapTilesLocal: boolean; + mapTilesUrl: string; + positionTtlMinutes: number; display_quick_messages: string[]; }> = {}) { return { @@ -49,6 +54,11 @@ function makeConfig(overrides: Partial<{ rxMode: 'voice', netDay: '', netTime: '', + stationLat: null, + stationLon: null, + mapTilesLocal: false, + mapTilesUrl: '', + positionTtlMinutes: 1440, display_quick_messages: [], ...overrides, } @@ -296,6 +306,10 @@ describe('AdminPanel', () => { rx_mode: 'voice', neighborhood_net_day: '', neighborhood_net_time: '', + station_lat: null, + station_lon: null, + map_tiles_url: '', + position_ttl_minutes: 1440, display_quick_messages: [], }) expect(props.onClose).toHaveBeenCalledTimes(1) @@ -502,6 +516,142 @@ describe('AdminPanel', () => { // Wall displays admin section (device tokens + household quick messages) // ----------------------------------------------------------------------------- +describe('Station coordinates', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the stored coordinates in the fields', () => { + render() + + expect(screen.getByLabelText(/latitude/i)).toHaveValue('42.9634') + expect(screen.getByLabelText(/longitude/i)).toHaveValue('-85.6681') + }) + + it('leaves the fields blank rather than showing 0 when unset', () => { + render() + + expect(screen.getByLabelText(/latitude/i)).toHaveValue('') + expect(screen.getByLabelText(/longitude/i)).toHaveValue('') + }) + + it('saves typed coordinates as numbers', async () => { + const user = userEvent.setup() + const props = makeDefaultProps() + render() + + await user.type(screen.getByLabelText(/latitude/i), '42.9634') + await user.type(screen.getByLabelText(/longitude/i), '-85.6681') + await user.click(screen.getByRole('button', { name: /save/i })) + + expect(props.onSave).toHaveBeenCalledWith( + expect.objectContaining({ station_lat: 42.9634, station_lon: -85.6681 }) + ) + }) + + it('saves null when an admin clears a coordinate', async () => { + const user = userEvent.setup() + const props = makeDefaultProps() + render() + + await user.clear(screen.getByLabelText(/latitude/i)) + await user.click(screen.getByRole('button', { name: /save/i })) + + expect(props.onSave).toHaveBeenCalledWith( + expect.objectContaining({ station_lat: null, station_lon: -85.6681 }) + ) + }) + + it('flags an out-of-range latitude', async () => { + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText(/latitude/i), '91') + + expect(screen.getByText(/between -90 and 90/i)).toBeInTheDocument() + }) + + it('accepts a longitude past 90, which latitude would reject', async () => { + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText(/longitude/i), '-120.5') + + expect(screen.queryByText(/between -180 and 180/i)).not.toBeInTheDocument() + }) + + it('says an offline tile pack takes precedence over the URL field', () => { + render() + + expect(screen.getByText(/offline tile pack is installed/i)).toBeInTheDocument() + }) + + it('saves the tile URL and position expiry', async () => { + const user = userEvent.setup() + const props = makeDefaultProps() + render() + + // fireEvent, not user.type: {z} is userEvent's special-key syntax. + fireEvent.change(screen.getByLabelText(/map tile url/i), { + target: { value: 'https://tiles.example/{z}/{x}/{y}.png' }, + }) + await user.clear(screen.getByLabelText(/position expiry/i)) + await user.type(screen.getByLabelText(/position expiry/i), '120') + await user.click(screen.getByRole('button', { name: /save/i })) + + expect(props.onSave).toHaveBeenCalledWith( + expect.objectContaining({ + map_tiles_url: 'https://tiles.example/{z}/{x}/{y}.png', + position_ttl_minutes: 120, + }) + ) + }) + + it('falls back to the default expiry rather than saving zero', async () => { + const user = userEvent.setup() + const props = makeDefaultProps() + render() + + await user.clear(screen.getByLabelText(/position expiry/i)) + await user.click(screen.getByRole('button', { name: /save/i })) + + expect(props.onSave).toHaveBeenCalledWith( + expect.objectContaining({ position_ttl_minutes: 1440 }) + ) + }) +}) + +describe('parseCoord', () => { + it('treats blank as unset, not as the equator', () => { + expect(parseCoord('')).toBeNull() + expect(parseCoord(' ')).toBeNull() + }) + + it('keeps a real zero', () => { + expect(parseCoord('0')).toBe(0) + }) + + it('rejects text that is not a number', () => { + expect(parseCoord('north')).toBeNull() + }) +}) + +describe('coordError', () => { + it('does not flag an empty field', () => { + expect(coordError('', 90)).toBe(false) + }) + + it('flags values past the limit in either direction', () => { + expect(coordError('90.1', 90)).toBe(true) + expect(coordError('-90.1', 90)).toBe(true) + expect(coordError('90', 90)).toBe(false) + }) + + it('flags non-numeric text', () => { + expect(coordError('abc', 180)).toBe(true) + }) +}) + describe('Wall displays admin section', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/frontend/src/components/DesktopApp/DesktopApp.tsx b/frontend/src/components/DesktopApp/DesktopApp.tsx index a2e90a8..2331596 100644 --- a/frontend/src/components/DesktopApp/DesktopApp.tsx +++ b/frontend/src/components/DesktopApp/DesktopApp.tsx @@ -8,6 +8,7 @@ import { StatusRow } from '../StatusRow/StatusRow'; import { MessageInput } from '../MessageInput/MessageInput'; import type { MessageInputHandle } from '../MessageInput/MessageInput'; import { AttendancePanel } from '../AttendancePanel/AttendancePanel'; +import { PositionsPanel } from '../PositionsPanel/PositionsPanel'; import { JournalPanel } from '../JournalPanel/JournalPanel'; import { NCSPanel } from '../NCSPanel/NCSPanel'; import { Spectrogram } from '../Spectrogram/Spectrogram'; @@ -23,6 +24,7 @@ import type { StatusMsg, Contact, AttendanceStation, + StationPosition, JournalEntry, FccLookupResultMsg, UserProfile, @@ -61,6 +63,8 @@ export interface DesktopAppProps { // Attendance attendanceStations: AttendanceStation[]; + /** Stations heard by the position plugins, nearest first (server-sorted). */ + positions: StationPosition[]; onClearAttendance: () => void; // Journal @@ -143,6 +147,8 @@ export interface DesktopAppProps { // Panel visibility showAttendance: boolean; showJournal: boolean; + showPositions: boolean; + onTogglePositions: () => void; showContacts: boolean; showNcs: boolean; /** Master enable state of the NCS plugin; when false its button + panel hide. */ @@ -203,6 +209,7 @@ export function DesktopApp({ lastMessage, channelClear, attendanceStations, + positions, onClearAttendance, journals, journalResult, @@ -261,6 +268,8 @@ export function DesktopApp({ onClearChat, showAttendance, showJournal, + showPositions, + onTogglePositions, showContacts, showNcs, ncsEnabled, @@ -325,6 +334,9 @@ export function DesktopApp({ onToggleNotifications={onToggleNotifications} showAttendance={showAttendance} onToggleAttendance={onToggleAttendance} + showPositions={showPositions} + onTogglePositions={onTogglePositions} + positionsAvailable={positions.length > 0} showJournal={showJournal} onToggleJournal={onToggleJournal} showContacts={showContacts} @@ -427,6 +439,22 @@ export function DesktopApp({ + + + + = {}): DesktopAppProps { isKid: true, quickMessages: ["I'm OK", 'On my way', 'Call me'], messages: [], + positions: [], contacts: [], radioStatus: null, transmitting: false, @@ -137,6 +138,8 @@ function makeProps(overrides: Partial = {}): DesktopAppProps { showSettings: false, onToggleAttendance: vi.fn(), onToggleJournal: vi.fn(), + showPositions: false, + onTogglePositions: vi.fn(), onToggleContacts: vi.fn(), onToggleNcs: vi.fn(), onToggleSettings: vi.fn(), diff --git a/frontend/src/components/DesktopApp/__tests__/DesktopApp.tier.test.tsx b/frontend/src/components/DesktopApp/__tests__/DesktopApp.tier.test.tsx index d16d7e4..e6c6e3b 100644 --- a/frontend/src/components/DesktopApp/__tests__/DesktopApp.tier.test.tsx +++ b/frontend/src/components/DesktopApp/__tests__/DesktopApp.tier.test.tsx @@ -59,6 +59,7 @@ function makeProps(overrides: Partial = {}): DesktopAppProps { showCallsignChips: true, uiLevel: 'simple', messages: [], + positions: [], contacts: [], radioStatus: null, transmitting: false, @@ -135,6 +136,8 @@ function makeProps(overrides: Partial = {}): DesktopAppProps { showSettings: false, onToggleAttendance: vi.fn(), onToggleJournal: vi.fn(), + showPositions: false, + onTogglePositions: vi.fn(), onToggleContacts: vi.fn(), onToggleNcs: vi.fn(), onToggleSettings: vi.fn(), diff --git a/frontend/src/components/DisplayApp/DisplayApp.test.tsx b/frontend/src/components/DisplayApp/DisplayApp.test.tsx index 0c1c3aa..b65c6a0 100644 --- a/frontend/src/components/DisplayApp/DisplayApp.test.tsx +++ b/frontend/src/components/DisplayApp/DisplayApp.test.tsx @@ -4,6 +4,22 @@ import { axe } from 'jest-axe'; import { DisplayApp } from './DisplayApp'; import type { FamilyPresenceEntry } from '../../types/ws'; +// Leaflet needs real layout and a canvas, neither of which jsdom has. What +// these tests care about is which position view the kiosk picks, not what the +// map draws — MapPanel.test.tsx covers that. +vi.mock('leaflet', () => ({ + map: () => ({ setView: () => undefined, getZoom: () => 11, remove: () => undefined }), + tileLayer: () => ({ addTo: () => undefined }), + layerGroup: () => { + const group = { clearLayers: () => undefined, addTo: () => group }; + return group; + }, + circleMarker: () => { + const marker = { bindPopup: () => marker, addTo: () => marker, setLatLng: () => marker }; + return marker; + }, +})); + // --------------------------------------------------------------------------- // Fake WebSocket implementation (mirrors src/hooks/__tests__/useWebSocket.test.ts // and src/hooks/useDisplaySocket.test.ts) @@ -109,6 +125,25 @@ function noWordEntry(name: string): FamilyPresenceEntry { }; } +function positionsMsg(...labels: string[]) { + return { + type: 'positions', + stations: labels.map((label, i) => ({ + source: 'meshtastic', + node_id: `n${i}`, + label, + lat: 42.9 + i, + lon: -85.6, + alt_m: null, + age_s: 30, + distance_km: i + 1, + bearing_deg: 30, + compass: 'NNE', + extra: {}, + })), + }; +} + function chatMsg(text: string) { return { type: 'chat_echo', @@ -305,6 +340,44 @@ describe('DisplayApp passive layout', () => { expect(screen.getByText(/net tue/i)).toBeInTheDocument(); }); + it('shows the nearest stations as a list on an e-ink panel', () => { + render(); + act(() => { + mockServerSend({ type: 'display_config', eink: true, order: [] }); + mockServerSend(positionsMsg('Grandma mobile', 'Repeater')); + }); + expect(screen.getByRole('table', { name: /station positions/i })).toBeInTheDocument(); + expect(screen.getByText('Grandma mobile')).toBeInTheDocument(); + expect(screen.queryByTestId('map-container')).not.toBeInTheDocument(); + }); + + it('caps the e-ink list at the rows that fit on a wall panel', () => { + render(); + const labels = Array.from({ length: 9 }, (_, i) => `Node ${i}`); + act(() => { + mockServerSend({ type: 'display_config', eink: true, order: [] }); + mockServerSend(positionsMsg(...labels)); + }); + expect(screen.getByText('Node 5')).toBeInTheDocument(); + expect(screen.queryByText('Node 6')).not.toBeInTheDocument(); + }); + + it('shows the map instead on a normal kiosk', () => { + render(); + act(() => { + mockServerSend({ type: 'display_config', eink: false, order: [] }); + mockServerSend(positionsMsg('Grandma mobile')); + }); + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + expect(screen.queryByRole('table', { name: /station positions/i })).not.toBeInTheDocument(); + }); + + it('shows no position furniture at all when nothing has been heard', () => { + render(); + act(() => mockServerSend({ type: 'display_config', eink: true, order: [] })); + expect(screen.queryByText(/stations heard/i)).not.toBeInTheDocument(); + }); + it('has no axe violations', async () => { const { container } = render(); act(() => mockServerSend({ type: 'family_presence', entries: [okEntry('Grandma')] })); @@ -313,6 +386,26 @@ describe('DisplayApp passive layout', () => { vi.useRealTimers(); expect(await axe(container)).toHaveNoViolations(); }); + + it('has no axe violations with the map showing', async () => { + const { container } = render(); + act(() => { + mockServerSend({ type: 'display_config', eink: false, order: [] }); + mockServerSend(positionsMsg('Grandma mobile')); + }); + vi.useRealTimers(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no axe violations with the e-ink position list showing', async () => { + const { container } = render(); + act(() => { + mockServerSend({ type: 'display_config', eink: true, order: [] }); + mockServerSend(positionsMsg('Grandma mobile', 'Repeater')); + }); + vi.useRealTimers(); + expect(await axe(container)).toHaveNoViolations(); + }); }); describe('DisplayApp wake interaction', () => { diff --git a/frontend/src/components/DisplayApp/DisplayApp.tsx b/frontend/src/components/DisplayApp/DisplayApp.tsx index dc3caac..e343679 100644 --- a/frontend/src/components/DisplayApp/DisplayApp.tsx +++ b/frontend/src/components/DisplayApp/DisplayApp.tsx @@ -26,6 +26,7 @@ import { PresenceTile } from './PresenceTile'; import { SortablePresenceTile } from './SortablePresenceTile'; import { ConfirmOkDialog } from './ConfirmOkDialog'; import { DisplayChatConsole } from './DisplayChatConsole'; +import { DisplayPositions } from './DisplayPositions'; import type { DisplayImOkPayload, DisplayQuickMessagePayload, FamilyPresenceEntry } from '../../types/ws'; const DEVICE_TOKEN_KEY = 'radio_tty_device_token'; @@ -261,7 +262,10 @@ function ConnectedDisplay({ unpaired: boolean; onRepair: () => void; }) { - const { connected, presence, neighborhood, messages, alert, status, lastAck, eink, order, send } = socket; + const { + connected, presence, neighborhood, messages, alert, status, lastAck, positions, + eink, order, send, + } = socket; const [now, setNow] = useState(() => new Date()); const [driftIndex, setDriftIndex] = useState(0); @@ -504,6 +508,15 @@ function ConnectedDisplay({ + + {interactive && quickMessages.length > 0 && ( {quickMessages.map((text) => ( diff --git a/frontend/src/components/DisplayApp/DisplayPositions.tsx b/frontend/src/components/DisplayApp/DisplayPositions.tsx new file mode 100644 index 0000000..66a69c8 --- /dev/null +++ b/frontend/src/components/DisplayApp/DisplayPositions.tsx @@ -0,0 +1,56 @@ +import { Box, Typography } from '@mui/material'; +import type { StationPosition } from '../../types/ws'; +import { PositionList } from '../PositionList/PositionList'; +import { MapPanel } from '../MapPanel/MapPanel'; + +/** Nearest few only — a wall panel is read from across the room, not scrolled. */ +export const KIOSK_ROWS = 6; + +interface Props { + stations: StationPosition[]; + stationLat?: number | null; + stationLon?: number | null; + tilesLocal?: boolean; + tilesUrl?: string; + /** E-ink panels get the list; a map on e-ink is unreadable and smears. */ + eink: boolean; +} + +/** + * Positions block for the kiosk. + * + * E-ink shows a distance-sorted list: no tiles to render, nothing that moves, + * nothing that needs a partial refresh. Every other panel shows the map. + * Renders nothing at all when no position has been heard, so an install with + * no position sources sees no empty furniture on the wall. + */ +export function DisplayPositions({ + stations, + stationLat, + stationLon, + tilesLocal, + tilesUrl, + eink, +}: Props) { + if (stations.length === 0) return null; + + return ( + + + Stations heard + + {eink ? ( + + ) : ( + + )} + + ); +} diff --git a/frontend/src/components/MapPanel/MapPanel.test.tsx b/frontend/src/components/MapPanel/MapPanel.test.tsx new file mode 100644 index 0000000..cd560a6 --- /dev/null +++ b/frontend/src/components/MapPanel/MapPanel.test.tsx @@ -0,0 +1,213 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { axe } from 'jest-axe'; +import { MapPanel, LOCAL_TILES_URL, popupHtml } from './MapPanel'; +import type { StationPosition } from '../../types/ws'; + +// Leaflet needs real layout and a real canvas, neither of which jsdom has. +// The panel's own logic is "what did it ask Leaflet to draw", so record the +// calls instead: tile template, centre, and one marker per station. +const calls = { + maps: [] as Array<{ options: unknown }>, + tileLayers: [] as string[], + circleMarkers: [] as Array<{ latlng: [number, number]; options: Record }>, + popups: [] as string[], + setViews: [] as Array<[number, number]>, + clears: 0, + removes: 0, +}; + +vi.mock('leaflet', () => { + function marker(latlng: [number, number], options: Record) { + calls.circleMarkers.push({ latlng, options }); + const self = { + bindPopup(html: string) { + calls.popups.push(html); + return self; + }, + addTo() { + return self; + }, + setLatLng(next: [number, number]) { + calls.setViews.push(next); + return self; + }, + }; + return self; + } + return { + map: (_el: HTMLElement, options: unknown) => { + calls.maps.push({ options }); + return { + setView: (center: [number, number]) => calls.setViews.push(center), + getZoom: () => 11, + remove: () => { + calls.removes += 1; + }, + }; + }, + tileLayer: (template: string) => { + calls.tileLayers.push(template); + return { addTo: () => undefined }; + }, + layerGroup: () => { + const group = { + clearLayers: () => { + calls.clears += 1; + }, + addTo: () => group, + }; + return group; + }, + circleMarker: marker, + }; +}); + +function station(overrides: Partial = {}): StationPosition { + return { + source: 'meshtastic', + node_id: '!a1b2c3d4', + label: 'Base', + lat: 42.9, + lon: -85.6, + alt_m: null, + age_s: 30, + distance_km: 4.2, + bearing_deg: 30, + compass: 'NNE', + extra: {}, + ...overrides, + }; +} + +beforeEach(() => { + calls.maps = []; + calls.tileLayers = []; + calls.circleMarkers = []; + calls.popups = []; + calls.setViews = []; + calls.clears = 0; + calls.removes = 0; +}); + +describe('MapPanel', () => { + it('serves tiles from the offline pack when the server has one', async () => { + render(); + await waitFor(() => expect(calls.tileLayers).toEqual([LOCAL_TILES_URL])); + }); + + it('falls back to the configured remote template when there is no pack', async () => { + render(); + await waitFor(() => + expect(calls.tileLayers).toEqual(['https://tiles.example/{z}/{x}/{y}.png']), + ); + }); + + it('prefers the local pack over a remote URL — offline is the point', async () => { + render(); + await waitFor(() => expect(calls.tileLayers).toEqual([LOCAL_TILES_URL])); + }); + + it('says so, and adds no tile layer, when no tiles are configured', async () => { + render(); + expect(screen.getByText(/no map tiles configured/i)).toBeInTheDocument(); + await waitFor(() => expect(calls.maps).toHaveLength(1)); + expect(calls.tileLayers).toEqual([]); + }); + + it('centres on the own station once coordinates are set', async () => { + render(); + await waitFor(() => expect(calls.setViews).toContainEqual([42.9, -85.6])); + }); + + it('leaves the map at its fallback centre when no own position is configured', async () => { + render(); + await waitFor(() => expect(calls.maps).toHaveLength(1)); + expect(calls.setViews).toEqual([]); + }); + + it('draws one marker per station', async () => { + render(); + await waitFor(() => expect(calls.circleMarkers).toHaveLength(2)); + }); + + it('colours markers by source so one station heard twice reads as two dots', async () => { + render( + , + ); + await waitFor(() => expect(calls.circleMarkers).toHaveLength(2)); + const colors = calls.circleMarkers.map((m) => m.options.color); + expect(new Set(colors).size).toBe(2); + }); + + it('tears the map down on unmount rather than leaking it', async () => { + const { unmount } = render(); + await waitFor(() => expect(calls.maps).toHaveLength(1)); + unmount(); + expect(calls.removes).toBe(1); + }); + + it('hides the canvas from assistive tech — the list view carries the data', () => { + render(); + const container = screen.getByTestId('map-container'); + expect(container).toHaveAttribute('aria-hidden', 'true'); + // Leaflet's zoom buttons and attribution link live in here; focusable + // controls inside an aria-hidden subtree are a keyboard trap. + expect(container).toHaveAttribute('inert'); + }); + + it('turns off Leaflet keyboard handling — it would tabindex the hidden container', async () => { + render(); + await waitFor(() => expect(calls.maps).toHaveLength(1)); + expect(calls.maps[0].options).toMatchObject({ keyboard: false }); + }); + + it('passes axe', async () => { + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe('popupHtml', () => { + it('leads with the station name and its source', () => { + const html = popupHtml(station(), 'mi'); + expect(html).toContain('Base'); + expect(html).toContain('Meshtastic'); + }); + + it('includes distance with the bearing, and the age', () => { + const html = popupHtml(station(), 'mi'); + expect(html).toContain('2.6 mi NNE'); + expect(html).toContain('Heard now'); + }); + + it('omits distance entirely when there is no own position', () => { + const html = popupHtml(station({ distance_km: null, compass: null }), 'mi'); + expect(html).not.toContain('mi'); + }); + + it('escapes remote text — labels and APRS comments are attacker-controlled', () => { + const html = popupHtml( + station({ label: '', extra: { comment: '"&' } }), + 'mi', + ); + expect(html).not.toContain(' { + const html = popupHtml(station({ label: "O'Brien' onmouseover='x" }), 'mi'); + expect(html).not.toContain("'"); + expect(html).toContain('''); + }); + + it('appends whatever extras the source plugin attached', () => { + const html = popupHtml(station({ extra: { alt_m: '218', snr: '6.2 dB' } }), 'mi'); + expect(html).toContain('alt_m: 218'); + expect(html).toContain('snr: 6.2 dB'); + }); +}); diff --git a/frontend/src/components/MapPanel/MapPanel.tsx b/frontend/src/components/MapPanel/MapPanel.tsx new file mode 100644 index 0000000..b50d4c1 --- /dev/null +++ b/frontend/src/components/MapPanel/MapPanel.tsx @@ -0,0 +1,209 @@ +import { useEffect, useRef, useState } from 'react'; +import { Box, Typography } from '@mui/material'; +import type * as Leaflet from 'leaflet'; +import type { Map as LeafletMap, CircleMarker, LayerGroup } from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import type { StationPosition } from '../../types/ws'; +import { formatAge, formatDistance, sourceLabel } from '../PositionList/format'; + +/** Marker colour per source, so a station heard two ways is visibly two dots. */ +const SOURCE_COLORS: Record = { + meshtastic: '#22C55E', + meshcore: '#38BDF8', + aprs_rf: '#F59E0B', +}; +const OTHER_COLOR = '#A78BFA'; +const OWN_COLOR = '#EF4444'; + +/** Tiles the backend serves from the offline pack in /data/tiles. */ +export const LOCAL_TILES_URL = '/tiles/{z}/{x}/{y}.png'; + +const DEFAULT_ZOOM = 11; +/** Centre of the contiguous US — only used when no own position is set. */ +const FALLBACK_CENTER: [number, number] = [39.5, -98.35]; + +interface Props { + stations: StationPosition[]; + /** Own station; the map centres here when both are set. */ + stationLat?: number | null; + stationLon?: number | null; + /** True when the server is serving an offline tile pack at /tiles. */ + tilesLocal?: boolean; + /** Remote XYZ template, used only when there is no local pack. */ + tilesUrl?: string; + units?: 'mi' | 'km'; + height?: number | string; +} + +/** + * Leaflet map of every heard station. + * + * Leaflet is driven imperatively from an effect rather than through + * react-leaflet: one less version-coupled dependency, and the only React state + * involved is "which stations exist", which a diff-free redraw of a single + * layer group handles fine. + * + * Markers are circles, not pins, deliberately — Leaflet's default pin is a + * bundled PNG whose URL breaks under Vite, and a circle needs no asset at all. + * + * A map is not usable without sight. This component is therefore never the + * only way to read positions: PositionsPanel pairs it with the list view, and + * the map is hidden from assistive technology *and* taken out of the tab order + * (see the container below), so nobody navigating by keyboard or screen reader + * has to walk through a pan-and-zoom canvas to reach the data. + */ +export function MapPanel({ + stations, + stationLat, + stationLon, + tilesLocal = false, + tilesUrl = '', + units = 'mi', + height = 360, +}: Props) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const layerRef = useRef(null); + const ownRef = useRef(null); + // Held so the draw effects below stay synchronous; the dynamic import is + // resolved exactly once, in the create effect. + const leafletRef = useRef(null); + // Map creation is async (dynamic import), so the draw effects below would + // otherwise race it and silently skip the first batch of stations. + const [mapReady, setMapReady] = useState(false); + + const tileTemplate = tilesLocal ? LOCAL_TILES_URL : tilesUrl; + const hasOrigin = typeof stationLat === 'number' && typeof stationLon === 'number'; + + // Create the map once. Leaflet owns the DOM inside the container, so this + // must not re-run on every render or it would stack maps on one element. + useEffect(() => { + let cancelled = false; + // Dynamic import keeps Leaflet out of the initial bundle for the kiosks + // and mobile views that never open a map. + import('leaflet').then((L) => { + if (cancelled || !containerRef.current || mapRef.current) return; + const map = L.map(containerRef.current, { + center: FALLBACK_CENTER, + zoom: DEFAULT_ZOOM, + attributionControl: true, + // No keyboard handler: it would put tabindex="0" on a container that + // is aria-hidden, which is both an axe violation and a dead stop for + // anyone tabbing through. The list view is the keyboard path. + keyboard: false, + }); + if (tileTemplate) { + L.tileLayer(tileTemplate, { maxZoom: 19 }).addTo(map); + } + layerRef.current = L.layerGroup().addTo(map); + leafletRef.current = L; + mapRef.current = map; + setMapReady(true); + }); + return () => { + cancelled = true; + mapRef.current?.remove(); + mapRef.current = null; + layerRef.current = null; + ownRef.current = null; + leafletRef.current = null; + }; + // The tile template is read at creation; changing it needs a page reload, + // which is what a settings save already causes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Recentre on the own-station marker whenever it moves. + useEffect(() => { + const L = leafletRef.current; + const map = mapRef.current; + if (!mapReady || !hasOrigin || !L || !map) return; + const center: [number, number] = [stationLat as number, stationLon as number]; + map.setView(center, map.getZoom()); + if (ownRef.current) { + ownRef.current.setLatLng(center); + } else { + ownRef.current = L.circleMarker(center, { + radius: 8, + color: OWN_COLOR, + fillColor: OWN_COLOR, + fillOpacity: 0.9, + weight: 2, + }) + .bindPopup('This station') + .addTo(map); + } + }, [mapReady, hasOrigin, stationLat, stationLon]); + + // Redraw the station markers. Clearing the layer group and re-adding is + // cheaper than diffing at the sizes involved (the store caps at 500) and + // avoids holding a second index of markers alive. + useEffect(() => { + const L = leafletRef.current; + const layer = layerRef.current; + if (!mapReady || !L || !layer) return; + layer.clearLayers(); + for (const s of stations) { + const color = SOURCE_COLORS[s.source] ?? OTHER_COLOR; + L.circleMarker([s.lat, s.lon], { + radius: 6, + color, + fillColor: color, + fillOpacity: 0.8, + weight: 2, + }) + .bindPopup(popupHtml(s, units)) + .addTo(layer); + } + }, [mapReady, stations, units]); + + return ( + + {!tileTemplate && ( + + No map tiles configured. Install an offline tile pack in /data/tiles, or set a + tile URL in Settings → Station. + + )} + {/* Hidden from assistive tech on purpose: a pan-and-zoom canvas conveys + nothing to a screen reader, and the list view carries the same data. + `inert` goes with it — Leaflet adds zoom buttons and an attribution + link inside, and focusable controls inside an aria-hidden subtree are + exactly the trap the attribute is meant to prevent. */} + + ); +} + +/** Popup body. Escaped, because labels and APRS comments are remote input. */ +export function popupHtml(s: StationPosition, units: 'mi' | 'km'): string { + const rows = [ + `${escapeHtml(s.label || s.node_id)}`, + escapeHtml(sourceLabel(s.source)), + ]; + if (s.distance_km !== null) { + rows.push( + escapeHtml(`${formatDistance(s.distance_km, units)} ${s.compass ?? ''}`.trim()), + ); + } + rows.push(escapeHtml(`Heard ${formatAge(s.age_s)}`)); + for (const [key, value] of Object.entries(s.extra ?? {})) { + rows.push(escapeHtml(`${key}: ${value}`)); + } + return rows.join('
    '); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/frontend/src/components/PositionList/PositionList.test.tsx b/frontend/src/components/PositionList/PositionList.test.tsx new file mode 100644 index 0000000..08e8e27 --- /dev/null +++ b/frontend/src/components/PositionList/PositionList.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { axe } from 'jest-axe'; +import { PositionList } from './PositionList'; +import type { StationPosition } from '../../types/ws'; + +function station(overrides: Partial = {}): StationPosition { + return { + source: 'meshtastic', + node_id: '!a1b2c3d4', + label: 'Base', + lat: 42.9, + lon: -85.6, + alt_m: null, + age_s: 30, + distance_km: 4.2, + bearing_deg: 30, + compass: 'NNE', + extra: {}, + ...overrides, + }; +} + +describe('PositionList', () => { + it('renders one row per station in the order given', () => { + render( + , + ); + const rows = screen.getAllByRole('row').slice(1); // drop the header row + expect(rows).toHaveLength(2); + expect(within(rows[0]).getByText('Near')).toBeInTheDocument(); + expect(within(rows[1]).getByText('Far')).toBeInTheDocument(); + }); + + it('makes the station name the row header, so a row reads as one station', () => { + render(); + expect(screen.getByRole('rowheader', { name: 'Near' })).toBeInTheDocument(); + }); + + it('falls back to the node id when a station advertises no name', () => { + render(); + expect(screen.getByText('W8ABC-9')).toBeInTheDocument(); + }); + + it('spells the compass point out for screen readers', () => { + render(); + expect(screen.getByText('WSW')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByText('west-southwest')).toBeInTheDocument(); + }); + + it('shows a dash for bearing when there is no own position', () => { + render(); + expect(screen.getAllByText('—')).toHaveLength(2); + }); + + it('explains itself rather than showing an empty table', () => { + render(); + expect(screen.getByText(/no station positions received yet/i)).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('takes a caller-supplied empty caption', () => { + render(); + expect(screen.getByText('Nothing heard on the mesh.')).toBeInTheDocument(); + }); + + it('passes axe', async () => { + const { container } = render( + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('passes axe on an e-ink panel', async () => { + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/components/PositionList/PositionList.tsx b/frontend/src/components/PositionList/PositionList.tsx new file mode 100644 index 0000000..a508efc --- /dev/null +++ b/frontend/src/components/PositionList/PositionList.tsx @@ -0,0 +1,95 @@ +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import type { StationPosition } from '../../types/ws'; +import { visuallyHidden } from '../../ui/a11y'; +import { COMPASS_WORDS, formatAge, formatDistance, sourceLabel } from './format'; + +interface Props { + stations: StationPosition[]; + /** Distance unit. Miles by default — this is a US GMRS/ham application. */ + units?: 'mi' | 'km'; + /** True on e-ink kiosks: no hover, no zebra, higher-contrast text. */ + eink?: boolean; + /** Overrides the default caption, e.g. on the kiosk. */ + emptyText?: string; +} + +/** + * Stations heard, nearest first. + * + * Used both as the operator panel's list view and as the whole of the e-ink + * kiosk's position display, where a map is unrenderable. The server has + * already sorted by distance and resolved distance/bearing/age, so this + * component only formats — it never recomputes geography from coordinates. + */ +export function PositionList({ + stations, + units = 'mi', + eink = false, + emptyText = 'No station positions received yet.', +}: Props) { + if (stations.length === 0) { + return ( + + + {emptyText} + + + ); + } + + return ( + + + + + Station + Heard on + Distance + Bearing + Age + + + + {stations.map((s) => ( + + {/* The station names the row, so it is the row header — that is + what lets a screen reader read "Base, APRS, 2.6 mi" instead + of five unlabelled cells. */} + + {s.label || s.node_id} + + {sourceLabel(s.source)} + {formatDistance(s.distance_km, units)} + + {s.compass ? ( + <> + + + {COMPASS_WORDS[s.compass] ?? s.compass} + + + ) : ( + '—' + )} + + {formatAge(s.age_s)} + + ))} + +
    +
    + ); +} diff --git a/frontend/src/components/PositionList/format.test.ts b/frontend/src/components/PositionList/format.test.ts new file mode 100644 index 0000000..47c1411 --- /dev/null +++ b/frontend/src/components/PositionList/format.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { formatAge, formatDistance, sourceLabel } from './format'; + +describe('formatDistance', () => { + it('converts to miles by default and keeps a decimal when close', () => { + expect(formatDistance(4.2, 'mi')).toBe('2.6 mi'); + }); + + it('drops the decimal past ten, where it is noise', () => { + expect(formatDistance(100, 'km')).toBe('100 km'); + }); + + it('shows a dash when there is no own position to measure from', () => { + expect(formatDistance(null, 'mi')).toBe('—'); + }); +}); + +describe('formatAge', () => { + it.each([ + [5, 'now'], + [59, 'now'], + [60, '1m'], + [3599, '59m'], + [3600, '1h'], + [86399, '23h'], + [86400, '1d+'], + ])('renders %i seconds as %s', (seconds, expected) => { + expect(formatAge(seconds)).toBe(expected); + }); +}); + +describe('sourceLabel', () => { + it('names the sources the app ships with', () => { + expect(sourceLabel('aprs_rf')).toBe('APRS'); + expect(sourceLabel('meshcore')).toBe('MeshCore'); + }); + + it('passes an unknown third-party plugin id through unchanged', () => { + expect(sourceLabel('some_other_plugin')).toBe('some_other_plugin'); + }); +}); diff --git a/frontend/src/components/PositionList/format.ts b/frontend/src/components/PositionList/format.ts new file mode 100644 index 0000000..f9386e1 --- /dev/null +++ b/frontend/src/components/PositionList/format.ts @@ -0,0 +1,41 @@ +/** + * Presentation helpers for heard-station positions. + * + * Separate from the components so the map and the list can share them without + * the map importing a table component's module. The server resolves distance, + * bearing and age; nothing here recomputes geography. + */ + +/** Human-readable source names; an unknown plugin id is shown as-is. */ +const SOURCE_LABELS: Record = { + meshtastic: 'Meshtastic', + meshcore: 'MeshCore', + aprs_rf: 'APRS', +}; + +export function sourceLabel(source: string): string { + return SOURCE_LABELS[source] ?? source; +} + +/** Distance in the unit the operator reads on the air. */ +export function formatDistance(km: number | null, units: 'mi' | 'km'): string { + if (km === null) return '—'; + const value = units === 'mi' ? km * 0.621371 : km; + return `${value.toFixed(value < 10 ? 1 : 0)} ${units}`; +} + +/** Coarse age. Anything past a day is "1d+" — the TTL drops it soon after. */ +export function formatAge(seconds: number): string { + if (seconds < 60) return 'now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + return '1d+'; +} + +/** Spoken-friendly bearing for the screen reader, e.g. "north-northeast". */ +export const COMPASS_WORDS: Record = { + N: 'north', NNE: 'north-northeast', NE: 'northeast', ENE: 'east-northeast', + E: 'east', ESE: 'east-southeast', SE: 'southeast', SSE: 'south-southeast', + S: 'south', SSW: 'south-southwest', SW: 'southwest', WSW: 'west-southwest', + W: 'west', WNW: 'west-northwest', NW: 'northwest', NNW: 'north-northwest', +}; diff --git a/frontend/src/components/PositionsPanel/PositionsPanel.test.tsx b/frontend/src/components/PositionsPanel/PositionsPanel.test.tsx new file mode 100644 index 0000000..bdd76cd --- /dev/null +++ b/frontend/src/components/PositionsPanel/PositionsPanel.test.tsx @@ -0,0 +1,101 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { axe } from 'jest-axe'; +import { PositionsPanel } from './PositionsPanel'; +import type { StationPosition } from '../../types/ws'; + +// jsdom has no layout and no canvas, so Leaflet cannot run here. These tests +// are about which view the panel shows; MapPanel.test.tsx covers the drawing. +vi.mock('leaflet', () => ({ + map: () => ({ setView: () => undefined, getZoom: () => 11, remove: () => undefined }), + tileLayer: () => ({ addTo: () => undefined }), + layerGroup: () => { + const group = { clearLayers: () => undefined, addTo: () => group }; + return group; + }, + circleMarker: () => { + const marker = { bindPopup: () => marker, addTo: () => marker, setLatLng: () => marker }; + return marker; + }, +})); + +function station(overrides: Partial = {}): StationPosition { + return { + source: 'meshtastic', + node_id: '!a1b2c3d4', + label: 'Base', + lat: 42.9, + lon: -85.6, + alt_m: null, + age_s: 30, + distance_km: 4.2, + bearing_deg: 30, + compass: 'NNE', + extra: {}, + ...overrides, + }; +} + +const ORIGIN = { stationLat: 42.9634, stationLon: -85.6681 }; + +describe('PositionsPanel', () => { + it('opens on the map view', () => { + render(); + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('switches to the list view and back', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /list view/i })); + expect(screen.getByRole('table', { name: /station positions/i })).toBeInTheDocument(); + expect(screen.queryByTestId('map-container')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /map view/i })); + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + }); + + it('points a screen reader at the list, since the map is hidden from it', () => { + render(); + expect(screen.getByText(/choose list view for the same stations as text/i)) + .toBeInTheDocument(); + }); + + it('drops that pointer once the list is the view', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /list view/i })); + expect(screen.queryByText(/choose list view/i)).not.toBeInTheDocument(); + }); + + it('announces the station count without needing the map', () => { + render(); + expect(screen.getByRole('status')).toHaveTextContent('2 stations heard'); + }); + + it('says "1 station" rather than "1 stations"', () => { + render(); + expect(screen.getByRole('status')).toHaveTextContent('1 station heard'); + }); + + it('tells the operator where to set the coordinates when there is no origin', () => { + render(); + expect(screen.getByText(/latitude and longitude in settings/i)).toBeInTheDocument(); + }); + + it('drops that hint once an origin is configured', () => { + render(); + expect(screen.queryByText(/latitude and longitude in settings/i)).not.toBeInTheDocument(); + }); + + it('passes axe in the map view', async () => { + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('passes axe in the list view', async () => { + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: /list view/i })); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/components/PositionsPanel/PositionsPanel.tsx b/frontend/src/components/PositionsPanel/PositionsPanel.tsx new file mode 100644 index 0000000..4bdc516 --- /dev/null +++ b/frontend/src/components/PositionsPanel/PositionsPanel.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { Box, Paper, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'; +import type { StationPosition } from '../../types/ws'; +import { visuallyHidden } from '../../ui/a11y'; +import { PanelHeader } from '../PanelHeader/PanelHeader'; +import { PositionList } from '../PositionList/PositionList'; +import { MapPanel } from '../MapPanel/MapPanel'; + +interface Props { + stations: StationPosition[]; + stationLat?: number | null; + stationLon?: number | null; + tilesLocal?: boolean; + tilesUrl?: string; + units?: 'mi' | 'km'; +} + +/** + * Operator-side positions panel: the map, plus a list view that carries the + * same information for anyone who can't use a map. + * + * The list is a first-class view rather than a fallback — it is the faster way + * to read "who is nearest" during a net, and it is the only view an e-ink + * kiosk or a screen reader can use at all. + */ +export function PositionsPanel({ + stations, + stationLat, + stationLon, + tilesLocal, + tilesUrl, + units = 'mi', +}: Props) { + const [view, setView] = useState<'map' | 'list'>('map'); + const hasOrigin = typeof stationLat === 'number' && typeof stationLon === 'number'; + + return ( + + + + + next && setView(next)} + aria-label="Position view" + > + Map + List + + {/* Announced on change so the count is available without the map. */} + + {stations.length === 1 ? '1 station heard' : `${stations.length} stations heard`} + + + + {!hasOrigin && ( + + Set this station's latitude and longitude in Settings → Station to get distance + and bearing. + + )} + + + {view === 'map' ? ( + <> + {/* The map itself is hidden from assistive tech, so this view + would otherwise read as an empty region. Say where the data is + instead of leaving someone to guess the toggle does anything. */} + + The map is visual only. Choose List view for the same stations as text, + nearest first. + + + + ) : ( + + )} + + + ); +} diff --git a/frontend/src/components/TopBar/TopBar.tsx b/frontend/src/components/TopBar/TopBar.tsx index 44ded9e..2fecafc 100644 --- a/frontend/src/components/TopBar/TopBar.tsx +++ b/frontend/src/components/TopBar/TopBar.tsx @@ -39,6 +39,10 @@ interface Props { onToggleNotifications: () => void; showAttendance: boolean; onToggleAttendance: () => void; + showPositions: boolean; + onTogglePositions: () => void; + /** Hides the MAP toggle until a position source has actually been heard. */ + positionsAvailable: boolean; showJournal: boolean; onToggleJournal: () => void; showContacts: boolean; @@ -97,6 +101,9 @@ export function TopBar({ onToggleNotifications, showAttendance, onToggleAttendance, + showPositions, + onTogglePositions, + positionsAvailable, showJournal, onToggleJournal, showContacts, @@ -193,6 +200,19 @@ export function TopBar({ )} + {uiLevel === 'operator' && positionsAvailable && ( + + MAP + + )} + {uiLevel === 'operator' && ( ([]); const [alert, setAlert] = useState(null); const [lastAck, setLastAck] = useState(null); + const [positions, setPositions] = useState([]); const [eink, setEink] = useState(false); const [order, setOrder] = useState([]); @@ -219,6 +223,9 @@ export function useDisplaySocket(token: string | null): UseDisplaySocketResult { case 'neighborhood_alert': setAlert({ kind: 'street', message: msg.message, ts: msg.ts }); break; + case 'positions': + setPositions(msg.stations); + break; case 'display_ack': ackSeqRef.current += 1; setLastAck({ action: msg.action, seq: ackSeqRef.current }); @@ -309,5 +316,8 @@ export function useDisplaySocket(token: string | null): UseDisplaySocketResult { } }, []); - return { connected, authFailed, status, presence, neighborhood, messages, alert, lastAck, eink, order, send }; + return { + connected, authFailed, status, presence, neighborhood, messages, alert, lastAck, + positions, eink, order, send, + }; } diff --git a/frontend/src/types/appTypes.ts b/frontend/src/types/appTypes.ts index ddd0578..9dd5518 100644 --- a/frontend/src/types/appTypes.ts +++ b/frontend/src/types/appTypes.ts @@ -12,6 +12,15 @@ export interface AdminConfig { rxMode: string; netDay: string; netTime: string; + /** Own station coordinates; null until an admin sets them. Distance and + * bearing on the positions panel need both. */ + stationLat: number | null; + stationLon: number | null; + /** True when the server found an offline tile pack and is serving /tiles. */ + mapTilesLocal: boolean; + /** Remote XYZ tile template; used only when there is no offline pack. */ + mapTilesUrl: string; + positionTtlMinutes: number; /** Quick-message shortcuts offered on the kiosk display's "I'm OK" screen. */ display_quick_messages: string[]; } diff --git a/frontend/src/types/ws.ts b/frontend/src/types/ws.ts index 3919aad..55ce58f 100644 --- a/frontend/src/types/ws.ts +++ b/frontend/src/types/ws.ts @@ -92,6 +92,47 @@ export interface StatusMsg { plugins?: PluginManifest[]; /** Quick-message shortcuts offered on the kiosk display's "I'm OK" screen. */ display_quick_messages?: string[]; + /** Own station coordinates; null until an admin sets them. The map centres + * here, and distance/bearing are only computed when both are present. */ + station_lat?: number | null; + station_lon?: number | null; + /** Remote XYZ tile template, used only when no offline pack is installed. */ + map_tiles_url?: string; + /** True when /data/tiles exists, so the server is serving /tiles itself. */ + map_tiles_local?: boolean; + /** Positions older than this are dropped rather than plotted stale. */ + position_ttl_minutes?: number; +} + +/** One station heard by a position source (mesh node, APRS beacon). + * + * Distance, bearing and age are resolved server-side: a wall kiosk's clock is + * not to be trusted, and the e-ink list has no way to compute great-circle + * distance itself. All three are null when no own-station position is set. + */ +export interface StationPosition { + /** Plugin id that heard it — 'meshtastic', 'meshcore', 'aprs_rf'. */ + source: string; + /** Identifier within that source; unique only per source. */ + node_id: string; + /** Human-readable name, may be empty. */ + label: string; + lat: number; + lon: number; + alt_m: number | null; + /** Seconds since the fix was heard. */ + age_s: number; + distance_km: number | null; + bearing_deg: number | null; + /** 16-point compass abbreviation, e.g. 'NNE'. */ + compass: string | null; + /** Source-specific extras (SNR, APRS comment, symbol...), all strings. */ + extra: Record; +} + +export interface PositionsMsg { + type: 'positions'; + stations: StationPosition[]; } /** One declarative setting a plugin exposes; the frontend renders a form field. */ @@ -823,6 +864,7 @@ export type WsMessage = | NetSessionsMsg | NetSessionMsg | NetSessionDeletedMsg + | PositionsMsg | VoiceTxAckMsg | VoiceTxErrorMsg; diff --git a/frontend/src/ui/a11y.ts b/frontend/src/ui/a11y.ts new file mode 100644 index 0000000..65192ed --- /dev/null +++ b/frontend/src/ui/a11y.ts @@ -0,0 +1,16 @@ +/** Shared accessibility primitives. + * + * `display: none` and `visibility: hidden` take content away from screen + * readers too. This takes it away only from the eye, for text that is + * redundant when you can see the layout but essential when you can't — + * a spelled-out compass point, or a pointer to the view that carries the + * same data as a map. + */ +export const visuallyHidden = { + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', +} as const; From 5060859dc074e9437c19635070e414e75b3075fd Mon Sep 17 00:00:00 2001 From: Benjamin Date: Fri, 7 Aug 2026 16:22:34 -0400 Subject: [PATCH 2/2] docs: document station positions in README, manual, and site 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. --- README.md | 59 +++++++++++++++++++++------ USER_MANUAL.md | 104 +++++++++++++++++++++++++++++++++++++++++++++--- docs/index.html | 18 ++++++++- 3 files changed, 162 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1a38f36..f089cd8 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,19 @@ browser-based React frontend communicating over WebSocket. ## Features +- **Stations on a map** — positions heard over Meshtastic, MeshCore, or APRS on + RF (via a KISS TNC such as direwolf) are plotted on a **MAP** panel, with a + keyboard- and screen-reader-friendly **List** view giving each station's + distance, bearing, and how long ago it was heard. Distance and bearing are + measured from a fixed station latitude/longitude set in **Settings → Station**, + and are resolved on the server so a kiosk needs neither a trustworthy clock nor + any geography of its own. Map tiles come from an offline pack dropped into + `/data/tiles`, so the map keeps working with the internet down; a remote tile + URL is an optional fallback. Wall displays show the map too, and e-ink panels + get the nearest-first list instead. Every position source is **receive-only** — + none of them has a transmit path, and each is off by default (see + [Position reports](https://xpiatio.github.io/Hearthwave/legality.html#positions) + for why "APRS on GMRS" is not what it sounds like) - **Audio devices remembered by name** — device selections are stored by name rather than by index, so they survive new hardware being plugged in or a card that was busy at boot instead of silently shifting the station onto the wrong @@ -324,6 +337,13 @@ Remote access over the internet is the operator's responsibility. Hearthwave provides no port-forwarding, relay, or TURN/STUN infrastructure — use a VPN or private tunnel. +Position reporting is **receive-only**. Hearthwave plots what it hears over +Meshtastic, MeshCore, and APRS on RF; no position source has a transmit path, so +Part 95E's digital-data limits — which govern what a station *sends* — are not +engaged. "APRS on GMRS" is not a thing you can lawfully do with this or any other +software: see [Position reports](https://xpiatio.github.io/Hearthwave/legality.html#positions) +for the citations. + For the full rule-by-rule breakdown — verbatim Part 95E citations, the remote-control vs. repeater-linking distinction, and every automated function disclosed — see [Legality & FCC compliance](https://xpiatio.github.io/Hearthwave/legality.html). @@ -411,13 +431,13 @@ your callsign, audio devices, and PTT interface. # Backend (Python 3.13 in the images; 3.11+ works) pip install -r backend/requirements.txt -r backend/requirements-dev.txt uvicorn backend.server:app --reload --port 8765 -cd backend && python -m pytest # 2027 tests +cd backend && python -m pytest # 2026 tests # Frontend (Node 22, matching the image builder stage) cd frontend npm ci npm run dev # dev server on :5173, proxies /ws + /auth to :8765 -npm run test # vitest, 1035 tests +npm run test # vitest, 1221 tests npm run build # production build ``` @@ -474,8 +494,8 @@ class WordGuard(BasePlugin): The loader binds a `PluginContext` to `self.ctx`, calls `setup()`, registers the plugin, then dispatches `on_config_changed`. Core services are reached through -`self.ctx` — `broadcast`, `enqueue_tx`, `get_config`, `channel_clear`, `data_dir`, -and `logger`. The `PluginManifest` fields are `id`, `name`, `description`, +`self.ctx` — `broadcast`, `enqueue_tx`, `get_config`, `channel_clear`, +`report_position`, `data_dir`, and `logger`. The `PluginManifest` fields are `id`, `name`, `description`, `version`, `default_enabled`, `conflicts_with`, `config_schema`, and `tx_composition`. @@ -514,14 +534,29 @@ measure). MeshCore declares one so its packet-length budget is enforced as you t ### Examples and built-ins -MeshCore and Meshtastic are **example plugins**, shipped under -`examples/plugins/` and seeded into `/data/plugins` on first run — reference -implementations for writing your own, not core features. They are mutually -exclusive (enabling one disables the other via `conflicts_with`). MeshCore bridges -to a USB Companion radio over serial (with a configurable baud rate); Meshtastic -is serial too (no baud setting). Both build on a shared `MeshForwarderPlugin` base -(`backend/plugins/mesh_forwarder.py`, re-exported by the SDK) so a concrete -forwarder supplies only its config mapping and transport. +MeshCore, Meshtastic, and APRS (RF receive) are **example plugins**, shipped +under `examples/plugins/` and seeded into `/data/plugins` on first run — reference +implementations for writing your own, not core features. + +MeshCore and Meshtastic are mutually exclusive (enabling one disables the other +via `conflicts_with`), because both want the one serial mesh radio. MeshCore +bridges to a USB Companion radio over serial (with a configurable baud rate); +Meshtastic is serial too (no baud setting). Both build on a shared +`MeshForwarderPlugin` base (`backend/plugins/mesh_forwarder.py`, re-exported by +the SDK) so a concrete forwarder supplies only its config mapping and transport, +and both can additionally report the positions of nodes they hear — off by +default, enabled per plugin with **Show node positions on the map**. + +APRS (RF receive) is a different radio, so it conflicts with nothing and can run +alongside either mesh plugin. It reads a KISS TNC over TCP (direwolf, default +`127.0.0.1:8001`) or over a serial device, decodes AX.25 UI frames, and parses +the position with `aprslib` — an optional dependency, already in +`backend/requirements.txt`, that the plugin reports a clear error about if it is +enabled without it. An optional callsign allow/deny filter keeps a busy band down +to the stations you care about. It is receive-only and has no transmit path. + +All three feed positions through `ctx.report_position(...)`, and each owns a +`PositionPoller` rather than inheriting one — see [docs/plugins.md](docs/plugins.md). **NCS / SKYWARN remains built-in** — it is registered by the app rather than loaded from `/data/plugins`, because it is deeply integrated (contacts, FCC diff --git a/USER_MANUAL.md b/USER_MANUAL.md index 273f93b..25cd25f 100644 --- a/USER_MANUAL.md +++ b/USER_MANUAL.md @@ -35,7 +35,8 @@ This manual covers day-to-day operation of Hearthwave as a GMRS family hub or ne - [22a. Installing and managing plugins](#22a-installing-and-managing-plugins) - [22b. MeshCore example plugin](#22b-meshcore-example-plugin) - [22c. Meshtastic example plugin](#22c-meshtastic-example-plugin) - - [22d. Writing your own plugin](#22d-writing-your-own-plugin) + - [22d. APRS (RF receive) example plugin](#22d-aprs-rf-receive-example-plugin) + - [22e. Writing your own plugin](#22e-writing-your-own-plugin) 23. [FCC compliance and remote access](#23-fcc-compliance-and-remote-access) 24. [Transcription vocabulary biasing](#24-transcription-vocabulary-biasing) 25. [Deployment profiles and GPU acceleration (admin)](#25-deployment-profiles-and-gpu-acceleration-admin) @@ -47,6 +48,7 @@ This manual covers day-to-day operation of Hearthwave as a GMRS family hub or ne 31. [Neighborhood activity](#31-neighborhood-activity) 32. [Wall display (kiosk)](#32-wall-display-kiosk) 33. [Accessibility: switch scanning, visual alerts, and shortcuts](#33-accessibility-switch-scanning-visual-alerts-and-shortcuts) +34. [Station positions on a map](#34-station-positions-on-a-map) --- @@ -483,6 +485,8 @@ The **callsign**, **name**, **location**, **default TTS voice**, **Gemini API ke The **Default TTS Voice** dropdown sets which Piper voice the station uses when a user has not chosen a personal voice. Click the **mic icon** next to the dropdown to preview the selected voice without keying the radio. +The Station tab also holds the station's **Latitude** and **Longitude** in decimal degrees (e.g. `42.9634` / `-85.6681`; negative longitude is west). Leave them blank if you'd rather not record where the station is — everything else keeps working, but the map has nothing to centre on and no distances or bearings can be worked out. See [section 34](#34-station-positions-on-a-map) for the rest of the map settings. + The same Station tab has a **Neighborhood** area, below NCS/SKYWARN, where an admin sets the weekly **Net Day** and **Net Time** for the neighborhood watch net. This schedule is shown on the Neighborhood home card and inside the Neighborhood activity, and updates live for every connected user the moment it's saved — see [section 31](#31-neighborhood-activity). ### Dark / Light mode @@ -894,7 +898,7 @@ With **Final-pass model** set to `auto` (the default on new installs) the staged ## 22. Plugins -Hearthwave supports **installable third-party plugins** — self-contained add-ons that attach new capabilities to the radio pipeline without changing the core server. Plugins are installed and managed from the **Plugins** tab of the admin Settings dialog. Several plugins ship with Hearthwave: the **NCS / SKYWARN** net-control feature ([section 16](#16-ncs--net-control-station-mode)) is built in, and **MeshCore** ([section 22b](#22b-meshcore-example-plugin)) and **Meshtastic** ([section 22c](#22c-meshtastic-example-plugin)) are seeded as example plugins you can study, enable, or replace with your own. +Hearthwave supports **installable third-party plugins** — self-contained add-ons that attach new capabilities to the radio pipeline without changing the core server. Plugins are installed and managed from the **Plugins** tab of the admin Settings dialog. Several plugins ship with Hearthwave: the **NCS / SKYWARN** net-control feature ([section 16](#16-ncs--net-control-station-mode)) is built in, and **MeshCore** ([section 22b](#22b-meshcore-example-plugin)), **Meshtastic** ([section 22c](#22c-meshtastic-example-plugin)), and **APRS (RF receive)** ([section 22d](#22d-aprs-rf-receive-example-plugin)) are seeded as example plugins you can study, enable, or replace with your own. > **Trust warning:** Plugins run with **full server access** — they can read and transmit on the radio, touch your contacts and configuration, and reach the network. Only install plugins from sources you trust. The Plugins tab is admin-only and shows this warning prominently. @@ -930,7 +934,7 @@ The MeshCore plugin forwards every message you transmit onto a [MeshCore](https: - Every message that goes over the air is also sent to the mesh, **prefixed with the sender's name** (e.g. `Ben: heading home`), so mesh-only members know who is talking. - That covers every spoken surface, not just typed chat: family check-ins ("I'm OK", including from the wall display), wall-display quick messages, neighborhood incident reports and street alerts, and net scripts, round-table prompts and SKYWARN spot reports from Net Control. Two things carry no text to forward, so they are announced on the radio only: live voice transmissions, and the "This is" station ID. -- It is **outbound only** — messages *received* on the radio are never forwarded to the mesh. Only what your station transmits is bridged. +- Message bridging is **outbound only** — messages *received* on the radio are never forwarded to the mesh. Only what your station transmits is bridged. (The one thing that travels the other way is position reporting, if you switch it on below: the plugin reads positions your mesh radio has already heard. It never asks the mesh for them and never transmits.) - Because it taps the transmit pipeline after all checks, a message blocked by NCS **BREAK BREAK** is never forwarded either. - Forwarding is best-effort and never delays or blocks the radio transmission itself; if the mesh link is busy or down, the over still goes out on the radio normally. @@ -949,6 +953,8 @@ Enable the plugin from the Plugins tab and edit its settings there: | Max packet length | UTF-8 bytes per mesh packet, **including** the sender-name prefix (default `140`). This drives the message-box byte limit. | | Channel index | Which MeshCore channel to transmit on (default `0`). | | Name separator | Joins the sender name and the message on the mesh (default `": "` → `Alice: hello`). | +| Show contact positions on the map | Off by default. When on, MeshCore contacts that have advertised a position are plotted — see [section 34](#34-station-positions-on-a-map). | +| Position poll interval | Seconds between contact-list reads (default `60`). | Changes reconnect (or disconnect) the serial link immediately — no server restart needed. @@ -974,14 +980,47 @@ When enabled, it adds the same live byte counter under the message box, showing | Max packet length | UTF-8 bytes per mesh packet, including the sender-name prefix. Drives the message-box byte limit. | | Channel index | Which Meshtastic channel to transmit on. | | Name separator | Joins the sender name and the message on the mesh. | +| Show node positions on the map | Off by default. When on, nodes in the radio's node database that have a GPS fix are plotted — see [section 34](#34-station-positions-on-a-map). | +| Position poll interval | Seconds between node-database reads (default `60`). | Meshtastic has no baud-rate setting (the device link is configured differently from MeshCore). Changes reconnect the link immediately — no restart. --- -## 22d. Writing your own plugin +## 22d. APRS (RF receive) example plugin + +The APRS plugin listens to APRS traffic **on the air** and plots the stations it hears on the map ([section 34](#34-station-positions-on-a-map)). It is **receive-only** — there is no transmit path anywhere in it, and it never touches the internet APRS network (APRS-IS). It ships as an example plugin seeded into `/data/plugins` on first run and is **disabled by default**. + +It does not conflict with anything: APRS runs on its own receiver, so it can be enabled alongside MeshCore or Meshtastic. + +**What you need** + +- A radio tuned to your local APRS frequency, feeding a **TNC** that speaks KISS. The usual arrangement is [direwolf](https://github.com/wb2osz/direwolf) running as a software TNC with its KISS port open on `127.0.0.1:8001`; a hardware KISS TNC on a serial port works too. +- The optional `aprslib` Python package, which is already listed in `backend/requirements.txt` and so present in the standard Docker images. If it is missing, the plugin still loads and lists, but reports a clear error when you enable it. +- In a Docker install using a serial TNC, the device has to be passed into the container, the same way MeshCore's is. + +**Settings (Plugins tab)** + +| Setting | Description | +|---------|-------------| +| TNC connection | **KISS over TCP (direwolf)** or **KISS over serial TNC**. | +| TNC host | Host for the TCP connection (default `127.0.0.1`). | +| TNC port | Port for the TCP connection (default `8001`). | +| TNC serial device | Serial device when using a hardware TNC (e.g. `/dev/ttyUSB0`). | +| Serial baud rate | Serial speed for a hardware TNC. | +| Reconnect delay | Seconds to wait before retrying a dropped TNC connection. | +| Callsign filter | Comma-separated call signs. `W8ABC` matches every SSID of that call; `W8ABC-9` matches only that one. Leave empty for no filtering. | +| Filter mode | **Plot only these callsigns** (allow) or **Plot everything except these** (deny). | + +The filter is worth using on a busy band — a metro APRS channel can put dozens of stations on the map within an hour, most of them nothing to do with you. + +**A note on GMRS.** APRS here means the amateur (ham) service, or any other service you are licensed for. You cannot run APRS on GMRS: Part 95E allows only very short data bursts, from certified hand-helds with fixed antennas, addressed to one specific unit — and requires a voice or Morse station ID that a data packet cannot give. Receiving, which is all this plugin does, is not regulated at all. The citations are set out in [Position reports](https://xpiatio.github.io/Hearthwave/legality.html#positions). + +--- + +## 22e. Writing your own plugin -The two mesh-bridge plugins above are seeded into `/data/plugins` as references — copy one as a starting point for your own. A plugin is a directory with a `plugin.py` that subclasses `BasePlugin` and hooks into the RX/TX pipeline (receiving messages, queueing transmissions, capping the message box, and reacting to settings changes). It exposes its settings declaratively — the app renders the form, so a plugin ships no browser code. For the full hook reference, the settings-form schema, the TX-composition / character-limit API, and packaging your plugin as an installable `.zip`, see the authoring guide at [`docs/plugins.md`](docs/plugins.md). +The mesh-bridge and APRS plugins above are seeded into `/data/plugins` as references — copy one as a starting point for your own. A plugin is a directory with a `plugin.py` that subclasses `BasePlugin` and hooks into the RX/TX pipeline (receiving messages, queueing transmissions, capping the message box, and reacting to settings changes). It exposes its settings declaratively — the app renders the form, so a plugin ships no browser code. For the full hook reference, the settings-form schema, the TX-composition / character-limit API, and packaging your plugin as an installable `.zip`, see the authoring guide at [`docs/plugins.md`](docs/plugins.md). --- @@ -1462,6 +1501,7 @@ The screen is in two parts. The **radio log** fills the top left, with the clock - A **weather / street alert banner**, when one is active. - The **next scheduled net** (or "Net running now" while one is in progress). - A large **clock**. +- **Stations heard** — a small map of nearby stations, if any position source is running (see [section 34](#34-station-positions-on-a-map)). With nothing heard, the block isn't there at all, so an install with no position sources gets no empty furniture on the wall. The display dims to a dark theme automatically between 7 PM and 7 AM, and the whole layout drifts a few pixels every so often — a standard anti-burn-in measure for a screen that shows the same layout for hours at a stretch. None of this needs any setup; it just runs. (Both of these behaviours change when [E-ink mode](#e-ink-displays) is on.) @@ -1490,6 +1530,7 @@ If the wall panel is a real **e-ink** screen (the low-power, paper-like kind), t - **Finalised messages only** — the live, word-by-word partial transcripts are suppressed; a received message appears once, complete, instead of rewriting itself as it decodes. - **No layout drift** — e-ink has none of the burn-in that the drift protects against, and the movement would only add ghosting, so the layout stays pinned in place. - **No drag-to-sort** — dragging smears an e-ink panel, so tiles stay put. Sort the board on a normal tablet paired to the same household if you want a custom order there. +- **A list instead of a map** — the **Stations heard** block drops the map (a tile image is a poor match for grayscale e-ink and redraws badly) and shows the six nearest stations as text: name, source, distance, bearing, and age. The toggle takes effect on that display's next reconnect (reload the `/display` page on the tablet). It's per-device: a normal LCD tablet and an e-ink panel can be paired at the same time, each rendering in its own mode. Turning it back off returns that display to the standard dusk-aware, animated view. @@ -1577,3 +1618,56 @@ Examples include: These dialogs work with keyboard, mouse, touch, and switch scanning — focus the button you want and press **Enter** or **Space**, or click / tap / press your switch. --- + +## 34. Station positions on a map + +Hearthwave can plot the stations it hears — Meshtastic nodes, MeshCore contacts, and APRS stations on RF — on a map, with each one's distance and bearing from your station and how long ago it was heard. It is useful during a net or an incident for the same reason a paper map on the wall is: it tells you who is where without anyone having to say it on the air. + +> **Receive-only, all of it.** No position source in Hearthwave transmits. Nothing here puts your station's location on the air, on a mesh, or on the internet — the station latitude and longitude you set are used only to work out distances and to centre your own map. See [Position reports](https://xpiatio.github.io/Hearthwave/legality.html#positions) for the FCC side of this, including why "APRS on GMRS" is not something you can do. + +### Turning it on + +Nothing is plotted until you do two things. + +**1. Tell Hearthwave where the station is.** In **Settings → Station** (admin only), set **Latitude** and **Longitude** in decimal degrees — for example `42.9634` and `-85.6681` for Grand Rapids, Michigan. Longitude is negative in the western hemisphere. You can read these off any mapping app by long-pressing your house. Leaving them blank is allowed: stations are still plotted, but there is no "you are here" and no distances or bearings, and the map opens on a view of the whole country. + +**2. Switch on a position source.** Each source is a plugin, and each is off by default: + +- **Meshtastic** — turn on **Show node positions on the map** in its settings ([section 22c](#22c-meshtastic-example-plugin)). Hearthwave reads the radio's node database every **Position poll interval** seconds (default 60). +- **MeshCore** — turn on **Show contact positions on the map** ([section 22b](#22b-meshcore-example-plugin)). Same idea, reading the contact list. +- **APRS (RF receive)** — enable the plugin and point it at your KISS TNC ([section 22d](#22d-aprs-rf-receive-example-plugin)). Positions arrive as packets are heard, rather than on a poll. + +Meshtastic and MeshCore can't both run (they want the same serial radio), but either can run alongside APRS. + +### Reading the panel + +Once at least one station has been heard, a **MAP** button appears in the top bar. (It stays hidden until then — there is no point offering a map of nothing.) The panel has two views, switched with the **Map** / **List** buttons: + +- **Map** — every station is a coloured dot: green for Meshtastic, blue for MeshCore, amber for APRS, purple for anything else, and red for your own station. Click a dot for its name, source, distance and bearing, how long ago it was heard, and any extra the source supplied (an APRS comment, a signal-to-noise figure). +- **List** — the same stations as a table, **nearest first**: **Station**, **Heard on**, **Distance**, **Bearing**, **Age**. This is a proper table with row headers, not a picture, so it works with a screen reader and with keyboard navigation. The map is deliberately skipped by both — a pan-and-zoom canvas has nothing to say to a screen reader, and the list has all the same information. + +Distances are in **miles** by default. Bearings are given as compass points (`NNE`, `SW`) and read aloud in words ("north-northeast"). Ages are coarse on purpose — `now`, `14m`, `3h`, `1d+`. + +Everything — distance, bearing, age — is worked out on the server and sent ready to display, so a wall tablet with a wrong clock or no geography of its own still shows the right numbers. The panel refreshes at least every 30 seconds even on a dead-quiet channel, so the age counters keep moving rather than freezing at "now". + +### On the wall display + +Wall displays show a **Stations heard** block too ([section 32](#32-wall-display-kiosk)): a small map on a normal tablet, and on an **e-ink** panel the six nearest stations as a text list instead. If nothing has been heard, the block is absent rather than empty. + +### Map tiles (admin) + +A map needs tile images, and Hearthwave will not fetch them from the internet unless you tell it to — the whole point of a radio station is that it works when the internet doesn't. + +- **Offline tile pack (recommended).** Drop an XYZ tile pack into the server's `/data/tiles` directory, laid out as `/data/tiles/{z}/{x}/{y}.png`. Hearthwave serves it at `/tiles` and uses it automatically; the **Map Tile URL** field's help text changes to say so. A pack covering your county at the zoom levels you actually use is small — tens of megabytes — and needs no connection at all. (The directory is `/data/tiles` inside the container; override it with the `RADIO_TTY_TILES_DIR` environment variable if you keep it elsewhere.) +- **Remote tile URL (fallback).** With no local pack, set **Map Tile URL** in **Settings → Station** to an XYZ template such as `https://tiles.example.org/{z}/{x}/{y}.png`. Check the tile provider's usage policy before pointing at a public server. This is used only when there is no local pack — a pack always wins. +- **Neither.** The map area says *"No map tiles configured"* and the List view carries on working normally. Distances and bearings do not depend on tiles. + +### How long a station stays on the map + +**Position Expiry (minutes)** in **Settings → Station** controls how long a fix survives without being heard again — 24 hours (1440 minutes) by default. Past that, the station drops off the map and the list. + +The age shown is the age of the **fix**, not of the moment Hearthwave read it. A mesh radio keeps its node roster for days, so reading a row is not the same as hearing that station; where a source records when it last heard a node, Hearthwave uses that timestamp. This is why a node that has been on the roster for a week shows as a day old and then expires, instead of claiming to have been heard just now. APRS is different — the packet arriving *is* the hearing, so it is stamped on arrival. + +To keep a busy band from filling memory, at most 500 stations are held; the oldest are dropped first. Positions survive a restart (they are kept in `/data/positions.json`). + +--- diff --git a/docs/index.html b/docs/index.html index ee43393..5f44889 100644 --- a/docs/index.html +++ b/docs/index.html @@ -869,7 +869,8 @@

    Weather & the neighborhood

    Family check-ins

    A presence board shows who's OK, on air, or overdue; one big "I'm OK" button announces it on air and logs it; admins can set a daily check-in reminder per person. Kid accounts get a locked-down, preset-only interface.

    Wall display

    Point a spare tablet at an admin-issued display link and it becomes an always-on household glance screen — who's OK, weather and street alerts, the last few messages, and the next net. Tap to send a preset household message or Mark-OK; no login, no access to the rest of the app. A per-display e-ink mode renders it in fixed high-contrast grayscale with no animation for real paper-like panels.

    Neighborhood watch net

    One-tap roster check-in that works even before the net starts, standardized incident reports spoken on air and kept in a filterable log, and street alerts that banner and notify every connected device. A per-user coordinator grant runs the net and round-table — including checking in a caller who's radio-only with no account, calling any station out of order, and flagging one that doesn't answer — and on a wide screen it opens as a single-viewport ops console with radio traffic, roster, and the incident log all in view at once. Kids can check in and read the log, but can't file reports or coordinate.

    -
    Installable plugins

    Extend the radio without touching core code — drop a plugin into the plugins folder or upload a .zip from Settings, and it hot-loads with its own config form. MeshCore and Meshtastic ship as examples. See the plugin system →

    +
    Stations on a map

    Plot everyone you hear — Meshtastic nodes, MeshCore contacts, APRS stations on RF — with distance, bearing, and how long ago, worked out from your own station position. Map tiles come from an offline pack on the server, so it still works with the internet down, and a keyboard- and screen-reader-friendly list view carries the same data. Wall displays get the map too; e-ink panels get the nearest-first list. Receive-only — nothing here transmits. Why "APRS on GMRS" isn't a thing →

    +
    Installable plugins

    Extend the radio without touching core code — drop a plugin into the plugins folder or upload a .zip from Settings, and it hot-loads with its own config form. MeshCore, Meshtastic, and APRS ship as examples. See the plugin system →

    Session journals & FCC callsign lookup

    Every contact logged to a session journal; resolve any callsign against an FCC license lookup without leaving the page.

    @@ -920,7 +921,7 @@

    Remote control —
    Modules

    Extend it for your net.

    -

    Hearthwave has a real, installable plugin system. Net Control is built in; MeshCore and Meshtastic ship as example plugins. Add more by dropping a folder into the plugins directory or uploading a .zip — hot-reloaded, no restart.

    +

    Hearthwave has a real, installable plugin system. Net Control is built in; MeshCore, Meshtastic, and APRS ship as example plugins. Add more by dropping a folder into the plugins directory or uploading a .zip — hot-reloaded, no restart.

    @@ -955,6 +956,7 @@

    Extend it for your net.

  • Every spoken surface — chat overs, family check-ins, wall-display messages, street alerts and net scripts all reach the mesh, not just typed chat.
  • Fits one frame — a live byte counter caps the message box to the mesh packet length minus the name prefix, measured in UTF-8 bytes like the radio does.
  • Never blocks the radio — best-effort, non-blocking forward; the over always airs on GMRS normally.
  • +
  • Contacts on the map — optionally plots the positions your Companion radio has already heard. Off by default, and it only reads — it never asks the mesh for a position.
  • @@ -966,6 +968,18 @@

    Extend it for your net.

  • Every spoken surface — chat overs, family check-ins, wall-display messages, street alerts and net scripts all reach the mesh, not just typed chat.
  • Fits one frame — a live byte counter caps the message box to the mesh packet length minus the name prefix, measured in UTF-8 bytes like the radio does.
  • Never blocks the radio — best-effort, non-blocking forward; the over always airs on GMRS normally.
  • +
  • Nodes on the map — optionally plots the GPS fixes already sitting in your radio's node database. Off by default, read-only, and it ages each node from when the radio last heard it rather than when Hearthwave read the row.
  • + +
    +
    +

    APRS (RF receive)

    EXAMPLE PLUGIN
    +

    Ships as an example plugin. Listens to APRS on the air through a KISS TNC and puts the stations it hears on the map. Receive-only, and it never touches APRS-IS.

    +
      +
    • Off the air, not the internet — decodes AX.25 UI frames straight from a KISS TNC, over TCP to direwolf or over a serial device.
    • +
    • Nothing transmits — there is no TX path in the plugin at all. Receiving is unregulated; sending APRS on GMRS is not legal, whatever you may have read.
    • +
    • Every position format — uncompressed, compressed, and MIC-E, parsed with aprslib.
    • +
    • Callsign filter — allow or deny by call, with or without SSID, so a busy metro channel doesn't bury the stations you care about.
    • +
    • Runs alongside the mesh — a separate receiver, so it conflicts with nothing.