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>
This commit is contained in:
malice 2026-07-08 23:58:09 -06:00 committed by GitHub
commit 0feb8adaca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 414 additions and 65 deletions

View file

@ -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
<InfoButton info="For each notification family, choose which MeshCore channels fire at each severity, the MeshCore channel name to broadcast on, and the DM contacts to unicast to. Enabling a family and its severity threshold are configured on the Data Feeds page." />
</div>
<div className="border border-[#1e2a3a] p-3">
<Toggle
label="Enable MeshCore region routing"
checked={config.region_routes?.mc_enabled ?? false}
onChange={(on) =>
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."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
const t = toggles[key] || ({} as NotificationToggle)

View file

@ -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<string, Record<string, RegionCell>>
// 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
<InfoButton info="Per-family Meshtastic delivery matrix. Choose which channels fire at each severity, the broadcast channel index, and DM node IDs. Family on/off and severity threshold are configured on the Data Feeds page." />
</div>
<div className="border border-[#1e2a3a] p-3">
<Toggle
label="Enable Meshtastic region routing"
checked={regionRoutes?.mt_enabled ?? regionRoutes?.enabled ?? false}
onChange={(on) =>
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."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{families.map(({ key, label, Icon }) => {
const t = toggles[key] || ({} as NotificationToggle)

View file

@ -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"]:

View file

@ -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}

View file

@ -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

View file

@ -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": {

View file

@ -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"