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>
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>
Defect A (audit gap): _post_broadcast_commit only wrote a mesh_broadcasts_out row
when event.data["_broadcast_audit"] was a dict. Native traffic/weather/roads events
from native adapters never set that key, so every matrix-dispatched send was
invisible in the audit table even when the dispatcher logged success. Fix: write
the audit row for every mesh delivery attempt (ch_type in _MESH_CH_TYPES),
unconditionally. source_event_table/source_event_pk come from _broadcast_audit when
present, else NULL (best-effort). The early-return on empty data is preserved only
for the _on_broadcast_committed callback, not for the audit write.
Defect B (channel routing): full trace of the send path confirms the channel IS
correctly threaded from the matrix cell through _toggle_to_rule (mt_override) →
create_channel(channel_index=rule.broadcast_channel) → MeshBroadcastChannel
(self._channel) → send_message_async(channel=self._channel) → CompositeTransport
→ MeshtasticTransport send_queue job closure → _blocking_mt_send(channel) →
sendText(channelIndex=channel). No code bug: the correct channel index reaches the
radio. The missing audit rows (Defect A) prevented confirming this from the DB.
/api/channels fix: the endpoint read connector._interface which does not exist on
CompositeTransport (only on bare MeshtasticTransport), so it always returned []
when MeshCore was also configured. Fix: detect CompositeTransport and route to
meshtastic_child()._interface instead.
Tests added: matrix send without _broadcast_audit writes audit row with correct
channel+transport+success; failed delivery writes success=0 row; matrix cell
channel index reaches _blocking_mt_send end-to-end (queue path exercised);
/api/channels returns real channel list via CompositeTransport.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(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>