Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions TASK_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ Format: `## YYYY-MM-DD — <summary>` 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.
- **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.
- **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).
- **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

- **Mesh Companion Status Sync**:
Expand Down
4 changes: 4 additions & 0 deletions config/sources.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ poller_sources:
# http://[email protected]:8000
# Add a companion parameter if you want to lock the connection to a specific companion identity:
# http://[email protected]: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

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -56,7 +56,7 @@ function Dashboard() {
})
}, [news, appendSystemEvent])

const { activeTab, mode } = useCivicStore()
const { activeTab, mode } = useCivicPick('activeTab', 'mode')
const isCritical = mode === 'critical'

return (
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/DevInsetInspector.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Metrics>(() => readMetrics())
const [corner, setCorner] = useState<Corner>('top-right')
const [copied, setCopied] = useState(false)
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/components/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -208,7 +207,6 @@ export function Map() {
<FirePerimeterLayer map={map} />
<GeofenceLayer map={map} />
<CustomLayersLayer map={map} />
<ObservationRingLayer map={map} />
<AnnotationOverlay map={map} />
<RegionLayer map={map} regions={regions} />
<MeshLinksLayer map={map} />
Expand Down
51 changes: 33 additions & 18 deletions frontend/src/components/MapOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -88,7 +88,6 @@ function buildReplayTracks(data: ReplayData, atMs: number): Record<string, Track

export function MapOverlay({ map }: Props) {
const deckRef = useRef<MapboxOverlay | null>(null)
const layersRef = useRef<any[]>([])
// 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.
Expand Down Expand Up @@ -139,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)
Expand Down Expand Up @@ -295,6 +288,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) {
Expand All @@ -303,6 +302,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 })

Expand Down Expand Up @@ -473,8 +482,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) => {
Expand All @@ -499,7 +514,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
Expand Down Expand Up @@ -698,18 +717,13 @@ 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,
})),
]

layersRef.current = layers
overlay.setProps({ layers })

rafRef.current = requestAnimationFrame(tick)
Expand All @@ -721,6 +735,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 = {}
Expand Down
84 changes: 0 additions & 84 deletions frontend/src/components/layers/MeshLayer.tsx

This file was deleted.

Loading
Loading