Mesh node bbox gating, stateful filters, render-pipeline perf, and bug fixes#116
Merged
Conversation
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 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
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 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
- 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 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
- 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 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
- 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 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
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:<host> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
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 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28
d3mocide
marked this pull request as ready for review
July 5, 2026 23:48
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Three user-reported issues, plus findings from a follow-up performance/bug audit:
Changes
Mesh node gating (poller)
_should_publish_node()drops mesh nodes advertising positions outside the configured region bbox(es), like the ADS-B/AIS/Amtrak pollers; applied to all three publish paths (REST advert sync, SSE events, stats neighbors)MESH_BBOX_FILTER(defaulttrue) andMESH_BBOX_PAD_DEG(default0.25°); unpositioned nodes always passRepeater self-node with GPS (remote monitoring)
mesh_nodeentity when its position is known: best-effort GPS extraction from/api/stats, or an explicit?lat=&lon=pin on the source URL (documented insources.example.yml). Carries battery/noise-floor status; bypasses the bbox gate since its position is explicit operator config"local"placeholder;mesh:statuscarrieslat/lonso the frontend anchors legacy"local"links at the actual station before falling back to the region centerLive neighbor list (EntityDetail)
meshLinksstore array (WS-fed) on the one-shot REST fetch — fresh SNR readings appear immediately, and the list survives REST failures (the failure mode from the 2026-05-10 mesh audit)"local"resolves to the repeater's site nameStateful filters (frontend)
entityFilteradded to the Zustand persist slice, with a deep-merge so filter keys added in future versions default to visiblemesh_node=falsenow also suppresses mesh updates server-side via the WS subscription built on connectPerformance
useCivicStore()call sites converted to a newuseCivicPick(...keys)shallow-equality helper. Previously every WS entity update re-rendered the entire React tree fromDashboarddown; this was the main-thread churn behind the P25 symptom.entity_updatemessages buffer and apply in one store commit per 250ms via a newupsertEntitiesaction (~4 store notifications/sec instead of one per message; PVB interpolates motion between commits)limit=200gave a partial first paint)Bug fixes
node_a="local", which never exists as an entity, soMeshLinksLayerdropped every feature. Now resolved via the repeater self-node /mesh:statusposition; the layer also subscribes to mesh nodes only instead of the whole entities maplink_qualitywas always written NULL — now computed from SNR on the 0–100 scale the CommsPanel meters expect; the WSmesh_linkspayload lackedlast_seen, so live-pushed links always drew at minimum opacity; SNR color thresholds used RSSI-scale values (−70/−90 dB, everything green) inMeshLinksLayerand EntityDetail — rescaled to LoRa SNR (green ≥ 5 dB, amber ≥ −10 dB) with correct dB unitsAnnotationOverlay+ a passive deck.gl copy inMapOverlay) — deck copy removedmouseout, notmouseleave; also now removed on cleanup_entity_cacheleak — entries now carry a last-seen timestamp and stale ones (>10 min) are swept every 4096 publishes; previously every unique aircraft ever seen stayed in memory for the process lifetimeDead code removed
components/layers/MeshLayer.tsx,StreamGaugeLayer.tsx(never imported),ObservationRingLayer.tsx(mounted no-op stub),buildAnnotationDrawPreviewLayers, write-onlylayersRef, uncallednormalize_mesh_node/_bridge_statusTesting
poller/: 134 tests pass, including 6 new bbox-gating tests and 16 new self-node/link testsnpx tsc --noEmitclean; fullnpm run build(tsc + vite + PWA) succeedspy_compileclean on all modified Python files🤖 Generated with Claude Code
https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28