mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 09:21:33 +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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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