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>
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>
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 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>
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>
API keys/secrets now live in /data/secrets/.env (gitignored, never in config
YAML), while remaining fully editable from the dashboard. Config YAML holds
only ${VAR} references.
Backend:
- meshai/secrets_store.py: get_status (SET/NOT-SET, never values), set_secret,
delete_secret over /data/secrets/.env (resolved like load_config); authoritative
SECRET_FIELD_TO_ENV map (traffic→TOMTOM_API_KEY, firms→FIRMS_MAP_KEY,
roads511→ROADS511_API_KEY, wzdx→WZDX_API_KEY, smtp→SMTP_PASSWORD,
mesh_sources→MESHMONITOR_API_TOKEN) + backend-dependent llm_env_var
- dashboard/api/secrets_routes.py: GET /api/secrets (status only), PUT/DELETE
/api/secrets/{env_var} (validated, restart_required); registered in server.py
- config_loader: save_section preserves ${VAR} secret refs on section save
(never rejects them); EXPECTED_SECRETS += ROADS511_API_KEY, WZDX_API_KEY
- config.example.yaml + docker-entrypoint default config use ${VAR} refs;
first-run bootstraps /data/secrets/.env; .gitignore covers it
Frontend:
- components/ManagedSecret.tsx: masked, Set/Not-set badge, reveal, Save->PUT,
"restart required"; carries no config value so secrets never enter a section
save payload
- wired into Environment (tomtom/roads511/wzdx/firms), Config LLM tab
(env var by backend), Notifications (smtp)
Restart required after a secret change (env read at config-load). 11 store
tests; suite at 10-failure baseline (1714 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend half of making the dashboard the complete config surface (per an
exhaustive per-key audit).
Delete 51 vestigial/unread config fields (load-safe: _dict_to_dataclass
whitelists by field, so existing files carrying these keys still load and the
keys drop on next save):
- 36 duplicated MQTT block (host/port/username/password/topic_root/use_tls)
on memory/context/commands + env nws/swpc/ducting (grep-proven unread;
mesh_sources keeps its real MQTT fields)
- 3 no-op history cleanup keys (auto_cleanup/cleanup_interval_hours/max_age_days)
- 5 alert scaffolding (alert_cooldown_minutes, RegionAnchor.nws_zones,
battery_{warning,critical,emergency}_voltage)
- 5 danger-zone non-fire min_acres (kept fire.min_acres via a fire subclass)
- 2 deprecated adapter_config keys (nws.broadcast_severities/warning_suffix_promotes)
Bug: usgs_quake native magnitude floor was unreachable from the GUI (native
reads config.min_magnitude; the GUI "Global Floor" wrote the registry
global_mag_floor that only the Central path reads). Reconciled: min_magnitude
is the canonical native floor the frontend will bind; registry floors marked
Central-path-only. Effective filtering unchanged.
Secret-flag consistency: add environmental.roads511.api_key + wzdx.api_key to
SECRET_FIELDS (secrets move to .env in the follow-up; ${VAR} interpolation kept).
Suite at 10-failure baseline (1703 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two live backlog-broadcast leaks traced to the in-memory first-poll seed:
(1) incremental-fetch adapters (wzdx: registry tick [0 events] then feeds
tick [many]) got marked _seeded on the EMPTY first tick, so the real batch
next tick all looked "new" and broadcast; (2) in-memory seed lost on restart.
Fix — durable baseline + guard:
- _seed_from_persistent() at store init: pre-load already-received item keys
from the persistent hazard tables into self._seen, so nothing ever received
can re-broadcast (immune to fetch staging + restart). Only sources whose
native emit key PROVABLY equals a persistent key are durably seeded:
wzdx (traffic_events.external_id) + usgs_quake (quake_events.event_id).
Resilient (per-table try/except; missing table -> skip).
- _seen_key() now namespaces by evt["source"] (matches persistent tables),
via shared _key_ext/_key_eid helpers used by both seed and live emit so
they can't drift.
- non-empty-seed guard: _ingest marks only sources that carried >=1 event
this poll as _seeded -> an empty first tick can never seed-then-leak. This
is the root-cause fix; covers all adapters (roads511/traffic fetch
atomically per tick, so the guard fully protects them).
- storage untouched (self._events populated for every event); Central
path/deciders untouched.
Live-DB verified: seed pre-loads 784 wzdx + 8 quake keys -> a live wzdx poll
of 784 known zones broadcasts 0. +6 tests (incremental staging, restart,
persistent-preseed, fresh-DB fallback); 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>
Replace the native path's "scan accumulated state + suppress what we've
already broadcast" model with "broadcast only what newly arrived from the
API this poll." Storage is unchanged (self._events + firms_pixels etc. are
populated for EVERY received item, so the LLM/get_active backlog is intact);
only the BROADCAST decision changes.
- env/store.py: per-adapter in-memory seen-set (_seen) + _seeded. First
data-bearing poll for an adapter seeds keys and emits NOTHING (that batch
is pre-existing backlog); later polls emit only keys not seen before.
Restart => empty sets => next poll re-seeds silently. Structurally
impossible to broadcast backlog on cold start / restart / re-enable.
Key = external_id -> event_id -> content hash, namespaced per adapter.
self._events[key]=evt still runs unconditionally (storage preserved).
- Fixes the ~175 (roads511) / ~782 (wzdx) cold-start bursts AND the latent
quake/nws version (they only looked safe because Central pre-populated
their broadcast tables).
- env/satpass.py: broadcast on AOS IMMINENCE (now < aos <= now+lead,
broadcast_lead_seconds default 3600), future-only; window_hours still
governs prediction depth. Strict norad_ids post-filter + fixed
_parse_norad_ids char-iteration bug (cause of GOES/METEOR leak).
- Central path + broadcast-state tables untouched (native-only gate).
13 new tests; full suite at 10-failure baseline (1697 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs found running native adapters against real upstreams:
- env/wzdx.py: _select_feeds() called .strip() on the registry url field,
but Socrata "URL"-column values arrive as {"url": "..."} — crashed ALL
native WZDx discovery with AttributeError. Added _unwrap_url() (dict/str/
None-robust), applied to url/apiurl/feed_url.
- composer._context_segment: appended optional fields by key presence, so
cause: None (set by native road adapters) leaked literal "None" onto the
wire. Guard on value (cause/expires_at truthiness; containment_pct is-not-
None so 0% still renders). Legacy Mode-B path — golden tests unchanged.
+3 regression tests; golden/composer suites pass; full suite 10-failure
baseline (1685 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the last standalone gap. Extract ingest_hotspot_pixel(pixel, *, now)
from firms_handler so the FIRMS attribution/fusion engine (firms_pixels ->
_attribute_or_cluster -> fire_pixels/fire_passes/centroid -> growth/spotting/
halt) is source-agnostic. Both the Central NATS path and native env/firms.py
drive one identical engine.
- shared _ingest_pixel_core(conn, ...) called by both ingest_hotspot_pixel
and handle_firms; Central path byte-identical (its tests pass unchanged)
- env/firms.py _fetch() feeds each fetched pixel into ingest_hotspot_pixel;
DB-level dedup makes re-fetched pixels no-ops (no double count); to_event()
returns None for raw hotspots, precomposed Event for fusion outputs
(wildfire_growth/spotting/halted via the Phase-3c formatters/gating)
- raw hotspots / new_ignition / cluster NEVER broadcast (cluster stays dead)
FLIP NOTE: keep wildfire_growth/spotting/halted OUT of cutover — native
emits precomposed with gating done inside the engine; cutover would re-run
the _kind-keyed decider on data lacking _kind and suppress.
10 new tests; Central firms/fire-tracker suites unchanged; full suite
10 failed/1682 passed (baseline 10, +10 new).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
meshai can now predict + broadcast satellite passes locally without Central.
Data plumbing (4c-1):
- env/tle_fetch.py: keyless Celestrak GP fetcher (GROUP/CATNR, FORMAT=tle) →
upserts the existing sat_tles table via a shared upsert_tle() helper
extracted into tle_handler (Central ingest refactored to call it, unchanged)
- observer_locations table (v23, SCHEMA_VERSION 22->23) + persistence helpers;
seeded from SatpassConfig.observers in main._init_components
- SatpassConfig: observers, tle_groups, norad_ids, tle_refresh_seconds,
min_elevation_deg, window_hours
Predictor + source-agnostic gate (4c-2):
- extracted gate_consolidated_pass(consolidated, *, now) from
consolidate_satpass_pending: dedup-vs-satpass_events + rate cap + format_pass
+ deferred commit. Central path byte-identical (114 tests unchanged)
- env/satpass.py: native adapter predicts passes for each sat x observer via
pass_predictor.compute_passes, consolidates IN-MEMORY per canonical hour
bucket (earliest AOS / latest LOS / max-el observer supplies peak_compass +
entry/exit observers), runs the shared gate, emits sat_pass. Commit rides
event.data so satpass_events dedups across ticks — NO satpass_pending, NO
Central-consumer timer dependency (works with Central off)
- registered in env/store.py gated on enabled and feed_source==native
32 new tests (tle_fetch 16, observer_locations 14... satpass_native 8, minus
overlaps); full suite 10-failure baseline (1672 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Satpass consolidations were scheduled with in-memory asyncio timers
(_pending_satpass_timers) lost on restart, orphaning satpass_pending rows
that never consolidated/broadcast. Persist a durable due_at and rebuild
timers on startup.
- v22.sql: satpass_pending.due_at INTEGER; SCHEMA_VERSION 21->22
- due_at = received_at + CONSOLIDATION_DELAY(5); the live +N*60 stagger is
in-memory only (meaningless across restart) so not persisted — live
call_later path unchanged, due_at is a pure reboot backstop
- consumer._sweep_pending_satpass() at start(): past-due rows fire (orphans
recovered), future rows re-armed for the remaining wait; skips cids the
live drain path already owns (no double-schedule); per-row try/except
- reuses _satpass_consolidation_fire so emit logic is identical
Non-reboot behavior byte-identical. 6 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>
New env/wzdx.py native adapter so meshai has a LOCAL source for the
work_zone category instead of Central-only. Keyless: discovers per-state
WZDx feeds from the FHWA registry (datahub.transportation.gov), filtered to
configured states (default [ID]), fetches each WZDx v4 GeoJSON feed, and
parses road_events into canonical work_zone Event.data.
- reuses central_normalizer._parse_wzdx_federal for identical field mapping
→ renders through the existing Phase-2 formatters/gating incident path
(no new formatter/gating); verified end-to-end to a valid work-zone wire
- stable external_id = {data_source_id}:{feature_id} for incident dedup
- registry cached 6h; per-feed failures logged + skipped (never crash)
- WZDxConfig extended (registry_url, registry_ttl, states); registered in
env/store.py; enabled=false + feed_source=native default (dormant until
turned on at the coordinated flip)
19 new tests; suite at the 10-failure baseline (1640 passed). Only env/wzdx.py
(new), env/store.py, config.py, tests/test_adapter_wzdx.py touched.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
env/firms.py to_event() now unconditionally returns None. The native FIRMS
path was broadcasting raw single-pixel hotspots (new_ignition/wildfire_hotspot,
both live "fire"-toggle categories) straight to the mesh — no decider gated
them (none registered for those categories) — violating the absolute
"we do NOT broadcast hotspots" rule and diverging from the Central handler's
storage-only contract.
Finding: native FIRMS has NO fusion wiring — it never wrote firms_pixels or
did attribution; the growth/spotting/halt fire-tracker lives entirely in
central/firms_handler.py driven by NATS. So neutralization loses no fusion
(there was none natively). Full native fire-tracking standalone would require
feeding native pixels into that attribution engine — a known, deferred gap.
Only env/firms.py + tests/test_adapter_firms.py touched; firms_handler.py,
gating/firms.py, store.py untouched. Suite at the 10-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add azimuth-at-peak-elevation compass to the satpass pipeline (Matt's
decision: extend pass_predictor) and repair the 24 stale satpass tests
that were written against the pre-async handle_satpass return contract.
- pass_predictor.PassInfo gains azimuth_at_peak; _build_pass populates it
from the already-computed peak sample
- !satpass DM wire + Central consolidated broadcast wire render aos→peak→los
- satpass_pending gains a peak_compass column (migration v21, SCHEMA_VERSION
20->21); persisted at ingest, carried through consolidation from the
max-elevation observer's row
- 24 stale satpass tests repaired to the ingest→consolidate two-call
contract (none weakened); satpass suite 112/112 green
- full suite 34->10 failures (the 24 were these stale tests)
NOTE: peak_compass changes the live Central satpass wire — deploy is HELD
until the coordinated all-native flip (per Matt).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the three FIRMS fire-tracker BROADCAST paths (wildfire_growth,
wildfire_spotting, wildfire_halted) behind the registry, NO cutover.
Attribution/pass/centroid/perimeter plumbing stays inline; legacy live
path is byte-identical.
- gating/firms.py decide() discriminates on handler-stamped _kind
(firms_growth/firms_spotting/firms_halt)
- wildfire_growth reuses formatters/fire.py (verified byte-identical:
growth SELECT uses current_* columns _render doesn't read, so the wire
is the movement+anchor line with "size/containment unknown" — a latent
legacy quirk, reproduced exactly, NOT fixed)
- formatters/firms.py renders spotting + halt wires
- tier-b (flagged): the eager latch writes (fires.last_spotting_broadcast_at,
fires.halt_broadcast_at) move into deferred commit closures — a dropped
broadcast no longer burns the latch. Validated by gate-sequence, not
golden bytes. Not-cutover live path keeps eager latches verbatim.
- FIRMS broadcasts never touch event_log (eager handled=1 at pixel
storage) → no event_log flip to wrap
- _maybe_emit_cluster stays dead (unconditional return None) + test
Deferred follow-ups (unchanged): env/firms.py native hotspot broadcast
neutralization; native canonical emission.
27 new tests; fire-tracker + firms handler suites preserved; full suite
at 34-failure baseline (1597 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move wfigs wire rendering into notifications/formatters/fire.py and the
full fire state machine into notifications/gating/fire.py, behind the
registry, NO cutover. handle_wfigs builds a canonical dict, calls
decide(), keeps the inline fires INSERT/UPDATE of current_* and the
tombstoned_at stamp unconditional, then branches on is_cutover(...) —
legacy _attach_commit_handles/all-clear path stays byte-identical while
the new path bakes in shadow.
Reproduces every legacy stamp through GateResult.data_patch:
- forward-only acres/containment growth + 8h cooldown gating
- tombstone wildfire_closed all-clear (row exists AND last_broadcast_at
IS NOT NULL) with _severity_override="priority", _dedup_suffix="closed"
- growth _dedup_suffix=f"{acres}|{contained_pct}", _cooldown_suffix=irwin_id
- idempotent commit UPSERT of fires(last_broadcast_*) + event_log flip
- full _location_anchor fallback chain (geocoder_city -> resolve_anchor
-> landclass -> county -> state) preserved in the formatter
Registered under the three explicit categories (wildfire_declared,
wildfire_incident, wildfire_closed) rather than the `fire` toggle, so the
family-fallback does NOT capture the still-deferred FIRMS categories
(wildfire_hotspot/new_ignition/wildfire_growth); a registration test
asserts those resolve elsewhere.
Native env/fires.py deferred (missing IRWIN/cause/landclass, no tombstone
concept); non-cutover so store._emit_event won't run it.
tier-a: 19 new golden+gate-sequence tests; wfigs handler 23/23 preserved;
suite at 34-failure baseline (1571 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move nwis wire rendering into notifications/formatters/hydro.py and the
threshold-crossing decision into notifications/gating/hydro.py, behind
the registry under the real category `stream_flow`. handle_nwis now
builds a canonical dict, calls decide(), keeps the gauge_readings INSERT
inline (append-only), and branches on is_cutover("stream_flow") exactly
like quake_handler — legacy _attach_commit path stays byte-identical
while the new path bakes in shadow. No cutover.
- gauge_readings INSERT stays inline; decider only reads prior state
- hydro has no per-event broadcast-state table → GateResult.commit=None;
event_log.handled flip stays handler-owned in the cutover wrapper
- tier-a: golden byte-identical wire + gate-sequence parity (12 tests)
- native env/usgs.py deferred (different category vocab, no
threshold_state); non-cutover so store._emit_event won't run it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shadow_gate called decider(data, now=now), omitting the required
keyword-only `source` arg from the decide(data, *, source, now)
contract. Every decider raised TypeError (swallowed at DEBUG), so the
Phase-1/2 gate-shadow was a silent no-op the entire bake and wrote zero
mismatch records.
- shadow.py:139 -> decider(data, source=source, now=now)
- add TestShadowGateForwardsSource regression guard (fake decider that
asserts source is forwarded; fails pre-fix, passes post-fix)
- retarget TestShadowInertWhenNoDecider at a genuinely-unregistered
category (__no_such_category__); it was using earthquake_event, whose
decider only "passed" before because the signature bug crashed it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(fixtures): rewrite capture_fixtures.py to use ephemeral push subscribe
Previous script hardcoded stream="CENTRAL" which does not exist — Central
partitions into domain streams (CENTRAL_QUAKE, CENTRAL_SPACE, etc.). It
also called pull_subscribe_bind() without await, making the fetch a no-op.
Fix: mirror the proven CentralConsumer.start() pattern — use
js.subscribe(subject, cb=..., AckPolicy.NONE, no durable) which auto-
discovers the correct stream via the subject filter, identical to how the
live consumer binds. Messages are funnelled through asyncio.Queue with
an idle-timeout to detect drain completion.
Adds live captured fixtures:
- tests/fixtures/quake/ — 3 envelopes (CENTRAL_QUAKE stream, mode=all)
- tests/fixtures/swpc/ — 40 envelopes (CENTRAL_SPACE, mode=all, proton_flux history)
- tests/fixtures/swpc_last/ — 23 envelopes (mode=last: 21 alert variants + kindex + proton_flux)
Avalanche: confirmed empty off-season (CENTRAL_AVY stream, 0 messages).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(phase1): migrate quake, swpc(Kp+flare), avalanche + staged-cutover gate
First hazards on the source-agnostic formatter+decider path, behind a
staged-cutover gate so deploy = shadow-only (old path still broadcasts; new
path dry-run-diffed) until MESHAI_CUTOVER_CATEGORIES flips a category live.
- formatters/{quake,swpc,avalanche}.py + gating/{quake,swpc,avalanche}.py:
source-agnostic format(event,*,now,budget) + decide(data,*,source,now)->GateResult.
quake (earthquake_event, tier-b: render PAGER + live update-prefix), swpc
(geomagnetic_storm + rf_propagation_alert, Kp+flare only; proton/solar_radiation
_storm stays legacy; geomag 600s window re-homed off the module-global into
gating/swpc with a deferred stamp; tier-b scale-based severity), avalanche
(avalanche_warning/watch; centralseverity->NAADS 1-5 remap; synthetic fixtures
off-season).
- central/{quake,swpc,avy}_handler.py bridges: cutover -> new decide()+canonical
data; not-cutover -> exact legacy behavior. env/{usgs_quake,swpc,avalanche}.py
emit canonical Event.data (avalanche stops precomposing). env/store.py generic
native decider hook (cutover-gated).
- notifications/cutover.py (is_cutover via MESHAI_CUTOVER_CATEGORIES); composer
dispatch + shadow hooks are cutover-aware (shadow no-ops once a category is live).
- scripts/capture_fixtures.py fixed (per-domain streams e.g. CENTRAL_QUAKE, await
bind); real quake/swpc fixtures captured; avalanche synthetic.
Tests: +~150 (quake/swpc/avalanche parity+cross-source+gate-sequence+cutover);
0 new failures (34 baseline, 1426 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Foundation for making all hazard formatting+gating source-agnostic. ZERO
behavior change — the formatter/decider registries are empty (get_formatter/
get_decider return None → existing precomposed/Mode-B path preserved), and the
shadow comparator is off unless MESHAI_SHADOW_CATEGORIES is set.
- notifications/formatters/ (registry+dispatch with family fallback), gating/
(GateResult + deferred-commit contract), both empty registries.
- notifications/clock.py determinism seam; route wfigs/quake/nws gating time
reads through it (identical values) so goldens can freeze time.
- formatters/_budget.py = copy of central/budget.py; central/budget.py is now a
re-export shim (import-smoke test guards it).
- compose_mesh_message consults the registry first (verbatim, no Mode-B re-cap),
falls back to legacy; _resolve_budget injects per-category budget.
- notifications/shadow.py + two DRY-RUN hooks (consumer._normalize, dispatcher
render): compute the new result and diff-log SHADOW_MISMATCH JSONL, but NEVER
commit/emit/write tables and always broadcast the OLD result. Inert by default.
- tests/harness (pinned_time/pinned_tz, byte-golden, gate-sequence) +
scripts/capture_fixtures.py (ephemeral read-only NATS capture); tzdata pinned.
Tests: +60 (18 scaffold + 42 harness/shadow); 0 new failures (34 baseline).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Central NATS consumer's start() was called unguarded during boot, so if
Central was enabled but unreachable at startup, nats.connect() raised
NoServersError, propagated through bot.start(), and crashed the process —
crash-looping under Docker restart:unless-stopped.
Now _start_central_consumer_guarded() wraps start() in try/except: on failure
it logs a warning and continues booting (LLM bot, Meshtastic/MeshCore,
mesh-health, and native feeds all start), then a background retry loop
(30s->300s backoff) re-attempts the initial connect until it succeeds. Once
connected, NATS's own allow_reconnect handles runtime drops. The retry task is
cancelled cleanly on stop(). No retry is scheduled when nothing is
central-sourced.
Tests: +tests/test_central_boot_guard.py (11); 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pyMC-companion FLOOD direct-messages are silently rejected by recipient
MeshCore nodes (43 sent / ~1 ack in 24h); DIRECT-routed packets deliver.
A bare inbound DM does NOT populate the contact's out_path on pyMC, so the
contact stays out_path_len=-1 (flood), and send_msg_with_retry actively
reset_path→flood, guaranteeing the broken route.
New behavior on a DM reply:
- _establish_direct_path(): path discovery (CMD 52 send_path_discovery_sync)
so the recipient returns a PATH packet → pyMC writes a real out_path →
works at ANY hop count. Fallback: seed from the sender's cached advert
(get_advert_path CMD 42 → update_contact CMD 9) if discovery is empty.
- send via plain send_msg (CMD 2) — uses the learned path → DIRECT. Drops
send_msg_with_retry (which forced flood). Logs the RESP_CODE_SENT route
(direct/flood) so we can confirm.
Tests reworked for the discover-then-direct-send path; 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MeshCore DM replies were passed a bare 6-byte pubkey prefix to
send_msg_with_retry. Per every working meshcore project (meshcore_py
examples, meshcore-cli, meshcore-bot, meshcore-ha), the destination must be
resolved to the FULL contact object (dict w/ 64-hex public_key) after
ensure_contacts — otherwise the lib can't upgrade the prefix to the full key,
skips reset_path, blind-floods, gets no ACK, and the DM silently never
delivers (matches our live symptom: inbound + channel send work, DM reply dies).
- Add _resolve_contact(dest): ensure_contacts() then get_contact_by_key_prefix().
- DM branch now passes the resolved contact object to send_msg_with_retry;
if the contact can't be resolved, log + return False (no blind-flood).
- Subscribe to EventType.ACK + log received ACKs (instrumentation to confirm
whether ACKs reach the dispatcher at all).
Tests updated (+2); 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four fixes surfaced by live testing (a MeshCore DM got no reply):
- DM REPLY DELIVERY (root cause): reply used the meshcore lib's fire-and-forget
send_msg (MSG_SENT != delivered, no flood, no ACK) so replies to nodes without
an established direct path silently vanished. Switch to send_msg_with_retry
(contact resolve + flood fallback + ACK wait); a None return (no ACK) is now a
real failure, not silent success. _run_coro timeout raised to 40s for the ACK cycle.
- 422 on save: register meshcore_context in config_loader SECTION_TO_FILE
(config.yaml) — it was in VALID_SECTIONS but not the save-routing table.
- test-llm endpoint: called backend.generate() with (str, []) instead of
(messages:list, system_prompt:str) → "string indices" error; fixed the call.
- Inbound observability + robustness: subscribe to CONTACT_MSG_RECV BEFORE
start_auto_message_fetching (+ ensure_contacts) so a DM queued at connect isn't
drained before the handler registers; add INFO/DEBUG logging across the inbound
DM + dispatch + send path (was entirely unlogged).
Tests: +test_meshcore_dm_delivery, +test_fix_meshcore_save_and_llm_test; 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Config → Settings → Context: relabel the raw "Max Age (sec)" field to
"Chat context retention (days)" (days<->seconds conversion, min 1,
default 14). Governs the shared per-mesh chat memory window.
- Make PUT /api/config/context apply LIVE: MeshContext.update_settings()
updates max_age/observe_channels/ignore_nodes in place; config_routes
refreshes the running MeshContext via app.state.mesh_context (mirrors
the existing _refresh_toggle_filter pattern) so retention changes take
effect without a restart.
Tests: +tests/test_context_hot_reload.py (10); 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- router.should_respond branches on message.transport: MeshCore DMs are
governed solely by meshcore_context.respond_to_dms (enforced at the
transport); Meshtastic solely by bot.respond_to_dms. No global gate.
- MeshObservation tagged per-transport; the LLM "recent traffic" block is
scoped to the originating mesh (keyword override for the other mesh),
labeled by mesh so the model knows which it is describing.
- MeshCore observe_channels is now opt-in (empty = observe none).
- Chat-context retention 30d -> 14d (both meshes).
- Meshtastic integer channel-index filter no longer misapplied to MeshCore
observations (their channel is a companion slot index).
- Frontend: relabel DM toggles per-mesh ("Answer direct messages",
Meshtastic-only / MeshCore-only), remove the false channel-mention
tooltips, opt-in wording for MeshCore observe-channels.
Tests: +tests/test_llm_scoping.py (10), context-filter updated for opt-in;
0 new failures (34 pre-existing).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(dashboard): MeshCore Contacts roster + Companion status (read-only)
Expose the live companion's contact roster (get_contacts) and self/channel
status via /api/meshcore/contacts + /api/meshcore/self. Fill the Contacts
(roster table) and Companion (status + channels) pages. Telemetry auto-poll
comes next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(meshcore): self-advertisement (send-advert + advert-on-connect + periodic)
AIDA now announces itself: send_advert(flood=True) on every connect, an
optional periodic auto-advert (meshcore_advert_interval_seconds), and a
manual "Send Advert" button + POST /api/meshcore/advert. Makes the
companion discoverable/DM-able on the mesh.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>