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"