Split the single region_routes.enabled master switch into per-transport
mt_enabled (Meshtastic) and mc_enabled (MeshCore) flags so the two
transports can be region-routed independently. Previously the shared
switch forced MeshCore into the region matrix; with all mc cells null it
routed MeshCore nowhere instead of falling through to the toggle-level
meshcore_channel. The dispatcher is now authoritative per-transport: a
disabled transport falls through to its toggle path, and matched-but-
inactive cells still suppress the toggle for enabled transports. The
destinations delivery branch also honors matrix-handled suppression to
prevent double-broadcast. Loader maps legacy enabled:true to
mt_enabled:true, mc_enabled:false. Each GUI routing page gains its own
master enable toggle.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends TEXT_HINTS with three missing legacy prefixes so NULL-source audit rows
render the correct family label in the Activity Log UI:
- ⏳ (U+23F3) → Weather (legacy composer weather-watch: "⏳ WX: …")
- 🌡️ → Weather (NWS heat-alert format added in PR #96)
- ⚠️ Road Incident (U+26A0+FE0F) → Traffic (legacy incident_handler rows)
Placed more-specific prefix before shorter variants to avoid false matches.
DB backfill: 0 residual NULL rows found (all rows already tagged after PR #99);
no audit-table writes performed. No mesh transmission.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend: add _SOURCE_TO_TABLE class constant in Dispatcher mapping event.source
("nws", "nifc", "wzdx", "traffic", "511") to canonical audit table names.
_post_broadcast_commit now falls back to this map when _broadcast_audit is
absent/None, so native env adapter sends (nws.py, fires.py, wzdx.py,
roads511.py, traffic.py) write a non-NULL source_event_table instead of NULL.
Existing _broadcast_audit paths (Central handlers, scheduled broadcasts) are
unchanged.
Frontend: replace naive familyLabel() string transform with explicit
TABLE_LABELS lookup (10 known tables → friendly names) plus a TEXT_HINTS
emoji-prefix heuristic for legacy NULL-source rows, so historical orphan rows
still display a meaningful label before/after backfill.
Tests: three new unit tests in test_dispatcher_persistence.py covering the
fallback path (nws→nws_alerts), the full _SOURCE_TO_TABLE map, and that
explicit _broadcast_audit is never overridden by the fallback.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Add WFIGS_Incident_Locations_Current point layer (IRWIN superset, ~6 ID fires)
alongside the existing perimeter layer (~2 ID fires). Fires are merged by IrwinID:
point layer is the authoritative superset, perimeter layer supplies polygon
geometry and validated acreage when available. Point-only fires surface with
lat/lon from the point geometry and no polygon. cold-start silent-seed path is
unchanged (first-poll batch is always silent regardless of source). Perimeter
fetch failure falls back to perimeter-only stubs; point fetch failure falls back
to perimeter-only; both failing bumps the consecutive error counter. county is
now populated from POOCounty on the point layer. Five off-air unit tests cover:
T1 merge-dedup, T2 point-only-new, T3 perimeter-geom-preferred, T4 cold-start
silent-seed 6 fires, T5 FIRMS _get_known_fires attribution.
No changes to store.py, gating/fire.py, schema, or coverage.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
CHANGE 1 (store.py): after applying gate.data_patch into event.data, promote
_severity_override and category keys onto event.severity / event.category.
Previously, decider overrides (e.g. fire: "priority" on every New/Update)
landed only in event.data, leaving event.severity at the adapter's raw value
("routine" for fires >=25 km from an anchor). This silently failed the
toggle/matrix min_severity floor. Native and Central now share identical
broadcast decisions at the shared choke point.
CHANGE 2 (dispatcher.py + v27 migration): both the toggle-path and
matrix per-cell severity-floor drop paths now emit a WARN log and
increment a new persisted counter (severity_floor_dropped) in
dispatcher_state, following the existing drop-counter pattern exactly.
v27.sql adds the column; the counter restores on restart and appears
in dispatch_stats().
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
weather_watch and weather_advisory were falling through to the legacy
Mode-B renderer (composer.py), which appended the raw expiry epoch
(`exp 1783615500.0` from _context_segment) and overflowed 140 chars,
splitting into two packets with the epoch stranded in packet 2.
Fix: register both categories with the NWS formatter and gating decider
(same path as weather_warning/weather_statement), and add them to
MESHAI_CUTOVER_CATEGORIES in docker-compose.yml so the pipeline routes
them to the new formatter on deploy.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Defect A (audit gap): _post_broadcast_commit only wrote a mesh_broadcasts_out row
when event.data["_broadcast_audit"] was a dict. Native traffic/weather/roads events
from native adapters never set that key, so every matrix-dispatched send was
invisible in the audit table even when the dispatcher logged success. Fix: write
the audit row for every mesh delivery attempt (ch_type in _MESH_CH_TYPES),
unconditionally. source_event_table/source_event_pk come from _broadcast_audit when
present, else NULL (best-effort). The early-return on empty data is preserved only
for the _on_broadcast_committed callback, not for the audit write.
Defect B (channel routing): full trace of the send path confirms the channel IS
correctly threaded from the matrix cell through _toggle_to_rule (mt_override) →
create_channel(channel_index=rule.broadcast_channel) → MeshBroadcastChannel
(self._channel) → send_message_async(channel=self._channel) → CompositeTransport
→ MeshtasticTransport send_queue job closure → _blocking_mt_send(channel) →
sendText(channelIndex=channel). No code bug: the correct channel index reaches the
radio. The missing audit rows (Defect A) prevented confirming this from the DB.
/api/channels fix: the endpoint read connector._interface which does not exist on
CompositeTransport (only on bare MeshtasticTransport), so it always returned []
when MeshCore was also configured. Fix: detect CompositeTransport and route to
meshtastic_child()._interface instead.
Tests added: matrix send without _broadcast_audit writes audit row with correct
channel+transport+success; failed delivery writes success=0 row; matrix cell
channel index reaches _blocking_mt_send end-to-end (queue path exercised);
/api/channels returns real channel list via CompositeTransport.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(transport): per-radio serialized+paced outbound send queue
Prevents simultaneous LoRa transmissions when N events arrive at once.
## Mechanism
Two `RadioSendQueue` instances (one MT, one MC), each a FIFO asyncio.Queue
with a long-running drain task. The MT queue drains on the main asyncio
loop; the MC queue drains on MeshCore's dedicated event-loop thread.
- MT sends: `run_in_executor` offloads the blocking `sendText` call;
queue started in `set_message_callback`, cancelled in `disconnect`.
- MC sends: drain loop runs pure-async MC lib coroutines directly on the
MC loop (no `_run_coro` deadlock); cross-loop callers bridge via
`concurrent.futures.Future` + `asyncio.wrap_future`.
- Pacing: `await asyncio.sleep(pacing_seconds)` between items; read live
from config per iteration; floor clamped to 0.25 s.
- Config knobs: `meshtastic_send_pacing_seconds` (default 2.0) and
`meshcore_send_pacing_seconds` (default 2.0) on `ConnectionConfig`.
## Send sites rerouted
All callers now `await connector.send_message_async(...)`:
- `notifications/channels.py` — MeshBroadcast/MeshCoreBroadcast/MeshDM/
MeshCoreDM deliver(), test_connection(), deliver_test()
- `responder.py` — DM replies in send_response()
- `transport/meshcore_transport.py` — periodic_advert_loop, telemetry
poll loop, send_advert() → send_advert_async(), req_telemetry()
→ req_telemetry_async() (all queue-routed from main loop)
- `dashboard/api/mesh_send_routes.py` — test-send, advert, telemetry poll
## Audit accuracy
`deliver()` now returns the actual bool from the radio send (not
optimistic True), so `mesh_broadcasts_out` reflects the real result.
## Tests
17 new tests in tests/test_send_queue.py covering FIFO ordering, no drops,
pacing gap, pacing floor enforcement, event-loop non-blocking, serialization,
lifecycle, MT fallback, config round-trip. Existing test stubs updated to
wire `send_message_async = AsyncMock(side_effect=send_message)` so prior
call_count / call_args assertions remain valid without changes.
Full suite: 2135 passed, 17 pre-existing failures (unchanged), 0 new regressions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(send-queue): resolve MC telemetry self-deadlock + resolve pending futures on teardown/reconnect; composite MC-channel kwarg; audit no-op false
BLOCKER 1 — req_telemetry_async self-deadlock (meshcore_transport.py):
_req_telemetry_async was calling _enqueue_mc_loop_send inside itself;
when _telem_job_outer ran inside the drain it nested another enqueue+await
on the same single-threaded drain — permanent deadlock on first telemetry poll.
Fix: _req_telemetry_async is now fully inline (no _enqueue_mc_loop_send).
_telemetry_poll_loop wraps its call in _enqueue_mc_loop_send for serialization.
req_telemetry_async's outer job calls _req_telemetry_async inline (safe).
BLOCKER 2 — pending futures abandoned on teardown/reconnect:
RadioSendQueue.stop() only cancelled the drain task; queue-sitting items had
their concurrent.futures.Futures left unresolved, causing wrap_future() callers
to hang indefinitely. Fix: stop() drains the remaining queue with get_nowait()
and cancels every pending cfut. _cancel_mc_queue() schedules the same drain-
and-cancel via call_soon_threadsafe. _start_mc_queue() cancels old drain task
and drains old queue cfuts before arming the new queue (reconnect path).
connector.disconnect() now .result(timeout=5) on stop() instead of fire-and-forget.
SHOULD-FIX 3 — composite passes MC channel as wrong kwarg (composite_transport.py):
_broadcast_async no-hint loop was calling send_message_async(channel=child_channel)
for the meshcore child; should be meshcore_channel=child_channel. Silent drop fixed.
NIT 5 — false success on zero-channel MC send (meshcore_transport.py):
send_message_async returned True when meshcore_channel is None (nothing sent).
Now returns False so audit does not record a success for a no-op.
NIT 7 — config comment contradiction (config.py):
meshtastic_send_pacing_seconds comment said "0 disables the floor" while
simultaneously stating "still floored at 0.25". Removed the contradiction.
Regression tests (tests/test_send_queue.py — 3 new, all in TestDeadlockRegression):
- test_telemetry_queue_no_deadlock: drives req_telemetry_async through a real
_mc_send_queue with fake MC commands; times out on pre-fix code (deadlock).
- test_teardown_resolves_pending_futures: enqueues slow+fast jobs, stops mid-drain,
asserts every task resolves promptly; hangs on pre-fix code.
- test_reconnect_resolves_old_futures: calls _start_mc_queue twice, asserts old
cfuts are cancelled; pre-fix leaves them unresolved.
All 17 pre-existing send-queue tests still pass (20 total now).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(meshcore): opt-in telemetry auto-poll on selected contacts
req_telemetry + a poller for selected contacts (meshcore_telemetry_contacts,
interval with a min floor, availability detection). Contacts page gains
per-node auto-poll toggles + battery/sensor readouts + Poll-now, and maps
numeric contact type codes to Chat/Repeater/Room/Sensor badges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(meshcore-telemetry): reconcile with current transport/meshcore-lib API
- Add EventType.ACK + NEW_CONTACT to the telemetry test's fake module;
_setup_subscriptions() subscribes to both (added in main before rebase)
and the stale stub caused all three TestPollerScheduler tests to abort
with AttributeError on connect().
- Same NEW_CONTACT gap fixed in test_meshcore_conn_type.py and
test_meshcore_dm_delivery.py — these ran first (alphabetically) via
setdefault, contaminating the shared sys.modules["meshcore"] stub for
all downstream test files and causing 9 extra connect()-path failures
suite-wide (TestPeriodicAdvertScheduler, TestAdvertOnConnect, etc.).
- req_telemetry_sync(contact, min_timeout=5) matches the installed lib
(meshcore-2.3.7 binary.py) exactly — no production-code change needed.
- All 30 test_meshcore_telemetry tests pass; full-suite failures drop
16 → 7 (remaining 7 are pre-existing, unrelated to telemetry).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(region-routing): P1 tagging + region_routes primitive + read/write API + preview launcher
- config.py: add Coverage.region_tagging (bool=False); add RegionRouteMatrix
dataclass (enabled, cells) above NotificationsConfig; add region_routes field
to NotificationsConfig; add explicit hydration branch for region_routes in
_dict_to_dataclass mirroring destinations pattern.
- coverage_area.py: add MonitoringArea.name (str|None=None, frozen); update
areas_from_config to preserve name; refactor inline geom extraction from
classify_event_areas into shared _event_geom_json helper; add
matching_area_names(geom_json, areas)->list[str] (additive, all named
matches, config-order, deduped; gate unchanged); add event_region_names
convenience wrapper.
- coverage_filter.py: add region_tagging ctor kwarg; stamp event.region/
regions before the gate when region_tagging=True and areas non-empty and
not event.regions (never clobbers satpass preset).
- pipeline/__init__.py: wire region_tagging into CoverageFilter construction.
- notification_routes.py: add GET /notifications/regions (named coverage area
names, config-order, deduped); GET /notifications/region-routing (matrix as
JSON); POST /notifications/region-routing (explicit RMW — only region_routes
changes, toggles/rules/destinations survive).
- scripts/preview_dashboard.py: mesh-free launcher — dashboard API only, no
mesh connector, no broadcast loop; vite runs separately.
All 87 coverage tests pass; 300 total pass; 6 pre-existing failures unchanged
(adapter config count mismatch + MeshCore EventType.NEW_CONTACT).
* feat(region-routing): manual region x family matrix editor page
Adds RegionRoutingMatrix.tsx — a plain editor over the region_routes
config primitive. Rows = families (via useFamilies()), cols = regions
(from GET /api/notifications/regions). Each cell exposes MT channel
(ChannelPicker single + includeDisabled), MC channel name (text input),
min_severity select (routine/priority/critical/immediate), and an enabled
checkbox. Only cells where MT or MC is set are included in the sparse
POST payload. Master enable toggle maps to top-level enabled. MT budget
guard warns when more than 7 distinct MT indices are in use. Sticky
family column; horizontal scroll for wide region sets.
Registers route /region-routing in App.tsx and adds "Region Routing"
nav entry (Map icon) under the Meshtastic section in Layout.tsx,
immediately after Routing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(region-routing): regions endpoint reads saved (disk) coverage so routing columns are dynamic without a bot restart; preview reloads config after writes
* feat(routing): unify MT/MC routing into per-family cards; region routing as an in-card expand; remove rules/destinations UI + standalone page
* refactor(routing): move Meshtastic Routing from /notifications to /meshtastic/routing (mirror /meshcore/routing); redirect legacy path
* feat(region-routing): dispatcher honors region_routes matrix (authoritative-on-match, per-region cooldown, per-channel dedup); non-matrix path unchanged
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(region-routing): matrix dedup key must match boot-restore 2-tuple form (prevents restart re-broadcast flood); regression test
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The WFIGS adapter computed changed = (old event_id set != new event_id set),
so growth of an already-known fire produced changed=False. The store only runs
_ingest_fires (and the Phase-3 fire decider) when tick() reports a change, so
growth/update broadcasts for stable fires never fired — only brand-new or
dropped fire NAMES woke the path. Include acres + containment in the change
signature. The decider stays the broadcast gate (forward-only + cooldown), so
no backlog is dumped.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MeshAI/1.0 UA intermittently trips WAFs (Idaho Power's Azure Front Door
403s it ~2/30; a browser UA gets 200 every time). Default the adapter +
preview to a browser User-Agent, retry once on 403/429, and add optional
per-source custom headers (UA/auth) editable in the GUI. Makes WAF'd and
keyed feeds pollable.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Destinations manager to the Routing page — define each delivery target
(mesh channel / email / webhook / digest) once — and a destination picker on
each family and rule. The duplicated per-family inline email/webhook editors
move under an Advanced/legacy disclosure. De-fragments delivery config: no
more configuring the same email in two places.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add NotificationDestination + config.notifications.destinations and a
`destinations` reference list on toggles/rules. When a toggle/rule references
destinations, delivery resolves from the shared destination; when empty, the
existing inline-field delivery path runs UNCHANGED (zero regression). Lets
email/webhook/mesh-channel be defined once and reused, de-duplicating the
delivery config. UI to follow (C2).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the generic-source editor into a reusable GenericSourcesEditor and render
it as a "Custom Sources" section on the Data Feeds (Environment) page, so a
custom source sits with the built-in feeds instead of an orphan page. Remove
the standalone /data-sources route + nav. Custom sources are now managed as
first-class data feeds; enable their family in Notifications to route them.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add GET /api/notifications/families and make the Routing UI merge the static
built-in families with registered dynamic families, so a generic source's
family appears as a toggle the operator can enable and assign a delivery
destination (mesh/meshcore/email/webhook). Closes "there's nowhere to
broadcast it" — a custom source is now routable from the GUI. Data Feeds fold
+ orphan-page retirement follow in B2.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Categories/families can now be registered at runtime, not just the hardcoded
ALERT_CATEGORIES/VALID_TOGGLES. A generic data source registers its category
as a first-class family with its own (default-disabled) toggle, so its events
resolve to that family instead of being dropped as "other" or buried in
mesh_health — it becomes routable. Existing families/categories unchanged.
Phase A of making custom sources first-class feeds.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Whitelist generic_sources as a config section and add a Data Sources page:
add/edit/delete sources with the full field-mapping UI (items/id/lat/lon/
geometry/title paths + field_mappings list + summary template) and a
server-side URL Preview that shows the endpoint's JSON so operators can map
fields without knowing the structure ahead of time. Makes the generic
adapter truly no-code — point it at any public REST/GeoJSON feed from the GUI.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Universal, no-code data sources: one GenericHttpAdapter polls any public
REST/GeoJSON feed per config.generic_sources[] — dotted-path field mapping
(items/id/lat/lon/geometry/title/fields) → coverage-gated, persisted
(generic_events, v26), cold-start-silent, LLM-queryable events. Ports
Central's GenericHttpAdapter to meshai native. First real use case: Idaho
Power outages, configured (not hardcoded) — anyone can point it at their own
utility/feed. GUI editor is a follow-up.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stop waiting 25s for a PATH_RESPONSE that never arrives. Send the reply
directly, wait ~6s for the delivery ACK the lib exposes; on ACK we're done
(~1-3s, the common case). Only on no-ACK do we run path discovery + resend —
and discovery's wait drops from 25s to a config default (8s). Both timeouts
are config knobs (meshcore_ack_wait_seconds / meshcore_discovery_wait_seconds)
for live tuning. Fixes the real bug behind PR #57 (checked is_error, should
have checked ACK).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite the satellite-pass wire to a single clean line: short ham names
(ISS/AO-27/AO-91), numeric max elevation (max 77°) instead of a bucket word,
collapsed compass sweeps (no E→E→E), and friendly observer names — dropping
the meaningless synthetic coverage_center parenthetical (and no longer seeding
that observer when explicit observers are configured). Absolute local time
kept for the 12h-advance heads-up.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Activity Log endpoint wasn't reading mesh_broadcasts_out, so it only
surfaced a partial set (MT band-propagation + satpass) and missed the
event-driven weather broadcasts and the entire MeshCore side. Query
mesh_broadcasts_out for all broadcasts across both transports and all
categories, newest-first with pagination, so the feed reflects everything
that actually went to the mesh.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the LLM data gaps: add build_satpass_detail (satpass_events was written
but had no reader), and give avalanche + ducting durable tables (v24/v25) with
native writers + env_reporter readers so the mesh LLM can answer avalanche,
satellite-pass, and RF-propagation questions. Persistence-only; no broadcast/
gating changes.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(firms): curated new-fire cluster broadcasts (no per-pixel, no cold-start dump)
Enable the built _maybe_emit_cluster path (was dead-coded) so FIRMS broadcasts
curated hotspot clusters as possible new fires — clustered, deduped via
cluster_broadcast_at, attributed against known WFIGS fires first (so MORA's
hotspots don't false-cluster). Give FIRMS a default Idaho bbox so it fetches
when coverage is off (coverage bbox still overrides). First-fetch silent-seed
prevents a cold-start dump of the day's existing hotspots. Raw pixels stay
store-only. Coverage geometry gate filters cluster broadcasts to the region.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(firms): first-fetch silent-seed suppresses fusion wires too (no cold-start)
Extend the FIRMS cold-start seed to suppress growth/spotting/halt fusion
broadcasts on the first fetch, not just clusters — enabling FIRMS must emit
zero broadcasts on the initial hotspot sweep. Persistence, attribution, and
dedup baselines still run during seed; only later new activity broadcasts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(fire): route native WFIGS through the Phase-3 growth decider (fix updates)
Completes the Phase-3 fire migration for the native adapter. env/fires.py now
emits canonical data (_kind/irwin_id/declared_at/acres/contained), native
fires bypass the received-delta gate and run the shared gating.fire.decide +
fire formatter (forward-only growth + containment + 8h cooldown + deferred
commit), and a native-only cold-start pre-pass silent-seeds old/known fires so
no backlog spam. Fixes growth/containment silence (MORA) and revives the
fires-table-backed reminders/digest. Reuses the existing decider — no dup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(fire): cold start seeds ALL current fires silently (no 48h dump)
Drop the fresh-ignition age window from the native cold-start seed — a fresh
deploy with an empty fires table must not broadcast fires discovered in the
last 48h. Now every fire present at boot is seeded silently; a fire only
broadcasts New if it appears on a later poll (a genuine ignition since
startup). Growth/containment updates unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The multi-box gate is authoritative, but adapters still need to FETCH the
right data — otherwise a box crossing a state line never pulls the cross-
state side. Feed each adapter's fetch scope (fires envelope, nws area=states,
hydro bBox, etc.) from the enclosing bbox of config.coverage.areas (falling
back to legacy coverage.bbox). The Shapely gate still narrows to the exact
areas; the enclosing box just ensures cross-state / multi-area data is pulled.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Upgrades the Coverage page from one bbox to a list of named areas, matching
the backend coverage.areas / Shapely set-union gate. Draw multiple boxes,
name/edit/delete each, all rendered on the map; saves config.coverage.areas
(clears legacy bbox). Coords rounded to 6dp. Enabled + per-adapter override
toggles unchanged.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The real LA leak was a zone-only advisory with no polygon and no centroid —
the fail-open gate kept it. Now NWS attaches the full GeoJSON alert geometry
(Polygon/MultiPolygon) to the event, and the coverage gate drops weather
alerts it cannot locate (fail-closed, matching Central), while staying
fail-open for other categories. Removes the old buggy adapter-level
_in_coverage heuristic (the gate supersedes it).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the hand-rolled per-adapter region heuristics (which leaked LA/OR
broadcasts) with Central's proven mechanism: bounding-box(es) + Shapely
full-geometry intersection. Ports MonitoringArea/build_geom_json/
classify_geom_areas from the central repo; adds config.coverage.areas
(multi-box, set-union) and a CoverageFilter that gates every event on
geometry-in-any-area before broadcast. Adapter geometry enrichment (NWS
polygons, fail-closed) follows in the next phase.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
USGS rejects bBox coords with >7 decimals (raw Leaflet clicks have 14) —
round all coverage-derived coordinates to 6dp so USGS/others accept them.
TomTom flow 400 ("Point too far from nearest existing segment") on rural
grid cells is expected no-data, not an error — log debug and skip instead
of warning. Fix the fires log to not claim "in US-ID" under coverage mode.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a GENERAL > Coverage page: a Leaflet map to draw/set the universal
coverage bbox (with numeric W/S/E/N inputs), an enabled toggle, and per-
adapter override toggles (excluded_adapters). Environment's per-adapter
geographic-scope fields now show only when that adapter is overridden;
otherwise they point to the Coverage map. Saves via PUT /api/config/coverage.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds coverage.excluded_adapters: adapters listed here ignore the universal
coverage bbox and use their own config (the "advanced override", native
only). _coverage_for() returns None for excluded adapters; satpass seeding
respects it too. Backend for the GUI override toggles.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2d: hydro discovers gauges via the USGS IV bBox query across the
coverage box (thresholds stay NWPS-cached; unrated sites never emit) instead
of a hand-listed site set; satpass seeds a single centroid observer derived
from the box. Completes wiring all native env adapters to the universal
coverage bbox. Coverage governs when set; each adapter's config is fallback.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2c: nws derives its area= state filter from the bbox (states it
overlaps) AND drops alerts whose centroid falls outside the box for precise
scoping; traffic samples a 3x3 grid of flow points across the bbox instead
of hand-listed corridors. Coverage governs when set; each adapter's own
config is the fallback.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2b: fires now scopes via the ArcGIS envelope from the coverage bbox
(dropping the single-state WHERE; event_id uses the fire's own POOState so
multi-state boxes work and Idaho dedup keys are unchanged); firms uses the
coverage bbox for its NASA query; ducting observes at the bbox centroid.
Coverage governs when set; each adapter's own config is the fallback.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2a: plumb config.coverage.bbox through EnvironmentalStore to adapter
constructors (mirroring region_anchors) and wire the four straightforward
native adapters to derive their scope from it — usgs_quake/roads511 bbox,
wzdx states+bbox, avalanche center_ids — via resolve_adapter_coverage().
Coverage bbox (when set) governs; the adapter's own config field is the
fallback when unset. Central-fed adapters are unaffected (never native-
constructed). fires/firms/hydro/traffic/satpass/ducting/nws follow.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 of replacing per-adapter geographic scoping with one coverage
bbox. Adds a `coverage.bbox` [W,S,E,N] config and meshai/coverage.py — a
pure, tested derivation layer: geometry primitives (point_in_bbox,
intersects, centroid, grid_points, arcgis_envelope) + static US-state and
avalanche-center bbox tables + resolve_adapter_coverage() mapping one bbox
to each native adapter's effective scope. Central-fed adapters return None
(Central governs). No adapter wiring or GUI yet — foundation only.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_establish_direct_path ran a CMD-52 probe (25s timeout) on every DM reply,
even when the contact already had a valid out_path (out_path_len >= 0),
adding ~25s latency to every reply. Only probe for flood/unknown-path
contacts; for already-routed contacts send direct immediately. If a cached
route turns out stale (send fails), fall back to discovery + one retry so a
moved/broken path still self-heals. Cuts routed-contact reply latency from
~25s to ~2s.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_resolve_contact relied on ensure_contacts(), which is a no-op once the
lib's contact mirror is populated — so firmware-side auto-added senders
never synced in and every reply dropped with "could not resolve contact".
On a prefix-lookup miss, force get_contacts(lastmod=0) to pull the full
roster from the radio, then retry the lookup. Lets AIDA reply to anyone
the firmware has (auto-added or previously heard) even when the cached
mirror is stale.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enable firmware auto-add (set_autoadd_config CMD 58) at connect when
connection.meshcore_auto_add_contacts is set (default on), and refresh
the contact roster on NEW_CONTACT so replies resolve immediately. GUI
toggle on the MeshCore Connection page. So the USB AIDA companion adds
every node it hears an advert from and can send/decrypt DMs without
manual contact exchange.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The banner said 'Container restart required' and a code comment claimed 'the
container will tear down' — both wrong. /api/restart touches /tmp/meshai_restart,
which the entrypoint watches to restart the BOT PROCESS in place (~seconds); the
container is not recreated. Fix the copy + point users at the existing 'Restart
now' button. Also mention transport connections in the restart-required list.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pyserial comports() reads /sys USB metadata, absent inside a container for a
bind-mounted device node — so /api/serial-ports returned [] and the GUI "Detect"
showed nothing despite /dev/meshcore-rak (major 166) being present + openable.
Supplement comports() with a direct /dev scan: include char devices whose major
is a USB-serial major (166 ttyACM, 188 ttyUSB), catching /dev/ttyACM*/ttyUSB*
AND custom udev names like /dev/meshcore-rak that a tty* glob misses; exclude
legacy ttyS* (major 4). Merge deduped by realpath (pyserial metadata wins on
overlap). stable_path: by-id > stable custom name > by-path > raw. likely_radio
heuristic on the name (mesh|rak|lora|tbeam|heltec|nrf|companion) for bare nodes.
Resilient (unreadable /dev / stat error skipped, never raises).
8 new tests; suite at 10-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MeshCore can now connect over USB serial (and BLE) directly, not just TCP to
the pyMC companion. The meshcore lib already supported create_serial/create_ble;
we just wire it up. Plus a USB auto-detect scanner that resolves stable device
paths to fix ttyACM enumeration hopping across replug/reboot.
Backend:
- ConnectionConfig: meshcore_conn_type (tcp|serial|ble, default tcp),
meshcore_serial_port, meshcore_baud=115200, meshcore_ble_address (validated)
- meshcore_transport._do_connect dispatches per mode: serial ->
MeshCore.create_serial(port, baudrate, auto_reconnect, max_reconnect_attempts),
ble -> create_ble(address or None), tcp -> create_tcp (unchanged). Mode-aware
logging/reconnect. Transport otherwise unchanged (mode-agnostic once _mc exists).
- factory.meshcore_enabled(config): active when the selected mode is configured
(serial port / ble address / tcp host); back-compat — meshcore_host + default
tcp still activates exactly as before.
- serial_ports.list_serial_ports(): pyserial comports + stable_path resolution
by-id -> by-path -> raw (by-id keyed on USB serial = stable across replug),
likely_radio flag by VID (RAK/nRF/CP210x/CH340), excludes legacy ttyS*, never
raises. GET /api/serial-ports (+ container by-id passthrough hint).
Frontend:
- SerialPortPicker component: "Detect USB devices" -> lists ports (likely-radio
badge, shows stable_path) -> onChange sets the stable by-id path; manual text
fallback; empty/error/note states.
- MeshCore Connection: type selector TCP/Serial/BLE + per-mode fields (serial
picker + baud; ble address). Meshtastic serial branch now uses the picker too.
Code-ready; not activated (defaults keep TCP). 35 new tests; suite at 10-failure
baseline. Container needs /dev/serial passed through for by-id paths.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
roads511 emitted external_id=None, so it couldn't be durably pre-seeded from
the persistent tables (only wzdx/usgs_quake were) — it relied solely on the
in-memory first-poll seed. Thread a stable external_id="511_{itd_id}" through
consistently so it joins the durable layer:
- env/roads511.py: _parse_event raw event + to_event both carry
external_id="511_{id}" (== event_id). Flips _seen_key to the ext: branch and
makes the incident decider persist traffic_events(source='511', external_id)
— which ALSO restores the decider's own dedup (external_id=None was the
original roads511 leak cause).
- env/store.py _seed_from_persistent: add a "511" spec (seed from
traffic_events where source='511', by external_id) mirroring wzdx; shared
_key_ext helper so keys can't drift.
- consistency proven byte-identical (raw _seen_key == pre-seed key ==
511\x1eext:511_{id}); durable-preseed + regression tests added.
Live DB: 0 source='511' rows yet (flip recent) -> durability engages as native
rows accumulate; layer-2 in-memory seed covers the interim (atomic fetch).
Central-era itd_511 rows use a different keyspace, intentionally not covered.
Suite at 10-failure baseline (1716 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>