From 10c7aab0da89dcca766d983a2660e456731863a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 16:37:42 +0000 Subject: [PATCH 01/11] Gate mesh nodes to region bbox and persist entity filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pyMC-Repeater's advert table covers every node it has ever heard — often an entire regional mesh — so the meshcore poller was publishing hundreds of far-away nodes, cluttering the map and dragging down the render pipeline. Gate mesh_node entities to the configured region bbox(es) at ingest, the same way the ADS-B/AIS/Amtrak pollers do: - New settings: MESH_BBOX_FILTER (default true) and MESH_BBOX_PAD_DEG (default 0.25°, keeps nearby ridge-top repeaters just outside the box) - Applied to all three publish paths: REST advert sync, SSE advert_received / contact_path_updated, and stats neighbors - Nodes with no advertised position always pass — they can't clutter the map and direct RF neighbors often advertise without GPS Also persist entityFilter in the Zustand store so layer toggles (e.g. hiding mesh nodes) survive PWA reloads. A custom merge deep-merges the filter so keys added in future versions default to visible instead of disappearing for users with older persisted state. Since the WS subscription is built from entityFilter on connect, a persisted mesh_node=false now also suppresses those updates server-side. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- .env.example | 9 +++ frontend/src/store.ts | 12 ++++ poller/config.py | 9 +++ poller/pollers/meshcore.py | 49 +++++++++++++--- poller/tests/test_meshcore_bbox.py | 91 ++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 poller/tests/test_meshcore_bbox.py diff --git a/.env.example b/.env.example index 3ee9589..bfaf2b2 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,15 @@ APRS_CALLSIGN=N0CALL APRS_PASSCODE=-1 APRS_FILTER_RADIUS_KM=80 +# MeshCore node gating — a pyMC-Repeater advertises every node it has ever +# heard, which can span an entire regional mesh. When enabled (default), +# nodes whose advertised position falls outside the configured region +# bbox(es) are dropped, like the ADS-B/AIS bbox gating. The pad widens the +# bbox by N degrees so nearby ridge-top repeaters just outside the box are +# still kept. Nodes with no advertised position always pass. +MESH_BBOX_FILTER=true +MESH_BBOX_PAD_DEG=0.25 + # ADS-B ingest strategy # Mode A — BEAST TCP (set ADSB_ENABLE_BEAST=true + configure sources.yml) # Mode B — ultrafeeder HTTP only (BEAST disabled, sources.yml has adsb entries) diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 69a5d16..a2fb85e 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -655,7 +655,19 @@ export const useCivicStore = create()( }), { name: 'vertex.ui.prefs', + // Shallow merge except entityFilter, which is deep-merged so filter keys + // added in later versions default to visible instead of vanishing for + // users with older persisted state. + merge: (persisted, current) => { + const p = (persisted ?? {}) as Partial + return { + ...current, + ...p, + entityFilter: { ...current.entityFilter, ...(p.entityFilter ?? {}) }, + } + }, partialize: (state) => ({ + entityFilter: state.entityFilter, mode: state.mode, trailsVisible: state.trailsVisible, radarVisible: state.radarVisible, diff --git a/poller/config.py b/poller/config.py index 3510e34..0a8b77e 100644 --- a/poller/config.py +++ b/poller/config.py @@ -114,6 +114,15 @@ def regions(self) -> list[RegionConfig]: aprs_passcode: str = "-1" aprs_filter_radius_km: int = 80 + # MeshCore node gating — a pyMC-Repeater advert table covers the whole + # regional mesh (every node it has ever heard an advert for), not just + # nearby RF neighbors. When enabled, nodes advertising a position outside + # the configured region bbox(es), padded by mesh_bbox_pad_deg degrees, + # are dropped — mirroring the ADS-B/AIS/Amtrak bbox gating. Nodes with no + # advertised position always pass (they cannot clutter the map). + mesh_bbox_filter: bool = True + mesh_bbox_pad_deg: float = 0.25 + # ADS-B ingest strategy adsb_enable_beast: bool = False adsb_beast_host: str = "localhost" diff --git a/poller/pollers/meshcore.py b/poller/pollers/meshcore.py index 128aa71..2e25fce 100644 --- a/poller/pollers/meshcore.py +++ b/poller/pollers/meshcore.py @@ -24,6 +24,7 @@ import httpx from bus import get_bus, publish_entity, set_feed +from config import settings from normalizers.mesh_node import normalize_pymc_repeater_advert, snr_to_quality from sanitize import sanitize_payload from .base import BasePoller @@ -116,12 +117,20 @@ async def _poll_once(self, src: dict): resp = await client.get(f"{base_url}/api/adverts_by_contact_type") if resp.status_code == 200: count = 0 + skipped = 0 for advert in _iter_items(resp.json()): entity = normalize_pymc_repeater_advert(advert, base_url) - if entity: - await publish_entity(entity) - count += 1 - logger.debug("[meshcore] synced %d adverts from %s", count, base_url) + if not entity: + continue + if not _should_publish_node(entity): + skipped += 1 + continue + await publish_entity(entity) + count += 1 + logger.debug( + "[meshcore] synced %d adverts from %s (%d outside region bbox)", + count, base_url, skipped, + ) except Exception as exc: logger.debug("[meshcore] advert fetch error: %s", exc) @@ -218,7 +227,7 @@ async def _handle_sse_event(self, event_type: str | None, data: dict, base_url: if event_type == "advert_received": entity = normalize_pymc_repeater_advert(payload, base_url) - if entity: + if entity and _should_publish_node(entity): await publish_entity(entity) elif event_type in ("message_received", "channel_message_received"): @@ -235,12 +244,38 @@ async def _handle_sse_event(self, event_type: str | None, data: dict, base_url: elif event_type == "contact_path_updated": entity = normalize_pymc_repeater_advert(payload, base_url) - if entity: + if entity and _should_publish_node(entity): await publish_entity(entity, merge=True, record_observation=False) # ─── helpers ────────────────────────────────────────────────────────────────── +def _in_region(lat: float, lon: float) -> bool: + """True when the coordinates fall inside any configured region bbox (padded).""" + from config import load_regions + pad = settings.mesh_bbox_pad_deg + for region in load_regions(): + b = region.bbox + if (b.min_lat - pad) <= lat <= (b.max_lat + pad) \ + and (b.min_lon - pad) <= lon <= (b.max_lon + pad): + return True + return False + + +def _should_publish_node(entity: dict) -> bool: + """Bbox gate for mesh_node entities, like the ADS-B/AIS/Amtrak pollers. + + Nodes with no advertised position always pass — they cannot clutter the + map, and direct RF neighbors often advertise without GPS. + """ + if not settings.mesh_bbox_filter: + return True + lat, lon = entity.get("lat"), entity.get("lon") + if lat is None or lon is None: + return True + return _in_region(lat, lon) + + def _parse_source(url: str) -> dict: """Extract API key and companion from URL; return clean base_url, api_key, and companion.""" parsed = urlparse(url) @@ -411,7 +446,7 @@ async def _publish_health(stats: dict, base_url: str, companion: str | None = No node_copy["public_key"] = pub_key node_copy["name"] = node.get("node_name") entity = normalize_pymc_repeater_advert(node_copy, base_url) - if entity: + if entity and _should_publish_node(entity): await publish_entity(entity) r = await get_bus() diff --git a/poller/tests/test_meshcore_bbox.py b/poller/tests/test_meshcore_bbox.py new file mode 100644 index 0000000..0ec5528 --- /dev/null +++ b/poller/tests/test_meshcore_bbox.py @@ -0,0 +1,91 @@ +"""Tests for MeshCore node bbox gating. + +Run from poller/: + pytest tests/test_meshcore_bbox.py +""" +from __future__ import annotations + +import os +import sys +from unittest.mock import patch + +_POLLER_ROOT = os.path.join(os.path.dirname(__file__), "..") +if _POLLER_ROOT not in sys.path: + sys.path.insert(0, _POLLER_ROOT) + +from config import RegionConfig, RegionBbox, settings +from pollers.meshcore import _in_region, _should_publish_node + + +# Portland-metro-ish region +MOCK_REGIONS = [ + RegionConfig( + id="portland", + name="Portland Metro", + bbox=RegionBbox( + min_lat=44.8, + max_lat=45.9, + min_lon=-123.5, + max_lon=-121.8, + ), + enabled=True, + ), +] + + +def _node(lat, lon) -> dict: + return { + "entity_id": "mesh_node:abc123", + "entity_type": "mesh_node", + "lat": lat, + "lon": lon, + } + + +class TestInRegion: + @patch.object(settings, "mesh_bbox_pad_deg", 0.25) + @patch("config.load_regions") + def test_inside_bbox(self, mock_load_regions): + mock_load_regions.return_value = MOCK_REGIONS + assert _in_region(45.38, -122.76) is True # Tualatin + + @patch.object(settings, "mesh_bbox_pad_deg", 0.25) + @patch("config.load_regions") + def test_far_outside_bbox(self, mock_load_regions): + mock_load_regions.return_value = MOCK_REGIONS + assert _in_region(47.6, -122.3) is False # Seattle + assert _in_region(44.05, -123.09) is False # Eugene + + @patch.object(settings, "mesh_bbox_pad_deg", 0.25) + @patch("config.load_regions") + def test_pad_extends_bbox(self, mock_load_regions): + mock_load_regions.return_value = MOCK_REGIONS + # Just north of max_lat 45.9 but within the 0.25° pad + assert _in_region(46.1, -122.5) is True + # Beyond the pad + assert _in_region(46.2, -122.5) is False + + +class TestShouldPublishNode: + @patch.object(settings, "mesh_bbox_filter", True) + @patch.object(settings, "mesh_bbox_pad_deg", 0.25) + @patch("config.load_regions") + def test_gates_out_of_region_node(self, mock_load_regions): + mock_load_regions.return_value = MOCK_REGIONS + assert _should_publish_node(_node(45.38, -122.76)) is True + assert _should_publish_node(_node(47.6, -122.3)) is False + + @patch.object(settings, "mesh_bbox_filter", True) + @patch("config.load_regions") + def test_unpositioned_node_always_passes(self, mock_load_regions): + mock_load_regions.return_value = MOCK_REGIONS + assert _should_publish_node(_node(None, None)) is True + assert _should_publish_node(_node(45.38, None)) is True + mock_load_regions.assert_not_called() + + @patch.object(settings, "mesh_bbox_filter", False) + @patch("config.load_regions") + def test_disabled_filter_passes_everything(self, mock_load_regions): + mock_load_regions.return_value = MOCK_REGIONS + assert _should_publish_node(_node(47.6, -122.3)) is True + mock_load_regions.assert_not_called() From 3929a7054498214da4258e7bb15ecc13ceb35fd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 16:38:49 +0000 Subject: [PATCH 02/11] Add TASK_LOG entry for mesh bbox gating and filter persistence Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- TASK_LOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/TASK_LOG.md b/TASK_LOG.md index df39b3c..e30bfd6 100644 --- a/TASK_LOG.md +++ b/TASK_LOG.md @@ -5,6 +5,20 @@ Format: `## YYYY-MM-DD — ` with bullet points for details. --- +## 2026-07-05 — Gated mesh nodes to region bbox and made entity filters persistent + +- **Mesh node bbox gating** ([meshcore.py](poller/pollers/meshcore.py), [config.py](poller/config.py)): + - A pyMC-Repeater's advert table covers every node it has ever heard (the whole Cascade mesh), so the poller was publishing hundreds of far-away nodes that cluttered the map and dragged down the render pipeline. + - Added `_should_publish_node()` gate that drops `mesh_node` entities whose advertised position falls outside the configured region bbox(es), mirroring the ADS-B/AIS/Amtrak gating. Applied to all three publish paths: REST advert sync, SSE `advert_received`/`contact_path_updated`, and stats neighbors. + - New settings `MESH_BBOX_FILTER` (default true) and `MESH_BBOX_PAD_DEG` (default 0.25°, keeps nearby ridge-top repeaters just outside the box). Documented in `.env.example`. + - Nodes with no advertised position always pass — they can't clutter the map and direct RF neighbors often advertise without GPS. Gated nodes age out of Redis (120s entity TTL) within ~2 minutes of a poller restart. +- **Stateful entity filters** ([store.ts](frontend/src/store.ts)): + - Added `entityFilter` to the Zustand `persist` partialize so layer toggles (e.g. hiding mesh nodes) survive PWA close/reopen — previously the toggle reset to all-visible on every reload. + - Added a custom `merge` that deep-merges the persisted filter over defaults, so filter keys added in future versions default to visible instead of disappearing for users with older persisted state. + - Side benefit: the WebSocket subscription is built from `entityFilter` on connect, so a persisted `mesh_node=false` now also suppresses mesh entity updates server-side. +- **Tests**: New [test_meshcore_bbox.py](poller/tests/test_meshcore_bbox.py) — 6 tests covering in/out-of-bbox, pad behavior, unpositioned nodes, and the disabled-filter path. +- **Motivation**: User reported the entire Cascade mesh rendering (not just local nodes), render-pipeline jank severe enough to block the P25 audio stream from connecting, and mesh-node filter state not surviving PWA reloads. + ## 2026-06-15 — Enhanced Mesh Companion Connectivity & Streamlined Tab Layout - **Mesh Companion Status Sync**: From e1d09d913dfb7c31400b2f3f0697d56c29a76dba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:05:56 +0000 Subject: [PATCH 03/11] Narrow store subscriptions to picked keys via useCivicPick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bare useCivicStore() call subscribes the component to the entire store, so each WebSocket entity update (dozens per second on a live ADS-B feed) re-rendered the whole React tree from Dashboard down — Header, Sidebar, panels, everything. This main-thread churn is what starved the P25 audio stream when mesh nodes multiplied the update rate. Add a typed useCivicPick(...keys) helper (useShallow under the hood) and convert all 29 selector-less call sites. Components now re-render only when a key they actually read changes; action-only consumers like useWebSocket never re-render at all since actions are stable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- frontend/src/App.tsx | 6 +- frontend/src/components/DevInsetInspector.tsx | 4 +- .../src/components/layout/AlertStatusBar.tsx | 4 +- frontend/src/components/layout/EnvBar.tsx | 4 +- frontend/src/components/layout/Header.tsx | 4 +- frontend/src/components/layout/MobileNav.tsx | 4 +- .../src/components/layout/SettingsPanel.tsx | 61 ++++++++++++------- frontend/src/components/layout/Sidebar.tsx | 4 +- .../src/components/panels/CameraModal.tsx | 15 +++-- frontend/src/components/panels/CommsPanel.tsx | 6 +- .../src/components/panels/CustomLayersTab.tsx | 4 +- .../src/components/panels/EntityDetail.tsx | 14 +++-- .../components/panels/EntitySearchPanel.tsx | 25 +++++--- .../src/components/panels/EventLogPanel.tsx | 4 +- .../src/components/panels/FlightLogPanel.tsx | 4 +- .../src/components/panels/GeofencePanel.tsx | 13 ++-- frontend/src/components/panels/HelpPanel.tsx | 4 +- .../src/components/panels/IncidentsPanel.tsx | 6 +- .../components/panels/InfrastructureGrid.tsx | 18 ++++-- frontend/src/components/panels/IntelPanel.tsx | 4 +- .../components/panels/PlaybackController.tsx | 19 +++--- frontend/src/hooks/useAlerts.ts | 4 +- frontend/src/hooks/useMeshHistory.ts | 4 +- frontend/src/hooks/useMeshLinks.ts | 4 +- frontend/src/hooks/useSystemHealth.ts | 4 +- frontend/src/hooks/useWebSocket.ts | 4 +- frontend/src/store.ts | 22 ++++++- 27 files changed, 170 insertions(+), 99 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7b3b575..45aac2a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { useCivicStore } from './store' +import { useCivicPick } from './store' import { useAlerts } from './hooks/useAlerts' import { useSystemHealth } from './hooks/useSystemHealth' import { useTrailHydration } from './hooks/useTrailHydration' @@ -43,7 +43,7 @@ function Dashboard() { usePreferences() useMeshHistory() - const { news, appendSystemEvent } = useCivicStore() + const { news, appendSystemEvent } = useCivicPick('news', 'appendSystemEvent') // Background Intelligence Processor // Elevates critical news headlines to system events (Incidents) @@ -56,7 +56,7 @@ function Dashboard() { }) }, [news, appendSystemEvent]) - const { activeTab, mode } = useCivicStore() + const { activeTab, mode } = useCivicPick('activeTab', 'mode') const isCritical = mode === 'critical' return ( diff --git a/frontend/src/components/DevInsetInspector.tsx b/frontend/src/components/DevInsetInspector.tsx index dfd4fa1..61d9d83 100644 --- a/frontend/src/components/DevInsetInspector.tsx +++ b/frontend/src/components/DevInsetInspector.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback } from 'react' -import { useCivicStore } from '../store' +import { useCivicPick } from '../store' // ── Developer overlay: surfaces the exact iOS safe-area insets, viewport // geometry, and DOM-layer heights on-device, so we never have to guess at @@ -214,7 +214,7 @@ function LayerRow({ k, rect }: { k: LayerKey; rect: LayerRect }) { } export function DevInsetInspector() { - const { debugInsets, setDebugInsets } = useCivicStore() + const { debugInsets, setDebugInsets } = useCivicPick('debugInsets', 'setDebugInsets') const [metrics, setMetrics] = useState(() => readMetrics()) const [corner, setCorner] = useState('top-right') const [copied, setCopied] = useState(false) diff --git a/frontend/src/components/layout/AlertStatusBar.tsx b/frontend/src/components/layout/AlertStatusBar.tsx index e010701..c7ca12e 100644 --- a/frontend/src/components/layout/AlertStatusBar.tsx +++ b/frontend/src/components/layout/AlertStatusBar.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { useCivicStore } from '../../store' +import { useCivicPick } from '../../store' type Level = 'green' | 'yellow' | 'red' @@ -44,7 +44,7 @@ const LEVEL_ICONS: Record = { } export function AlertStatusBar() { - const { mode, alerts, weather, setActiveTab } = useCivicStore() + const { mode, alerts, weather, setActiveTab } = useCivicPick('mode', 'alerts', 'weather', 'setActiveTab') const hasEmergency = weather.alerts.some( (a) => a.severity === 'Extreme' || a.severity === 'Severe' diff --git a/frontend/src/components/layout/EnvBar.tsx b/frontend/src/components/layout/EnvBar.tsx index ddaea84..9f6691c 100644 --- a/frontend/src/components/layout/EnvBar.tsx +++ b/frontend/src/components/layout/EnvBar.tsx @@ -1,4 +1,4 @@ -import { useCivicStore } from '../../store' +import { useCivicPick } from '../../store' import { DEFAULT_CENTER } from '../../config' function aqiColor(aqi: number | undefined): string { @@ -24,7 +24,7 @@ function formatCoord(val: number, pos: string, neg: string): string { } export function EnvBar() { - const { weather, mode } = useCivicStore() + const { weather, mode } = useCivicPick('weather', 'mode') const [centerLon, centerLat] = DEFAULT_CENTER // In critical mode, highlight the entire bar if severe alerts exist diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index a916444..bf290e2 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { useCivicStore, SystemEvent, NavTab } from '../../store' +import { SystemEvent, NavTab, useCivicPick } from '../../store' import { exportDashboardSnapshot } from '../../snapshotExport' const TABS: { id: NavTab; label: string; icon: string }[] = [ @@ -50,7 +50,7 @@ function NotificationsDropdown({ events, onClose }: { events: SystemEvent[]; onC } export function Header() { - const { activeTab, setActiveTab, mode, setSettingsOpen, setHelpOpen, systemEvents } = useCivicStore() + const { activeTab, setActiveTab, mode, setSettingsOpen, setHelpOpen, systemEvents } = useCivicPick('activeTab', 'setActiveTab', 'mode', 'setSettingsOpen', 'setHelpOpen', 'systemEvents') const [notificationsOpen, setNotificationsOpen] = useState(false) const notifRef = useRef(null) diff --git a/frontend/src/components/layout/MobileNav.tsx b/frontend/src/components/layout/MobileNav.tsx index 5160522..69ddfe3 100644 --- a/frontend/src/components/layout/MobileNav.tsx +++ b/frontend/src/components/layout/MobileNav.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useCivicStore, NavTab } from '../../store' +import { NavTab, useCivicPick } from '../../store' import { exportDashboardSnapshot } from '../../snapshotExport' const PRIMARY_TABS: { id: NavTab; label: string; icon: string }[] = [ @@ -17,7 +17,7 @@ const SECONDARY_TABS: { id: NavTab; label: string; icon: string }[] = [ ] export function MobileNav() { - const { activeTab, setActiveTab, setSettingsOpen, setHelpOpen } = useCivicStore() + const { activeTab, setActiveTab, setSettingsOpen, setHelpOpen } = useCivicPick('activeTab', 'setActiveTab', 'setSettingsOpen', 'setHelpOpen') const [showMore, setShowMore] = useState(false) const handleTabClick = (id: NavTab) => { diff --git a/frontend/src/components/layout/SettingsPanel.tsx b/frontend/src/components/layout/SettingsPanel.tsx index 1bb1c60..8530015 100644 --- a/frontend/src/components/layout/SettingsPanel.tsx +++ b/frontend/src/components/layout/SettingsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback } from 'react' -import { useCivicStore } from '../../store' +import { useCivicPick } from '../../store' import { notificationPermission, requestNotificationPermission } from '../../notifications' import { getUserRole, clearToken, authHeaders } from '../../auth' import { API_BASE } from '../../config' @@ -8,26 +8,45 @@ import { AlertRulesSection } from './AlertRulesSection' export function SettingsPanel() { const { - settingsOpen, setSettingsOpen, - radarVisible, setRadarVisible, - radarOpacity, setRadarOpacity, - smokeVisible, setSmokeVisible, - goesVisible, setGoesVisible, - firePerimetersVisible, setFirePerimetersVisible, - camerasVisible, setCamerasVisible, - geofencesVisible, setGeofencesVisible, - trailsVisible, setTrailsVisible, - lightningVisible, setLightningVisible, - radarReflectivityVisible, setRadarReflectivityVisible, - nwsAlertsVisible, setNwsAlertsVisible, - lightningDensityVisible, setLightningDensityVisible, - railTracksVisible, setRailTracksVisible, - gaugesVisible, setGaugesVisible, - terrainEnabled, setTerrainEnabled, - terrainExaggeration, setTerrainExaggeration, - entityFilter, setEntityFilter, - debugInsets, setDebugInsets, - } = useCivicStore() + settingsOpen, + setSettingsOpen, + radarVisible, + setRadarVisible, + radarOpacity, + setRadarOpacity, + smokeVisible, + setSmokeVisible, + goesVisible, + setGoesVisible, + firePerimetersVisible, + setFirePerimetersVisible, + camerasVisible, + setCamerasVisible, + geofencesVisible, + setGeofencesVisible, + trailsVisible, + setTrailsVisible, + lightningVisible, + setLightningVisible, + radarReflectivityVisible, + setRadarReflectivityVisible, + nwsAlertsVisible, + setNwsAlertsVisible, + lightningDensityVisible, + setLightningDensityVisible, + railTracksVisible, + setRailTracksVisible, + gaugesVisible, + setGaugesVisible, + terrainEnabled, + setTerrainEnabled, + terrainExaggeration, + setTerrainExaggeration, + entityFilter, + setEntityFilter, + debugInsets, + setDebugInsets, + } = useCivicPick('settingsOpen', 'setSettingsOpen', 'radarVisible', 'setRadarVisible', 'radarOpacity', 'setRadarOpacity', 'smokeVisible', 'setSmokeVisible', 'goesVisible', 'setGoesVisible', 'firePerimetersVisible', 'setFirePerimetersVisible', 'camerasVisible', 'setCamerasVisible', 'geofencesVisible', 'setGeofencesVisible', 'trailsVisible', 'setTrailsVisible', 'lightningVisible', 'setLightningVisible', 'radarReflectivityVisible', 'setRadarReflectivityVisible', 'nwsAlertsVisible', 'setNwsAlertsVisible', 'lightningDensityVisible', 'setLightningDensityVisible', 'railTracksVisible', 'setRailTracksVisible', 'gaugesVisible', 'setGaugesVisible', 'terrainEnabled', 'setTerrainEnabled', 'terrainExaggeration', 'setTerrainExaggeration', 'entityFilter', 'setEntityFilter', 'debugInsets', 'setDebugInsets') const [notifPermission, setNotifPermission] = useState(() => notificationPermission()) const userRole = getUserRole() diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 7f4d3f9..a1da256 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useCivicStore, ALT_RANGE_DEFAULT, SPD_RANGE_DEFAULT, type EntityTypeFilter } from '../../store' +import { ALT_RANGE_DEFAULT, SPD_RANGE_DEFAULT, type EntityTypeFilter, useCivicPick } from '../../store' import { isMajorTrafficIncident } from '../../incidentUtils' const INCIDENTS_COLLAPSE_KEY = 'vertex.sidebar.incidentsCollapsed' @@ -122,7 +122,7 @@ export function Sidebar() { setGaugesVisible, lightningVisible, setLightningVisible, - } = useCivicStore() + } = useCivicPick('alerts', 'news', 'health', 'entities', 'connected', 'cameras', 'weather', 'trafficIncidents', 'lightningStrikes', 'setActiveTab', 'entityFilter', 'setEntityFilter', 'setEntitySearchQuery', 'setEntityAltRange', 'setEntitySpeedRange', 'camerasVisible', 'setCamerasVisible', 'gaugesVisible', 'setGaugesVisible', 'lightningVisible', 'setLightningVisible') const entityList = Object.values(entities) const aircraft = entityList.filter((e) => e.entity_type === 'aircraft').length diff --git a/frontend/src/components/panels/CameraModal.tsx b/frontend/src/components/panels/CameraModal.tsx index d41689a..9ba02b9 100644 --- a/frontend/src/components/panels/CameraModal.tsx +++ b/frontend/src/components/panels/CameraModal.tsx @@ -1,11 +1,14 @@ -import { useCivicStore, TrafficCamera } from '../../store' +import { TrafficCamera, useCivicPick } from '../../store' export function CameraModal() { - const { - selectedCamId, setSelectedCamId, - cameras, ldiMode, - favoriteCamIds, toggleFavoriteCam - } = useCivicStore() + const { + selectedCamId, + setSelectedCamId, + cameras, + ldiMode, + favoriteCamIds, + toggleFavoriteCam, + } = useCivicPick('selectedCamId', 'setSelectedCamId', 'cameras', 'ldiMode', 'favoriteCamIds', 'toggleFavoriteCam') if (!selectedCamId) return null diff --git a/frontend/src/components/panels/CommsPanel.tsx b/frontend/src/components/panels/CommsPanel.tsx index 2bc893f..d07b7a1 100644 --- a/frontend/src/components/panels/CommsPanel.tsx +++ b/frontend/src/components/panels/CommsPanel.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react' -import { useCivicStore, MeshMessage, Entity, SystemEvent, Track, MeshLink } from '../../store' +import { MeshMessage, Entity, SystemEvent, Track, MeshLink, useCivicPick } from '../../store' import { getDistanceMeters } from '../../layers/geoUtils' import { DEFAULT_CENTER, API_BASE } from '../../config' import { MeshFleetPanel } from './MeshFleetPanel' @@ -161,7 +161,7 @@ function formatAge(iso: string | undefined): string { } function SpectralMonitor({ links, history, status }: { links: MeshLink[]; history: Record; status: any }) { - const { radio, entities } = useCivicStore() + const { radio, entities } = useCivicPick('radio', 'entities') // Calculate top 3 links by SNR const topLinks = useMemo(() => { @@ -371,7 +371,7 @@ function SpectralMonitor({ links, history, status }: { links: MeshLink[]; histor } export function CommsPanel() { - const { radio, meshMessages, entities, systemEvents, tracks, meshLinks, linkHistory, meshStatus } = useCivicStore() + const { radio, meshMessages, entities, systemEvents, tracks, meshLinks, linkHistory, meshStatus } = useCivicPick('radio', 'meshMessages', 'entities', 'systemEvents', 'tracks', 'meshLinks', 'linkHistory', 'meshStatus') const [msgFilter, setMsgFilter] = useState('') const [selectedConv, setSelectedConv] = useState('all') const [activeTab, setActiveTab] = useState<'chat' | 'fleet' | 'p25'>('chat') diff --git a/frontend/src/components/panels/CustomLayersTab.tsx b/frontend/src/components/panels/CustomLayersTab.tsx index e7632df..25cc5d5 100644 --- a/frontend/src/components/panels/CustomLayersTab.tsx +++ b/frontend/src/components/panels/CustomLayersTab.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { useCivicStore, CustomLayerItem } from '../../store' +import { CustomLayerItem, useCivicPick } from '../../store' import { API_BASE } from '../../config' import { authHeaders } from '../../auth' @@ -58,7 +58,7 @@ function kmlToGeoJson(kmlText: string): object { } export function CustomLayersTab() { - const { customLayers, setCustomLayers } = useCivicStore() + const { customLayers, setCustomLayers } = useCivicPick('customLayers', 'setCustomLayers') const fileInputRef = useRef(null) const [layerImportName, setLayerImportName] = useState('') diff --git a/frontend/src/components/panels/EntityDetail.tsx b/frontend/src/components/panels/EntityDetail.tsx index 1de2cf9..2c338e5 100644 --- a/frontend/src/components/panels/EntityDetail.tsx +++ b/frontend/src/components/panels/EntityDetail.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback } from 'react' -import { useCivicStore } from '../../store' +import { useCivicPick } from '../../store' import type { EntityMissionTag } from '../../store' import { API_BASE } from '../../config' import { authHeaders } from '../../auth' @@ -49,9 +49,15 @@ const TAG_PRESETS = ['#FF4444', '#FF8800', '#FFB800', '#44DD88', '#00BBFF', '#AA export function EntityDetail() { const { - entities, airports, selectedEntityId, selectEntity, - entityMissionTags, setEntityMissionTags, addEntityMissionTag, removeEntityMissionTag, - } = useCivicStore() + entities, + airports, + selectedEntityId, + selectEntity, + entityMissionTags, + setEntityMissionTags, + addEntityMissionTag, + removeEntityMissionTag, + } = useCivicPick('entities', 'airports', 'selectedEntityId', 'selectEntity', 'entityMissionTags', 'setEntityMissionTags', 'addEntityMissionTag', 'removeEntityMissionTag') const entity = selectedEntityId ? entities[selectedEntityId] : null // Fetch trail for sparklines (aircraft only) diff --git a/frontend/src/components/panels/EntitySearchPanel.tsx b/frontend/src/components/panels/EntitySearchPanel.tsx index ee3a142..be80a53 100644 --- a/frontend/src/components/panels/EntitySearchPanel.tsx +++ b/frontend/src/components/panels/EntitySearchPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useCivicStore, ALT_RANGE_DEFAULT, SPD_RANGE_DEFAULT } from '../../store' +import { ALT_RANGE_DEFAULT, SPD_RANGE_DEFAULT, useCivicPick } from '../../store' import { getDistanceMeters } from '../../layers/geoUtils' import { DEFAULT_CENTER } from '../../config' @@ -69,15 +69,22 @@ function RangeSlider({ export function EntitySearchPanel() { const { - tracks, entities, - entitySearchQuery, setEntitySearchQuery, - entityAltRange, setEntityAltRange, - entitySpeedRange, setEntitySpeedRange, - entityFilter, setEntityFilter, - trailsVisible, setTrailsVisible, - selectEntity, selectedEntityId, + tracks, + entities, + entitySearchQuery, + setEntitySearchQuery, + entityAltRange, + setEntityAltRange, + entitySpeedRange, + setEntitySpeedRange, + entityFilter, + setEntityFilter, + trailsVisible, + setTrailsVisible, + selectEntity, + selectedEntityId, entityMissionTags, - } = useCivicStore() + } = useCivicPick('tracks', 'entities', 'entitySearchQuery', 'setEntitySearchQuery', 'entityAltRange', 'setEntityAltRange', 'entitySpeedRange', 'setEntitySpeedRange', 'entityFilter', 'setEntityFilter', 'trailsVisible', 'setTrailsVisible', 'selectEntity', 'selectedEntityId', 'entityMissionTags') const [filtersOpen, setFiltersOpen] = useState(false) const [taggedOnly, setTaggedOnly] = useState(false) diff --git a/frontend/src/components/panels/EventLogPanel.tsx b/frontend/src/components/panels/EventLogPanel.tsx index 84348a3..7450248 100644 --- a/frontend/src/components/panels/EventLogPanel.tsx +++ b/frontend/src/components/panels/EventLogPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useCivicStore, SystemEvent } from '../../store' +import { SystemEvent, useCivicPick } from '../../store' import { API_BASE } from '../../config' import { authHeaders, clearToken } from '../../auth' @@ -94,7 +94,7 @@ function EventRow({ event }: { event: SystemEvent }) { } export function EventLogPanel() { - const { systemEvents, setSystemEvents } = useCivicStore() + const { systemEvents, setSystemEvents } = useCivicPick('systemEvents', 'setSystemEvents') const [severityFilter, setSeverityFilter] = useState('all') const [search, setSearch] = useState('') const [sitrepHours, setSitrepHours] = useState(24) diff --git a/frontend/src/components/panels/FlightLogPanel.tsx b/frontend/src/components/panels/FlightLogPanel.tsx index 52a2ebb..0419d7f 100644 --- a/frontend/src/components/panels/FlightLogPanel.tsx +++ b/frontend/src/components/panels/FlightLogPanel.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react' import maplibregl from 'maplibre-gl' -import { useCivicStore, Entity } from '../../store' +import { Entity, useCivicPick } from '../../store' import type { AcarsMessage } from '../../storeTypes' import { API_BASE, MAP_STYLE, DEFAULT_CENTER } from '../../config' import { authHeaders } from '../../auth' @@ -498,7 +498,7 @@ function FlightMiniMap({ trailPoints, entity }: { // ─── Main Panel ─────────────────────────────────────────────────────────────── export function FlightLogPanel() { - const { entities, selectedEntityId, selectEntity, acarsMessages } = useCivicStore() + const { entities, selectedEntityId, selectEntity, acarsMessages } = useCivicPick('entities', 'selectedEntityId', 'selectEntity', 'acarsMessages') const [timeWindow, setTimeWindow] = useState(60) const [search, setSearch] = useState('') diff --git a/frontend/src/components/panels/GeofencePanel.tsx b/frontend/src/components/panels/GeofencePanel.tsx index 2461cf9..fbe3ee5 100644 --- a/frontend/src/components/panels/GeofencePanel.tsx +++ b/frontend/src/components/panels/GeofencePanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { useCivicStore } from '../../store' +import { useCivicPick } from '../../store' import { API_BASE } from '../../config' import { authHeaders } from '../../auth' import { CustomLayersTab } from './CustomLayersTab' @@ -36,11 +36,14 @@ type PanelTab = 'geofences' | 'layers' export function GeofencePanel() { const { - geofenceDrawing, setGeofenceDrawing, - geofenceDrawMode, setGeofenceDrawMode, - geofenceDrawPoints, clearGeofenceDrawPoints, + geofenceDrawing, + setGeofenceDrawing, + geofenceDrawMode, + setGeofenceDrawMode, + geofenceDrawPoints, + clearGeofenceDrawPoints, customLayers, - } = useCivicStore() + } = useCivicPick('geofenceDrawing', 'setGeofenceDrawing', 'geofenceDrawMode', 'setGeofenceDrawMode', 'geofenceDrawPoints', 'clearGeofenceDrawPoints', 'customLayers') const [panelTab, setPanelTab] = useState('geofences') const [fences, setFences] = useState([]) diff --git a/frontend/src/components/panels/HelpPanel.tsx b/frontend/src/components/panels/HelpPanel.tsx index d41469b..8d8b20f 100644 --- a/frontend/src/components/panels/HelpPanel.tsx +++ b/frontend/src/components/panels/HelpPanel.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react' -import { useCivicStore } from '../../store' +import { useCivicPick } from '../../store' import { DOC_PAGES, type DocPage } from '../../docs/content' const SECTIONS = ['Usage', 'Capabilities', 'Dashboards', 'Navigation'] as const export function HelpPanel() { - const { helpOpen, setHelpOpen } = useCivicStore() + const { helpOpen, setHelpOpen } = useCivicPick('helpOpen', 'setHelpOpen') const [activePage, setActivePage] = useState(DOC_PAGES[0]) const [mobileNavOpen, setMobileNavOpen] = useState(false) diff --git a/frontend/src/components/panels/IncidentsPanel.tsx b/frontend/src/components/panels/IncidentsPanel.tsx index aae5583..9708b13 100644 --- a/frontend/src/components/panels/IncidentsPanel.tsx +++ b/frontend/src/components/panels/IncidentsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { useCivicStore, WeatherAlert, SystemEvent } from '../../store' +import { WeatherAlert, SystemEvent, useCivicPick } from '../../store' import { isMajorTrafficIncident, isIncidentInRadius } from '../../incidentUtils' import ReactMarkdown from 'react-markdown' import { API_BASE } from '../../config' @@ -37,7 +37,7 @@ function deriveIncidentTitle(incident: { } function AiTrafficSummary() { - const { summary } = useCivicStore() + const { summary } = useCivicPick('summary') if (!summary.summary) return null return ( @@ -100,7 +100,7 @@ function sysSeverityColorClass(severity: string) { } export function IncidentsPanel() { - const { weather, trafficIncidents, systemEvents } = useCivicStore() + const { weather, trafficIncidents, systemEvents } = useCivicPick('weather', 'trafficIncidents', 'systemEvents') // Request an on-demand AI summary refresh whenever this panel is opened. // The updated result arrives via the existing WebSocket → store flow. diff --git a/frontend/src/components/panels/InfrastructureGrid.tsx b/frontend/src/components/panels/InfrastructureGrid.tsx index 8f1a759..fe5a93d 100644 --- a/frontend/src/components/panels/InfrastructureGrid.tsx +++ b/frontend/src/components/panels/InfrastructureGrid.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { useCivicStore, TrafficCamera } from '../../store' +import { TrafficCamera, useCivicPick } from '../../store' import { isMajorTrafficIncident } from '../../incidentUtils' function CctvThumbnail({ @@ -141,10 +141,18 @@ const PLACEHOLDER_CAMERAS: TrafficCamera[] = [ export function InfrastructureGrid() { const { - cameras, trafficFlow, trafficIncidents, utilityStatus, oregonStatus, ldiMode, setLdiMode, - selectedCamId, setSelectedCamId, - favoriteCamIds, toggleFavoriteCam, - } = useCivicStore() + cameras, + trafficFlow, + trafficIncidents, + utilityStatus, + oregonStatus, + ldiMode, + setLdiMode, + selectedCamId, + setSelectedCamId, + favoriteCamIds, + toggleFavoriteCam, + } = useCivicPick('cameras', 'trafficFlow', 'trafficIncidents', 'utilityStatus', 'oregonStatus', 'ldiMode', 'setLdiMode', 'selectedCamId', 'setSelectedCamId', 'favoriteCamIds', 'toggleFavoriteCam') const [radiusKm, setRadiusKm] = useState(5) const [page, setPage] = useState(0) const PAGE_SIZE = 12 diff --git a/frontend/src/components/panels/IntelPanel.tsx b/frontend/src/components/panels/IntelPanel.tsx index d6aa305..9802e89 100644 --- a/frontend/src/components/panels/IntelPanel.tsx +++ b/frontend/src/components/panels/IntelPanel.tsx @@ -1,4 +1,4 @@ -import { useCivicStore, AlertItem, NewsItem } from '../../store' +import { AlertItem, NewsItem, useCivicPick } from '../../store' type FeedItem = { key: string @@ -106,7 +106,7 @@ function toFeedItem(item: AlertItem | NewsItem, i: number, isAlert: boolean): Fe } export function IntelPanel() { - const { alerts, news } = useCivicStore() + const { alerts, news } = useCivicPick('alerts', 'news') // Merge and sort by published date descending const allItems = [ diff --git a/frontend/src/components/panels/PlaybackController.tsx b/frontend/src/components/panels/PlaybackController.tsx index a78892d..d2ba2ef 100644 --- a/frontend/src/components/panels/PlaybackController.tsx +++ b/frontend/src/components/panels/PlaybackController.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { useCivicStore, ReplayEvent } from '../../store' +import { ReplayEvent, useCivicPick } from '../../store' import { API_BASE } from '../../config' import { authHeaders } from '../../auth' @@ -29,12 +29,17 @@ function toDatetimeLocal(d: Date): string { export function PlaybackController() { const { - replayMode, setReplayMode, - replayData, setReplayData, - replayCurrentTs, setReplayCurrentTs, - replayPlaying, setReplayPlaying, - replaySpeed, setReplaySpeed, - } = useCivicStore() + replayMode, + setReplayMode, + replayData, + setReplayData, + replayCurrentTs, + setReplayCurrentTs, + replayPlaying, + setReplayPlaying, + replaySpeed, + setReplaySpeed, + } = useCivicPick('replayMode', 'setReplayMode', 'replayData', 'setReplayData', 'replayCurrentTs', 'setReplayCurrentTs', 'replayPlaying', 'setReplayPlaying', 'replaySpeed', 'setReplaySpeed') const [windowHours, setWindowHours] = useState(2) const [useAbsolute, setUseAbsolute] = useState(false) diff --git a/frontend/src/hooks/useAlerts.ts b/frontend/src/hooks/useAlerts.ts index 946e560..e545827 100644 --- a/frontend/src/hooks/useAlerts.ts +++ b/frontend/src/hooks/useAlerts.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { API_BASE, ALERTS_POLL_MS, NEWS_POLL_MS, WEATHER_POLL_MS, CAMERAS_POLL_MS } from '../config' -import { useCivicStore } from '../store' +import { useCivicPick } from '../store' import { authHeaders, clearToken } from '../auth' import type { TrafficFlowSensor, UtilityStatus, OregonStatus } from '../storeTypes' @@ -26,7 +26,7 @@ export function useAlerts() { setUtilityStatus, setOregonStatus, setSummary, - } = useCivicStore() + } = useCivicPick('setAlerts', 'setNews', 'setWeather', 'setCameras', 'setTrafficFlow', 'setTrafficIncidents', 'setUtilityStatus', 'setOregonStatus', 'setSummary') const timers = useRef[]>([]) useEffect(() => { diff --git a/frontend/src/hooks/useMeshHistory.ts b/frontend/src/hooks/useMeshHistory.ts index bb85efd..1573f1b 100644 --- a/frontend/src/hooks/useMeshHistory.ts +++ b/frontend/src/hooks/useMeshHistory.ts @@ -1,10 +1,10 @@ import { useEffect } from 'react' -import { useCivicStore } from '../store' +import { useCivicPick } from '../store' import { API_BASE } from '../config' import { authHeaders } from '../auth' export function useMeshHistory() { - const { setMeshLinks, setMeshMessages, setMeshStatus, connected } = useCivicStore() + const { setMeshLinks, setMeshMessages, setMeshStatus, connected } = useCivicPick('setMeshLinks', 'setMeshMessages', 'setMeshStatus', 'connected') useEffect(() => { if (!connected) return diff --git a/frontend/src/hooks/useMeshLinks.ts b/frontend/src/hooks/useMeshLinks.ts index 5a8d8c4..4221142 100644 --- a/frontend/src/hooks/useMeshLinks.ts +++ b/frontend/src/hooks/useMeshLinks.ts @@ -1,10 +1,10 @@ import { useEffect } from 'react' -import { useCivicStore } from '../store' +import { useCivicPick } from '../store' import { API_BASE } from '../config' import { authHeaders } from '../auth' export function useMeshLinks() { - const { setMeshLinks, connected } = useCivicStore() + const { setMeshLinks, connected } = useCivicPick('setMeshLinks', 'connected') useEffect(() => { if (!connected) return diff --git a/frontend/src/hooks/useSystemHealth.ts b/frontend/src/hooks/useSystemHealth.ts index b48e7e7..4337c46 100644 --- a/frontend/src/hooks/useSystemHealth.ts +++ b/frontend/src/hooks/useSystemHealth.ts @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react' import { HEALTH_POLL_MS } from '../config' -import { useCivicStore } from '../store' +import { useCivicPick } from '../store' export function useSystemHealth() { - const { setHealth } = useCivicStore() + const { setHealth } = useCivicPick('setHealth') const timer = useRef | null>(null) useEffect(() => { diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 9e1b898..8f92705 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { WS_URL, API_BASE } from '../config' -import { useCivicStore } from '../store' +import { useCivicStore, useCivicPick } from '../store' import type { Entity, EntityTypeFilter } from '../storeTypes' import { wsTokenParam, authHeaders } from '../auth' import { initNotifications, maybeNotify, notifyMeshMessage } from '../notifications' @@ -99,7 +99,7 @@ export function useWebSocket() { updateLinkHistory, setMeshStatus, appendAcarsMessage, - } = useCivicStore() + } = useCivicPick('setEntities', 'setAircraftSnapshot', 'upsertEntity', 'purgeStaleEntities', 'setConnected', 'setRadio', 'appendSystemEvent', 'setUtilityStatus', 'setOregonStatus', 'setAirports', 'setWeather', 'setAlerts', 'setNews', 'setCameras', 'setTrafficFlow', 'setTrafficIncidents', 'setSummary', 'appendLightningStrikes', 'appendMeshMessage', 'updateLinkHistory', 'setMeshStatus', 'appendAcarsMessage') useEffect(() => { let cancelled = false diff --git a/frontend/src/store.ts b/frontend/src/store.ts index a2fb85e..802da54 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { useShallow } from 'zustand/react/shallow' import { entityToTrack, mergeEntityState, loadFavoriteCamIds } from './entityUtils' // Re-export all types so existing imports from '../../store' keep working. @@ -15,7 +16,7 @@ import type { import { ALT_RANGE_DEFAULT, SPD_RANGE_DEFAULT } from './storeTypes' // ─── Store ──────────────────────────────────────────────────────────────────── -interface CivicStore { +export interface CivicStore { // Live data entities: Record tracks: Record @@ -204,6 +205,25 @@ interface CivicStore { setTerrainExaggeration: (v: number) => void } +/** + * Subscribe to a named subset of store keys with shallow equality. + * + * Use this instead of a bare `useCivicStore()` — a selector-less hook + * re-renders the component on EVERY store change, including the dozens of + * entity updates per second arriving over the WebSocket. Picking only the + * keys a component reads limits its re-renders to changes of those keys. + * (Action functions are stable, so picking them never causes a re-render.) + */ +export function useCivicPick(...keys: K[]): Pick { + return useCivicStore( + useShallow((s: CivicStore) => { + const out = {} as Pick + for (const k of keys) out[k] = s[k] + return out + }), + ) +} + export interface LightningStrike { lat: number lon: number From 445f846018ad1a89879df62ee90d87f47297afd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:08:05 +0000 Subject: [PATCH 04/11] Batch WS entity updates and trim per-frame render-loop work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Buffer entity_update messages and apply them in one store commit every 250ms via a new upsertEntities action — a busy ADS-B feed now causes ~4 store notifications/sec instead of one per WS message. PVB already interpolates icon motion between commits, so nothing looks slower. - Raise the deck.gl layer-rebuild throttle from 16ms (a no-op at 60fps rAF) to 33ms, halving filter/PVB/layer-construction work per second. - Throttle tooltip hover picking to 50ms; deck picking is a GPU readback and fast mouse movement was stalling the render loop. - Fix the tooltip-hide handler: MapLibre's canvas-level event is 'mouseout', not 'mouseleave', so the old anonymous handler never fired (sticky tooltips) and was never removed on cleanup. - Uncap the cold-start REST seed (backend default limit=200 gave a partial first paint on busy feeds). - Drop the write-only layersRef. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- frontend/src/components/MapOverlay.tsx | 33 ++++++++++++++++++++++---- frontend/src/hooks/useWebSocket.ts | 24 +++++++++++++++---- frontend/src/store.ts | 16 +++++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/MapOverlay.tsx b/frontend/src/components/MapOverlay.tsx index e511b47..e60cdcf 100644 --- a/frontend/src/components/MapOverlay.tsx +++ b/frontend/src/components/MapOverlay.tsx @@ -88,7 +88,6 @@ function buildReplayTracks(data: ReplayData, atMs: number): Record(null) - const layersRef = useRef([]) // Per-group layer cache so static/slow-changing layers are rebuilt only when // their inputs change instead of on every animation frame. Reusing the same // Layer instances lets deck.gl skip re-diffing them entirely. @@ -295,6 +294,12 @@ export function MapOverlay({ map }: Props) { tooltip.className = 'absolute pointer-events-none z-[100] opacity-0 transition-opacity duration-150' container.appendChild(tooltip) + // Deck picking is a GPU readback — throttle it so fast mouse movement + // doesn't stall the render loop. Between picks the tooltip just tracks + // the cursor. + const PICK_INTERVAL_MS = 50 + let lastPickMs = 0 + const onMapMouseMove = (e: maplibregl.MapMouseEvent) => { const isMobileViewport = window.innerWidth < 1024 if (isMobileViewport) { @@ -303,6 +308,16 @@ export function MapOverlay({ map }: Props) { return } + const nowMs = performance.now() + if (nowMs - lastPickMs < PICK_INTERVAL_MS) { + if (tooltip.style.opacity === '1') { + tooltip.style.left = `${e.point.x + 15}px` + tooltip.style.top = `${e.point.y + 15}px` + } + return + } + lastPickMs = nowMs + // 1. Pick from Deck.gl (returns null until the overlay GL context is ready) const picked = overlay.pickObject({ x: e.point.x, y: e.point.y, radius: 5 }) @@ -473,8 +488,14 @@ export function MapOverlay({ map }: Props) { } } + // MapLibre's canvas-level leave event is 'mouseout' — 'mouseleave' only + // fires for the layer-id overload and would never trigger here. + const onMapMouseOut = () => { + tooltip.style.opacity = '0' + map.getCanvas().style.cursor = '' + } map.on('mousemove', onMapMouseMove) - map.on('mouseleave', () => { tooltip.style.opacity = '0' }) + map.on('mouseout', onMapMouseOut) // Allow selecting entities and cameras while preserving normal map interaction. const onMapClick = (e: maplibregl.MapMouseEvent) => { @@ -499,7 +520,11 @@ export function MapOverlay({ map }: Props) { let last = performance.now() let lastLayerBuild = 0 - const LAYER_BUILD_INTERVAL_MS = 16 + // rAF fires every ~16.7ms, so anything <= 16 here is a no-op throttle. + // 33ms rebuilds layers at ~30fps — half the filter/PVB/layer-construction + // work per second, and PVB keeps icon motion smooth between rebuilds while + // MapLibre still pans/zooms the drawn frame at full 60fps. + const LAYER_BUILD_INTERVAL_MS = 33 // When the tab is backgrounded the browser pauses/throttles rAF while the // WebSocket keeps delivering position updates. On return, re-anchor motion @@ -709,7 +734,6 @@ export function MapOverlay({ map }: Props) { })), ] - layersRef.current = layers overlay.setProps({ layers }) rafRef.current = requestAnimationFrame(tick) @@ -721,6 +745,7 @@ export function MapOverlay({ map }: Props) { document.removeEventListener('visibilitychange', onVisibility) map.off('click', onMapClick) map.off('mousemove', onMapMouseMove) + map.off('mouseout', onMapMouseOut) map.removeControl(overlay as unknown as maplibregl.IControl) tooltip.remove() layerMemoRef.current = {} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 8f92705..90f6928 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -7,6 +7,13 @@ import { initNotifications, maybeNotify, notifyMeshMessage } from '../notificati const RECONNECT_DELAY_INITIAL_MS = 1000 const RECONNECT_DELAY_MAX_MS = 60_000 +// Entity updates are buffered and applied in one store commit per flush so a +// busy feed produces ~4 store notifications/sec instead of one per WS message. +// PVB interpolates icon motion between flushes, so this adds no visible lag. +const ENTITY_FLUSH_MS = 250 +// Cold-start REST seed page size — the backend defaults to 200, which can be +// a partial snapshot on a busy feed. +const ENTITY_SEED_LIMIT = 2000 // All known entity types tracked by EntityTypeFilter keys. // adsbLocal / adsbSupplement are frontend sub-filters of 'aircraft'; the @@ -73,13 +80,14 @@ function buildSubscription( export function useWebSocket() { const wsRef = useRef(null) + const entityBufferRef = useRef([]) const reconnectDelayRef = useRef(RECONNECT_DELAY_INITIAL_MS) const emptyAircraftSnapshotStreakRef = useRef(0) const degradedAircraftSnapshotStreakRef = useRef(0) const { setEntities, setAircraftSnapshot, - upsertEntity, + upsertEntities, purgeStaleEntities, setConnected, setRadio, @@ -99,7 +107,7 @@ export function useWebSocket() { updateLinkHistory, setMeshStatus, appendAcarsMessage, - } = useCivicPick('setEntities', 'setAircraftSnapshot', 'upsertEntity', 'purgeStaleEntities', 'setConnected', 'setRadio', 'appendSystemEvent', 'setUtilityStatus', 'setOregonStatus', 'setAirports', 'setWeather', 'setAlerts', 'setNews', 'setCameras', 'setTrafficFlow', 'setTrafficIncidents', 'setSummary', 'appendLightningStrikes', 'appendMeshMessage', 'updateLinkHistory', 'setMeshStatus', 'appendAcarsMessage') + } = useCivicPick('setEntities', 'setAircraftSnapshot', 'upsertEntities', 'purgeStaleEntities', 'setConnected', 'setRadio', 'appendSystemEvent', 'setUtilityStatus', 'setOregonStatus', 'setAirports', 'setWeather', 'setAlerts', 'setNews', 'setCameras', 'setTrafficFlow', 'setTrafficIncidents', 'setSummary', 'appendLightningStrikes', 'appendMeshMessage', 'updateLinkHistory', 'setMeshStatus', 'appendAcarsMessage') useEffect(() => { let cancelled = false @@ -111,7 +119,7 @@ export function useWebSocket() { // seconds). Seed the store immediately over REST so the map renders right // away. Guarded on an empty store so it never clobbers live WS data that // may have already arrived. - fetch(`${API_BASE}/entities`, { headers: authHeaders() }) + fetch(`${API_BASE}/entities?limit=${ENTITY_SEED_LIMIT}`, { headers: authHeaders() }) .then((r) => (r.ok ? r.json() : null)) .then((list: Entity[] | null) => { if (cancelled || !Array.isArray(list) || list.length === 0) return @@ -124,6 +132,13 @@ export function useWebSocket() { purgeStaleEntities() }, 10000) + const flushInterval = setInterval(() => { + if (entityBufferRef.current.length === 0) return + const batch = entityBufferRef.current + entityBufferRef.current = [] + upsertEntities(batch) + }, ENTITY_FLUSH_MS) + const sendSubscription = (ws: WebSocket) => { if (ws.readyState !== WebSocket.OPEN) return const state = useCivicStore.getState() @@ -167,7 +182,7 @@ export function useWebSocket() { setEntities(msg.data as Parameters[0]) break case 'entity_update': - upsertEntity(msg.data as Parameters[0]) + entityBufferRef.current.push(msg.data as Entity) break case 'aircraft_snapshot': { if (msgData?.schema_version !== undefined && msgData.schema_version !== 1) { @@ -317,6 +332,7 @@ export function useWebSocket() { return () => { cancelled = true clearInterval(cleanupInterval) + clearInterval(flushInterval) unsubscribeStore() if (wsRef.current) { wsRef.current.onopen = null diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 802da54..ed796b3 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -63,6 +63,7 @@ export interface CivicStore { setEntities: (entities: Entity[]) => void setAircraftSnapshot: (entities: Entity[]) => void upsertEntity: (entity: Entity) => void + upsertEntities: (entities: Entity[]) => void purgeStaleEntities: () => void appendSystemEvent: (event: SystemEvent) => void setSystemEvents: (events: SystemEvent[]) => void @@ -471,6 +472,21 @@ export const useCivicStore = create()( tracks: track ? { ...s.tracks, [entity.entity_id]: track } : s.tracks, } }), + // Batch variant — one store notification for a whole buffer of WS updates + // instead of one per message. Order within the batch is preserved. + upsertEntities: (list) => + set((s) => { + if (list.length === 0) return {} + const entities = { ...s.entities } + const tracks = { ...s.tracks } + for (const entity of list) { + const merged = mergeEntityState(entities[entity.entity_id], entity) + entities[entity.entity_id] = merged + const track = entityToTrack(merged, tracks[entity.entity_id]) + if (track) tracks[entity.entity_id] = track + } + return { entities, tracks } + }), purgeStaleEntities: () => set((s) => { const now = Date.now() From c0318da88a7f9bafae08cc64119f336dbece16fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:10:54 +0000 Subject: [PATCH 05/11] Fix mesh links rendering, doubled draw preview, and poller cache leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mesh links never drew on the map: packet-derived links are stored with node_a="local" (the repeater itself), which never exists as an entity, so MeshLinksLayer dropped every feature. Resolve "local" to the configured region center and other endpoints from mesh_node entities. Also subscribe to mesh nodes only instead of the whole entities map, which had the layer rebuilding its GeoJSON on every aircraft update. - The annotation draw preview rendered twice — once by the interactive MapLibre AnnotationOverlay and again by deck.gl in MapOverlay from the same store state. Drop the passive deck.gl copy. - Bound the poller's _entity_cache: entries now carry a monotonic last-seen timestamp and stale ones (>10 min) are swept every 4096 publishes. Previously every unique aircraft ever seen stayed in memory for the life of the process. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- frontend/src/components/MapOverlay.tsx | 18 ++------- .../src/components/layers/MeshLinksLayer.tsx | 38 +++++++++++++------ poller/bus.py | 36 +++++++++++++++--- 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/MapOverlay.tsx b/frontend/src/components/MapOverlay.tsx index e60cdcf..31cbe74 100644 --- a/frontend/src/components/MapOverlay.tsx +++ b/frontend/src/components/MapOverlay.tsx @@ -7,7 +7,7 @@ import { buildEntityLayers } from '../layers/buildEntityLayers' import { buildTrailLayers } from '../layers/buildTrailLayers' import { buildCameraLayer } from '../layers/buildCameraLayer' import { buildEventLayers } from '../layers/buildEventLayers' -import { buildAnnotationLayers, buildAnnotationDrawPreviewLayers } from '../layers/AnnotationLayer' +import { buildAnnotationLayers } from '../layers/AnnotationLayer' import { buildGeofenceLayers, type GeofenceItem } from '../layers/buildGeofenceLayers' import { buildObservationRingLayers } from '../layers/buildObservationRingLayer' import { buildCustomLayers } from '../layers/buildCustomLayers' @@ -138,14 +138,8 @@ export function MapOverlay({ map }: Props) { const annotationsVisible = useCivicStore((s) => s.annotationsVisible) const customLayers = useCivicStore((s) => s.customLayers) const annotationDrawMode = useCivicStore((s) => s.annotationDrawMode) - const annotationDrawPoints = useCivicStore((s) => s.annotationDrawPoints) - const annotationDrawCursor = useCivicStore((s) => s.annotationDrawCursor) const annotationDrawModeRef = useRef<'marker' | 'line' | 'polygon' | null>(null) - const annotationDrawPointsRef = useRef<[number, number][]>([]) - const annotationDrawCursorRef = useRef<[number, number] | null>(null) useEffect(() => { annotationDrawModeRef.current = annotationDrawMode }, [annotationDrawMode]) - useEffect(() => { annotationDrawPointsRef.current = annotationDrawPoints }, [annotationDrawPoints]) - useEffect(() => { annotationDrawCursorRef.current = annotationDrawCursor }, [annotationDrawCursor]) const annotationsRef = useRef(annotations) const annotationsVisibleRef = useRef(annotationsVisible) const customLayersRef = useRef(customLayers) @@ -723,15 +717,11 @@ export function MapOverlay({ map }: Props) { () => (camerasVisibleRef.current ? [buildCameraLayer(camerasRef.current, selectedCamRef.current, zoom)] : [])), + // Draw preview intentionally omitted: AnnotationOverlay owns the + // interactive drawing UX and already renders the preview via its + // MapLibre source — rendering it here too drew it twice. ...memoGroup('annotation', [annotationsRef.current, annotationsVisibleRef.current], () => buildAnnotationLayers(annotationsRef.current, annotationsVisibleRef.current)), - ...memoGroup('annotationDraw', - [annotationDrawModeRef.current, annotationDrawPointsRef.current, annotationDrawCursorRef.current], - () => buildAnnotationDrawPreviewLayers({ - mode: annotationDrawModeRef.current, - points: annotationDrawPointsRef.current, - cursor: annotationDrawCursorRef.current, - })), ] overlay.setProps({ layers }) diff --git a/frontend/src/components/layers/MeshLinksLayer.tsx b/frontend/src/components/layers/MeshLinksLayer.tsx index cd7a392..db93d10 100644 --- a/frontend/src/components/layers/MeshLinksLayer.tsx +++ b/frontend/src/components/layers/MeshLinksLayer.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import type { Map as MapLibreMap } from 'maplibre-gl' import { useCivicStore } from '../../store' -import { API_BASE } from '../../config' -import { authHeaders } from '../../auth' +import type { Entity } from '../../store' +import { useEntitiesByType } from '../../hooks/useEntities' +import { DEFAULT_CENTER } from '../../config' interface MeshLink { node_a: string @@ -32,7 +33,9 @@ interface Props { } export function MeshLinksLayer({ map }: Props) { - const entities = useCivicStore((s) => s.entities) + // Subscribe to mesh nodes only — depending on the whole entities map made + // this effect rebuild its GeoJSON on every aircraft/vessel update. + const meshNodes = useEntitiesByType('mesh_node') const meshLinks = useCivicStore((s) => s.meshLinks) // Initialize Layer and Source @@ -76,10 +79,24 @@ export function MeshLinksLayer({ map }: Props) { const now = Date.now() + const byId: Record = {} + for (const n of meshNodes) byId[n.entity_id] = n + + // Packet-derived links are recorded with node_a = "local" (the repeater + // itself), which never exists as an entity — previously every such link + // was silently dropped and the layer drew nothing. The repeater's own + // position isn't in the entity stream, so anchor "local" at the + // configured region center. + const resolve = (id: string): [number, number] | null => { + if (id === 'local') return DEFAULT_CENTER + const n = byId[id] + return n && n.lat != null && n.lon != null ? [n.lon, n.lat] : null + } + const features = meshLinks.flatMap((link) => { - const nodeA = entities[link.node_a] - const nodeB = entities[link.node_b] - if (!nodeA?.lat || !nodeA?.lon || !nodeB?.lat || !nodeB?.lon) return [] + const coordA = resolve(link.node_a) + const coordB = resolve(link.node_b) + if (!coordA || !coordB) return [] const ageMinutes = (now - new Date(link.last_seen).getTime()) / 60_000 const opacity = ageMinutes < 5 ? 0.9 : ageMinutes < 15 ? 0.6 : ageMinutes < 30 ? 0.35 : 0.15 @@ -90,10 +107,7 @@ export function MeshLinksLayer({ map }: Props) { type: 'Feature' as const, geometry: { type: 'LineString' as const, - coordinates: [ - [nodeA.lon, nodeA.lat], - [nodeB.lon, nodeB.lat], - ], + coordinates: [coordA, coordB], }, properties: { snr: link.snr, @@ -108,7 +122,7 @@ export function MeshLinksLayer({ map }: Props) { type: 'FeatureCollection', features }) - }, [map, entities, meshLinks]) + }, [map, meshNodes, meshLinks]) return null } diff --git a/poller/bus.py b/poller/bus.py index b848b53..d875974 100644 --- a/poller/bus.py +++ b/poller/bus.py @@ -1,5 +1,6 @@ import json import logging +import time from redis.asyncio import Redis from config import settings from sanitize import sanitize_payload @@ -7,10 +8,18 @@ logger = logging.getLogger(__name__) _redis: Redis | None = None -# In-memory mirror of the last-published entity state per entity_id. -# Eliminates the Redis GET on every publish_entity call when adsb_publish_only_changes -# is enabled — the comparison is done locally instead of via a round-trip. -_entity_cache: dict[str, dict] = {} +# In-memory mirror of the last-published entity state per entity_id, stored as +# (monotonic_last_seen, entity). Eliminates the Redis GET on every +# publish_entity call when adsb_publish_only_changes is enabled — the +# comparison is done locally instead of via a round-trip. +# +# Entries for entities that stop reporting are swept periodically; without +# eviction the cache grows unboundedly with every unique aircraft ever seen, +# a slow leak on a Pi that runs for weeks. +_entity_cache: dict[str, tuple[float, dict]] = {} +_ENTITY_CACHE_MAX_AGE_S = 600.0 +_ENTITY_CACHE_SWEEP_EVERY = 4096 +_entity_cache_ops = 0 async def get_bus() -> Redis: @@ -48,11 +57,27 @@ async def publish_entity( except Exception: pass + global _entity_cache_ops + now = time.monotonic() + should_publish = True if settings.adsb_publish_only_changes: previous = _entity_cache.get(entity_id) if previous is not None: - should_publish = _entity_changed(previous, entity) + should_publish = _entity_changed(previous[1], entity) + # Refresh the timestamp on every call so active-but-static entities + # (e.g. mesh nodes) are not evicted while still reporting. + _entity_cache[entity_id] = (now, entity) + + _entity_cache_ops += 1 + if _entity_cache_ops >= _ENTITY_CACHE_SWEEP_EVERY: + _entity_cache_ops = 0 + cutoff = now - _ENTITY_CACHE_MAX_AGE_S + stale = [k for k, (ts, _) in _entity_cache.items() if ts < cutoff] + for k in stale: + del _entity_cache[k] + if stale: + logger.debug("entity cache: evicted %d stale entries", len(stale)) # Always refresh the Redis TTL so the key stays alive while the entity is active. # ⚡ Bolt Optimization: Cache json.dumps result to avoid re-serializing large payload for the pubsub wrapper @@ -60,7 +85,6 @@ async def publish_entity( await r.set(key, payload, ex=ttl) if should_publish: - _entity_cache[entity_id] = entity await r.publish("civic:updates", f'{{"type":"entity_update","data":{payload}}}') from db import write_entity_observation # lazy import — db imports geofence which imports bus From b2f2d80e74dbb42e844d3a04f69a0ab401b1fed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:12:46 +0000 Subject: [PATCH 06/11] Remove dead rendering code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete components/layers/MeshLayer.tsx and StreamGaugeLayer.tsx — never imported; both were superseded by the deck.gl builders in src/layers/. - Delete components/layers/ObservationRingLayer.tsx — still mounted in Map.tsx but a literal no-op stub; the ring is drawn by deck.gl's buildObservationRingLayers. - Remove buildAnnotationDrawPreviewLayers from AnnotationLayer.tsx — orphaned by the draw-preview de-duplication. - Remove unused snr_to_quality import in the meshcore poller and the uncalled normalize_mesh_node/_bridge_status leftovers from the old MeshCore bridge path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- frontend/src/components/Map.tsx | 2 - frontend/src/components/layers/MeshLayer.tsx | 84 ------------ .../layers/ObservationRingLayer.tsx | 9 -- .../components/layers/StreamGaugeLayer.tsx | 127 ------------------ frontend/src/layers/AnnotationLayer.tsx | 64 --------- poller/normalizers/mesh_node.py | 39 ------ poller/pollers/meshcore.py | 2 +- 7 files changed, 1 insertion(+), 326 deletions(-) delete mode 100644 frontend/src/components/layers/MeshLayer.tsx delete mode 100644 frontend/src/components/layers/ObservationRingLayer.tsx delete mode 100644 frontend/src/components/layers/StreamGaugeLayer.tsx diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index a36c86b..71dbb8b 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -7,7 +7,6 @@ import { SmokeLayer } from './layers/SmokeLayer' import { GOESLayer } from './layers/GOESLayer' import { FirePerimeterLayer } from './layers/FirePerimeterLayer' import { GeofenceLayer } from './layers/GeofenceLayer' -import { ObservationRingLayer } from './layers/ObservationRingLayer' import { CustomLayersLayer } from './layers/CustomLayersLayer' import { AnnotationOverlay } from './layers/AnnotationOverlay' import { TerrainLayer } from './layers/TerrainLayer' @@ -208,7 +207,6 @@ export function Map() { - diff --git a/frontend/src/components/layers/MeshLayer.tsx b/frontend/src/components/layers/MeshLayer.tsx deleted file mode 100644 index 5ec80e0..0000000 --- a/frontend/src/components/layers/MeshLayer.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useEffect } from 'react' -import maplibregl from 'maplibre-gl' -import { useEntitiesByType } from '../../hooks/useEntities' -import { useCivicStore } from '../../store' - -interface Props { map: maplibregl.Map } - -const SRC = 'mesh-nodes' -const LAYER = 'mesh-node-points' -const STALE_MS = 10 * 60 * 1000 // 10 minutes - -export function MeshLayer({ map }: Props) { - const nodes = useEntitiesByType('mesh_node') - const selectEntity = useCivicStore((s) => s.selectEntity) - const meshVisible = useCivicStore((s) => s.entityFilter.mesh_node) - - useEffect(() => { - const handleClick = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { - const f = e.features?.[0] - if (f?.properties?.id) selectEntity(f.properties.id as string) - } - const handleEnter = () => { map.getCanvas().style.cursor = 'pointer' } - const handleLeave = () => { map.getCanvas().style.cursor = '' } - - if (!map.getSource(SRC)) { - map.addSource(SRC, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) - map.addLayer({ - id: `${LAYER}-ring`, - type: 'circle', - source: SRC, - paint: { - 'circle-radius': 9, - 'circle-color': ['case', ['get', 'stale'], '#555', '#1a9641'], - 'circle-opacity': 0.3, - }, - }) - map.addLayer({ - id: LAYER, - type: 'circle', - source: SRC, - paint: { - 'circle-radius': 5, - 'circle-color': ['case', ['get', 'stale'], '#888', '#4dac26'], - 'circle-stroke-width': 1, - 'circle-stroke-color': '#fff', - }, - }) - map.on('click', LAYER, handleClick) - map.on('mouseenter', LAYER, handleEnter) - map.on('mouseleave', LAYER, handleLeave) - } - - return () => { - map.off('click', LAYER, handleClick) - map.off('mouseenter', LAYER, handleEnter) - map.off('mouseleave', LAYER, handleLeave) - if (map.getLayer(`${LAYER}-ring`)) map.removeLayer(`${LAYER}-ring`) - if (map.getLayer(LAYER)) map.removeLayer(LAYER) - if (map.getSource(SRC)) map.removeSource(SRC) - } - }, [map, selectEntity]) - - useEffect(() => { - const src = map.getSource(SRC) as maplibregl.GeoJSONSource | undefined - if (!src) return - const now = Date.now() - const visibleNodes = meshVisible ? nodes : [] - const geojson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: visibleNodes.map((n) => { - const lastMs = n.last_seen ? Date.parse(n.last_seen) : 0 - const isStale = now - lastMs > STALE_MS - return { - type: 'Feature', - geometry: { type: 'Point', coordinates: [n.lon!, n.lat!] }, - properties: { id: n.entity_id, name: n.display_name ?? n.entity_id, status: n.status ?? '', stale: isStale }, - } - }), - } - src.setData(geojson) - }, [nodes, map, meshVisible]) - - return null -} diff --git a/frontend/src/components/layers/ObservationRingLayer.tsx b/frontend/src/components/layers/ObservationRingLayer.tsx deleted file mode 100644 index 8c1d2e2..0000000 --- a/frontend/src/components/layers/ObservationRingLayer.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import maplibregl from 'maplibre-gl' - -interface Props { map: maplibregl.Map } - -export function ObservationRingLayer({ map }: Props) { - void map - - return null -} diff --git a/frontend/src/components/layers/StreamGaugeLayer.tsx b/frontend/src/components/layers/StreamGaugeLayer.tsx deleted file mode 100644 index d8a65da..0000000 --- a/frontend/src/components/layers/StreamGaugeLayer.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import maplibregl from 'maplibre-gl' -import { useEntitiesByType } from '../../hooks/useEntities' -import { useCivicStore } from '../../store' -import type { Entity } from '../../store' -import { API_BASE } from '../../config' -import { authHeaders } from '../../auth' - -interface Props { map: maplibregl.Map } - -const SRC = 'stream-gauges' -const LAYER = 'stream-gauge-dots' -const RING = 'stream-gauge-ring' -const LABEL = 'stream-gauge-label' - -// Flow stage → map color -const STAGE_COLOR: Record = { - normal: '#4fc3f7', // light blue - elevated: '#fff176', // yellow - 'minor flood': '#ffb74d', // orange - 'moderate flood':'#ef5350', // red - 'major flood': '#b71c1c', // dark red - unknown: '#90a4ae', // grey -} - -export function StreamGaugeLayer({ map }: Props) { - const gauges = useEntitiesByType('stream_gauge') - const gaugesVisible = useCivicStore((s) => s.gaugesVisible) - const selectEntity = useCivicStore((s) => s.selectEntity) - const [fallbackGauges, setFallbackGauges] = useState([]) - - // Defensive fallback: if websocket snapshot misses stream_gauge entities, - // fetch them from REST so operators still see hydrology overlays. - useEffect(() => { - let cancelled = false - - const loadFallback = async () => { - if (gauges.length > 0) { - setFallbackGauges([]) - return - } - try { - const res = await fetch(`${API_BASE}/entities?entity_type=stream_gauge`, { - headers: authHeaders(), - }) - if (!res.ok) return - const data = await res.json() - if (!cancelled && Array.isArray(data)) { - setFallbackGauges(data as Entity[]) - } - } catch { - // Non-fatal: map can still render websocket-fed entities. - } - } - - loadFallback() - const id = window.setInterval(loadFallback, 60_000) - return () => { - cancelled = true - window.clearInterval(id) - } - }, [gauges]) - - const displayGauges = useMemo( - () => (gauges.length > 0 ? gauges : fallbackGauges), - [gauges, fallbackGauges], - ) - - useEffect(() => { - const handleClick = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { - const f = e.features?.[0] - if (f?.properties?.id) selectEntity(f.properties.id as string) - } - const handleEnter = () => { map.getCanvas().style.cursor = 'pointer' } - const handleLeave = () => { map.getCanvas().style.cursor = '' } - - if (!map.getSource(SRC)) { - map.addSource(SRC, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) - map.addLayer({ id: RING, type: 'circle', source: SRC, paint: { 'circle-radius': 14, 'circle-color': ['get', 'color'], 'circle-opacity': 0.45 } }) - map.addLayer({ id: LAYER, type: 'circle', source: SRC, paint: { 'circle-radius': 7, 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } }) - map.addLayer({ - id: LABEL, type: 'symbol', source: SRC, - layout: { 'text-field': ['get', 'label'], 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], 'text-size': 10, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-max-width': 8 }, - paint: { 'text-color': '#e0f7fa', 'text-halo-color': '#050505', 'text-halo-width': 1 }, - }) - map.on('click', LAYER, handleClick) - map.on('mouseenter', LAYER, handleEnter) - map.on('mouseleave', LAYER, handleLeave) - } - - return () => { - map.off('click', LAYER, handleClick) - map.off('mouseenter', LAYER, handleEnter) - map.off('mouseleave', LAYER, handleLeave) - if (map.getLayer(LABEL)) map.removeLayer(LABEL) - if (map.getLayer(LAYER)) map.removeLayer(LAYER) - if (map.getLayer(RING)) map.removeLayer(RING) - if (map.getSource(SRC)) map.removeSource(SRC) - } - }, [map, selectEntity]) - - useEffect(() => { - const src = map.getSource(SRC) as maplibregl.GeoJSONSource | undefined - if (!src) return - const visible = gaugesVisible ? displayGauges : [] - src.setData({ - type: 'FeatureCollection', - features: visible.map((g) => { - const ident = (g.identity ?? {}) as Record - const flow = typeof ident.flow_cfs === 'number' ? ident.flow_cfs : null - const height = typeof ident.height_ft === 'number' ? ident.height_ft : null - const stage = typeof ident.stage === 'string' ? ident.stage : 'unknown' - const color = STAGE_COLOR[stage] ?? STAGE_COLOR.unknown - let label = g.display_name ?? g.entity_id - if (flow !== null) label += `\n${Math.round(flow)} cfs` - else if (height !== null) label += `\n${height.toFixed(1)} ft` - return { - type: 'Feature', - geometry: { type: 'Point', coordinates: [g.lon!, g.lat!] }, - properties: { id: g.entity_id, name: g.display_name ?? g.entity_id, stage, color, label, flow: flow ?? '', height: height ?? '' }, - } - }), - }) - }, [displayGauges, map, gaugesVisible]) - - return null -} diff --git a/frontend/src/layers/AnnotationLayer.tsx b/frontend/src/layers/AnnotationLayer.tsx index 2e498dc..6bec4bb 100644 --- a/frontend/src/layers/AnnotationLayer.tsx +++ b/frontend/src/layers/AnnotationLayer.tsx @@ -2,12 +2,6 @@ import { ScatterplotLayer, LineLayer, PolygonLayer, TextLayer } from '@deck.gl/l import type { Layer } from '@deck.gl/core' import type { AnnotationItem } from '../storeTypes' -interface AnnotationDrawPreview { - mode: 'marker' | 'line' | 'polygon' | null - points: [number, number][] - cursor: [number, number] | null -} - function hexToRgb(hex: string): [number, number, number] { const cleaned = hex.replace('#', '') const r = parseInt(cleaned.slice(0, 2), 16) @@ -185,61 +179,3 @@ export function buildAnnotationLayers(annotations: AnnotationItem[], visible: bo return layers } - -export function buildAnnotationDrawPreviewLayers(preview: AnnotationDrawPreview): Layer[] { - const { mode, points, cursor } = preview - if (!mode || mode === 'marker' || points.length === 0) return [] - - const previewPoints = cursor ? [...points, cursor] : points - const lineData: Array<{ sourcePosition: [number, number]; targetPosition: [number, number] }> = [] - for (let i = 0; i < previewPoints.length - 1; i++) { - lineData.push({ sourcePosition: previewPoints[i], targetPosition: previewPoints[i + 1] }) - } - - const layers: Layer[] = [] - - if (lineData.length > 0) { - layers.push( - new LineLayer({ - id: 'annotation-draw-preview-line', - data: lineData, - getSourcePosition: (d: any) => d.sourcePosition, - getTargetPosition: (d: any) => d.targetPosition, - getColor: [255, 184, 0, 230] as [number, number, number, number], - getWidth: 2, - widthUnits: 'pixels', - pickable: false, - }) - ) - } - - if (mode === 'polygon' && previewPoints.length >= 3) { - layers.push( - new PolygonLayer({ - id: 'annotation-draw-preview-fill', - data: [{ polygon: [...previewPoints, previewPoints[0]] }], - getPolygon: (d: any) => d.polygon, - getFillColor: [255, 184, 0, 28] as [number, number, number, number], - stroked: false, - pickable: false, - }) - ) - } - - layers.push( - new ScatterplotLayer({ - id: 'annotation-draw-preview-points', - data: points, - getPosition: (d: [number, number]) => d, - getRadius: 4, - radiusUnits: 'pixels', - getFillColor: [255, 184, 0, 240] as [number, number, number, number], - getLineColor: [0, 0, 0, 220] as [number, number, number, number], - getLineWidth: 1, - lineWidthUnits: 'pixels', - pickable: false, - }) - ) - - return layers -} diff --git a/poller/normalizers/mesh_node.py b/poller/normalizers/mesh_node.py index b5465d5..b5dedcb 100644 --- a/poller/normalizers/mesh_node.py +++ b/poller/normalizers/mesh_node.py @@ -72,44 +72,5 @@ def normalize_pymc_repeater_advert(data: dict, source_url: str) -> Optional[dict } -def normalize_mesh_node(data: dict) -> Optional[dict]: - """Normalize a MeshCore bridge node_update payload to canonical Entity.""" - node_id = data.get("entity_id", "").replace("mesh_node:", "") or data.get("identity", {}).get("node_id") - if not node_id: - return None - - if data.get("entity_type") == "mesh_node": - return data # already canonical - - return { - "entity_id": f"mesh_node:{node_id}", - "entity_type": "mesh_node", - "source": "meshcore", - "display_name": data.get("name") or data.get("short_name") or node_id, - "identity": { - "node_id": node_id, - "short_name": data.get("short_name", ""), - "hw_model": data.get("hw_model") or None, - }, - "lat": data.get("lat"), - "lon": data.get("lon"), - "altitude": data.get("altitude"), - "status": _bridge_status(data), - "last_seen": data.get("timestamp") or _now(), - "tags": ["mesh_node"], - } - - -def _bridge_status(node: dict) -> str: - parts = [] - if (v := node.get("battery_pct")) is not None: - parts.append(f"bat:{v}%") - if (v := node.get("snr")) is not None: - parts.append(f"snr:{v:.1f}") - if (v := node.get("rssi")) is not None: - parts.append(f"rssi:{v}") - return " ".join(parts) - - def _now() -> str: return datetime.now(timezone.utc).isoformat() diff --git a/poller/pollers/meshcore.py b/poller/pollers/meshcore.py index 2e25fce..f546f24 100644 --- a/poller/pollers/meshcore.py +++ b/poller/pollers/meshcore.py @@ -25,7 +25,7 @@ from bus import get_bus, publish_entity, set_feed from config import settings -from normalizers.mesh_node import normalize_pymc_repeater_advert, snr_to_quality +from normalizers.mesh_node import normalize_pymc_repeater_advert from sanitize import sanitize_payload from .base import BasePoller From 21c8d42ead36791a9969d2b44a033cdb36cad368 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:13:38 +0000 Subject: [PATCH 07/11] Update TASK_LOG for perf audit fixes and dead code cleanup Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- TASK_LOG.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/TASK_LOG.md b/TASK_LOG.md index e30bfd6..a36a7c9 100644 --- a/TASK_LOG.md +++ b/TASK_LOG.md @@ -17,7 +17,18 @@ Format: `## YYYY-MM-DD — ` with bullet points for details. - Added a custom `merge` that deep-merges the persisted filter over defaults, so filter keys added in future versions default to visible instead of disappearing for users with older persisted state. - Side benefit: the WebSocket subscription is built from `entityFilter` on connect, so a persisted `mesh_node=false` now also suppresses mesh entity updates server-side. - **Tests**: New [test_meshcore_bbox.py](poller/tests/test_meshcore_bbox.py) — 6 tests covering in/out-of-bbox, pad behavior, unpositioned nodes, and the disabled-filter path. -- **Motivation**: User reported the entire Cascade mesh rendering (not just local nodes), render-pipeline jank severe enough to block the P25 audio stream from connecting, and mesh-node filter state not surviving PWA reloads. +- **Narrowed store subscriptions** ([store.ts](frontend/src/store.ts) + 26 components/hooks): + - Added `useCivicPick(...keys)` (shallow-equality picker) and converted all 29 bare `useCivicStore()` call sites — previously every WS entity update re-rendered the whole React tree from `Dashboard` down, which was the main-thread churn starving the P25 audio stream. +- **Batched WS entity updates** ([useWebSocket.ts](frontend/src/hooks/useWebSocket.ts), [store.ts](frontend/src/store.ts)): + - New `upsertEntities` action; `entity_update` messages buffer and flush in one store commit per 250ms (~4 notifications/sec instead of one per message). Cold-start REST seed now requests `limit=2000` (backend default of 200 gave a partial first paint). +- **Render-loop trims** ([MapOverlay.tsx](frontend/src/components/MapOverlay.tsx)): + - Layer-rebuild throttle 16ms→33ms (16 was a no-op at 60fps rAF); tooltip GPU picking throttled to 50ms; fixed tooltip-hide handler (`mouseout`, not the never-firing map-level `mouseleave`) and added its cleanup; removed write-only `layersRef`. +- **Bug fixes**: + - Mesh links now render: packet-derived links use `node_a="local"` which never resolved to an entity, so [MeshLinksLayer.tsx](frontend/src/components/layers/MeshLinksLayer.tsx) dropped every feature. `"local"` anchors at the region center; the layer also now subscribes to mesh nodes only instead of the whole entities map. + - Annotation draw preview no longer renders twice (removed the passive deck.gl copy in MapOverlay; the interactive MapLibre `AnnotationOverlay` owns it). + - Bounded the poller's `_entity_cache` in [bus.py](poller/bus.py) — entries carry a monotonic last-seen timestamp and stale ones (>10 min) are swept every 4096 publishes (was an unbounded slow leak). +- **Dead code removed**: `components/layers/MeshLayer.tsx`, `StreamGaugeLayer.tsx` (never imported), `ObservationRingLayer.tsx` (mounted no-op stub), `buildAnnotationDrawPreviewLayers`, unused `snr_to_quality` import, and uncalled `normalize_mesh_node`/`_bridge_status` in the poller. +- **Motivation**: User reported the entire Cascade mesh rendering (not just local nodes), render-pipeline jank severe enough to block the P25 audio stream from connecting, and mesh-node filter state not surviving PWA reloads; a follow-up audit request surfaced the re-render storm, batching gaps, bugs, and dead code fixed above. ## 2026-06-15 — Enhanced Mesh Companion Connectivity & Streamlined Tab Layout From ed6a09a82b67ce9394f3d2042b846c96f9ea618f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:49:43 +0000 Subject: [PATCH 08/11] Publish repeater self-node with GPS and fix mesh link quality metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repeater station itself now appears on the map and can be monitored remotely: - Best-effort GPS extraction from /api/stats (_extract_self_position checks top-level and common nested containers), with an explicit ?lat=&lon= pin on the source URL taking precedence for builds that don't report position. Documented in sources.example.yml. - When a position is known, the repeater publishes as a mesh_node entity (real pubkey from stats when available, else a stable mesh_node:repeater: id) with battery/noise-floor status, and packet-derived links anchor on that entity id instead of "local". The self entity bypasses the bbox gate — its position is explicit operator config, and monitoring an out-of-region repeater is a supported case. - mesh:status now carries lat/lon; the frontend anchors legacy "local" links there before falling back to the region center. Link metric fixes that surfaced while wiring this up: - link_quality was always written as NULL; now computed from SNR on the 0-100 scale the CommsPanel meters and line-width styling expect. - The WS mesh_links payload lacked last_seen, so live-pushed links always rendered at minimum opacity (Date.parse(undefined) -> NaN). Now included, and the frontend treats missing timestamps as fresh. - snrToColor used RSSI-scale thresholds (-70/-90 dB) so every MeshCore link read green; rescaled to LoRa SNR (green >= 5 dB, amber >= -10). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- config/sources.example.yml | 4 + .../src/components/layers/MeshLinksLayer.tsx | 30 ++-- poller/pollers/meshcore.py | 170 ++++++++++++++++-- poller/tests/test_meshcore_self_node.py | 95 ++++++++++ 4 files changed, 274 insertions(+), 25 deletions(-) create mode 100644 poller/tests/test_meshcore_self_node.py diff --git a/config/sources.example.yml b/config/sources.example.yml index f956c23..d3766d6 100644 --- a/config/sources.example.yml +++ b/config/sources.example.yml @@ -93,6 +93,10 @@ poller_sources: # http://MY_API_KEY@192.168.1.x:8000 # Add a companion parameter if you want to lock the connection to a specific companion identity: # http://MY_API_KEY@192.168.1.x:8000?companion=MyCompanionNode + # Pin the repeater's own position with lat/lon parameters — used when the + # repeater API doesn't report GPS. The station then appears on the map as + # a mesh_node entity and anchors its packet-derived link lines: + # http://192.168.1.x:8000?lat=45.3842&lon=-122.7635 enabled: true source: config diff --git a/frontend/src/components/layers/MeshLinksLayer.tsx b/frontend/src/components/layers/MeshLinksLayer.tsx index db93d10..ff26168 100644 --- a/frontend/src/components/layers/MeshLinksLayer.tsx +++ b/frontend/src/components/layers/MeshLinksLayer.tsx @@ -16,10 +16,12 @@ interface MeshLink { const SOURCE_ID = 'mesh-links' const LAYER_ID = 'mesh-links-line' +// MeshCore LoRa SNR spans roughly -20..+12 dB. (The old -70/-90 thresholds +// were RSSI-scale values, so every link always rendered green.) function snrToColor(snr: number | null): [number, number, number] { if (snr === null) return [150, 150, 150] - if (snr >= -70) return [68, 221, 136] // strong — green - if (snr >= -90) return [255, 184, 0] // medium — amber + if (snr >= 5) return [68, 221, 136] // strong — green + if (snr >= -10) return [255, 184, 0] // medium — amber return [255, 80, 80] // weak — red } @@ -37,6 +39,7 @@ export function MeshLinksLayer({ map }: Props) { // this effect rebuild its GeoJSON on every aircraft/vessel update. const meshNodes = useEntitiesByType('mesh_node') const meshLinks = useCivicStore((s) => s.meshLinks) + const meshStatus = useCivicStore((s) => s.meshStatus) // Initialize Layer and Source useEffect(() => { @@ -82,13 +85,17 @@ export function MeshLinksLayer({ map }: Props) { const byId: Record = {} for (const n of meshNodes) byId[n.entity_id] = n - // Packet-derived links are recorded with node_a = "local" (the repeater - // itself), which never exists as an entity — previously every such link - // was silently dropped and the layer drew nothing. The repeater's own - // position isn't in the entity stream, so anchor "local" at the - // configured region center. + // Packet-derived links may be recorded with node_a = "local" (the + // repeater itself). Anchor those at the repeater's reported position + // from mesh:status (GPS from /api/stats or the ?lat=&lon= source pin), + // falling back to the configured region center. const resolve = (id: string): [number, number] | null => { - if (id === 'local') return DEFAULT_CENTER + if (id === 'local') { + const lat = Number(meshStatus?.lat) + const lon = Number(meshStatus?.lon) + if (Number.isFinite(lat) && Number.isFinite(lon)) return [lon, lat] + return DEFAULT_CENTER + } const n = byId[id] return n && n.lat != null && n.lon != null ? [n.lon, n.lat] : null } @@ -98,8 +105,11 @@ export function MeshLinksLayer({ map }: Props) { const coordB = resolve(link.node_b) if (!coordA || !coordB) return [] + // Missing/unparsable last_seen (NaN) counts as fresh rather than + // dimming the link to minimum opacity. const ageMinutes = (now - new Date(link.last_seen).getTime()) / 60_000 - const opacity = ageMinutes < 5 ? 0.9 : ageMinutes < 15 ? 0.6 : ageMinutes < 30 ? 0.35 : 0.15 + const opacity = !Number.isFinite(ageMinutes) || ageMinutes < 5 ? 0.9 + : ageMinutes < 15 ? 0.6 : ageMinutes < 30 ? 0.35 : 0.15 const quality = link.link_quality ?? 0 const width = 1.5 + (Math.min(Math.max(quality, 0), 100) / 100) * 2.5 @@ -122,7 +132,7 @@ export function MeshLinksLayer({ map }: Props) { type: 'FeatureCollection', features }) - }, [map, meshNodes, meshLinks]) + }, [map, meshNodes, meshLinks, meshStatus]) return null } diff --git a/poller/pollers/meshcore.py b/poller/pollers/meshcore.py index f546f24..968a404 100644 --- a/poller/pollers/meshcore.py +++ b/poller/pollers/meshcore.py @@ -25,7 +25,7 @@ from bus import get_bus, publish_entity, set_feed from config import settings -from normalizers.mesh_node import normalize_pymc_repeater_advert +from normalizers.mesh_node import normalize_pymc_repeater_advert, snr_to_quality from sanitize import sanitize_payload from .base import BasePoller @@ -134,23 +134,26 @@ async def _poll_once(self, src: dict): except Exception as exc: logger.debug("[meshcore] advert fetch error: %s", exc) - # Recent packets → SNR / RSSI link metrics + # Recent packets → SNR / RSSI link metrics. Links anchor on the + # repeater's own entity once _publish_health has identified it. try: resp = await client.get( f"{base_url}/api/recent_packets", params={"limit": _PACKET_LIMIT} ) if resp.status_code == 200: - links = _extract_links_from_packets(resp.json(), base_url) + links = _extract_links_from_packets( + resp.json(), base_url, src.get("self_entity_id") or "local" + ) if links: await _upsert_mesh_links(links) except Exception as exc: logger.debug("[meshcore] packet fetch error: %s", exc) - # System health + # System health + the repeater's own entity try: resp = await client.get(f"{base_url}/api/stats") if resp.status_code == 200: - await _publish_health(resp.json(), base_url, src.get("companion")) + await _publish_health(resp.json(), src) except Exception as exc: logger.debug("[meshcore] stats fetch error: %s", exc) @@ -277,14 +280,27 @@ def _should_publish_node(entity: dict) -> bool: def _parse_source(url: str) -> dict: - """Extract API key and companion from URL; return clean base_url, api_key, and companion.""" + """Extract API key, companion, and optional self-position pin from the URL. + + Query parameters: + companion= lock the SSE connection to a specific companion identity + lat=&lon= pin the repeater's own position (used when the + repeater API does not report its GPS location) + """ parsed = urlparse(url) api_key = None companion = None + self_lat = self_lon = None if parsed.query: qs = parse_qs(parsed.query) if "companion" in qs: companion = qs["companion"][0] + if "lat" in qs and "lon" in qs: + try: + self_lat = float(qs["lat"][0]) + self_lon = float(qs["lon"][0]) + except (TypeError, ValueError): + self_lat = self_lon = None if parsed.username: api_key = parsed.username @@ -292,7 +308,56 @@ def _parse_source(url: str) -> dict: url = urlunparse(parsed._replace(netloc=netloc, query="")) else: url = urlunparse(parsed._replace(query="")) - return {"base_url": url.rstrip("/"), "api_key": api_key, "companion": companion} + return { + "base_url": url.rstrip("/"), + "api_key": api_key, + "companion": companion, + "self_lat": self_lat, + "self_lon": self_lon, + } + + +def _extract_self_position(stats: dict) -> tuple[float, float] | None: + """Best-effort extraction of the repeater's own GPS position from /api/stats. + + pyMC-Repeater builds vary in where (and whether) they report the station's + coordinates, so check the top level plus the common nested containers. + """ + if not isinstance(stats, dict): + return None + containers = [stats] + [ + stats.get(k) for k in + ("self", "node_info", "position", "gps", "location", "radio_device_info") + ] + for obj in containers: + if not isinstance(obj, dict): + continue + lat = obj.get("gps_lat") or obj.get("lat") or obj.get("latitude") + lon = obj.get("gps_lon") or obj.get("lon") or obj.get("longitude") + try: + lat_f, lon_f = float(lat), float(lon) + except (TypeError, ValueError): + continue + if lat_f == 0.0 and lon_f == 0.0: + continue + if -90.0 <= lat_f <= 90.0 and -180.0 <= lon_f <= 180.0: + return lat_f, lon_f + return None + + +def _extract_self_pubkey(stats: dict) -> str | None: + """Best-effort extraction of the repeater's own public key from /api/stats.""" + if not isinstance(stats, dict): + return None + containers = [stats] + [stats.get(k) for k in ("self", "node_info")] + for obj in containers: + if not isinstance(obj, dict): + continue + for key in ("public_key", "self_public_key", "node_pubkey", "pubkey"): + v = obj.get(key) + if isinstance(v, str) and len(v) >= 8 and v != "0" * len(v): + return v + return None def _api_headers(api_key: str | None) -> dict[str, str]: @@ -331,8 +396,13 @@ async def _discover_all_companions(base_url: str, headers: dict) -> list[str]: return [] -def _extract_links_from_packets(payload, source_url: str) -> list[dict]: - """Deduplicate per-sender SNR/RSSI from the recent-packet list.""" +def _extract_links_from_packets(payload, source_url: str, self_id: str = "local") -> list[dict]: + """Deduplicate per-sender SNR/RSSI from the recent-packet list. + + self_id is the local end of every link — the repeater's own entity id when + known, else the legacy "local" placeholder (which the frontend anchors at + the repeater position from mesh:status, falling back to the region center). + """ packets = _iter_items(payload) now = time.time() links: list[dict] = [] @@ -370,7 +440,7 @@ def _extract_links_from_packets(payload, source_url: str) -> list[dict]: age_secs = max(0.0, now - float(ts)) if isinstance(ts, (int, float)) else 0.0 links.append({ "source_url": source_url, - "node_a": "local", + "node_a": self_id, "node_b": node_b, "snr": float(snr), "rssi": float(rssi) if rssi is not None else None, @@ -383,13 +453,20 @@ def _extract_links_from_packets(payload, source_url: str) -> list[dict]: async def _upsert_mesh_links(links: list[dict]) -> None: from db import get_pool now_utc = datetime.datetime.now(datetime.timezone.utc) + + # Normalise SNR to the 0-100 link_quality scale the frontend meters and + # link-width styling expect. + for lnk in links: + quality = snr_to_quality(lnk.get("snr")) + lnk["link_quality"] = round(quality * 100) if quality is not None else None + rows = [ ( lnk["source_url"], lnk["node_a"], lnk["node_b"], lnk.get("snr"), - None, + lnk.get("link_quality"), now_utc - datetime.timedelta(seconds=int(lnk.get("secs_ago", 0))), ) for lnk in links @@ -414,18 +491,39 @@ async def _upsert_mesh_links(links: list[dict]) -> None: "node_a": lnk["node_a"], "node_b": lnk["node_b"], "snr": lnk.get("snr"), - "link_quality": None, + "link_quality": lnk.get("link_quality"), + # Frontend line opacity is age-based; without this the WS + # variant of the payload rendered every link at minimum + # opacity (Date.parse(undefined) → NaN). + "last_seen": ( + now_utc - datetime.timedelta(seconds=int(lnk.get("secs_ago", 0))) + ).isoformat(), } for lnk in links ], })) -async def _publish_health(stats: dict, base_url: str, companion: str | None = None) -> None: +async def _publish_health(stats: dict, src: dict) -> None: if not isinstance(stats, dict): return + base_url = src["base_url"] + companion = src.get("companion") connected = stats.get("radio_connected", stats.get("connected", True)) - uptime = stats.get("uptime_seconds") or stats.get("uptime_secs") + radio_stats = stats.get("radio_stats") if isinstance(stats.get("radio_stats"), dict) else {} + uptime = ( + stats.get("uptime_seconds") + or stats.get("uptime_secs") + or radio_stats.get("uptime_secs") + ) + + # The repeater's own position: an explicit ?lat=&lon= pin on the source URL + # wins; otherwise try to read GPS coordinates from the stats payload. + if src.get("self_lat") is not None and src.get("self_lon") is not None: + self_pos = (src["self_lat"], src["self_lon"]) + else: + self_pos = _extract_self_position(stats) + payload = { "connected": connected, "url": base_url, @@ -433,9 +531,51 @@ async def _publish_health(stats: dict, base_url: str, companion: str | None = No "version": stats.get("version"), "site_name": stats.get("site_name"), "companion": companion, + "lat": self_pos[0] if self_pos else None, + "lon": self_pos[1] if self_pos else None, } await set_feed("mesh:status", payload) - + + # Publish the repeater itself as a mesh_node entity so it renders on the + # map, records observations, and anchors packet-derived links. Bypasses + # the bbox gate: the station's position is explicit operator config, and + # remotely monitoring a repeater outside the region is a supported case. + if self_pos: + pubkey = _extract_self_pubkey(stats) + entity_id = ( + f"mesh_node:{pubkey}" + if pubkey + else f"mesh_node:repeater:{urlparse(base_url).hostname}" + ) + src["self_entity_id"] = entity_id + name = stats.get("site_name") or "pyMC Repeater" + status_parts = [] + battery_mv = radio_stats.get("battery_mv") + if isinstance(battery_mv, (int, float)): + status_parts.append(f"bat:{battery_mv / 1000:.2f}V") + noise_floor = radio_stats.get("noise_floor") + if isinstance(noise_floor, (int, float)): + status_parts.append(f"nf:{noise_floor:.0f}dBm") + await publish_entity({ + "entity_id": entity_id, + "entity_type": "mesh_node", + "source": "meshcore", + "display_name": name, + "identity": { + "node_id": (pubkey or entity_id)[:12], + "short_name": str(name)[:12], + "contact_type": "repeater", + "source_url": base_url, + "is_self": True, + }, + "lat": self_pos[0], + "lon": self_pos[1], + "altitude": None, + "status": " ".join(status_parts), + "last_seen": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "tags": ["mesh_node", "repeater", "self"], + }) + # Process neighbors from stats and publish as mesh_node entities neighbors = stats.get("neighbors", {}) if isinstance(neighbors, dict): diff --git a/poller/tests/test_meshcore_self_node.py b/poller/tests/test_meshcore_self_node.py new file mode 100644 index 0000000..52d049c --- /dev/null +++ b/poller/tests/test_meshcore_self_node.py @@ -0,0 +1,95 @@ +"""Tests for repeater self-node discovery and link normalization. + +Run from poller/: + pytest tests/test_meshcore_self_node.py +""" +from __future__ import annotations + +import os +import sys + +_POLLER_ROOT = os.path.join(os.path.dirname(__file__), "..") +if _POLLER_ROOT not in sys.path: + sys.path.insert(0, _POLLER_ROOT) + +from pollers.meshcore import ( + _extract_links_from_packets, + _extract_self_position, + _extract_self_pubkey, + _parse_source, +) + + +class TestParseSourceSelfPin: + def test_lat_lon_query_params(self): + src = _parse_source("http://KEY@192.168.1.10:8000?lat=45.38&lon=-122.76") + assert src["base_url"] == "http://192.168.1.10:8000" + assert src["api_key"] == "KEY" + assert src["self_lat"] == 45.38 + assert src["self_lon"] == -122.76 + + def test_no_pin_defaults_none(self): + src = _parse_source("http://192.168.1.10:8000") + assert src["self_lat"] is None + assert src["self_lon"] is None + + def test_invalid_pin_ignored(self): + src = _parse_source("http://192.168.1.10:8000?lat=abc&lon=-122.76") + assert src["self_lat"] is None + assert src["self_lon"] is None + + def test_companion_and_pin_together(self): + src = _parse_source("http://h:8000?companion=Base&lat=45.0&lon=-122.0") + assert src["companion"] == "Base" + assert src["self_lat"] == 45.0 + + +class TestExtractSelfPosition: + def test_top_level_gps_fields(self): + assert _extract_self_position({"gps_lat": 45.4, "gps_lon": -122.7}) == (45.4, -122.7) + + def test_nested_node_info(self): + stats = {"node_info": {"latitude": 45.4, "longitude": -122.7}} + assert _extract_self_position(stats) == (45.4, -122.7) + + def test_zero_zero_rejected(self): + assert _extract_self_position({"lat": 0.0, "lon": 0.0}) is None + + def test_out_of_range_rejected(self): + assert _extract_self_position({"lat": 91.0, "lon": -122.7}) is None + + def test_missing_position(self): + stats = {"radio_connected": True, "radio_stats": {"battery_mv": 3807}} + assert _extract_self_position(stats) is None + + def test_non_dict(self): + assert _extract_self_position(None) is None + + +class TestExtractSelfPubkey: + def test_top_level_public_key(self): + assert _extract_self_pubkey({"public_key": "abcdef123456"}) == "abcdef123456" + + def test_nested_self(self): + assert _extract_self_pubkey({"self": {"pubkey": "cafe" * 4}}) == "cafe" * 4 + + def test_all_zero_key_rejected(self): + assert _extract_self_pubkey({"public_key": "0" * 64}) is None + + def test_missing(self): + assert _extract_self_pubkey({"version": "1.15.0"}) is None + + +class TestExtractLinksSelfId: + PACKETS = [{"data": {"snr": 8.5, "rssi": -48, "sender_pubkey": "aa11"}}] + + def test_default_local(self): + links = _extract_links_from_packets(self.PACKETS, "http://h:8000") + assert links[0]["node_a"] == "local" + assert links[0]["node_b"] == "mesh_node:aa11" + + def test_self_entity_id(self): + links = _extract_links_from_packets( + self.PACKETS, "http://h:8000", "mesh_node:selfkey" + ) + assert links[0]["node_a"] == "mesh_node:selfkey" From 0a0436d9a512082c2ebc40d50c16100abbeb2244 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:50:09 +0000 Subject: [PATCH 09/11] Update TASK_LOG for repeater self-node and link metric fixes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- TASK_LOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TASK_LOG.md b/TASK_LOG.md index a36a7c9..399bf79 100644 --- a/TASK_LOG.md +++ b/TASK_LOG.md @@ -28,6 +28,11 @@ Format: `## YYYY-MM-DD — ` with bullet points for details. - Annotation draw preview no longer renders twice (removed the passive deck.gl copy in MapOverlay; the interactive MapLibre `AnnotationOverlay` owns it). - Bounded the poller's `_entity_cache` in [bus.py](poller/bus.py) — entries carry a monotonic last-seen timestamp and stale ones (>10 min) are swept every 4096 publishes (was an unbounded slow leak). - **Dead code removed**: `components/layers/MeshLayer.tsx`, `StreamGaugeLayer.tsx` (never imported), `ObservationRingLayer.tsx` (mounted no-op stub), `buildAnnotationDrawPreviewLayers`, unused `snr_to_quality` import, and uncalled `normalize_mesh_node`/`_bridge_status` in the poller. +- **Repeater self-node with GPS** ([meshcore.py](poller/pollers/meshcore.py), [MeshLinksLayer.tsx](frontend/src/components/layers/MeshLinksLayer.tsx), [sources.example.yml](config/sources.example.yml)): + - The repeater station now publishes itself as a `mesh_node` entity when its position is known: best-effort GPS extraction from `/api/stats` (top-level + nested containers), or an explicit `?lat=&lon=` pin on the source URL. Entity carries battery/noise-floor status for remote monitoring and bypasses the bbox gate (explicit operator config). + - Packet-derived links anchor on the repeater's entity id instead of the `"local"` placeholder; `mesh:status` carries `lat`/`lon` so the frontend anchors legacy `"local"` links at the actual station before falling back to the region center. + - Link metric fixes: `link_quality` now computed from SNR on the 0–100 scale the UI expects (was always NULL); WS `mesh_links` payload now includes `last_seen` (missing value rendered every live link at minimum opacity); `snrToColor` rescaled from RSSI-style −70/−90 thresholds (everything green) to LoRa SNR (green ≥ 5 dB, amber ≥ −10 dB). + - 16 new tests in [test_meshcore_self_node.py](poller/tests/test_meshcore_self_node.py). - **Motivation**: User reported the entire Cascade mesh rendering (not just local nodes), render-pipeline jank severe enough to block the P25 audio stream from connecting, and mesh-node filter state not surviving PWA reloads; a follow-up audit request surfaced the re-render storm, batching gaps, bugs, and dead code fixed above. ## 2026-06-15 — Enhanced Mesh Companion Connectivity & Streamlined Tab Layout From 6300bc38727f00d72682cbd0d049a7c38e0a1a42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 22:17:04 +0000 Subject: [PATCH 10/11] Overlay live meshLinks onto the EntityDetail neighbor list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Neighbors section fetched /mesh/links once per selection, so fresh WS-pushed SNR readings never appeared and the list stayed empty when the REST call failed (the exact failure mode from the 2026-05-10 mesh audit, where /api/neighbors 404s left the mesh_links table empty). The live meshLinks store array now overlays the REST window, with live readings winning per link pair. Also in this panel: - Neighbor rows show the peer's display name (raw id in the hover title) and click through to select known peers; "local" resolves to the repeater's site name from mesh:status. - Fixed the remaining RSSI-scale SNR thresholds (-70/-90 shown as "dBm") — Node SNR and neighbor links now color on the LoRa SNR scale (green >= 5 dB, amber >= -10 dB) and label dB. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- .../src/components/panels/EntityDetail.tsx | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/panels/EntityDetail.tsx b/frontend/src/components/panels/EntityDetail.tsx index 2c338e5..91a4abb 100644 --- a/frontend/src/components/panels/EntityDetail.tsx +++ b/frontend/src/components/panels/EntityDetail.tsx @@ -45,6 +45,10 @@ const TYPE_ICONS: Record = { const TAG_PRESETS = ['#FF4444', '#FF8800', '#FFB800', '#44DD88', '#00BBFF', '#AA44FF', '#FF44AA'] +// MeshCore LoRa SNR spans roughly -20..+12 dB. +const snrColor = (snr: number | null): string => + snr === null ? '#999' : snr >= 5 ? '#44dd88' : snr >= -10 ? '#ffb800' : '#ff5050' + export function EntityDetail() { @@ -58,6 +62,7 @@ export function EntityDetail() { addEntityMissionTag, removeEntityMissionTag, } = useCivicPick('entities', 'airports', 'selectedEntityId', 'selectEntity', 'entityMissionTags', 'setEntityMissionTags', 'addEntityMissionTag', 'removeEntityMissionTag') + const { meshLinks, meshStatus } = useCivicPick('meshLinks', 'meshStatus') const entity = selectedEntityId ? entities[selectedEntityId] : null // Fetch trail for sparklines (aircraft only) @@ -77,11 +82,14 @@ export function EntityDetail() { return () => { cancelled = true } }, [selectedEntityId, entity?.entity_type]) - // Fetch mesh neighbors for mesh_node entities - const [meshNeighbors, setMeshNeighbors] = useState([]) + // Mesh neighbors for mesh_node entities: REST gives the persisted 60-min + // window; the live meshLinks store array (fed by WS mesh_links events) + // overlays it so fresh SNR readings appear without waiting on a refetch — + // and the list still populates when the REST call fails entirely. + const [restNeighbors, setRestNeighbors] = useState([]) useEffect(() => { if (!selectedEntityId || !entity || entity.entity_type !== 'mesh_node') { - setMeshNeighbors([]) + setRestNeighbors([]) return } let cancelled = false @@ -89,7 +97,7 @@ export function EntityDetail() { .then((r) => r.ok ? r.json() : []) .then((links: MeshNeighbor[]) => { if (!cancelled) { - setMeshNeighbors( + setRestNeighbors( links.filter( (l) => l.node_a === selectedEntityId || l.node_b === selectedEntityId ) @@ -100,6 +108,18 @@ export function EntityDetail() { return () => { cancelled = true } }, [selectedEntityId, entity?.entity_type]) + const meshNeighbors: MeshNeighbor[] = (() => { + if (!selectedEntityId || entity?.entity_type !== 'mesh_node') return [] + const byPair = new Map() + for (const l of restNeighbors) byPair.set(`${l.node_a}|${l.node_b}`, l) + for (const l of meshLinks) { + if (l.node_a === selectedEntityId || l.node_b === selectedEntityId) { + byPair.set(`${l.node_a}|${l.node_b}`, l) // live reading wins + } + } + return Array.from(byPair.values()) + })() + // Load mission tags when entity changes const loadTags = useCallback(async (entityId: string) => { try { @@ -291,10 +311,8 @@ export function EntityDetail() { {nodeSnr != null && (
Node SNR - = -70 ? '#44dd88' : nodeSnr >= -90 ? '#ffb800' : '#ff5050', - }}> - {nodeSnr} dBm + + {nodeSnr} dB
)} @@ -309,19 +327,31 @@ export function EntityDetail() {
    {meshNeighbors.map((lnk, i) => { const peerId = lnk.node_a === selectedEntityId ? lnk.node_b : lnk.node_a + const peer = entities[peerId] + const peerName = peerId === 'local' + ? (meshStatus?.site_name ?? 'Repeater (local)') + : (peer?.display_name ?? peerId) return (
  • - {peerId} + {peer ? ( + + ) : ( + + {peerName} + + )} = -70 ? '#44dd88' - : lnk.snr >= -90 ? '#ffb800' - : '#ff5050', - }} + style={{ color: snrColor(lnk.snr) }} > - {lnk.snr !== null ? `${lnk.snr} dBm` : '—'} + {lnk.snr !== null ? `${lnk.snr} dB` : '—'}
  • ) From f830747333849be632f81ae43b361ec7bee4c445 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 22:17:22 +0000 Subject: [PATCH 11/11] Update TASK_LOG for live neighbor list Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28 --- TASK_LOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TASK_LOG.md b/TASK_LOG.md index 399bf79..5e95a65 100644 --- a/TASK_LOG.md +++ b/TASK_LOG.md @@ -33,6 +33,10 @@ Format: `## YYYY-MM-DD — ` with bullet points for details. - Packet-derived links anchor on the repeater's entity id instead of the `"local"` placeholder; `mesh:status` carries `lat`/`lon` so the frontend anchors legacy `"local"` links at the actual station before falling back to the region center. - Link metric fixes: `link_quality` now computed from SNR on the 0–100 scale the UI expects (was always NULL); WS `mesh_links` payload now includes `last_seen` (missing value rendered every live link at minimum opacity); `snrToColor` rescaled from RSSI-style −70/−90 thresholds (everything green) to LoRa SNR (green ≥ 5 dB, amber ≥ −10 dB). - 16 new tests in [test_meshcore_self_node.py](poller/tests/test_meshcore_self_node.py). +- **Live neighbor list in EntityDetail** ([EntityDetail.tsx](frontend/src/components/panels/EntityDetail.tsx)): + - The Neighbors section now overlays the live `meshLinks` store array (WS-fed) on top of the one-shot REST fetch, per the 2026-05-10 mesh audit recommendation — fresh SNR readings appear immediately and the list survives REST failures. + - Neighbor rows show peer display names (raw id on hover) and click through to select known peers; `"local"` resolves to the repeater's site name from `mesh:status`. + - Fixed the panel's remaining RSSI-scale SNR thresholds (−70/−90 labeled "dBm") to the LoRa SNR scale with dB units. - **Motivation**: User reported the entire Cascade mesh rendering (not just local nodes), render-pipeline jank severe enough to block the P25 audio stream from connecting, and mesh-node filter state not surviving PWA reloads; a follow-up audit request surfaced the re-render storm, batching gaps, bugs, and dead code fixed above. ## 2026-06-15 — Enhanced Mesh Companion Connectivity & Streamlined Tab Layout