Commit graph

495 commits

Author SHA1 Message Date
8dddf88119
feat(coverage): wire hydro + satpass to the coverage bbox (native adapters complete) (#63)
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>
2026-07-06 10:07:23 -06:00
a6110f3261
feat(coverage): wire nws + traffic to the coverage bbox (#62)
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>
2026-07-06 09:59:08 -06:00
ef8977b172
feat(coverage): wire fires/firms/ducting to the coverage bbox (#61)
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>
2026-07-06 08:42:43 -06:00
dd90f4a961
feat(coverage): wire quake/roads511/wzdx/avalanche to the coverage bbox (#60)
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>
2026-07-06 08:36:25 -06:00
997729241e
feat(coverage): universal coverage-bbox foundation (config + derivation module) (#59)
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>
2026-07-06 08:27:53 -06:00
b38c16b3c2
Revert "fix(meshcore): skip 25s path discovery when the contact is already routed (#57)" (#58)
This reverts commit 34c6f336cf.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
2026-07-06 00:37:30 -06:00
34c6f336cf
fix(meshcore): skip 25s path discovery when the contact is already routed (#57)
_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>
2026-07-06 00:29:50 -06:00
0512bc764b
fix(meshcore): force full contact re-fetch on DM resolve miss (#56)
_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>
2026-07-06 00:10:17 -06:00
2593e50aee
feat(meshcore): auto-add contacts so AIDA can DM anyone it hears (#55)
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>
2026-07-05 23:48:41 -06:00
7709fc1ec0
fix(gui): restart banner says 'Restart required' (process, not container recreate) (#54)
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>
2026-07-05 23:13:10 -06:00
d01bef5172
fix(serial): detect passed-through /dev USB-serial nodes (container GUI detect) (#53)
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>
2026-07-05 23:01:08 -06:00
b1ebdd1434
feat(transport): MeshCore serial/USB + BLE support + USB auto-detect with stable paths (#52)
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>
2026-07-05 22:12:03 -06:00
fcb24b9d3d
fix(roads511): stable external_id -> durably pre-seedable (belt + suspenders) (#51)
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>
2026-07-05 20:38:48 -06:00
339d7e3c80
fix(gui): make 'API key required' banner reflect live /api/secrets status (#50)
The banner used a static META.hasKey flag (false for firms+roads511), so
those always showed 'API key not configured' even when keys were set —
contradicting the ManagedSecret SET badge. Now fetch /api/secrets and show
the banner only when a keyed adapter's secret is genuinely unset; copy
softened to 'API key required — set it in the field below'. wzdx kept keyless
(no banner). Keyless adapters never banner.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:33:01 -06:00
adfe9ff4f2
fix(gui): exclude timezone from SectionKey so section maps stay exhaustive (tsc) (#49)
The 100%-coverage pass added timezone to FullConfig, but it's a top-level
scalar (own PUT /api/config/timezone), not a section tab — so SectionKey =
keyof FullConfig broke SECTION_DESCRIPTIONS: Record<SectionKey,string>.
Exclude 'timezone' from SectionKey. Docker frontend build failed on this;
live container was untouched.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:25:35 -06:00
ad5509cb12
feat(gui): surface remaining config in the dashboard — 100% GUI coverage (#48)
Add bound GUI controls for every remaining user-facing + internal config
setting so the dashboard is the complete config surface (secrets stay in
.env via the ManagedSecret widgets). Per the exhaustive per-key audit.

19 GAP controls (real settings with no prior control):
- Config: global timezone (+ backend: timezone in VALID_SECTIONS + scalar
  save_section branch), commands.custom_commands (kv editor), knowledge
  sparse_host/port, alert_rules.high_util_hours
- Data Feeds: nws.areas, usgs.flood_thresholds (json), usgs_quake
  feed_url/min_magnitude(native floor)/bbox, wzdx.registry_ttl,
  satpass.broadcast_lead_seconds, central.connect_timeout,
  toggles.<family>.regions (un-hidden + save-merge fix)
- Notifications: band_conditions_tz, digest.schedule, digest.include
- Danger Zones: snow.enabled + buffer_mi (unlocked)

18 INTERNAL knobs (Advanced subsections):
- connection reconnect/timing (7) split across MT/MeshCore Connection
- environmental.geocoder url/timeout/radius/limit (4)
- identity.contact_email (+ LOCAL_FIELDS mirror -> local.yaml; still feeds
  NWS User-Agent); mesh_sources url + regions lat/lon already editable
- toggles.<family>.name/webhook_headers, danger_zones.webhook_headers
  (new reusable KeyValueInput component)

alert_node_ids intentionally not surfaced (synthetic local default with no
dataclass home; overlaps the editable per-rule node_ids). No dead controls.

Frontend validated at Docker build. Backend py_compile OK.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:22:55 -06:00
3495eb31de
feat(secrets): GUI-managed .env secrets store — keys are config, but gitignored (#47)
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>
2026-07-05 17:57:45 -06:00
a502778990
chore(config): delete 51 dead keys, fix usgs_quake floor, secret-flag consistency (#46)
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>
2026-07-05 17:34:27 -06:00
5de683f7a7
chore: remove legacy TUI configurator; surface last config gap in GUI (#45)
Audit confirmed the Rich TUI (`meshai --config`) is a strict subset of the
dashboard GUI (covers FEWER sections) and nothing depends on it for bootstrap.
Strip it so the dashboard is the single config surface.

- delete meshai/cli/configurator.py (~1435 lines); cli/__init__ minimal
- main.py: drop --config + run_configurator dispatch (keep --config-file);
  bootstrap log now points to config.example.yaml / the dashboard
- docker-entrypoint.sh: remove the ttyd `meshai --config` block on :7682
  (default-config heredoc + bot exec loop untouched)
- docker-compose.yml + Dockerfile: drop the 7682 port + ttyd install (EXPOSE 8080)
- README / config.py header / config.example.yaml: de-reference the TUI
- drop `rich` from pyproject + requirements (grep-verified unused)

Close the only GUI config gap the audit found:
- Config.tsx weather tab: add openmeteo.url + wttr.url inputs (backend already
  round-trips the nested WeatherConfig dataclasses)

Bootstrap intact: config.example.yaml copy, entrypoint default-config write,
and defaulted Config() when no file exists all remain. `python3 -m meshai`
and `--config-file` unchanged. TS/import validated at Docker build.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:21:32 -06:00
7ec0883b44
fix(env): leak-proof received-delta — durable persistent baseline + non-empty seed guard (#44)
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>
2026-07-05 15:18:48 -06:00
0c76a806b8
feat(env): native adapters broadcast only newly-RECEIVED items (no backlog) (#43)
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>
2026-07-05 14:38:40 -06:00
63245b8fba
fix: WZDx registry nested-url crash + composer "None" leak (dry-run findings) (#42)
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>
2026-07-05 10:10:09 -06:00
6595b10bdd
feat(gui): native satpass + wzdx fields editable in dashboard; fix satpass enable layer (#40)
Close the GUI gaps so a standalone (all-native) deployment needs no
hand-editing of /data/config.yaml:

- Environment.tsx: native satpass (SGP4) section — observers editor
  ({slug,name,lat,lon,alt_m} list, mirrors traffic corridors), tle_groups,
  norad_ids, min_elevation_deg, window_hours, tle_refresh_seconds (all
  environmental.satpass YAML). Central adapter_config satpass panel unchanged.
- Environment.tsx: wzdx states + registry_url added (were file-only).
- BUG FIX: the satpass enable toggle wrote adapter_config.satpass.enabled
  (Central), but env/store.py gates the native adapter on
  environmental.satpass.enabled — toggle now writes the native/YAML layer.

Backend round-trips automatically (SatpassConfig/WZDxConfig already declare
all fields; _dict_to_dataclass whitelists by field). Frontend-only change.

NOTE: TS build (tsc && vite build) not runnable offline (node_modules absent);
validated at Docker build / deploy time.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:40:17 -06:00
0a75930ade
feat(firms): source-agnostic fire-fusion — native FIRMS feeds growth/spotting/halt (#41)
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>
2026-07-05 09:39:05 -06:00
53eadf5135
feat(phase4c): native SGP4 satpass — standalone satellite passes, no Central (#39)
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>
2026-07-05 02:25:55 -06:00
351f13ca64
fix(satpass): persist consolidation schedule — survive reboot (4b) (#38)
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>
2026-07-05 01:52:00 -06:00
f338673f0c
feat(wzdx): native FHWA WZDx work-zone adapter (standalone work_zone source) (#37)
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>
2026-07-05 01:36:52 -06:00
8cf0851964
fix(firms): native adapter attribution-only — never broadcast raw hotspots (#36)
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>
2026-07-05 01:18:44 -06:00
a756143284
feat(phase4a): peak_compass end-to-end + repair stale satpass test suite (#35)
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>
2026-07-05 01:06:29 -06:00
531bd20378
refactor(phase3c): migrate FIRMS fusion broadcasts to formatter+decider (#34)
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>
2026-07-04 23:18:51 -06:00
8bc9b14dba
refactor(phase3b): migrate WFIGS fire to formatter+decider (tier-a) (#33)
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>
2026-07-04 22:51:26 -06:00
f3990841f8
refactor(phase3a): migrate hydro/nwis to formatter+decider (tier-a) (#32)
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>
2026-07-04 22:22:33 -06:00
3252fe28fa
fix(shadow): forward source kwarg to gating decider in shadow_gate (#31)
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>
2026-07-04 18:37:12 -06:00
bdc42aa341
refactor(phase2): migrate nws + incident/roads to formatter+decider (tier-a) (#30)
Behavior-preserving relocation (byte-identical goldens), behind the staged-
cutover gate.

- formatters/nws.py + gating/nws.py: move _render + all CAP parsing (HTML-strip,
  _parse_nws_description, motion/hail/wind, county scoping, short-expiry) and the
  gating (3h re-alert window, Update/Cancel/Expire tombstone, first-sighting,
  warning->immediate severity). Native env/nws.py stops truncating description +
  carries parameters/eventCode/certainty/msgType/references/geocoder. Registers
  weather_warning + weather_statement.
- formatters/incident.py + gating/incident.py: absorb the incident _render AND
  the work_zone renderer, reconciling the two sub_type vocabularies; traffic_events
  change-detection gating. Native env/{traffic,roads511}.py emit canonical data.
  Registers work_zone/road_incident/road_closure/traffic_congestion.
- formatters/_anchor.py: shared town/distance/bearing resolver (consolidates WFIGS
  _location_anchor + incident nearest_town; WFIGS fire reuses in Phase 3).
- Fixtures captured: nws x37, traffic x46.

Tier-a byte-identical goldens for all fixtures. Tests: +~150; 0 new failures
(34 baseline, 1539 passed).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:16:34 -06:00
4e23fc737d
refactor(phase1): quake + swpc(Kp+flare) + avalanche, behind staged-cutover gate (#29)
* 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>
2026-07-04 15:58:46 -06:00
f06bf3ce92
refactor(phase0): source-agnostic formatter/gating scaffold + harness (inert) (#28)
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>
2026-07-04 12:55:48 -06:00
d117a3ea72
docs: update Central caveat — boot no longer requires Central (graceful + retry) (#27)
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 01:15:45 -06:00
0a9252329a
fix(central): don't crash boot when Central is unreachable at startup (#26)
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>
2026-07-04 01:13:50 -06:00
09c6425caf
docs: document meshai <-> Central (NATS firehose) + native/central feed_source (#25)
Add a "Native adapters vs. Central" subsection: each hazard feed chooses its
data path with feed_source (native = direct public-API fetch, the default and
original path; central = subscribe to Central's pre-aggregated NATS JetStream
firehose). Documents the environmental.central config, mutual exclusivity,
the satpass (central-only) / ducting (native-only) exceptions, runtime
auto-reconnect behavior, and the startup-needs-Central caveat.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:56:27 -06:00
fbb4fa0e94
docs: refresh README for dual-mesh (Meshtastic + MeshCore) + dashboard (#24)
Rewrite the README to reflect the current project: dual-transport
(Meshtastic base + MeshCore auto-on via meshcore_host), the web dashboard
(with live screenshots), per-mesh routing, the conversational bot with
per-mesh scoped context + three privacy lanes, environmental/hazard
broadcasts, mesh-health scoring, and the RAG knowledge base. Removes the
retired subscription backend/commands, updates the LLM model + architecture,
and adds live dashboard screenshots under docs/images/.

For transparency, documents that the project was vibecoded (built with LLM
coding assistants).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:47:11 -06:00
5c0f4b42f3
fix(meshcore): reply to DMs via discovered DIRECT route, not flood (#23)
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>
2026-07-03 22:25:40 -06:00
e7c8ef507e
fix(meshcore): resolve DM dest to full contact object before send (canonical pattern) (#22)
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>
2026-07-03 20:28:26 -06:00
ddd47afa87
fix(meshcore): deliver DM replies (flood/ACK), save meshcore_context, test-llm, inbound logging (#21)
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>
2026-07-03 19:54:16 -06:00
70a0fd1657
feat(context): GUI control for chat-context retention (days) + live apply (#20)
- 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>
2026-07-03 19:03:37 -06:00
a191200a12
feat(meshcore): decouple per-mesh LLM DM gate + mesh-scoped chat context (#19)
- 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>
2026-07-03 18:39:26 -06:00
284fb5cbf2
MeshCore Contacts roster + Companion status (read-only) (#17)
* 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>
2026-07-03 16:14:10 -06:00
61ca0057e2
fix(nws): tighten hazard wording across all product types; kill dangling '— …' in L4 (#16)
Special Weather Statements (and other SPS/WSW/FFW/FLW products) rendered a
verbose raw hazard sentence on L3 that ate the packet budget, collapsing L4 to
a dangling 'Moving SW 24 mph —…' with every town lost (seen live in the
Activity Log).

- Add _tighten_hazard(): compacts free-form NWS hazard text into the terse SVR
  idiom for ALL branches — 'Wind gusts in excess of 45 mph' -> '45mph gusts',
  'in excess of' -> '>', '45 mph' -> '45mph', 'pea size hail' -> '0.25" hail'.
  Applied to the FFW/FLW and SPS/WSW/else branches (SVR already terse).
- Rework L4 assembly to be budget-aware BEFORE the final hard cap: try location
  forms richest->poorest (full list -> first->mid->last -> first->last ->
  first-only -> none) and only attach '— {locs}' when the whole message fits.
  If no location fits, degrade to motion-only; if even that overflows, drop L4.
  A dangling '— …' / trailing '—' is now structurally impossible.
- Tests: SPS worst-case (tightened + no dangling), WSW, pathological
  motion-only degrade, SVR no-dangling re-verify; shared dangling-separator
  assertion.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 15:22:07 -06:00
0460462485
Phase A — 4-section nav; move Scheduled Broadcasts + Danger Zones off Routing (#15)
* feat(dashboard): Phase A — 4-section nav; move Scheduled Broadcasts + Danger Zones off Routing

Regroup nav into GENERAL/MESHTASTIC/MESHCORE/DOCUMENTATION (<=5 pages each,
MT & MC mirror). Consolidate via tabs (Places, Nodes & Health, Contacts &
Companion) reusing existing components. Move Band Conditions, cold-start,
and fire digest to per-mesh Scheduled Broadcasts pages; move Danger Zones
to its own page. Routing keeps its sending rules unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): Phase B — clean identical Routing grids; relocate per-family gating to Data Feeds

Meshtastic Routing becomes an always-visible pure-delivery grid matching
MeshCore (no master-toggle expand/collapse). Per-family gating (enable/
severity/freshness/cooldown) moves to a Family Settings section on Data
Feeds. Sending rules + Notification Rules unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): Phase C — MeshCore bot-behavior parity (observe channels / ignore contacts / DMs)

Add meshcore context (observe channels by name, ignore contacts, DM policy)
and wire the MeshCore inbound path to honor it, mirroring Meshtastic's
observe/ignore filtering. Symmetric "Bot behavior" sections on both
Connection pages. Meshtastic path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): Phase D — dedupe Environment/Adapter Config into one Data Feeds surface

Curated family panels are the single home for the shared adapter keys;
Adapter Config becomes an Advanced/raw escape hatch (owned keys no longer
double-editable). Surface include_in_llm_context per adapter. Fix the
adapter-config array-vs-object parsing (fire digest values now load).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): Phase E — Activity Log (per-mesh broadcast feed); remove subscription backend

Replace Alerts with an Activity Log fed by per-mesh broadcast logging
(transport+channel+success on mesh_broadcasts_out, additive migration).
Remove the entire subscription backend (commands, DM dispatch, storage,
API) and its UI.

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>
2026-07-03 14:57:24 -06:00
11bac716d0
feat(dashboard): send-test-message + MeshCore channel list (#14)
Add POST /api/mesh/test-send (fire a labeled test broadcast on a chosen
mesh+channel via the live transport) and GET /api/meshcore/channels
(surface the companion's enumerated channel names). "Send test message"
cards on both Connection pages, with the MeshCore one listing real
channels. Lets the operator confirm a mesh's send path on demand.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:06:31 -06:00
47b56adab5
refactor(transport): derive active transports from config, drop transport setting (#13)
* refactor(transport): derive active transports from config, drop transport setting

A mesh is active when its connection is configured: Meshtastic is the
always-on base; MeshCore runs whenever meshcore_host is set (blank = off);
both configured = both. Removes the transport mode field/toggle entirely
so there's no separate flag to miss.

* docs: fix stale transport comment after field removal

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>
2026-07-03 00:18:53 -06:00