From 0feb8adaca39e180123dabcf6adc93d10cda1cdc Mon Sep 17 00:00:00 2001 From: malice Date: Wed, 8 Jul 2026 23:58:09 -0600 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/pages/MeshCoreRouting.tsx | 36 ++- .../src/pages/Notifications.tsx | 41 ++- work/meshai/config.py | 12 +- .../dashboard/api/notification_routes.py | 25 +- .../notifications/pipeline/dispatcher.py | 67 +++- ...est_native_severity_promotion_and_floor.py | 2 +- work/tests/test_region_routing_dispatcher.py | 296 ++++++++++++++++-- 7 files changed, 414 insertions(+), 65 deletions(-) diff --git a/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx b/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx index 1906f5d..9a55037 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx @@ -49,7 +49,10 @@ function mergeRegionRoutesForMc( fresh: RegionRoutes | undefined, mine: RegionRoutes | undefined, ): RegionRoutes { - const enabled = mine?.enabled ?? fresh?.enabled ?? false + // This page OWNS mc_enabled (from its own toggle); carry mt_enabled through + // UNCHANGED from the freshly-fetched server value so we never clobber it. + const mc_enabled = mine?.mc_enabled ?? false + const mt_enabled = fresh?.mt_enabled ?? fresh?.enabled ?? false const freshCells = fresh?.cells || {} const mineCells = mine?.cells || {} @@ -83,7 +86,7 @@ function mergeRegionRoutesForMc( } } - return { enabled, cells: newCells } + return { mt_enabled, mc_enabled, cells: newCells } } export default function MeshCoreRouting() { @@ -163,7 +166,11 @@ export default function MeshCoreRouting() { } setConfig({ ...config, - region_routes: { enabled: config.region_routes?.enabled ?? false, cells: newCells }, + region_routes: { + mt_enabled: config.region_routes?.mt_enabled ?? config.region_routes?.enabled ?? false, + mc_enabled: config.region_routes?.mc_enabled ?? false, + cells: newCells, + }, }) } @@ -177,7 +184,11 @@ export default function MeshCoreRouting() { const newCells = { ...(config.region_routes?.cells || {}), [family]: clearedFamily } setConfig({ ...config, - region_routes: { enabled: config.region_routes?.enabled ?? false, cells: newCells }, + region_routes: { + mt_enabled: config.region_routes?.mt_enabled ?? config.region_routes?.enabled ?? false, + mc_enabled: config.region_routes?.mc_enabled ?? false, + cells: newCells, + }, }) } @@ -321,6 +332,23 @@ export default function MeshCoreRouting() { MeshCore Delivery +
+ + setConfig({ + ...config, + region_routes: { + mt_enabled: config.region_routes?.mt_enabled ?? config.region_routes?.enabled ?? false, + mc_enabled: on, + cells: config.region_routes?.cells || {}, + }, + }) + } + helper="Master switch for per-region MeshCore channel routing. When off, families deliver only to their default MeshCore channels." + /> +
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => { const t = toggles[key] || ({} as NotificationToggle) diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx index fb9c772..7f6a121 100644 --- a/work/dashboard-frontend/src/pages/Notifications.tsx +++ b/work/dashboard-frontend/src/pages/Notifications.tsx @@ -69,9 +69,15 @@ export interface RegionCell { } // The full region_routes block stored under notifications config. +// Region routing has an independent master switch per transport: mt_enabled +// (Meshtastic) and mc_enabled (MeshCore). `enabled` is a legacy single-switch +// field tolerated on READ only (older payloads) — never written back. export interface RegionRoutes { - enabled: boolean + mt_enabled: boolean + mc_enabled: boolean cells: Record> + // Legacy: pre-split single master switch. Read-only fallback; not serialized. + enabled?: boolean } export interface NotificationsConfig { @@ -488,7 +494,10 @@ function mergeRegionRoutesForMt( fresh: RegionRoutes | undefined, mine: RegionRoutes | undefined, ): RegionRoutes { - const enabled = mine?.enabled ?? fresh?.enabled ?? false + // This page OWNS mt_enabled (from its own toggle); carry mc_enabled through + // UNCHANGED from the freshly-fetched server value so we never clobber it. + const mt_enabled = mine?.mt_enabled ?? mine?.enabled ?? false + const mc_enabled = fresh?.mc_enabled ?? false const freshCells = fresh?.cells || {} const mineCells = mine?.cells || {} @@ -521,7 +530,7 @@ function mergeRegionRoutesForMt( } } - return { enabled, cells: newCells } + return { mt_enabled, mc_enabled, cells: newCells } } // ── MeshtasticDeliveryGrid ──────────────────────────────────────────────────── @@ -559,7 +568,11 @@ function MeshtasticDeliveryGrid({ [region]: { ...existing, mt }, }, } - onRegionRoutesChange({ enabled: regionRoutes?.enabled ?? false, cells: newCells }) + onRegionRoutesChange({ + mt_enabled: regionRoutes?.mt_enabled ?? regionRoutes?.enabled ?? false, + mc_enabled: regionRoutes?.mc_enabled ?? false, + cells: newCells, + }) } const clearMtForFamily = (family: string) => { @@ -569,7 +582,11 @@ function MeshtasticDeliveryGrid({ clearedFamily[r] = { ...c, mt: null } } const newCells = { ...(regionRoutes?.cells || {}), [family]: clearedFamily } - onRegionRoutesChange({ enabled: regionRoutes?.enabled ?? false, cells: newCells }) + onRegionRoutesChange({ + mt_enabled: regionRoutes?.mt_enabled ?? regionRoutes?.enabled ?? false, + mc_enabled: regionRoutes?.mc_enabled ?? false, + cells: newCells, + }) } return ( @@ -578,6 +595,20 @@ function MeshtasticDeliveryGrid({ Meshtastic Delivery
+
+ + onRegionRoutesChange({ + mt_enabled: on, + mc_enabled: regionRoutes?.mc_enabled ?? false, + cells: regionRoutes?.cells || {}, + }) + } + helper="Master switch for per-region Meshtastic channel routing. When off, families deliver only to their default Meshtastic channels." + /> +
{families.map(({ key, label, Icon }) => { const t = toggles[key] || ({} as NotificationToggle) diff --git a/work/meshai/config.py b/work/meshai/config.py index 89d915d..63da025 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -775,7 +775,10 @@ class NotificationDestination: class RegionRouteMatrix: """Compact region×family routing matrix (P3 primitive). - enabled: master switch; False = matrix never consulted (byte-identical to today). + mt_enabled: Meshtastic region routing master switch; gates the `mt` column. + mc_enabled: MeshCore region routing master switch; gates the `mc` column. + Each is independent; False = that transport is never consulted + (byte-identical to pre-split behavior for that transport). cells: nested dict family -> region -> cell_dict. Cell shape (plain dict, not a dataclass): cells[family][region] = { @@ -787,7 +790,8 @@ class RegionRouteMatrix: Sparse: only filled cells are stored; omitted cells are no-ops. """ - enabled: bool = False + mt_enabled: bool = False # Meshtastic region routing master switch + mc_enabled: bool = False # MeshCore region routing master switch cells: dict = field(default_factory=dict) @@ -1202,8 +1206,10 @@ def _dict_to_dataclass(cls, data: dict): # P3: coerce region_routes dict -> RegionRouteMatrix (mirrors destinations above). if "region_routes" in value and isinstance(value["region_routes"], dict): rr = value["region_routes"] + _legacy = bool(rr.get("enabled", False)) # pre-split single master switch notifications.region_routes = RegionRouteMatrix( - enabled=bool(rr.get("enabled", False)), + mt_enabled=bool(rr.get("mt_enabled", _legacy)), + mc_enabled=bool(rr.get("mc_enabled", False)), cells=rr.get("cells", {}), ) if "channels" in value and isinstance(value["channels"], list) and value["channels"]: diff --git a/work/meshai/dashboard/api/notification_routes.py b/work/meshai/dashboard/api/notification_routes.py index 9174407..4bdf276 100644 --- a/work/meshai/dashboard/api/notification_routes.py +++ b/work/meshai/dashboard/api/notification_routes.py @@ -349,7 +349,9 @@ async def send_rule_live(request: Request, rule_index: int): class RegionRoutingBody(BaseModel): """Request body for POST /notifications/region-routing.""" - enabled: bool = False + mt_enabled: bool = False + mc_enabled: bool = False + enabled: Optional[bool] = None # legacy field: if sent and mt_enabled not, maps to mt_enabled cells: Dict[str, Any] = {} @@ -399,18 +401,19 @@ async def get_regions(request: Request): async def get_region_routing(request: Request): """Return the current region×family routing matrix. - Shape: {"enabled": bool, "cells": {family: {region: {mt, mc, min_severity, enabled}}}} - Defaults to enabled=false, cells={} when not configured. + Shape: {"mt_enabled": bool, "mc_enabled": bool, + "cells": {family: {region: {mt, mc, min_severity, enabled}}}} + Defaults to mt_enabled=false, mc_enabled=false, cells={} when not configured. """ config = getattr(request.app.state, "config", None) if config is None: - return {"enabled": False, "cells": {}} + return {"mt_enabled": False, "mc_enabled": False, "cells": {}} rr = getattr(getattr(config, "notifications", None), "region_routes", None) if rr is None: - return {"enabled": False, "cells": {}} + return {"mt_enabled": False, "mc_enabled": False, "cells": {}} - return {"enabled": bool(rr.enabled), "cells": rr.cells} + return {"mt_enabled": bool(rr.mt_enabled), "mc_enabled": bool(rr.mc_enabled), "cells": rr.cells} @router.post("/region-routing") @@ -420,7 +423,7 @@ async def save_region_routing(request: Request, body: RegionRoutingBody): Only region_routes is updated; all other notification fields (toggles, rules, destinations, etc.) survive untouched. - Returns: {"ok": true, "saved": {"enabled": bool, "cells": {...}}} + Returns: {"ok": true, "saved": {"mt_enabled": bool, "mc_enabled": bool, "cells": {...}}} """ config = getattr(request.app.state, "config", None) config_path = getattr(request.app.state, "config_path", None) @@ -428,7 +431,11 @@ async def save_region_routing(request: Request, body: RegionRoutingBody): raise HTTPException(status_code=500, detail="Config not available") # Build the new RegionRouteMatrix from the request body. - new_rr = RegionRouteMatrix(enabled=body.enabled, cells=body.cells) + _mt = body.mt_enabled if body.mt_enabled is not None else False + # legacy single-switch clients: enabled -> mt_enabled + if getattr(body, "enabled", None) is not None and not body.mt_enabled: + _mt = bool(body.enabled) + new_rr = RegionRouteMatrix(mt_enabled=_mt, mc_enabled=bool(body.mc_enabled), cells=body.cells) # Explicit server-side read-modify-write: # 1. Serialize the CURRENT notifications object to dict (preserves toggles/rules/destinations). @@ -455,5 +462,5 @@ async def save_region_routing(request: Request, body: RegionRoutingBody): except Exception: pass # best-effort; disk is authoritative - saved = {"enabled": new_rr.enabled, "cells": new_rr.cells} + saved = {"mt_enabled": new_rr.mt_enabled, "mc_enabled": new_rr.mc_enabled, "cells": new_rr.cells} return {"ok": True, "saved": saved} diff --git a/work/meshai/notifications/pipeline/dispatcher.py b/work/meshai/notifications/pipeline/dispatcher.py index dc1b569..ea82273 100644 --- a/work/meshai/notifications/pipeline/dispatcher.py +++ b/work/meshai/notifications/pipeline/dispatcher.py @@ -382,7 +382,14 @@ class Dispatcher: # behaviour for all existing config. rr = getattr(self._config.notifications, "region_routes", None) _matrix_matched = None - if rr is not None and getattr(rr, "enabled", False): + # Per-transport authority (v0.16.x): channel-TYPE strings the matrix + # authoritatively owns for THIS event. Initialized before the gate so it + # is always in scope at the toggle chokepoint below; stays empty when the + # matrix is skipped or matches nothing -> toggle path runs fully unchanged. + _matrix_handled: set = set() + if rr is not None and ( + getattr(rr, "mt_enabled", False) or getattr(rr, "mc_enabled", False) + ): fam_cells = (getattr(rr, "cells", None) or {}).get(fam) if fam_cells: ev_regions = [r for r in ([event.region, *(event.regions or [])]) if r] @@ -405,6 +412,20 @@ class Dispatcher: event_rank = self.SEVERITY_RANK.get(event.severity, 0) _chans: dict = {} # (ch_type, chan_val) -> [region, ...] for _mr, _cell in _matrix_matched: + # Per-transport authority (authoritative-suppress fix): this + # cell MATCHED the event's family+region, so each ENABLED + # transport is matrix-owned for THIS event REGARDLESS of whether + # the cell is active (passes its per-cell enabled + min_severity + # floor). Mark BEFORE the inactive-cell continues so a matched- + # but-inactive cell still suppresses the toggle for its enabled + # transports -- authoritative no-send preserved, identical to the + # null-column case. The floor / enabled checks below decide ONLY + # whether a concrete channel is APPENDED, never whether authority + # leaks back to the toggle. + if getattr(rr, "mt_enabled", False): + _matrix_handled.add("mesh_broadcast") + if getattr(rr, "mc_enabled", False): + _matrix_handled.add("meshcore_broadcast") _enabled_cell = _cell.get("enabled", True) if isinstance(_cell, dict) \ else getattr(_cell, "enabled", True) if not _enabled_cell: @@ -425,15 +446,26 @@ class Dispatcher: continue # below per-cell floor for this region _mt = _cell.get("mt") if isinstance(_cell, dict) else getattr(_cell, "mt", None) _mc = _cell.get("mc") if isinstance(_cell, dict) else getattr(_cell, "mc", None) - if _mt is not None: + # Per-transport authority: a transport whose master flag is on is + # matrix-owned for this matched event even when the cell column is + # null (operator chose "no channel here" -> suppress the toggle for + # that transport too, do not leak it back). A transport whose flag + # is OFF is left unmarked -> it falls through to the toggle path + # (e.g. MeshCore reaching #aida while mc_enabled is False). + # Authority already marked at loop top; here we ONLY decide + # whether a concrete channel is appended (per-cell floor/enabled + # gating above has passed). A null column appends nothing but the + # transport stays matrix-owned (marked above) -> toggle suppressed. + if getattr(rr, "mt_enabled", False) and _mt is not None: _chans.setdefault(("mesh_broadcast", _mt), []).append(_mr) - if _mc: # truthy: non-empty string + if getattr(rr, "mc_enabled", False) and _mc: # truthy: non-empty string _chans.setdefault(("meshcore_broadcast", _mc), []).append(_mr) - if not _chans: - # Every matched region below its floor or cell disabled/empty. - # Authoritative no-send: do NOT fall through to toggle default. - return + # NOTE (v0.16.x per-transport authority): no blanket no-send return + # here. An empty _chans can still carry marks in _matrix_handled + # (e.g. mt_enabled with a null mt column) that must suppress the + # toggle. Dispatch whatever _chans holds (possibly nothing), then + # fall through so any UNhandled transport still reaches the toggle. # Compose once; reused across all channels (shadow render skipped). try: @@ -550,8 +582,11 @@ class Dispatcher: self._toggle_cooldown[_rk] = _commit_now self._persist_cooldown(_rk, _commit_now, _cooldown_s) - # Authoritative: the matrix handled this event; skip toggle default. - return + # Per-transport fall-through (v0.16.x): the matrix has dispatched the + # transports it owns (tracked in _matrix_handled). Do NOT return -- + # fall through to Sections 2-6 so transports the matrix did NOT own + # (disabled flag, or no matching cell) still deliver via the toggle. + # The chokepoint in Section 2 filters out _matrix_handled types. # ---------- Section 2 — region scope + severity floor + matrix ---- # v0.6-4 (B13 fix): resolution before commitment. Region scope, the @@ -596,10 +631,20 @@ class Dispatcher: # digest-typed destinations belong to the digest scheduler, not the # live broadcast path (mirrors the inline "digest" exclusion below). delivery_plan = [("dest", d) for d in dests - if getattr(d, "type", "") != "digest"] + if getattr(d, "type", "") != "digest" + and getattr(d, "type", "") not in _matrix_handled] else: sev_channels = getattr(tog, "severity_channels", None) or {} - ch_types = [c for c in sev_channels.get(event.severity, []) if c != "digest"] + # Per-transport matrix authority (v0.16.x): drop any channel-TYPE the + # region_routes matrix already owns for this event (_matrix_handled), + # so a matrix-enabled transport is never double-broadcast here while a + # matrix-DISABLED transport still falls through and emits (this is how + # MeshCore reaches #aida while mc_enabled is False). _matrix_handled is + # empty whenever the matrix was skipped or matched nothing -> no-op. + ch_types = [ + c for c in sev_channels.get(event.severity, []) + if c != "digest" and c not in _matrix_handled + ] delivery_plan = [("toggle", ct) for ct in ch_types] if not delivery_plan: return diff --git a/work/tests/test_native_severity_promotion_and_floor.py b/work/tests/test_native_severity_promotion_and_floor.py index 476e9a3..787803e 100644 --- a/work/tests/test_native_severity_promotion_and_floor.py +++ b/work/tests/test_native_severity_promotion_and_floor.py @@ -342,7 +342,7 @@ class TestMatrixFloorObservability: cfg.notifications.toggles = {"fire": tog} # Matrix: enabled, one cell for fire/US-ID cfg.notifications.region_routes = RegionRouteMatrix( - enabled=True, + mt_enabled=True, mc_enabled=True, # v0.16.x per-transport split cells={ "fire": { "US-ID": { diff --git a/work/tests/test_region_routing_dispatcher.py b/work/tests/test_region_routing_dispatcher.py index 116a347..cc8f455 100644 --- a/work/tests/test_region_routing_dispatcher.py +++ b/work/tests/test_region_routing_dispatcher.py @@ -17,9 +17,9 @@ Test inventory 5. per_region_cooldown_independence — cooldown on region A does not block region B 6. per_channel_dedup_independence — failed channel leaves no dedup; retries on next call 7. authoritative_no_double_send — matrix match does NOT also fire toggle default -8. empty_chans_authoritative_no_send — every cell below floor → return, no toggle fallback +8. below_floor_cell_suppresses — matched below-floor cell still owns its ENABLED transport (muted); a DISABLED transport falls through 9. dedup_suppresses_repeat_same_channel — same event id + same channel → dedup on second call -10. cell_disabled_flag_skipped — cell with enabled=False is ignored +10. disabled_cell_suppresses — matched disabled cell still owns its ENABLED transport (muted); a DISABLED transport falls through """ import asyncio @@ -27,7 +27,7 @@ import time import pytest -from meshai.config import Config, RegionRouteMatrix +from meshai.config import Config, RegionRouteMatrix, NotificationDestination from meshai.notifications.pipeline.dispatcher import Dispatcher from meshai.notifications.events import make_event @@ -85,8 +85,17 @@ def _base_cfg(fam="fire", cooldown_s=0, min_severity="priority"): return cfg -def _rr(cells: dict, enabled: bool = True) -> RegionRouteMatrix: - return RegionRouteMatrix(enabled=enabled, cells=cells) +def _rr(cells: dict, enabled: bool = True, + mt_enabled: bool = None, mc_enabled: bool = None) -> RegionRouteMatrix: + # v0.16.x per-transport split: legacy `enabled=` maps to BOTH transports so + # existing tests keep their "matrix authoritative for mt AND mc" semantics. + # New tests pass mt_enabled=/mc_enabled= explicitly to exercise per-transport + # authority (a DISABLED transport falls through to the toggle path). + if mt_enabled is None: + mt_enabled = enabled + if mc_enabled is None: + mc_enabled = enabled + return RegionRouteMatrix(mt_enabled=mt_enabled, mc_enabled=mc_enabled, cells=cells) def _ev(fam="fire", region=None, regions=None, severity="immediate", @@ -318,25 +327,30 @@ def test_authoritative_no_double_send(): # ============================================================ Test 8 # Empty chans (every cell below floor) → authoritative no-send, not toggle fallback -def test_empty_chans_authoritative_no_send(): - """When every matched cell is below its min_severity floor the matrix - still consumes the event (authoritative) and returns without sending. - The toggle default must NOT run as a fallback.""" - cfg = _base_cfg(fam="fire", min_severity="routine") - cfg.notifications.region_routes = _rr(cells={ - "fire": { - "SCI": { - "mt": 3, "mc": None, - "min_severity": "immediate", # floor = immediate - "enabled": True, - }, - } - }) - # routine event — below the cell floor +def test_below_floor_cell_suppresses_enabled_transport(): + """Authoritative-suppress (restored): a matched cell BELOW its min_severity + floor appends no channel, but the ENABLED transport is still matrix-owned + for this matched event -> its toggle send is suppressed (authoritative + no-send), identical to the null-column case (row5). A DISABLED transport is + not owned and still emits via the toggle.""" + cfg = _dual_cfg(fam="fire") # toggle: MT ch0 + MC #aida + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": 3, "mc": None, + "min_severity": "immediate", # cell floor + "enabled": True}}}, + mt_enabled=True, mc_enabled=False, + ) + # routine event: below the cell floor but at/above the toggle floor (routine) ev = _ev(fam="fire", region="SCI", severity="routine") _, rec = _dispatch(cfg, ev) - assert rec == [], "below floor → authoritative no-send, toggle default does NOT fire" + mesh = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + # MT owned by matrix + below floor -> NO mesh_broadcast anywhere (toggle muted) + assert mesh == [], "below-floor MT must be suppressed everywhere, got %r" % (mesh,) + # MC not owned (mc_enabled False) -> emits via toggle #aida + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida" + assert len(rec) == 1 # ============================================================ Test 9 @@ -364,19 +378,47 @@ def test_dedup_suppresses_repeat_same_channel(): # ============================================================ Test 10 # cell enabled=False is skipped -def test_cell_disabled_flag_skipped(): - """A cell with enabled=False must be ignored even when its region matches.""" - cfg = _base_cfg(fam="fire", min_severity="routine") - cfg.notifications.region_routes = _rr(cells={ - "fire": { - "SCI": {"mt": 3, "mc": None, "min_severity": "routine", "enabled": False}, - } - }) +def test_disabled_cell_suppresses_enabled_transport(): + """Authoritative-suppress (restored): a matched cell with enabled=False + appends no channel, but the ENABLED transport stays matrix-owned -> its + toggle send is suppressed (authoritative no-send). A DISABLED transport is + not owned and still emits via the toggle.""" + cfg = _dual_cfg(fam="fire") # toggle: MT ch0 + MC #aida + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": 3, "mc": None, + "min_severity": "routine", + "enabled": False}}}, + mt_enabled=True, mc_enabled=False, + ) ev = _ev(fam="fire", region="SCI") _, rec = _dispatch(cfg, ev) - # Cell disabled → chans empty → authoritative no-send - assert rec == [] + mesh = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + # MT owned by matrix + cell disabled -> NO mesh_broadcast anywhere + assert mesh == [], "disabled-cell MT must be suppressed everywhere, got %r" % (mesh,) + # MC not owned (mc_enabled False) -> emits via toggle #aida + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida" + assert len(rec) == 1 + + +def test_both_enabled_inactive_cell_full_no_send(): + """Both transports enabled + matched cell inactive (below floor) -> both are + matrix-owned, so BOTH toggle sends are suppressed: full authoritative + no-send preserved (nothing emitted on either transport).""" + cfg = _dual_cfg(fam="fire") + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": 3, "mc": "#swi", + "min_severity": "immediate", # cell floor + "enabled": True}}}, + mt_enabled=True, mc_enabled=True, + ) + # routine event: below the cell floor, at/above the toggle floor (routine) + ev = _ev(fam="fire", region="SCI", severity="routine") + _, rec = _dispatch(cfg, ev) + + assert rec == [], \ + "both transports matrix-owned + inactive -> nothing sends, got %r" % (rec,) # ============================================================ Bonus: existing tests @@ -577,7 +619,7 @@ def test_matrix_cell_channel_reaches_connector_send(): t.cooldown_seconds = 0 t.broadcast_channel = 0 # toggle default — must NOT reach the radio cfg.notifications.region_routes = RegionRouteMatrix( - enabled=True, + mt_enabled=True, mc_enabled=True, cells={"roads": {"SW Idaho": {"mt": 3, "mc": None, "min_severity": "routine", "enabled": True}}}, ) @@ -668,3 +710,193 @@ def test_get_channels_composite_transport(): assert by_idx[3]["name"] == "SWI Alerts" assert by_idx[3]["role"] == "SECONDARY" assert by_idx[3]["enabled"] is True + + +# ================================================================== +# v0.16.x — per-transport region-route authority (mt_enabled / mc_enabled) +# ------------------------------------------------------------------ +# The matrix is authoritative ONLY for the transport(s) whose master flag is on. +# A DISABLED transport falls through to the toggle path so its alert still goes +# out (this is how MeshCore reaches #aida while mc_enabled is False). These tests +# cover all five rows of the design truth table plus a no-region-match case. + + +def _dual_cfg(fam="fire", min_severity="routine"): + """Toggle configured to emit BOTH transports via the toggle path: + mesh_broadcast on channel 0 and meshcore_broadcast on '#aida'. Lets us prove + per-transport matrix authority: a matrix-owned transport is suppressed here + while a matrix-DISABLED transport still emits via this toggle.""" + cfg = _base_cfg(fam=fam, min_severity=min_severity) + t = cfg.notifications.toggles[fam] + t.severity_channels = { + "routine": ["mesh_broadcast", "meshcore_broadcast"], + "priority": ["mesh_broadcast", "meshcore_broadcast"], + "immediate": ["mesh_broadcast", "meshcore_broadcast"], + } + t.broadcast_channel = 0 # toggle default MT channel + t.meshcore_channel = "#aida" # toggle default MC channel + return cfg + + +def test_ptx_row1_mt_only_matrix_mt_toggle_mc(): + """Row 1 (PRODUCTION): mt_enabled=True, mc_enabled=False, cell mt=3, mc=null. + -> MT ch3 via matrix; MC via toggle #aida; NO toggle MT (suppressed).""" + cfg = _dual_cfg(fam="fire") + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": 3, "mc": None, + "min_severity": "routine", "enabled": True}}}, + mt_enabled=True, mc_enabled=False, + ) + ev = _ev(fam="fire", region="SCI") + _, rec = _dispatch(cfg, ev) + + mesh = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + # exactly one MT (matrix ch3) — NO double mesh_broadcast + assert len(mesh) == 1, "exactly one mesh_broadcast, got %r" % (mesh,) + assert mesh[0]["broadcast_channel"] == 3, "MT via matrix cell ch3, not toggle 0" + # MC via toggle #aida (mc_enabled False -> matrix did not own it) + assert len(mc) == 1, "meshcore must emit via toggle, got %r" % (mc,) + assert mc[0]["meshcore_channel"] == "#aida" + assert len(rec) == 2 + + +def test_ptx_row2_mc_only_matrix_mc_toggle_mt(): + """Row 2: mt_enabled=False, mc_enabled=True, cell mc='#swi' (mt=9 ignored). + -> MC '#swi' via matrix; MT via toggle ch0; NO toggle MC (suppressed).""" + cfg = _dual_cfg(fam="fire") + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": 9, "mc": "#swi", + "min_severity": "routine", "enabled": True}}}, + mt_enabled=False, mc_enabled=True, + ) + ev = _ev(fam="fire", region="SCI") + _, rec = _dispatch(cfg, ev) + + mesh = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + # MC owned by matrix -> '#swi', exactly one, NO double meshcore_broadcast + assert len(mc) == 1, "exactly one meshcore_broadcast, got %r" % (mc,) + assert mc[0]["meshcore_channel"] == "#swi", "MC via matrix cell, not toggle #aida" + # MT falls through to the toggle default ch0 (mt_enabled False; cell mt=9 ignored) + assert len(mesh) == 1, "MT must emit via toggle, got %r" % (mesh,) + assert mesh[0]["broadcast_channel"] == 0 + assert len(rec) == 2 + + +def test_ptx_row3_both_enabled_matrix_owns_both(): + """Row 3: both flags True, cell mt=3 mc='#swi'. -> both via matrix; toggle + emits neither (both channel-types suppressed).""" + cfg = _dual_cfg(fam="fire") + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": 3, "mc": "#swi", + "min_severity": "routine", "enabled": True}}}, + mt_enabled=True, mc_enabled=True, + ) + ev = _ev(fam="fire", region="SCI") + _, rec = _dispatch(cfg, ev) + + mesh = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mesh) == 1 and mesh[0]["broadcast_channel"] == 3 + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#swi" + assert len(rec) == 2, "no toggle duplicates — both transports matrix-owned" + + +def test_ptx_row4_both_disabled_toggle_owns_both(): + """Row 4: both flags False -> matrix gate skipped; toggle emits BOTH + transports (ch0 + #aida) unchanged.""" + cfg = _dual_cfg(fam="fire") + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": 3, "mc": "#swi", + "min_severity": "routine", "enabled": True}}}, + mt_enabled=False, mc_enabled=False, + ) + ev = _ev(fam="fire", region="SCI") + _, rec = _dispatch(cfg, ev) + + mesh = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mesh) == 1 and mesh[0]["broadcast_channel"] == 0, "toggle MT default" + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida", "toggle MC default" + assert len(rec) == 2 + + +def test_ptx_row5_mt_enabled_null_column_suppresses_mt_entirely(): + """Row 5: mt_enabled=True but cell mt=null -> matrix marks mesh_broadcast + handled (suppressing the toggle MT) yet sends nothing on MT => NO MT at all. + MC (mc_enabled=False) still emits via the toggle #aida.""" + cfg = _dual_cfg(fam="fire") + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": None, "mc": None, + "min_severity": "routine", "enabled": True}}}, + mt_enabled=True, mc_enabled=False, + ) + ev = _ev(fam="fire", region="SCI") + _, rec = _dispatch(cfg, ev) + + mesh = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert mesh == [], "null mt under mt_enabled -> NO MT anywhere (toggle suppressed)" + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida", "MC via toggle" + assert len(rec) == 1 + + +def test_ptx_no_region_match_runs_full_toggle_path(): + """No matching cell for the event region -> _matrix_handled stays empty -> + BOTH transports emit via the toggle path (no suppression).""" + cfg = _dual_cfg(fam="fire") + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": 3, "mc": "#swi", + "min_severity": "routine", "enabled": True}}}, + mt_enabled=True, mc_enabled=True, + ) + # region SWI has no cell + ev = _ev(fam="fire", region="SWI") + _, rec = _dispatch(cfg, ev) + + mesh = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + mc = [r for r in rec if r["delivery_type"] == "meshcore_broadcast"] + assert len(mesh) == 1 and mesh[0]["broadcast_channel"] == 0 + assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida" + assert len(rec) == 2 + + +def test_ptx_destination_mesh_broadcast_not_double_when_matrix_owns_mt(): + """Regression (double-MT-broadcast): a family that is matrix-routed for MT + (mt_enabled=True, matched cell with `mt` set) AND whose toggle delivers via a + mesh_broadcast DESTINATION must NOT broadcast on MT twice. + + The matrix owns mesh_broadcast for the matched event (it delivers in + Section 1.5 and marks the transport in `_matrix_handled`). The Section 2 + destinations branch previously ignored `_matrix_handled`, so the same + mesh_broadcast transport was delivered a SECOND time via the destination. + A destination whose type is a matrix-owned transport must be filtered, + leaving EXACTLY ONE MT send on the matrix cell channel. + """ + cfg = _base_cfg(fam="fire", min_severity="routine") + # Toggle delivers via a shared mesh_broadcast DESTINATION (ch7) rather than + # the inline severity_channels path. + cfg.notifications.destinations = { + "mesh_dest": NotificationDestination( + name="mesh_dest", type="mesh_broadcast", broadcast_channel=7), + } + cfg.notifications.toggles["fire"].destinations = ["mesh_dest"] + # Matrix owns MT for region SCI at channel 3 (mc not owned / not present). + cfg.notifications.region_routes = _rr( + cells={"fire": {"SCI": {"mt": 3, "mc": None, + "min_severity": "routine", "enabled": True}}}, + mt_enabled=True, mc_enabled=False, + ) + ev = _ev(fam="fire", region="SCI") # immediate severity -> above floor + _, rec = _dispatch(cfg, ev) + + mesh = [r for r in rec if r["delivery_type"] == "mesh_broadcast"] + # EXACTLY ONE mesh_broadcast: the matrix cell (ch3), NOT also the + # destination (ch7). Two here is the double-MT-broadcast bug. + assert len(mesh) == 1, "exactly one mesh_broadcast (no double MT), got %r" % (mesh,) + assert mesh[0]["broadcast_channel"] == 3, \ + "the single MT send must be the matrix cell (ch3), not the destination (ch7)" + assert all(r["broadcast_channel"] != 7 for r in rec), \ + "the mesh_broadcast destination (ch7) must be filtered by _matrix_handled" + assert len(rec) == 1, "only the matrix MT send; no extra destination delivery"