mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
3ee33015bd
commit
0feb8adaca
7 changed files with 414 additions and 65 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue