Skip to content

Mesh node bbox gating, stateful filters, render-pipeline perf, and bug fixes#116

Merged
d3mocide merged 11 commits into
mainfrom
claude/mesh-node-filtering-state-deztih
Jul 5, 2026
Merged

Mesh node bbox gating, stateful filters, render-pipeline perf, and bug fixes#116
d3mocide merged 11 commits into
mainfrom
claude/mesh-node-filtering-state-deztih

Conversation

@d3mocide

@d3mocide d3mocide commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Problem

Three user-reported issues, plus findings from a follow-up performance/bug audit:

  1. A pyMC-Repeater's advert table covers every node it has ever heard — the entire Cascade regional mesh — and the meshcore poller re-published all of them every 60s.
  2. The render pipeline degraded badly enough with mesh nodes visible that the P25 audio stream couldn't connect.
  3. Layer filters (e.g. hiding mesh nodes) didn't survive a PWA close/reopen.

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)
  • New settings MESH_BBOX_FILTER (default true) and MESH_BBOX_PAD_DEG (default 0.25°); unpositioned nodes always pass

Repeater self-node with GPS (remote monitoring)

  • The repeater station publishes itself as a mesh_node entity when its position is known: best-effort GPS extraction from /api/stats, or an explicit ?lat=&lon= pin on the source URL (documented in sources.example.yml). Carries battery/noise-floor status; bypasses the bbox gate since its position is 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

Live neighbor list (EntityDetail)

  • The Neighbors section overlays the live meshLinks store 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)
  • Neighbor rows show peer display names, click through to select known peers, and "local" resolves to the repeater's site name

Stateful filters (frontend)

  • entityFilter added to the Zustand persist slice, with a deep-merge so filter keys added in future versions default to visible
  • A persisted mesh_node=false now also suppresses mesh updates server-side via the WS subscription built on connect

Performance

  • Narrowed store subscriptions — all 29 bare useCivicStore() call sites converted to a new useCivicPick(...keys) shallow-equality helper. Previously every WS entity update re-rendered the entire React tree from Dashboard down; this was the main-thread churn behind the P25 symptom.
  • Batched WS entity updatesentity_update messages buffer and apply in one store commit per 250ms via a new upsertEntities action (~4 store notifications/sec instead of one per message; PVB interpolates motion between commits)
  • Render-loop trims — deck.gl layer-rebuild throttle raised 16ms→33ms (16 was a no-op at 60fps rAF); tooltip GPU picking throttled to 50ms; cold-start REST seed uncapped (backend default limit=200 gave a partial first paint)

Bug fixes

  • Mesh links never rendered: packet-derived links store node_a="local", which never exists as an entity, so MeshLinksLayer dropped every feature. Now resolved via the repeater self-node / mesh:status position; the layer also subscribes to mesh nodes only instead of the whole entities map
  • Link metrics: link_quality was always written NULL — now computed from SNR on the 0–100 scale the CommsPanel meters expect; the WS mesh_links payload lacked last_seen, so live-pushed links always drew at minimum opacity; SNR color thresholds used RSSI-scale values (−70/−90 dB, everything green) in MeshLinksLayer and EntityDetail — rescaled to LoRa SNR (green ≥ 5 dB, amber ≥ −10 dB) with correct dB units
  • Annotation draw preview rendered twice (MapLibre AnnotationOverlay + a passive deck.gl copy in MapOverlay) — deck copy removed
  • Tooltip-hide handler never fired — MapLibre's canvas-level event is mouseout, not mouseleave; also now removed on cleanup
  • Poller _entity_cache leak — 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 lifetime

Dead code removed

  • components/layers/MeshLayer.tsx, StreamGaugeLayer.tsx (never imported), ObservationRingLayer.tsx (mounted no-op stub), buildAnnotationDrawPreviewLayers, write-only layersRef, uncalled normalize_mesh_node/_bridge_status

Testing

  • poller/: 134 tests pass, including 6 new bbox-gating tests and 16 new self-node/link tests
  • npx tsc --noEmit clean; full npm run build (tsc + vite + PWA) succeeds
  • py_compile clean on all modified Python files

🤖 Generated with Claude Code

https://claude.ai/code/session_0134TiT5uCSFJYDzMPs87u28

claude added 7 commits July 5, 2026 16:37
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
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
@d3mocide d3mocide changed the title Gate mesh nodes to region bbox and persist entity filters Mesh node bbox gating, stateful filters, render-pipeline perf, and bug fixes Jul 5, 2026
claude added 4 commits July 5, 2026 17:49
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
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
@d3mocide
d3mocide marked this pull request as ready for review July 5, 2026 23:48
@d3mocide
d3mocide merged commit a717931 into main Jul 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants