From a44bc9cd6f87e67b2f00707e7029c902f97feaa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 02:44:40 +0000 Subject: [PATCH] feat: 1A-C, Feature 3, and pyMC MeshCore poller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1A — /mesh/topology?include_coords=true enriches each node with lat/lon fetched via Redis MGET on entity:{node_id} keys. 1B — APRS weather comment parser (poller/enrichment/aprs_weather.py) extracts temp, humidity, pressure, wind, rain from Ultimeter/Davis comment strings; wired into aprs.py for station_type=weather entities, stored under identity.wx. 1C — SNR threshold alert infrastructure: mesh_link_alerts.py tracks per-link SNR history and fires mesh_link_degraded events on the falling edge with a cooldown gate; wired into _upsert_mesh_links in meshcore.py. DB migration 10_mesh_alert_config.sql adds the per-source config table. Feature 3 — APRS weather panel: AprsOverview shows a live conditions card (temp/humidity/pressure/wind/rain) when identity.wx is present; EntityDetail exposes a Weather tab for APRS weather stations alongside the existing aircraft METAR tab. pyMC poller — meshcore_pymc.py uses the `meshcore` Python library (meshcore>=2.3.0) to connect directly to a MeshCore device over TCP or serial, fetching the full contact table with adv_lat/adv_lon coordinates and calling fetch_all_neighbours() per contact to capture SNR as measured by remote nodes — a dimension unavailable via the RemoteTerm REST/WS path. Also notes: Feature 1 (APRS symbol color-coding) was already complete in buildEntityLayers.ts (aprsColor, emergencyRingLayer, aprsLabelLayer). https://claude.ai/code/session_01J3kpNrV5xm55N17Uu7NU61 --- backend/routers/mesh.py | 29 +- db/init/10_mesh_alert_config.sql | 14 + .../src/components/panels/EntityDetail.tsx | 49 +++- .../components/panels/entity/AprsOverview.tsx | 86 +++++- poller/enrichment/aprs_weather.py | 65 +++++ poller/main.py | 2 + poller/mesh_link_alerts.py | 133 +++++++++ poller/pollers/aprs.py | 5 + poller/pollers/meshcore.py | 9 + poller/pollers/meshcore_pymc.py | 270 ++++++++++++++++++ poller/requirements.txt | 1 + 11 files changed, 653 insertions(+), 10 deletions(-) create mode 100644 db/init/10_mesh_alert_config.sql create mode 100644 poller/enrichment/aprs_weather.py create mode 100644 poller/mesh_link_alerts.py create mode 100644 poller/pollers/meshcore_pymc.py diff --git a/backend/routers/mesh.py b/backend/routers/mesh.py index 54abdd4..b840a9a 100644 --- a/backend/routers/mesh.py +++ b/backend/routers/mesh.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone, timedelta +import json import logging from fastapi import APIRouter, Depends, Query @@ -7,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from deps import get_db +from redis_bus import get_redis router = APIRouter(tags=["mesh"]) logger = logging.getLogger(__name__) @@ -69,6 +71,7 @@ async def list_mesh_links( @router.get("/mesh/topology") async def mesh_topology( stale_minutes: int = Query(30, ge=1, le=1440), + include_coords: bool = Query(False, description="Enrich each node with lat/lon from Redis entity cache"), db: AsyncSession = Depends(get_db), ): """Return nodes and links as a graph for visualization.""" @@ -80,11 +83,33 @@ async def mesh_topology( ).bindparams(cutoff=cutoff) ) links = [dict(r) for r in result.mappings().all()] - node_ids = set() + node_ids: set[str] = set() for lnk in links: node_ids.add(lnk["node_a"]) node_ids.add(lnk["node_b"]) - return {"nodes": list(node_ids), "links": links} + + if not include_coords: + return {"nodes": list(node_ids), "links": links} + + # Enrich nodes with coordinates from the Redis entity cache + node_list = list(node_ids) + keys = [f"entity:{nid}" for nid in node_list] + r = get_redis() + values = await r.mget(*keys) + + nodes_with_coords = [] + for nid, raw in zip(node_list, values): + entry: dict = {"id": nid, "lat": None, "lon": None} + if raw: + try: + data = json.loads(raw) + entry["lat"] = data.get("lat") + entry["lon"] = data.get("lon") + except Exception: + pass + nodes_with_coords.append(entry) + + return {"nodes": nodes_with_coords, "links": links} @router.get("/mesh/messages") diff --git a/db/init/10_mesh_alert_config.sql b/db/init/10_mesh_alert_config.sql new file mode 100644 index 0000000..2cf3073 --- /dev/null +++ b/db/init/10_mesh_alert_config.sql @@ -0,0 +1,14 @@ +-- SNR threshold alert configuration, one row per MeshCore source URL. +-- Rows are created on demand by the admin UI; the poller falls back to +-- DEFAULT_THRESHOLD (-90 dBm) when no matching row exists. +CREATE TABLE IF NOT EXISTS mesh_alert_configs ( + id SERIAL PRIMARY KEY, + source_url TEXT NOT NULL, + snr_threshold REAL NOT NULL DEFAULT -90.0, + cooldown_secs INTEGER NOT NULL DEFAULT 300, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_mesh_alert_configs_url + ON mesh_alert_configs (source_url); diff --git a/frontend/src/components/panels/EntityDetail.tsx b/frontend/src/components/panels/EntityDetail.tsx index 245586a..21f8aa1 100644 --- a/frontend/src/components/panels/EntityDetail.tsx +++ b/frontend/src/components/panels/EntityDetail.tsx @@ -109,12 +109,14 @@ export function EntityDetail() { // Tab state const [activeTab, setActiveTab] = useState<'overview' | 'weather' | 'tags'>('overview') - // Reset tab to overview if switching from aircraft (which has weather) to non-aircraft + // Reset tab to overview when switching to an entity type that has no weather tab useEffect(() => { - if (activeTab === 'weather' && entity?.entity_type !== 'aircraft') { + const isAprsWeather = entity?.entity_type === 'aprs' + && (entity?.identity as Record | undefined)?.station_type === 'weather' + if (activeTab === 'weather' && entity?.entity_type !== 'aircraft' && !isAprsWeather) { setActiveTab('overview') } - }, [entity?.entity_type, activeTab]) + }, [entity?.entity_type, (entity?.identity as Record | undefined)?.station_type, activeTab]) const handleAddTag = async () => { if (!selectedEntityId || !tagInput.trim()) return @@ -202,7 +204,11 @@ export function EntityDetail() { {/* Tabs */}
{(['overview', 'weather', 'tags'] as const).map(tab => { - if (tab === 'weather' && entity.entity_type !== 'aircraft') return null + if (tab === 'weather') { + const isAprsWeather = entity.entity_type === 'aprs' + && (identity as Record).station_type === 'weather' + if (entity.entity_type !== 'aircraft' && !isAprsWeather) return null + } return (