Commit graph

136 commits

Author SHA1 Message Date
16f01b29d4
fix(persistence): derive SCHEMA_VERSION from migrations; unbreak the red suite (#140)
* fix(persistence): derive SCHEMA_VERSION from the migrations directory

db.py hardcoded SCHEMA_VERSION = 26 while migrations/ had already reached
v29 (v27 dispatcher floor-drop counter, v28 mesh_observations, v29 IPAWS).
The migration runner globs the directory and applies every vN.sql it finds
regardless of the constant, so a fresh DB actually landed at 29 while the
constant claimed 26 -- a three-version drift that three tests were
correctly catching.

Derive it from the highest vN.sql present instead of bumping the literal,
so it cannot drift again the next time someone adds a migration. Falls back
to 0 if the directory is missing so import never fails; the migrations dir
sits alongside db.py and ships with the package (Dockerfile COPYs meshai/).

Adds a regression guard asserting the constant matches the highest
migration file, and updates three tests that hardcoded 26 as a literal.

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

* test(tle): make TLE fixtures time-relative so they cannot expire

The ISS fixtures hardcoded epochs of 2026-06-30/07-01/07-02. tle_handler
sets STALE_DAYS = 14 and get_tle_by_norad() filters on epoch >= now - 14d,
so the fixtures silently aged out on 2026-07-02 and the tests began
failing -- a time bomb, not a regression.

Compute epochs relative to wall-clock now (base = now - 2d, +/-1d for
newer/older) with correct TLE epoch-field encoding and mod-10 checksum.
STALE_DAYS is untouched -- widening it in product code would have changed
production behavior to paper over a test bug.

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

* test(fire-tracker): pin config + history db to tmp_path

load_config() defaults HistoryConfig.database to the relative path
"conversations.db", resolved against the process CWD, so every test calling
load_config() with no override shares one file for the whole session. The
conftest DB-isolation fixture only covers MESHAI_DB_PATH, not this.

Point both the config dir and the history database at the test's tmp_path.

NOTE: this does NOT resolve the order-dependent failure -- the test still
passes standalone and fails in a full run, so the polluting state lives
somewhere other than config/history. Left failing rather than weakened;
root cause still unidentified.

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-17 13:03:40 -06:00
8460ab50e0
fix(ipaws): route county-only CAP alerts + auto-refresh toggles live (#159)
County-only civil CAP alerts (SAME geocode, no <polygon>) had no geometry, so
the geometry-based region tagger could not place them: no region -> no
region_routes match -> silently not broadcast. Many CEMs / 911 outages / some
AMBER alerts are county-only.

- Bundle work/meshai/county_centroids.py: Census 2023 national county gazetteer
  internal points (3,222 counties + DC/territories), FIPS->(lat,lon), with
  SAME PSSCCC -> 5-digit FIPS helpers.
- env/ipaws.py: when a CAP alert has SAME geocode(s) but NO polygon, set a Point
  (single county) or MultiPoint (multi-county) geometry from the county
  centroid(s) so the EXISTING coverage/region tagger locates it and tags ALL
  matching regions. Real polygon geometry always wins (never overridden).
- dashboard/server.py: call register_config_routes_hooks(app) in create_app so
  the toggle auto-refresh middleware is actually wired in prod (was test-only);
  saving a family toggle now takes effect live without POST
  /api/notifications/refresh-toggles.

Tests: county-only alert tags SW Idaho + matches emergency route cell;
multi-county tags all regions; polygon path unchanged; create_app wires the
refresh middleware; panhandle coverage-gap documented.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:08:29 -06:00
e244405230
feat(dashboard): bring ipaws adapter + emergency family to full GUI parity (#158)
The IPAWS civil-alert adapter shipped backend-complete but frontend-partial:
it rendered only in the generic adapter-config page and the Advanced (raw)
Data Feeds tab, and the `emergency` family was absent from the curated Data
Feeds panel, the family-settings toggles, and the MeshCore routing matrix.

Adapter (ipaws), benchmarked against firms/nws:
- Environment.tsx: add `ipaws` to AdapterKey union, EnvConfig interface,
  META (native-only, keyless), a new `emergency` FAMILIES group, PANEL_META_KEY
  (LLM toggle), and a hand-written renderSettings panel exposing base_url,
  user_agent, tick_seconds, state_fips, same_codes, exclude_weather,
  status_actual_only — with coverage-scope handling like the other adapters.
- Environment.tsx: IPAWS_DEFAULT backfill so pre-ipaws GET payloads don't crash.
- Dashboard.tsx: SOURCE_ICONS entry (Siren/IPAWS) so ipaws events aren't a slug.
- ActivityLog.tsx: TABLE_LABELS + CATEGORIES + text-hint so ipaws_alerts rows
  show labeled "Emergency" and honor the category filter.
- dispatcher.py: _SOURCE_TO_TABLE fallback ipaws -> ipaws_alerts so region-routed
  emergency sends land labeled in the audit feed (not NULL).

Family (emergency), benchmarked against fire:
- Notifications.tsx: add `emergency` to TOGGLE_FAMILY_META (Siren icon). This
  cascades to Family Settings, the Meshtastic delivery matrix, and the MeshCore
  routing matrix (the last was hardcoded to the static list and previously
  omitted emergency entirely). Backend VALID_TOGGLES/gating/categories were
  already complete — no backend family change needed.

Tests: update the _SOURCE_TO_TABLE exact-match guard and add an ipaws audit-row
parity test. Full suite 2407 passed / 6 pre-existing unrelated failures.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:36:13 -06:00
c04daa6e4d
fix(config): merge partial PUT bodies instead of resetting to defaults (#155)
Saving the "Auto-advert interval" dropdown on the MeshCore Companion page
took BOTH radios offline on 2026-07-17 06:46:52. One click, full outage.

The page PUT a single-key body to /api/config/connection:

    {"meshcore_advert_interval_seconds": 10800}

_dict_to_dataclass() builds kwargs only from the keys present in the body
and lets `cls(**kwargs)` default the rest, so every OMITTED field was reset
to its dataclass default and written to disk:

    type:                 tcp   -> serial            (Meshtastic offline)
    tcp_host:             192.168.1.100 -> <lost>    (LOCAL_FIELDS, see below)
    tcp_port:             4404  -> 4403              (wrong meshmonitor vnode)
    meshcore_host:        192.168.1.253 -> ''        (MeshCore off; blank = off)
    meshcore_conn_type:   serial -> tcp              (wrong transport)
    meshcore_serial_port: /dev/meshcore-rak -> ''    (RAK radio lost)

It was silent twice over. `connection` is restart-required, so the running
process kept the good in-memory config while the file sat gutted, waiting
for any restart to detonate. And save_section() writes the domain file
FIRST and local.yaml SECOND: meshtastic.yaml hit the disk already gutted,
then the local.yaml write (which owns connection.tcp_host via LOCAL_FIELDS)
died on `[Errno 13] Permission denied` -- so tcp_host landed in neither
file, and the 500 that would have named the cause was swallowed by the UI.
The operator saw nothing happen.

This was never one page's bug: PUT /api/config/{section} was destructive on
a partial payload for EVERY section. Other callers only survive because they
happen to spread the full object first.

Fixes, in depth:

* Route (the durable fix): merge the body over the CURRENT live section
  before coercing, so omitted keys keep their live values while present
  keys -- including '' / False / [] -- still apply. The base is the live
  config, the same values GET serves, so a partial PUT now lands exactly
  where a full-object PUT from that same GET would. Full-object callers are
  unaffected. Fixed at the HTTP boundary, not in _dict_to_dataclass():
  absent-key-means-default is CORRECT at config-load time, where a file
  legitimately omits fields it does not override.

* Nested semantics keyed off the dataclass schema, not "is it a dict":
  nested dataclass fields DEEP-MERGE (a partial region_routes must not drop
  sibling cells), while bare dict/list fields REPLACE at the key (cells,
  toggles, destinations, rules are dynamic maps -- deep-merging them would
  resurrect deleted keys and make deletion impossible, the mirror image of
  the bug being fixed).

* Page: send the full connection object like every other caller does.

* Errors are visible: the save handler no longer swallows the exception,
  and updateConfig() surfaces the server's `detail` rather than a bare
  "API error: 500", which is what hid Permission denied from the operator.

* Default advert interval 10800 -> 86400 (24h). 3h is far too frequent a
  default for a public mesh; the UI "(default)" label moves to match.

Tests: tests/test_config_partial_save_merge.py reproduces the outage with
the exact payload, and pins merge semantics across connection AND
notifications, intentional clearing, deep-merge, and map-deletion.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:28:41 -06:00
ea4c010967
feat(meshcore): report the true connection + add roster/channel management (#153)
self_info() reported host/port straight from config regardless of
conn_type, so a serial companion still advertised whatever stale
meshcore_host sat in the config — the API named a device meshai was not
talking to, which is enough to send an investigation to the wrong radio.
Connection details now come from one _connection_descriptor() shared with
connect(), so the log line and the API can't drift; only the live
conn_type's fields are populated and the rest are null.

meshai's device view is otherwise built once at connect and never re-read
— contacts via ensure_contacts(), channels via _enumerate_channels(). The
lib's contact handler only ever merges (meshcore.py::_update_contacts), so
a cached roster can never shrink, and a channel provisioned on the radio
stays invisible until the process restarts. There was no refetch path at
all. Adds an explicit resync that re-reads BOTH halves: a FULL
get_contacts(lastmod=0) reconciled with replace semantics (absent contacts
are dropped) plus a channel re-enumeration, each reporting what changed.

Also adds a preventive route-health check: every region_routes cell whose
MeshCore target cannot be resolved against the live roster/channel table
is surfaced, since such a send fails silently. Room targets are matched by
pubkey prefix, exactly as the dispatcher resolves them, so a picker-stored
prefix is not misreported as dangling. Same-name/different-pubkey roster
entries are flagged too — a name alone cannot identify a contact, which is
the trap behind a room rebuilt under a new keypair.

Backend:
- meshcore_roster.py: pure reconcile_contacts / check_route_health /
  find_name_collisions (no device I/O — unit-testable without a radio)
- transport: _connection_descriptor, resync, refresh_contacts,
  remove_contact, import_contact, export_roster, contacts_synced_at;
  auto_update_contacts enabled (configurable — it costs one incremental
  fetch per advert heard, which is real chatter on a dense mesh)
- API: POST contacts/refresh, DELETE contacts/{pubkey}, GET
  contacts/export, POST contacts/import, GET route-health

Frontend (existing Contacts & Companion page — no new page or nav entry):
- dangling-route + name-collision banners; resync/export/add-contact
  toolbar with last-synced and the added/removed counts; staleness badges;
  search, filters and sortable columns; per-contact delete behind a
  confirm; Companion tab shows the real transport + target.

A full pubkey is required to delete or add: the lib resolves by prefix,
and a prefix could silently hit the wrong node.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:24:49 -06:00
bbe97398bc
feat(ipaws): add FEMA IPAWS-OPEN civil-alert adapter (disabled by default) (#138)
Adds a new native `ipaws` adapter for FEMA IPAWS-OPEN EAS civil alerts.

- Two-stage CAP fetch via base_url (direct FEMA or Conduit proxy): Atom
  index -> per-entry CAP 1.2 documents.
- Non-weather civil alerts only (evacuation, Civil Emergency Message,
  AMBER, 911 outage, law-enforcement, HazMat); NWS/NOAA CAP dropped so
  weather is never double-broadcast.
- Idaho + neighbour statefips scope gate applied before stage-2 fetch.
- Own `ipaws_alerts` dedup table (migration v29); reuses the NWS CAP
  severity + formatter pattern.
- Ships enabled=False (no transmit until explicitly enabled).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:05:01 -06:00
1199b3576a
fix: make TomTom + FIRMS API keys optional (keyless-capable) (#137)
traffic and firms no longer idle when their key is blank — they build a
keyless request (traffic: omit key= param; firms: omit the map_key path
segment), matching roads511's existing optional-key pattern. Enables
routing these feeds through a key-injecting proxy (Conduit) with the key
held only there. Key-set behavior is byte-identical.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:04:23 -06:00
998838bcb2
fix: make all native feed URLs config-driven (no hardcoded upstreams) (#136)
Adds a base-URL config field (default = the current value, backward-compatible)
to the 9 adapters that hardcoded their upstream URL — nws, swpc (4 endpoints),
ducting, fires (perimeter+points), firms, avalanche, usgs streams (3 bases),
traffic, satpass/tle_fetch — mirroring the already-compliant roads511 pattern.
Every feed URL is now overridable via config, enforcing the "everything
configurable" rule and making each adapter live-repointable via a config PUT.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:34:27 -06:00
8b4826f8db
feat: hot-reload the environmental config section (no restart) (#135)
EnvironmentalStore.apply_config() rebuilds only the changed native
adapters in place on a config PUT -- dedup/seen state (store-level) is
preserved, unchanged adapters are untouched. Drops "environmental" from
RESTART_REQUIRED_SECTIONS; falls back to restart-required only for the
narrow feed_source->central case. Cascades nifc/fires -> firms. The old
restart requirement was a Central-era coupling, now moot (all-native,
central.enabled=false, CentralConsumer inert).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 01:21:12 -06:00
4fd431f907
fix(reminders): pace the reminder roll-call so N fires don't burst (#130)
The ReminderScheduler is the third fire-broadcast exit and the only one
that does not pass through the EventBus, so FirePacer (which paces the
Central and native fire-event exits to <=1/60s) never sees it. Its tick
is a roll-call: every eligible row is its own broadcast, dispatched in a
plain `for` loop with no gap. Unpaced, N eligible fires produce N
back-to-back mesh transmissions; the only downstream protection is
RadioSendQueue's ~2.2-2.6s per-transport inter-packet jitter, which
prevents packet collision but still lets a roll-call monopolise the mesh.

Not currently firing in production (every overdue fire is filtered by
terminate_when, so the eligible set is 0) -- this is fire-season
hardening against a latent burst, not a live incident.

Adds `spacing_seconds` (adapter_config, default 60 to match FirePacer)
enforcing a minimum gap between consecutive SUCCESSFUL reminder
deliveries. Deliberately a pure spacing change:

  * WHAT gets broadcast is untouched; nothing is dropped.
  * The ok-gated last_broadcast_at stamp still uses the tick's `now`.
  * A failed dispatch sent no packet, so it does not arm the gap.
  * Rows filtered by terminate_when/render never burn a spacing slot.
  * A lone eligible fire has nothing to pace against -> zero added latency.
  * The wait is interruptible by stop(): a 15-fire roll-call holds
    tick_once() for ~14 min and stop() awaits the tick task, so a plain
    sleep would stall shutdown.

Chose in-loop spacing over routing reminders through FirePacer itself:
reminders re-derive their targets from live DB state every tick and only
clear a row via last_broadcast_at after a confirmed send, so enqueuing
into a 60s-drain FIFO would re-enqueue the same fire on every intervening
tick -- the queue would grow faster than it drains. pacer.py, consumer.py,
store.py and main.py are untouched.

Tests fake the clock end-to-end, so 60s spacing costs the suite nothing.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:38:07 -06:00
afd045aa96
fix(gating): firms decide() spotting/halt severity key, issue #121 (#126)
gating/firms.py's decide() stamped a plain "severity" key in the
data_patch for the wildfire_spotting and wildfire_halted broadcast
paths. central/consumer.py only ever promotes data["_severity_override"]
onto Event.severity -- the plain key is a silent no-op, the same class
of bug as #118 (fixed for firms_handler.py's own inline stamps in
PR #120). Currently inert (MESHAI_CUTOVER_CATEGORIES is unset by
default), but the moment wildfire_spotting/wildfire_halted are cut
over, spotting would silently stop being "immediate".

- gating/firms.py: both data_patch sites now use _severity_override.
  Checked the other gating modules (fire.py, avalanche.py, swpc.py,
  quake.py, nws.py) -- all already use _severity_override correctly;
  firms.py was the only one with the plain-key mistake.
- Fixed the stale module docstring claiming the unattributed-hotspot
  cluster path "is DEAD" -- it has been live since d479ca53 (#73); the
  stale comment directly caused a bogus bug report against production.
- test_firms_refactor.py: updated two existing tests that had codified
  the buggy plain-"severity" behavior as expected, and added
  TestCutoverSeverityReachesEvent, which drives the real cutover path
  end-to-end through CentralConsumer._normalize and asserts the
  emitted Event's severity (immediate for spotting, routine for halt).
  Verified both new tests fail against the unfixed decider and pass
  against the fix.

Full suite: 20 failed, 2242 passed, 72 skipped (vs. origin/main
baseline 20 failed, 2240 passed, 72 skipped -- same 20 pre-existing
failures, confirmed identical with this change stashed out; +2 passed
are the new regression tests).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:37:53 -06:00
a85de23af7
fix(meshcore): un-shadow _resolve_contact; restore PR #56 refetch-on-miss (#127) (#133)
MeshCoreTransport defined _resolve_contact TWICE:

  - L171  (PR #56)  cache lookup -> on miss, get_contacts(lastmod=0) full
                    refetch -> retry. The DM / path-establishment resolver.
  - L1227 (PR #92)  key-prefix -> by-name lookup, no refetch. The telemetry
                    resolver, added later without noticing the collision.

Python silently keeps only the LAST definition in a class body, so the
line-171 implementation was dead code and PR #56 was nullified: every DM
and path-establishment caller was getting the telemetry resolver instead.
No error, no warning, invisible to the linter and the type checker.

Fix: rename the telemetry resolver to _resolve_contact_for_telemetry and
repoint its sole caller (_req_telemetry_async). The DM path (send_message)
and _establish_direct_path now get PR #56's refetch-on-miss behavior back,
which is what they need — replying to an inbound DM from a firmware
auto-added contact requires the refetch, and re-resolving after path
discovery is pointless without it.

Deliberately NOT merged into one resolver: telemetry auto-polls on a timer
against operator-selected contacts already in the roster, so a full-roster
refetch on every miss is recurring airtime for nothing; and its by-name
fallback is telemetry-specific and must not widen DM address resolution.
The two want different semantics — the bug was the name collision, not that
they should be one function.

_resolve_contact_async (the MC-event-loop twin) already carried the refetch
and was never shadowed, so the async/queue DM send path was unaffected.

Add tests/test_no_duplicate_methods.py: AST-walks every ClassDef under
work/meshai/ and fails if any class body defines the same method name twice.
This failure mode is invisible to review, the linter, and the type checker —
which is exactly why it survived. Exempts the legitimate same-name patterns
(@property/@setter/@deleter groups, @overload stacks). Verified it flags the
bug on the pre-fix source and finds no other duplicates in the tree.

Suite: 20 failed -> 17 failed (the 3 meshcore failures gone), 2240 -> 2245
passed (+3 fixed, +2 new guard tests), 72 skipped unchanged.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:37:26 -06:00
74a5fa44d4
fix(pipeline): let wildfire_spotting skip the grouper (category-scoped) (#131)
wildfire_spotting is the most urgent signal in the system (a fire
throwing embers past its own containment line), but every fire event
carries a group_key (= event_id), so the Grouper held spotting for the
full grouper_window_seconds (60s live) before it could even reach the
dispatcher -- a ~60s latency FLOOR, and ~120s+ once FirePacer queuing
is added on top.

Add a module-level _NEVER_COALESCE_CATEGORIES frozenset and bypass the
coalescing window for the categories in it. Currently: wildfire_spotting
only.

This is safe because spotting is already rate-limited AT THE SOURCE: a
per-fire 1h cooldown (adapter_config.fires.spotting_cooldown_seconds,
default 3600, latched on fires.last_spotting_broadcast_at per irwin_id
in gating/firms.py) gates spotting DETECTION itself, so N active fires
yield at most N spotting alerts per hour. FirePacer (60s interval, with
head-of-line ordering for immediate severity) and the dispatcher's
per-(toggle, category, region) cooldown still apply downstream.

The bypass is scoped by CATEGORY, never by severity. Commit 85d48ce3
deliberately removed a severity == "immediate" bypass from this exact
spot because ALL fire events carry _severity_override="immediate", so a
severity bypass exempts the entire fire family from rate control. The
comment on the constant spells that out so it does not get re-added.

Tests: spotting with a group_key passes straight through; wildfire_growth
and wildfire_incident at immediate severity are STILL held (proving no
severity bypass crept back in); spotting with no group_key still passes
through. PR #129's test_immediate_severity_is_also_coalesced_no_bypass is
untouched and still passes.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:37:07 -06:00
c82cceffde
test: fix 13 stale tests in the red suite, leave 6 real-bug failures (#124) (#129)
Triaged all 20 known-red tests. 13 were stale tests asserting rotted
expectations against deliberate, documented behavior changes; fixed by
deriving expected values instead of hard-coding, or updating the
expectation to match a documented policy change:

- test_adapter_config_foundation.py / test_adapter_config_api.py:
  REGISTRY/API key-count and key-set guards hard-coded magic numbers
  (59/94/17) that rotted repeatedly. Now derive expectations from
  REGISTRY itself and, for the schema version, from the migrations
  directory, so they can't rot the same way again.
- test_fire_tracker_phase4.py: two tests hardcoded a nonexistent
  deployment path (/opt/meshai/meshai/router.py) that matches no
  Dockerfile WORKDIR in this repo; resolve the module path via
  importlib.util.find_spec instead.
- test_tombstone_broadcast.py: asserted fire severity == "immediate",
  which commit 2f677e85 deliberately downgraded to "priority" (to stop
  fire broadcasts bypassing the Grouper/cooldown during NATS backlog
  replay) without updating this test.
- test_pipeline_grouper.py: test_immediate_severity_bypasses_grouper
  asserted an immediate-severity bypass that commit 85d48ce3
  ("fix(fire): remove immediate-severity exemption from grouper +
  cooldown") DELETED on purpose -- fire events carry
  _severity_override="immediate", and the exemption left fire with no
  rate control at all in normal live operation. Re-adding the bypass
  would re-open that fire-spam hole on a public-safety mesh, so the test
  moves, not the source. Renamed + inverted to assert the real contract
  (all severities coalesce; only a missing group_key passes through).
- test_tail_followups.py: dispatcher mock was missing
  dispatch_scheduled_fire_broadcast (a method added alongside the
  generic dispatch_scheduled_broadcast; test_reminders.py already
  mocks both).
- test_tracking_v057.py: guard required an empty tracking-family
  adapter list in Environment.tsx, but the frontend has long grouped
  the pre-existing native satpass adapter under the "Tracking" display
  section (its own "satpass" backend toggle, unrelated to the Phase-7
  tracking family every other guard in this file confirms is still
  unimplemented). Narrowed the guard to allow only that known entry.
- test_v052_dispatcher.py: two tests used category="wildfire_incident",
  which the phase3b fire migration (#33) forced onto a dedicated
  formatter via NATIVE_ALWAYS_DECIDE; swapped to wildfire_hotspot
  (same emoji/label, not in NATIVE_ALWAYS_DECIDE) to keep exercising
  the generic composer logic under test.

Also fixes one stale COMMENT (comment-only, no logic change) in
meshai/notifications/pipeline/__init__.py's start_pipeline(): it still
claimed "Immediate events bypass the grouper and don't need this
[periodic flush]", which has been false since 85d48ce3 and is precisely
what makes the deleted bypass look like a missing feature. The comment
now records that the removal was deliberate and must not be reverted.

The remaining 6 failures are left untouched -- 2 confirmed real bugs, to
be fixed deliberately in their own changes:
- meshcore_transport.py defines `_resolve_contact` TWICE on
  MeshCoreTransport (line 171 from PR #56, line 1227 from PR #92). The
  second silently shadows the first, so the DM contact-resolution
  refetch-on-miss that #56 added is dead code in production. (3 tests)
- SCHEMA_VERSION (persistence/db.py:33) is stale at 26 vs. the actual
  highest migration v28; v27 and v28 shipped without bumping it.
  (3 tests)

Plus 1 environment gap, not a code defect:
test_natural_language_fire_question_routes_to_llm needs the `openai`
package, which is declared in requirements.txt but not installed here.

Suite: 20 failed, 2240 passed, 72 skipped -> 7 failed, 2254 passed, 72
skipped. All 7 remaining failures are ones classified above; no new
failures introduced elsewhere in the suite.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:36:19 -06:00
8ba8700466
fix(fires): wire FirePacer into the native fire broadcast path (#123)
Native fire adapters (env/fires.py source="nifc", env/firms.py
source="firms") emitted straight to the EventBus from
EnvironmentalStore._emit_event with no rate limiting of their own.
FirePacer was only ever attached to CentralConsumer (main.py), which
never runs in the actual production deployment (central.enabled=False,
all adapters feed_source=native) -- so the <=1/60s throttle + immediate
head-of-line behavior fixed for Central in #120 (issue #119) was
completely inert in production. A poll that produces several distinct
fires/clusters at once (a lightning outbreak, or several tracked fires
crossing a satellite-pass boundary together) would dump all of them on
the mesh back-to-back instead of at the intended cadence.

_emit_event() now routes fire-family Events (source in
{"nifc","firms"}, severity in {"priority","immediate"}) through an
attached FirePacer, mirroring the exact gate CentralConsumer._handle
applies. main.py attaches the same FirePacer instance to env_store
right after constructing it. "routine"-severity fire events, non-fire
native adapters, and the Central path are all unaffected; a paced event
cannot re-enter either gate (native vs Central are mutually exclusive
per feed_source), so nothing can be paced twice.

Added tests/test_native_fire_pacer.py covering: native fire events
route through the pacer, an immediate event jumps an already-queued
priority queue with nothing dropped, routine-severity fire events and
non-fire native events are NOT paced, and the no-pacer-attached
fallback is unchanged.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 21:46:13 -06:00
5dd8266abe
fix(firms): repair the FIRMS fire-fusion Event contract (issues #117-#119) (#120)
Three independent bugs kept firms_handler's growth/spotting/halt/cluster
fusion decisions from reaching a correct mesh Event:

- #117: consumer._normalize() computed `category` from the raw Central
  category BEFORE the per-adapter handler ran and never re-read
  data["category"] afterward, so every firms_handler category stamp was a
  silent no-op. Now re-read post-dispatch, validated against the known
  category registry (unrecognized overrides are logged and ignored).

- #118: consumer.py only ever honors data["_severity_override"], but
  firms_handler's halt/spotting/cluster sites stamped the plain
  data["severity"] key instead (only growth used the right key). Switched
  all three sites to `_severity_override` for one consistent contract.
  This is severity plumbing only -- it does not change which events fire.

- #119: FirePacer's gate only matched source in ("fires","wfigs") at
  severity=="priority", so FIRMS fusion broadcasts (source="firms",
  growth/spotting at "immediate") never reached the pacer. Broadened the
  gate to cover "firms" + {"priority","immediate"}, and gave FirePacer
  head-of-line insertion so an "immediate" event is never stuck behind
  already-queued "priority" events. Still unbounded/never-drops.

Cluster detection is left exactly as main ships it: live, always on, no
toggle (PR #73's curated new-fire cluster broadcasts with cold-start
silent-seeding). Only its severity-override key changes, under #118.

Updated existing tests that asserted the old (buggy) data["severity"]
contract, and added tests/test_firms_fusion_event_contract.py covering
all three fixes end-to-end through consumer._normalize()/_handle() and
FirePacer directly.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:41:40 -06:00
f50c2e54d8
fix(dispatcher): qualify region-cooldown key by channel type (#115)
Section 1.5 (region_routes matrix branch) armed and checked the
per-region cooldown key as (toggle, category, region) with no
channel-type component. _chans always inserts mesh_broadcast before
meshcore_broadcast (insertion order in the per-cell append loop), so
for any matched cell with BOTH mt and mc populated, the mesh_broadcast
send armed the cooldown key first; the very next iteration checked
that SAME key for meshcore_broadcast and saw it as freshly cooled
down, dropping it every time. Net effect: meshcore_broadcast never
succeeded via the matrix branch whenever cooldown_seconds > 0 (true
for weather/roads/fire, all 300s), so it never armed its own
dedup/cooldown state either -- a silent, permanent MC blackout for
every region-routed family. Confirmed live: dispatcher_dedup had 555
rows, zero meshcore_broadcast; mesh_broadcasts_out was 120:4 MT:MC for
nws_alerts and 98:8 for traffic_events over 10 days (the few MC rows
that got through came from a different, non-matrix code path). fires'
98:77 near-1:1 ratio is not evidence the matrix branch worked for
fire -- those MC sends are dominated by the cooldown-exempt scheduled
reminder path (dispatch_scheduled_fire_broadcast); fire's own live
event-driven path has the identical latent bug, just masked.

Fix folds ch_type into the region string (mirrors the existing
_cd_suffix convention) so mesh_broadcast and meshcore_broadcast get
independent cooldown windows. Kept the cooldown key a 3-tuple
(instead of widening to 4) to avoid a dispatcher_cooldowns schema
migration -- _persist_cooldown() and the boot-restore SELECT are both
hard-coded to (toggle, category, region).

Verified in isolation (no live/deployed behavior change, no
transmit): a fresh Dispatcher built from the live production config
now dispatches both mesh_broadcast and meshcore_broadcast for
weather/roads/fire matched cells under a 300s cooldown. Added two
regression tests covering the gap that let this ship untested: no
existing test combined cooldown_seconds > 0 with a cell that has BOTH
mt and mc populated (test_cell_match_routes_mt_and_mc uses the
cooldown_s=0 default; test_per_region_cooldown_independence uses
cooldown_s=300 but with mc=None on every cell).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:22:54 -06:00
aa3e5943c4
feat(context): durable SQLite-backed retention for MeshContext observations (#114)
Mesh-context buffer (recap/summary source) was pure in-memory
(deque(maxlen=50000)), wiped on every container restart. Adds
mesh_observations table (migration v28) + persistence/mesh_observations.py
accessors following the existing get_db()/observer_locations.py pattern.

MeshContext.observe() now write-throughs each observation to SQLite
(fail-safe -- DB errors are logged and swallowed, never block or drop the
in-memory path). __init__ loads recent rows (within max_age, up to the
hard cap) back into the deque on startup so recap works immediately after
a restart. prune() now also deletes SQLite rows older than max_age on the
same hourly cadence as the existing in-memory prune.

Verified: wrote one observation through the real observe() path, confirmed
1 row in mesh_observations; docker restart meshai; log confirms "Loaded 1
mesh observations from durable store" and a fresh MeshContext instance
recovers the observation via get_context_block().

Investigated a suspected recap-grounding bug (LLM ignoring the mesh-context
block and replying with a disclaimer) per the same task brief, but could
not reproduce it against gemini-3.1-flash-lite across 8 query phrasings
(minimal and full production-fidelity system prompts, including the
configured static prompt clause that explicitly permits the disclaimer
when no traffic is shown) -- the model correctly grounds its replies in
the observed traffic block in every case tested. No grounding-clause
change made; premise did not reproduce.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:20:16 -06:00
1a8ae710dd
MeshCore: channel provisioning, key sharing, location-based recommendations & transport-aware identity (#113)
* feat(meshcore): self-service add/remove channel provisioning from dashboard

Adds MeshCoreTransport.add_channel()/remove_channel() (meshcore_transport.py),
scanning the full 40-slot companion channel table for a free slot (no early
empty-run cutoff, unlike _enumerate_channels) and writing/clearing slots via
the only available write opcode (set_channel, 0x20) — the lib exposes no
delete-channel opcode, so removal writes the slot back to its empty state
(blank name + all-zero 16-byte secret).

POST /api/meshcore/channels and DELETE /api/meshcore/channels/{name} routes
(mesh_send_routes.py) validate name/hex-key and return the refreshed channel
list, matching the existing secrets_routes.py HTTPException idiom.

Frontend: Add-channel row (name + optional PSK hex) and a per-channel Remove
affordance inside the existing Observe MeshCore Channels block
(MeshCoreConnection.tsx), plus addMeshcoreChannel()/removeMeshcoreChannel()
API client functions (api.ts).

Built + deployed to CT 108 (docker compose build && up -d, container
healthy); self-cleaning smoke test passed — POST/DELETE of a #meshai-test
channel left the companion table exactly as found.

* MeshCore: display per-channel key (PSK) with reveal + copy

The Observe MeshCore Channels list now shows each channel's PSK hex so
operators can share it with people who want to join. Fetches the
/api/meshcore/channels/detail endpoint (name + key) instead of the
names-only list; keys are masked by default with a per-row eye reveal
toggle and a copy-to-clipboard button. Channels with no retrievable key
show a dash. Existing observe (checkbox), remove (trash), and add-row
controls are unchanged.

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

* feat(llm): recommend a MeshCore channel by location + flash-lite default

Add a location-aware MeshCore channel-recommendation context block to the
single-shot LLM prompt so "what channel should I join?" gets an answer with
the channel NAME and join KEY.

- router.py: new standalone helper _build_meshcore_channel_block(config,
  channel_details, position) — lists ONLY observed channels
  (meshcore_context.observe_channels), each with its PSK key
  (channel_details) and, where a region_routes cell maps to it, the covered
  region's human geography (local name, cities, centroid) built from a
  channel->regions reverse map over notifications.region_routes.cells and a
  best-effort fuzzy attach to mesh_intelligence regions. Injected inside the
  existing should_inject_mesh gate, right after the region-geography block;
  fetches channel_details() + get_node_position() off self.connector and is
  fully fail-safe (any error skips the block, never breaks the LLM path).
  Empty observe_channels => empty block (LLM cannot invent channels).
- composite_transport.py: add channel_details() passthrough mirroring
  known_channels().
- README.md: bump the example llm model to gemini-2.5-flash-lite so a fresh
  deploy is policy-compliant (flash-lite supports Google Search grounding).

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

* feat(identity): transport-aware bot identity + alert-channel awareness

Fixes the LLM system prompt being transport-blind: MeshCore DMs were
being told they were on the freq51 Meshtastic mesh network and were
physical node !27780c47 (AIDA-N2), and MeshMonitor/node-health text
(Meshtastic-only) leaked into MeshCore prompts too.

- config.py: BotConfig gains mt_mesh_name/mt_node/mc_mesh_name (generic
  OSS defaults empty; name/owner defaults now generic MeshAI/Unknown).
  LLMConfig.system_prompt code default no longer hardcodes freq51/Twin
  Falls.
- router.py: generate_llm_response() computes transport once and
  branches identity: MeshCore gets an AIDA/MeshCore-radio framing with
  no MT node id; Meshtastic keeps the physical-node framing from the
  new config fields. MeshMonitor block and MT node-health/gateway/packet
  reporting (mesh_reporter Tier1/region/node detail + _MESH_AWARENESS_PROMPT
  + region geography) are now gated to transport == meshtastic. New
  _build_alert_channels_line() adds a short identity-block line on both
  transports listing region_routes-derived alert channels (MT channel
  index or MC channel name). MeshCore channel-recommendation block stays
  transport-neutral.
- README.md: dead gemini-2.5-flash-lite example updated to
  gemini-3.1-flash-lite.

Live config (meshai_data volume, not git-tracked): bot.name=AIDA,
bot.owner=K7ZVX (local.yaml identity.owner, which overrides config.yaml
per config_loader.py LOCAL_FIELDS), mt_mesh_name/mt_node set to the
freq51/AIDA-N2 values, mc_mesh_name left empty. llm.yaml system_prompt
freq51 line removed.

Verified via in-container prompt-assembly dry run through the real
generate_llm_response() path (no LLM call) for both transports, and a
live gemini-3.1-flash-lite 3-query behavior test confirming the
MeshCore channel-recommendation feature still works correctly.

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-10 23:30:57 -06:00
22037c9a23
feat(meshcore): set/clear room-server passwords from the routing picker (#112)
Adds PUT/DELETE /api/meshcore/room-password/{pubkey} (dedicated route — the
generic secrets allowlist rejects dynamic per-room vars) and a password_set
flag on GET /api/meshcore/rooms. The routing picker gains an inline lock +
set/clear editor on room-mode cells; state is keyed by room pubkey and shared
across cells targeting the same room. Send-time login already reads the stored
password via secrets_store — no dispatch changes needed.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
2026-07-09 23:11:38 -06:00
2cc672e751
fix(meshcore): room-picker toggle now opens the room dropdown (#111)
The MeshCore routing cell's channel-vs-room toggle derived its mode
solely from whether the value started with `room:`, and the Room button
merely cleared the value -- so room mode was never entered, the room
<select> (gated on that) never rendered, and a room could never be
selected (chicken-and-egg: the select was the only writer of the `room:`
value). Add explicit per-cell mode state that the toggle sets directly
(falling back to value-derived on first render, so existing room: cells
load in room mode); Home now enters room mode and renders the dropdown
even with an empty value, Hash renders the channel input.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 22:41:06 -06:00
2e7b3d6934
feat(meshcore): route MeshCore cells to room servers (open rooms) (#110)
Extends MeshCore routing so a region_routes `mc` cell can target a room
server, not just a `#`-channel. A room = a contact with type==3; a cell
value of `room:<pubkey>` routes to it via the existing addressed DM path
(send_msg to the room's pubkey), with an optional login for
password-protected rooms; a bare cell value stays a channel broadcast
(unchanged). Adds transport get_rooms()/login_to_room()/send_to_room_async,
a GET /api/meshcore/rooms endpoint, per-room password storage in
secrets_store (env MESHCORE_ROOM_<prefix>_PWD), and a routing-GUI
channel-vs-room picker (rooms shown by name with a path indicator).

Open rooms work end-to-end. Password-protected rooms need a follow-up:
a backend endpoint to SET the per-room password from the GUI (the generic
secrets API is allowlist-gated); the storage + login already exist.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 22:14:42 -06:00
77e057ae86
fix(wzdx): persist current work zones to traffic_events via dedicated ingest (#109)
The WZDx daily summary + DM query count from traffic_events, but work
zones weren't landing there: wzdx rode the generic _delta_emit path,
which silent-seeds the seen-set and returns before the decider's INSERT
on the cold-start first poll, so the current zone set never persisted
(summary would count ~0). Add a dedicated _ingest_wzdx (mirroring the
fires ingest) that UPSERTs every current coalesced zone into
traffic_events each poll (persist-only, last_broadcast_at=NULL, no emit,
no broadcast) and reconciles zones that drop out of the feed (never wipes
on an empty/failed fetch). Per-event work-zone broadcast stays suppressed
(the decider's work_zone gate is untouched). Retargets 4 tests in
test_store_received_delta.py that used a fake 'wzdx' source to exercise
the generic gate onto a neutral routing name.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:08:13 -06:00
15ddedf22b
feat(adapters): WZDx daily work-zone summary + FIRMS restart-safe cold-start (#108)
1. WZDx work zones: replace per-event broadcasting with a once-a-day
   per-region count summary. Coalesce the upstream per-direction /
   per-schedule-day fan-out into one row per physical zone
   (road + lat3 + lon3 + sub_type); work zones are stored in
   traffic_events but no longer per-event broadcast, while 511 crash /
   closure / hazard incidents still broadcast live. A WZDxSummaryScheduler
   emits one count line per coverage region once a day (default 07:00
   America/Boise), routed via the region_routes 'roads' cells; work-zone
   details are DM-queryable (build_work_zones_detail). New config:
   wzdx.summary_enabled / summary_time / summary_tz.

2. FIRMS cold-start is now restart-safe: gate the silent-seed on the
   persisted firms_pixels baseline being empty (first-ever run) instead of
   an in-memory per-boot flag, so a restart no longer silently absorbs a
   genuinely-new hotspot cluster.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:13:17 -06:00
b0b0697bac
fire: remove fire digest feature and drop out-of-coverage fires at ingest (#107)
Two fire-scope cleanups:

1. Remove the fire digest feature entirely -- scheduler
   (notifications/scheduled/fire_digest.py), pipeline wiring, the
   fires.digest_* adapter_config key registrations, and the Fire Digest
   dashboard UI (ScheduledBroadcasts / Environment / Reference /
   AdapterConfig / ActivityLog). The unrelated generic per-rule
   notification digest is kept. Orphaned fires.digest_* config rows and the
   fire_digest_broadcasts table are left as inert data (v16 migration
   untouched).

2. Add a coverage-scope gate at fire ingest: _ingest_fires now skips any
   fire whose coordinates fall outside all configured coverage areas (same
   areas_from_config + classify_geom_areas membership the dispatch-level
   CoverageFilter uses), so out-of-coverage fires are never stored, tracked,
   alerted, reminded, or re-ingested. Fails open when coverage is disabled,
   has no areas, or excludes the fires adapter.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:46:12 -06:00
af826319c8
feat(reminders): route fire reminders through per-region fire routing (#106)
Fire (wfigs) reminders previously dispatched via the generic scheduled
path, which hardcoded the rf_propagation toggle (Meshtastic ch4 / MeshCore
#aida) and ignored the fire's region. They now route through a new
dispatch_scheduled_fire_broadcast() that builds a synthetic fire event from
the fire's lat/lon, derives its region the same way the live fire event
path does, and routes per region_routes.cells['fire'] (per-region MT/MC
channels), falling back to the fire toggle's own defaults when a transport
isn't matrix-owned -- never rf_propagation. rf_propagation and 511
reminders are unchanged. Reminders remain disabled (reminders_wfigs.enabled
stays false); this only fixes routing for when they are enabled.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:06:45 -06:00
fed63b2344
fix(channels): dedupe Meshtastic channel list, un-bury MeshCore channels card (#105)
The Meshtastic connection page listed channels twice (the pre-existing
Observe-Channels selector plus a redundant read-only Index/Name/Role
table); remove the redundant table and its now-unused getChannels() /
MeshtasticChannel API helpers. On the MeshCore companion page, move the
Channels (name/hash/key) card up to sit right after the Status card,
above the Advertising settings, so it is no longer buried at the bottom.
No behavior change; layout/UX only.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:55:29 -06:00
6e1831b606
refactor(channels): move channel info onto the connection pages + expose MeshCore channel key (#104)
Remove the standalone Channels page and its nav entry (avoids menu bloat)
and relocate the channel listings onto the existing connection pages:
MeshCore channels (name, on-air hash, and the channel key/PSK with a copy
button) on the MeshCore companion page, and Meshtastic channels (index,
name, role) on the Meshtastic connection page. The MeshCore transport now
retains the channel_secret it already fetches at enumeration and
/api/meshcore/channels/detail returns it as `key` (hex PSK) so operators
can provision companion radios. known_channels() and /api/meshcore/channels
are unchanged.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:19:40 -06:00
486ad1016f
feat(channels): read-only Channels view for Meshtastic + MeshCore (#103)
Add a read-only Channels page listing both transports' channels with the
correct identifier per transport: Meshtastic by channel index (index,
name, role) from /api/channels, and MeshCore by channel name plus the
on-air hash from a new /api/meshcore/channels/detail endpoint. The
MeshCore transport now retains the channel_hash it already fetches at
enumeration (previously discarded); known_channels() and the existing
/api/meshcore/channels endpoint are unchanged. Adds a Channels nav entry
and /channels route.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:36:31 -06:00
0feb8adaca
fix(region-routing): independent mt/mc region-routing enable switches (#102)
Split the single region_routes.enabled master switch into per-transport
mt_enabled (Meshtastic) and mc_enabled (MeshCore) flags so the two
transports can be region-routed independently. Previously the shared
switch forced MeshCore into the region matrix; with all mc cells null it
routed MeshCore nowhere instead of falling through to the toggle-level
meshcore_channel. The dispatcher is now authoritative per-transport: a
disabled transport falls through to its toggle path, and matched-but-
inactive cells still suppress the toggle for enabled transports. The
destinations delivery branch also honors matrix-handled suppression to
prevent double-broadcast. Loader maps legacy enabled:true to
mt_enabled:true, mc_enabled:false. Each GUI routing page gains its own
master enable toggle.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:58:09 -06:00
3ee33015bd
fix(activity-log): extend TEXT_HINTS with all observed legacy prefixes + backfill remaining 39 NULL rows (#101)
STEP 1 audit of the live DB revealed 6 distinct prefixes across the 39 residual
NULL rows, not just  WX / ⚠️ Road Incident as originally anticipated:

  nws_alerts (30):
    🌬️ Special Weather Statement (25) — NWS SPS via nws.py
    ⛈️ Severe Thunderstorm Warning  (3) — NWS SVR via nws.py
    🌩️ Update: Severe Thunderstorm  (1) — NWS update via nws.py
     WX:                          (1) — legacy composer weather-watch

  traffic_events (9):
    ⚠️ Road Incident                (7) — incident_handler.py (sub_type incident)
    🚫 Road Closed                  (2) — incident_handler.py (sub_type road_closed/closure)

TEXT_HINTS additions: 🌬️ / ⛈️ / 🌩️ → Weather; 🚫 → Traffic.
All 6 prefixes are unambiguous (confirmed against formatter source + live data).
DB: 39 rows backfilled (audit table only). Final NULL residual = 0.
No mesh transmission.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 17:03:25 -06:00
0857b92788
fix(activity-log): map legacy WX / ⚠️ Road Incident prefixes to Weather/Traffic + backfill residual orphans (#100)
Extends TEXT_HINTS with three missing legacy prefixes so NULL-source audit rows
render the correct family label in the Activity Log UI:
-  (U+23F3) → Weather  (legacy composer weather-watch: " WX: …")
- 🌡️ → Weather  (NWS heat-alert format added in PR #96)
- ⚠️ Road Incident (U+26A0+FE0F) → Traffic  (legacy incident_handler rows)

Placed more-specific prefix before shorter variants to avoid false matches.
DB backfill: 0 residual NULL rows found (all rows already tagged after PR #99);
no audit-table writes performed. No mesh transmission.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 16:53:40 -06:00
40fcbf88e8
fix(activity-log): stamp source_event_table for native adapter broadcasts + UI label fallback + backfill recent orphans (#99)
Backend: add _SOURCE_TO_TABLE class constant in Dispatcher mapping event.source
("nws", "nifc", "wzdx", "traffic", "511") to canonical audit table names.
_post_broadcast_commit now falls back to this map when _broadcast_audit is
absent/None, so native env adapter sends (nws.py, fires.py, wzdx.py,
roads511.py, traffic.py) write a non-NULL source_event_table instead of NULL.
Existing _broadcast_audit paths (Central handlers, scheduled broadcasts) are
unchanged.

Frontend: replace naive familyLabel() string transform with explicit
TABLE_LABELS lookup (10 known tables → friendly names) plus a TEXT_HINTS
emoji-prefix heuristic for legacy NULL-source rows, so historical orphan rows
still display a meaningful label before/after backfill.

Tests: three new unit tests in test_dispatcher_persistence.py covering the
fallback path (nws→nws_alerts), the full _SOURCE_TO_TABLE map, and that
explicit _broadcast_audit is never overridden by the fallback.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 16:41:12 -06:00
d988868257
feat(fires): fuse WFIGS incident-point layer with perimeter layer so non-perimeter fires surface (dedup by IrwinID, cold-start silent-seed) (#98)
Add WFIGS_Incident_Locations_Current point layer (IRWIN superset, ~6 ID fires)
alongside the existing perimeter layer (~2 ID fires). Fires are merged by IrwinID:
point layer is the authoritative superset, perimeter layer supplies polygon
geometry and validated acreage when available. Point-only fires surface with
lat/lon from the point geometry and no polygon. cold-start silent-seed path is
unchanged (first-poll batch is always silent regardless of source). Perimeter
fetch failure falls back to perimeter-only stubs; point fetch failure falls back
to perimeter-only; both failing bumps the consecutive error counter. county is
now populated from POOCounty on the point layer. Five off-air unit tests cover:
T1 merge-dedup, T2 point-only-new, T3 perimeter-geom-preferred, T4 cold-start
silent-seed 6 fires, T5 FIRMS _get_known_fires attribution.

No changes to store.py, gating/fire.py, schema, or coverage.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 15:58:48 -06:00
099cec783c
fix(native): promote decider severity/category onto Event + make silent severity-floor drop observable (counter+log) (#97)
CHANGE 1 (store.py): after applying gate.data_patch into event.data, promote
_severity_override and category keys onto event.severity / event.category.
Previously, decider overrides (e.g. fire: "priority" on every New/Update)
landed only in event.data, leaving event.severity at the adapter's raw value
("routine" for fires >=25 km from an anchor). This silently failed the
toggle/matrix min_severity floor. Native and Central now share identical
broadcast decisions at the shared choke point.

CHANGE 2 (dispatcher.py + v27 migration): both the toggle-path and
matrix per-cell severity-floor drop paths now emit a WARN log and
increment a new persisted counter (severity_floor_dropped) in
dispatcher_state, following the existing drop-counter pattern exactly.
v27.sql adds the column; the counter restores on restart and appears
in dispatch_stats().

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 14:42:20 -06:00
ad33e6e02b
feat(nws): register weather_watch/advisory with NWS formatter + cutover (#96)
weather_watch and weather_advisory were falling through to the legacy
Mode-B renderer (composer.py), which appended the raw expiry epoch
(`exp 1783615500.0` from _context_segment) and overflowed 140 chars,
splitting into two packets with the epoch stranded in packet 2.

Fix: register both categories with the NWS formatter and gating decider
(same path as weather_warning/weather_statement), and add them to
MESHAI_CUTOVER_CATEGORIES in docker-compose.yml so the pipeline routes
them to the new formatter on deploy.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 13:09:06 -06:00
aef9877ba6
feat(send-queue): jittered pacing (default 2.2-2.6s) per radio instead of fixed 2.0s (#95)
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 11:12:28 -06:00
cd5418728b
fix(region-routing): audit every broadcast (matrix+toggle); preserve cell channel through send queue; fix /api/channels for composite transport (#94)
Defect A (audit gap): _post_broadcast_commit only wrote a mesh_broadcasts_out row
when event.data["_broadcast_audit"] was a dict. Native traffic/weather/roads events
from native adapters never set that key, so every matrix-dispatched send was
invisible in the audit table even when the dispatcher logged success. Fix: write
the audit row for every mesh delivery attempt (ch_type in _MESH_CH_TYPES),
unconditionally. source_event_table/source_event_pk come from _broadcast_audit when
present, else NULL (best-effort). The early-return on empty data is preserved only
for the _on_broadcast_committed callback, not for the audit write.

Defect B (channel routing): full trace of the send path confirms the channel IS
correctly threaded from the matrix cell through _toggle_to_rule (mt_override) →
create_channel(channel_index=rule.broadcast_channel) → MeshBroadcastChannel
(self._channel) → send_message_async(channel=self._channel) → CompositeTransport
→ MeshtasticTransport send_queue job closure → _blocking_mt_send(channel) →
sendText(channelIndex=channel). No code bug: the correct channel index reaches the
radio. The missing audit rows (Defect A) prevented confirming this from the DB.

/api/channels fix: the endpoint read connector._interface which does not exist on
CompositeTransport (only on bare MeshtasticTransport), so it always returned []
when MeshCore was also configured. Fix: detect CompositeTransport and route to
meshtastic_child()._interface instead.

Tests added: matrix send without _broadcast_audit writes audit row with correct
channel+transport+success; failed delivery writes success=0 row; matrix cell
channel index reaches _blocking_mt_send end-to-end (queue path exercised);
/api/channels returns real channel list via CompositeTransport.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 10:47:26 -06:00
a7b7f5a6a4
feat(transport): per-radio serialized+paced outbound send queue (#93)
* feat(transport): per-radio serialized+paced outbound send queue

Prevents simultaneous LoRa transmissions when N events arrive at once.

## Mechanism

Two `RadioSendQueue` instances (one MT, one MC), each a FIFO asyncio.Queue
with a long-running drain task.  The MT queue drains on the main asyncio
loop; the MC queue drains on MeshCore's dedicated event-loop thread.

- MT sends: `run_in_executor` offloads the blocking `sendText` call;
  queue started in `set_message_callback`, cancelled in `disconnect`.
- MC sends: drain loop runs pure-async MC lib coroutines directly on the
  MC loop (no `_run_coro` deadlock); cross-loop callers bridge via
  `concurrent.futures.Future` + `asyncio.wrap_future`.
- Pacing: `await asyncio.sleep(pacing_seconds)` between items; read live
  from config per iteration; floor clamped to 0.25 s.
- Config knobs: `meshtastic_send_pacing_seconds` (default 2.0) and
  `meshcore_send_pacing_seconds` (default 2.0) on `ConnectionConfig`.

## Send sites rerouted

All callers now `await connector.send_message_async(...)`:
- `notifications/channels.py` — MeshBroadcast/MeshCoreBroadcast/MeshDM/
  MeshCoreDM deliver(), test_connection(), deliver_test()
- `responder.py` — DM replies in send_response()
- `transport/meshcore_transport.py` — periodic_advert_loop, telemetry
  poll loop, send_advert() → send_advert_async(), req_telemetry()
  → req_telemetry_async() (all queue-routed from main loop)
- `dashboard/api/mesh_send_routes.py` — test-send, advert, telemetry poll

## Audit accuracy

`deliver()` now returns the actual bool from the radio send (not
optimistic True), so `mesh_broadcasts_out` reflects the real result.

## Tests

17 new tests in tests/test_send_queue.py covering FIFO ordering, no drops,
pacing gap, pacing floor enforcement, event-loop non-blocking, serialization,
lifecycle, MT fallback, config round-trip.  Existing test stubs updated to
wire `send_message_async = AsyncMock(side_effect=send_message)` so prior
call_count / call_args assertions remain valid without changes.

Full suite: 2135 passed, 17 pre-existing failures (unchanged), 0 new regressions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(send-queue): resolve MC telemetry self-deadlock + resolve pending futures on teardown/reconnect; composite MC-channel kwarg; audit no-op false

BLOCKER 1 — req_telemetry_async self-deadlock (meshcore_transport.py):
_req_telemetry_async was calling _enqueue_mc_loop_send inside itself;
when _telem_job_outer ran inside the drain it nested another enqueue+await
on the same single-threaded drain — permanent deadlock on first telemetry poll.
Fix: _req_telemetry_async is now fully inline (no _enqueue_mc_loop_send).
_telemetry_poll_loop wraps its call in _enqueue_mc_loop_send for serialization.
req_telemetry_async's outer job calls _req_telemetry_async inline (safe).

BLOCKER 2 — pending futures abandoned on teardown/reconnect:
RadioSendQueue.stop() only cancelled the drain task; queue-sitting items had
their concurrent.futures.Futures left unresolved, causing wrap_future() callers
to hang indefinitely. Fix: stop() drains the remaining queue with get_nowait()
and cancels every pending cfut. _cancel_mc_queue() schedules the same drain-
and-cancel via call_soon_threadsafe. _start_mc_queue() cancels old drain task
and drains old queue cfuts before arming the new queue (reconnect path).
connector.disconnect() now .result(timeout=5) on stop() instead of fire-and-forget.

SHOULD-FIX 3 — composite passes MC channel as wrong kwarg (composite_transport.py):
_broadcast_async no-hint loop was calling send_message_async(channel=child_channel)
for the meshcore child; should be meshcore_channel=child_channel. Silent drop fixed.

NIT 5 — false success on zero-channel MC send (meshcore_transport.py):
send_message_async returned True when meshcore_channel is None (nothing sent).
Now returns False so audit does not record a success for a no-op.

NIT 7 — config comment contradiction (config.py):
meshtastic_send_pacing_seconds comment said "0 disables the floor" while
simultaneously stating "still floored at 0.25". Removed the contradiction.

Regression tests (tests/test_send_queue.py — 3 new, all in TestDeadlockRegression):
- test_telemetry_queue_no_deadlock: drives req_telemetry_async through a real
  _mc_send_queue with fake MC commands; times out on pre-fix code (deadlock).
- test_teardown_resolves_pending_futures: enqueues slow+fast jobs, stops mid-drain,
  asserts every task resolves promptly; hangs on pre-fix code.
- test_reconnect_resolves_old_futures: calls _start_mc_queue twice, asserts old
  cfuts are cancelled; pre-fix leaves them unresolved.

All 17 pre-existing send-queue tests still pass (20 total now).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 09:38:08 -06:00
6e74b82d51
feat(meshcore): opt-in telemetry auto-poll on selected contacts (#92)
* feat(meshcore): opt-in telemetry auto-poll on selected contacts

req_telemetry + a poller for selected contacts (meshcore_telemetry_contacts,
interval with a min floor, availability detection). Contacts page gains
per-node auto-poll toggles + battery/sensor readouts + Poll-now, and maps
numeric contact type codes to Chat/Repeater/Room/Sensor badges.

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

* fix(meshcore-telemetry): reconcile with current transport/meshcore-lib API

- Add EventType.ACK + NEW_CONTACT to the telemetry test's fake module;
  _setup_subscriptions() subscribes to both (added in main before rebase)
  and the stale stub caused all three TestPollerScheduler tests to abort
  with AttributeError on connect().
- Same NEW_CONTACT gap fixed in test_meshcore_conn_type.py and
  test_meshcore_dm_delivery.py — these ran first (alphabetically) via
  setdefault, contaminating the shared sys.modules["meshcore"] stub for
  all downstream test files and causing 9 extra connect()-path failures
  suite-wide (TestPeriodicAdvertScheduler, TestAdvertOnConnect, etc.).
- req_telemetry_sync(contact, min_timeout=5) matches the installed lib
  (meshcore-2.3.7 binary.py) exactly — no production-code change needed.
- All 30 test_meshcore_telemetry tests pass; full-suite failures drop
  16 → 7 (remaining 7 are pre-existing, unrelated to telemetry).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 00:54:59 -06:00
e3b93f652c
feat(nws): resolve zone-only alerts to geometry via affectedZones (cached) so in-coverage zone alerts are placed + region-tagged instead of dropped (#91)
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:23:19 -06:00
9b6053365a
feat(region-routing): tag satpass events by observer coordinates so they region-route; add satpass to VALID_TOGGLES (#90)
Co-authored-by: Matt Johnson <mj@k7zvx.com>
2026-07-07 21:43:49 -06:00
3be54fa2df
fix(region-routing): always region-tag when named coverage areas exist (kills the Coverage-GUI flag reset); add per-event region-tag DEBUG log for measurement (#89)
Co-authored-by: Matt Johnson <mj@k7zvx.com>
2026-07-07 17:34:42 -06:00
2c46c9104d
feat(region-routing): unified per-family routing cards + region-scoped family→channel routing (#87)
* feat(region-routing): P1 tagging + region_routes primitive + read/write API + preview launcher

- config.py: add Coverage.region_tagging (bool=False); add RegionRouteMatrix
  dataclass (enabled, cells) above NotificationsConfig; add region_routes field
  to NotificationsConfig; add explicit hydration branch for region_routes in
  _dict_to_dataclass mirroring destinations pattern.

- coverage_area.py: add MonitoringArea.name (str|None=None, frozen); update
  areas_from_config to preserve name; refactor inline geom extraction from
  classify_event_areas into shared _event_geom_json helper; add
  matching_area_names(geom_json, areas)->list[str] (additive, all named
  matches, config-order, deduped; gate unchanged); add event_region_names
  convenience wrapper.

- coverage_filter.py: add region_tagging ctor kwarg; stamp event.region/
  regions before the gate when region_tagging=True and areas non-empty and
  not event.regions (never clobbers satpass preset).

- pipeline/__init__.py: wire region_tagging into CoverageFilter construction.

- notification_routes.py: add GET /notifications/regions (named coverage area
  names, config-order, deduped); GET /notifications/region-routing (matrix as
  JSON); POST /notifications/region-routing (explicit RMW — only region_routes
  changes, toggles/rules/destinations survive).

- scripts/preview_dashboard.py: mesh-free launcher — dashboard API only, no
  mesh connector, no broadcast loop; vite runs separately.

All 87 coverage tests pass; 300 total pass; 6 pre-existing failures unchanged
(adapter config count mismatch + MeshCore EventType.NEW_CONTACT).

* feat(region-routing): manual region x family matrix editor page

Adds RegionRoutingMatrix.tsx — a plain editor over the region_routes
config primitive. Rows = families (via useFamilies()), cols = regions
(from GET /api/notifications/regions). Each cell exposes MT channel
(ChannelPicker single + includeDisabled), MC channel name (text input),
min_severity select (routine/priority/critical/immediate), and an enabled
checkbox. Only cells where MT or MC is set are included in the sparse
POST payload. Master enable toggle maps to top-level enabled. MT budget
guard warns when more than 7 distinct MT indices are in use. Sticky
family column; horizontal scroll for wide region sets.

Registers route /region-routing in App.tsx and adds "Region Routing"
nav entry (Map icon) under the Meshtastic section in Layout.tsx,
immediately after Routing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(region-routing): regions endpoint reads saved (disk) coverage so routing columns are dynamic without a bot restart; preview reloads config after writes

* feat(routing): unify MT/MC routing into per-family cards; region routing as an in-card expand; remove rules/destinations UI + standalone page

* refactor(routing): move Meshtastic Routing from /notifications to /meshtastic/routing (mirror /meshcore/routing); redirect legacy path

* feat(region-routing): dispatcher honors region_routes matrix (authoritative-on-match, per-region cooldown, per-channel dedup); non-matrix path unchanged

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

* fix(region-routing): matrix dedup key must match boot-restore 2-tuple form (prevents restart re-broadcast flood); regression test

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 16:52:03 -06:00
ceb95fb80e
fix(fires): detect acreage/containment growth, not just the fire-name set (#86)
The WFIGS adapter computed changed = (old event_id set != new event_id set),
so growth of an already-known fire produced changed=False. The store only runs
_ingest_fires (and the Phase-3 fire decider) when tick() reports a change, so
growth/update broadcasts for stable fires never fired — only brand-new or
dropped fire NAMES woke the path. Include acres + containment in the change
signature. The decider stays the broadcast gate (forward-only + cooldown), so
no backlog is dumped.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:26:05 -06:00
1a1aef2e6e
fix(generic): browser UA default + per-source custom headers + 403 retry (#85)
The MeshAI/1.0 UA intermittently trips WAFs (Idaho Power's Azure Front Door
403s it ~2/30; a browser UA gets 200 every time). Default the adapter +
preview to a browser User-Agent, retry once on 403/429, and add optional
per-source custom headers (UA/auth) editable in the GUI. Makes WAF'd and
keyed feeds pollable.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 02:24:27 -06:00
322793dab3
feat(gui): unified Delivery Destinations editor (define once, reference everywhere) (#84)
Add a Destinations manager to the Routing page — define each delivery target
(mesh channel / email / webhook / digest) once — and a destination picker on
each family and rule. The duplicated per-family inline email/webhook editors
move under an Advanced/legacy disclosure. De-fragments delivery config: no
more configuring the same email in two places.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 02:06:18 -06:00
9701511754
feat(notifications): reusable delivery destinations (additive, inline fallback) (#83)
Add NotificationDestination + config.notifications.destinations and a
`destinations` reference list on toggles/rules. When a toggle/rule references
destinations, delivery resolves from the shared destination; when empty, the
existing inline-field delivery path runs UNCHANGED (zero regression). Lets
email/webhook/mesh-channel be defined once and reused, de-duplicating the
delivery config. UI to follow (C2).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 01:51:41 -06:00
c229b1e574
feat(gui): fold custom sources into Data Feeds; retire orphan page (#82)
Move the generic-source editor into a reusable GenericSourcesEditor and render
it as a "Custom Sources" section on the Data Feeds (Environment) page, so a
custom source sits with the built-in feeds instead of an orphan page. Remove
the standalone /data-sources route + nav. Custom sources are now managed as
first-class data feeds; enable their family in Notifications to route them.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 01:34:26 -06:00
a60000485b
feat(gui): Routing renders families from the registry (custom families assignable) (#81)
Add GET /api/notifications/families and make the Routing UI merge the static
built-in families with registered dynamic families, so a generic source's
family appears as a toggle the operator can enable and assign a delivery
destination (mesh/meshcore/email/webhook). Closes "there's nowhere to
broadcast it" — a custom source is now routable from the GUI. Data Feeds fold
+ orphan-page retirement follow in B2.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 01:23:26 -06:00