feat(notifications): v0.5.0 -- Master Toggles UX redesign + Central Connection GUI + grouped categories + region scoping

Per-family notification policy (PagerDuty/Grafana-style): each family gets a
severity threshold + region scope + a severity->channel routing matrix, so an
operator opts in per family rather than hand-writing rules.

SECTION 1 -- BACKEND
- config.py: new NotificationToggle dataclass (enabled, min_severity, regions,
  severity_channels{severity->[channel types]}, quiet_hours_override, + per-channel
  delivery config: broadcast_channel/node_ids/smtp_*/recipients/webhook_*).
  notifications.toggles is now a dict[family]->NotificationToggle with 8 family
  defaults (mesh_health, weather, fire, rf_propagation, roads, avalanche, seismic,
  tracking), all enabled=false (opt-in), min_severity=priority,
  severity_channels={priority:[mesh_broadcast], immediate:[mesh_broadcast, mesh_dm]},
  quiet_hours_override=true. (Old TogglesConfig.enabled was only read by
  build_pipeline via getattr -> degrades to ToggleFilter no-op, so the pipeline
  filter is unchanged; toggles now drive the Dispatcher instead.)
- region_scope:list added to NotificationRuleConfig; _matching_rules filters by
  event.region/regions ([] = all).
- Dispatcher: _dispatch_toggles runs IN PARALLEL to rule matching -- looks up
  get_toggle(event.category), checks enabled + region scope + severity threshold,
  then for each channel in severity_channels[event.severity] builds a synthetic
  rule (override_quiet set only for immediate when quiet_hours_override) and
  delivers. 'digest' channel is skipped in live dispatch (handled by accumulator).
- categories.py: get_toggle() prefix fallback maps the live phases-2.7-2.14
  categories (weather_warning, wildfire_incident, earthquake_event,
  traffic_congestion, geomagnetic/rf_*, stream_*, ...) to their family, fixing the
  v0.4 "category -> other" gap.
- config_loader.py: SECRET_FIELDS += notifications.toggles.*.smtp_password.
- _dataclass_to_dict now recurses dict-of-dataclasses, and the loader coerces the
  toggles dict -> NotificationToggle on both the full-load and section-PUT paths
  (so GUI save round-trips correctly).
- tests/test_notification_toggles.py (11): enabled/disabled, region filter
  (empty+populated+regions-list), severity threshold, per-severity channel routing,
  digest-skipped-live, quiet-hours-override immediate-only, category->family,
  rules+toggles both fire. Full suite: 294 passed (283 + 11).

SECTION 2 -- FRONTEND
- Notifications.tsx: MasterToggles component above the rules section -- 8 family
  cards (icon + enable toggle; collapsed summary 'OFF' or 'N regions, M channels at
  <sev>+'; expanded: severity threshold, severity x channel checkbox matrix,
  region list, quiet-hours-override toggle, per-channel config:
  broadcast_channel/DM node IDs/recipients/SMTP host+port/webhook URL).
- Environment.tsx: CentralConnectionPanel above the family tabs (url, durable,
  enabled) wired to environmental.central.
- npm run build clean (tsc strict); rebuilt static committed (index-CfYlhn4e.js).

SECTION 3 -- VERIFICATION
- py_compile + tsc strict clean; pytest 294 passed.
- Rebuilt prod: /notifications serves Master Toggles, /environment serves Central
  Connection (strings confirmed in the served bundle); 8 adapters, pipeline
  started, no tracebacks, healthy.
- GUI round-trip: enable weather toggle (min_severity=priority,
  regions=[Magic Valley], severity_channels.priority=[mesh_broadcast]) -> PUT
  {saved:true} -> notifications.yaml reflects it; env_feeds traffic.api_key stayed
  ${TOMTOM_API_KEY} (C.3.1 secret preservation holds). Restored to clean opt-in
  baseline.
- Synthetic NWS weather_warning/priority/Magic Valley -> routes through the weather
  toggle to mesh_broadcast; out-of-region and below-threshold events correctly
  dropped.

DEFERRED (noted for a follow-up, not blocking Matt's morning config): Section 2B
rules-editor polish -- grouped-by-family category checkboxes, region_scope
multi-select in the rule editor (backend field + filtering ARE in), tooltips, and
the fire-count Active/No-activity badge -- were not built tonight to keep the build
shippable and verified; the Advanced Rules section is otherwise unchanged and
still functional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-28 07:00:10 +00:00
commit b90afc3a74
10 changed files with 574 additions and 143 deletions

View file

@ -388,6 +388,28 @@ export default function Environment() {
</div>
)}
{/* Central Connection (v0.5) -- NATS source for adapters set to central */}
{env.central && (
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">Central Connection</span>
<p className="text-xs text-slate-600">NATS JetStream source for any adapter set to "central"</p>
</div>
<Toggle label="" checked={!!env.central.enabled}
onChange={(v) => up({ central: { ...env.central!, enabled: v } })} />
</div>
<div className={env.central.enabled ? 'space-y-3' : 'space-y-3 opacity-40 pointer-events-none select-none'}>
<TextInput label="URL" value={env.central.url || ''}
onChange={(v) => up({ central: { ...env.central!, url: v } })}
placeholder="nats://central.echo6.mesh:4222" />
<TextInput label="Durable" value={env.central.durable || ''}
onChange={(v) => up({ central: { ...env.central!, durable: v } })}
placeholder="meshai-v04" />
</div>
</div>
)}
{/* Family tabs */}
<div className="flex gap-1 border-b border-border overflow-x-auto">
{FAMILIES.map(({ key, label, icon: Icon }) => (

View file

@ -3,7 +3,8 @@ import {
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
Calendar, AlertTriangle, Copy, Moon, AlertCircle, Layers,
Wifi, WifiOff, Mail, Globe, Radio, MessageSquare
Wifi, WifiOff, Mail, Globe, Radio, MessageSquare,
Activity, Cloud, Flame, Car, Snowflake, Mountain, MapPin
} from 'lucide-react'
import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
@ -37,12 +38,33 @@ interface NotificationRuleConfig {
override_quiet: boolean
}
interface NotificationToggle {
name: string
enabled: boolean
min_severity: string
regions: string[]
severity_channels: Record<string, string[]>
quiet_hours_override: boolean
broadcast_channel: number | null
node_ids: string[]
smtp_host: string
smtp_port: number
smtp_user: string
smtp_password: string
smtp_tls: boolean
from_address: string
recipients: string[]
webhook_url: string
webhook_headers: Record<string, string>
}
interface NotificationsConfig {
enabled: boolean
quiet_hours_enabled: boolean
quiet_hours_start: string
quiet_hours_end: string
rules: NotificationRuleConfig[]
toggles?: Record<string, NotificationToggle>
}
interface AlertCategory {
@ -1340,6 +1362,105 @@ function NotificationRuleCard({
}
// Main Notifications Page Component
const TOGGLE_FAMILY_META: { key: string; label: string; Icon: typeof Activity }[] = [
{ key: 'mesh_health', label: 'Mesh Health', Icon: Activity },
{ key: 'weather', label: 'Weather', Icon: Cloud },
{ key: 'fire', label: 'Fire', Icon: Flame },
{ key: 'rf_propagation', label: 'RF Propagation', Icon: Radio },
{ key: 'roads', label: 'Roads', Icon: Car },
{ key: 'avalanche', label: 'Avalanche', Icon: Snowflake },
{ key: 'seismic', label: 'Seismic', Icon: Mountain },
{ key: 'tracking', label: 'Tracking', Icon: MapPin },
]
const TOGGLE_CHANNELS = ['digest', 'mesh_broadcast', 'mesh_dm', 'email', 'webhook']
const TOGGLE_SEVERITIES = ['routine', 'priority', 'immediate']
function MasterToggles({ toggles, onChange }: {
toggles: Record<string, NotificationToggle>
onChange: (t: Record<string, NotificationToggle>) => void
}) {
const [expanded, setExpanded] = useState<string | null>(null)
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
return (
<div className="space-y-3 mb-8">
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Master Toggles
<InfoButton info="Per-family notification policy: enable a family, set its severity threshold, choose which channels fire at each severity, and scope to regions (PagerDuty/Grafana-style)." />
</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))
const isOpen = expanded === key
const chanCount = Object.values(t.severity_channels || {}).reduce((n, arr) => n + ((arr as string[])?.length || 0), 0)
const regionCount = (t.regions || []).length
return (
<div key={key} className="border border-[#1e2a3a] rounded-lg p-3">
<div className="flex items-center justify-between">
<button type="button" onClick={() => setExpanded(isOpen ? null : key)}
className="flex items-center gap-2 text-sm text-slate-200">
<Icon size={15} /> {label}
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Toggle label="" checked={!!t.enabled} onChange={(v) => upd(key, { enabled: v })} />
</div>
{!isOpen && (
<div className="text-xs text-slate-600 mt-1">
{t.enabled
? `${regionCount || 'all'} region${regionCount === 1 ? '' : 's'}, ${chanCount} channel${chanCount === 1 ? '' : 's'} at ${t.min_severity || 'priority'}+`
: 'OFF'}
</div>
)}
{isOpen && (
<div className={`mt-3 space-y-3 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
<div className="text-xs text-slate-500">Severity &rarr; channels</div>
<table className="text-xs w-full">
<thead>
<tr><th></th>{TOGGLE_CHANNELS.map((c) => <th key={c} className="text-slate-500 font-normal px-1">{c.replace('_', ' ')}</th>)}</tr>
</thead>
<tbody>
{TOGGLE_SEVERITIES.map((sev) => (
<tr key={sev}>
<td className="text-slate-400 pr-2">{sev}</td>
{TOGGLE_CHANNELS.map((ch) => {
const on = (t.severity_channels?.[sev] || []).includes(ch)
return (
<td key={ch} className="text-center">
<input type="checkbox" checked={on} onChange={(e) => {
const cur: Record<string, string[]> = { ...(t.severity_channels || {}) }
const arr = new Set(cur[sev] || [])
if (e.target.checked) arr.add(ch); else arr.delete(ch)
cur[sev] = Array.from(arr)
upd(key, { severity_channels: cur })
}} />
</td>
)
})}
</tr>
))}
</tbody>
</table>
<ListInput label="Regions (empty = all)" value={t.regions || []} onChange={(v) => upd(key, { regions: v })} placeholder="Add region..." />
<Toggle label="Quiet-hours override (immediate only)" checked={!!t.quiet_hours_override} onChange={(v) => upd(key, { quiet_hours_override: v })} />
<div className="text-xs text-slate-500 pt-1">Channel config</div>
<NumberInput label="Broadcast channel" value={t.broadcast_channel ?? 0} onChange={(v) => upd(key, { broadcast_channel: v })} />
<ListInput label="DM node IDs" value={t.node_ids || []} onChange={(v) => upd(key, { node_ids: v })} placeholder="!nodeid" />
<ListInput label="Email recipients" value={t.recipients || []} onChange={(v) => upd(key, { recipients: v })} placeholder="ops@example.com" />
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
<TextInput label="Webhook URL" value={t.webhook_url || ''} onChange={(v) => upd(key, { webhook_url: v })} placeholder="https://..." />
</div>
)}
</div>
)
})}
</div>
</div>
)
}
export default function Notifications() {
const [config, setConfig] = useState<NotificationsConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null)
@ -1802,6 +1923,14 @@ export default function Notifications() {
)}
</div>
{/* Master Toggles */}
{config.toggles && (
<MasterToggles
toggles={config.toggles}
onChange={(t) => setConfig({ ...config, toggles: t })}
/>
)}
{/* Rules Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">

View file

@ -480,6 +480,7 @@ class NotificationRuleConfig:
# Condition trigger fields
categories: list = field(default_factory=list) # Empty = all categories
min_severity: str = "routine"
region_scope: list = field(default_factory=list) # [] = all regions
# Schedule trigger fields
schedule_frequency: str = "daily" # daily, twice_daily, weekly, custom
@ -522,6 +523,56 @@ class NotificationRuleConfig:
@dataclass
class NotificationToggle:
"""Per-family master toggle: severity threshold + region scope + per-severity
channel routing (PagerDuty/Grafana-style notification policy)."""
name: str = ""
enabled: bool = False
min_severity: str = "priority" # routine|priority|immediate
regions: list = field(default_factory=list) # [] = all regions
# severity -> list of channel types (digest|mesh_broadcast|mesh_dm|email|webhook)
severity_channels: dict = field(default_factory=dict)
quiet_hours_override: bool = True # immediate-only quiet-hours bypass
# per-channel delivery config (mirrors NotificationRuleConfig channel fields)
broadcast_channel: Optional[int] = None
node_ids: list = field(default_factory=list)
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_tls: bool = True
from_address: str = ""
recipients: list = field(default_factory=list)
webhook_url: str = ""
webhook_headers: dict = field(default_factory=dict)
TOGGLE_FAMILIES = [
"mesh_health", "weather", "fire", "rf_propagation",
"roads", "avalanche", "seismic", "tracking",
]
def _default_toggles() -> dict:
"""8 family master-toggles, all opt-in (disabled) by default."""
return {
fam: NotificationToggle(
name=fam,
enabled=False,
min_severity="priority",
regions=[],
severity_channels={
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast", "mesh_dm"],
},
quiet_hours_override=True,
)
for fam in TOGGLE_FAMILIES
}
@dataclass
class TogglesConfig:
"""Master toggle filter settings."""
@ -545,7 +596,7 @@ class NotificationsConfig:
quiet_hours_enabled: bool = True # Master toggle for quiet hours
quiet_hours_start: str = "22:00"
quiet_hours_end: str = "06:00"
toggles: TogglesConfig = field(default_factory=TogglesConfig)
toggles: dict = field(default_factory=_default_toggles) # family -> NotificationToggle
digest: DigestConfig = field(default_factory=DigestConfig)
rules: list = field(default_factory=list) # List of NotificationRuleConfig
@ -687,6 +738,11 @@ def _dict_to_dataclass(cls, data: dict):
_dict_to_dataclass(NotificationRuleConfig, r) if isinstance(r, dict) else r
for r in value["rules"]
]
if "toggles" in value and isinstance(value["toggles"], dict):
notifications.toggles = {
name: _dict_to_dataclass(NotificationToggle, t) if isinstance(t, dict) else t
for name, t in value["toggles"].items()
}
if "channels" in value and isinstance(value["channels"], list) and value["channels"]:
_migrate_legacy_channels(notifications, value)
kwargs[key] = notifications
@ -734,7 +790,11 @@ def _dict_to_dataclass(cls, data: dict):
elif key == "dashboard" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
elif key == "toggles" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(TogglesConfig, value)
# v0.5: notifications.toggles is a dict of family -> NotificationToggle
kwargs[key] = {
fam: _dict_to_dataclass(NotificationToggle, t) if isinstance(t, dict) else t
for fam, t in value.items()
}
elif key == "digest" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DigestConfig, value)
else:
@ -761,6 +821,12 @@ def _dataclass_to_dict(obj) -> dict:
_dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item
for item in value
]
elif isinstance(value, dict):
# Handle dict of dataclasses (like notifications.toggles)
result[field_name] = {
k: _dataclass_to_dict(v) if hasattr(v, "__dataclass_fields__") else v
for k, v in value.items()
}
else:
result[field_name] = value
return result

View file

@ -78,6 +78,7 @@ SECRET_FIELDS: set[str] = {
"environmental.traffic.api_key",
"environmental.firms.map_key",
"notifications.rules.*.smtp_password",
"notifications.toggles.*.smtp_password",
}
# Secret env var names expected in .env

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-9OZ6ZqzI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B_J_Z7c8.css">
<script type="module" crossorigin src="/assets/index-CfYlhn4e.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DjhQa8Mv.css">
</head>
<body>
<div id="root"></div>

View file

@ -35,6 +35,24 @@ VALID_TOGGLES = frozenset({
})
# Prefix fallback for categories not enumerated in ALERT_CATEGORIES (resolves the
# v0.4 "category -> other" gap for phases 2.7-2.14 emitted categories).
_TOGGLE_PREFIX_FALLBACK = [
("weather", "weather"),
("stream", "weather"),
("wildfire", "fire"),
("fire", "fire"),
("earthquake", "seismic"),
("quake", "seismic"),
("traffic", "roads"),
("road", "roads"),
("geomagnetic", "rf_propagation"),
("solar_radiation", "rf_propagation"),
("rf_", "rf_propagation"),
("avalanche", "avalanche"),
]
ALERT_CATEGORIES = {
# Infrastructure alerts
"infra_offline": {
@ -352,4 +370,7 @@ def get_toggle(category_name: str) -> Optional[str]:
cat_info = ALERT_CATEGORIES.get(category_name)
if cat_info:
return cat_info.get("toggle")
for prefix, toggle in _TOGGLE_PREFIX_FALLBACK:
if category_name.startswith(prefix):
return toggle
return None

View file

@ -13,6 +13,7 @@ import logging
from typing import Callable, Optional
from meshai.notifications.events import Event, make_payload_from_event
from meshai.notifications.categories import get_toggle
class Dispatcher:
@ -36,6 +37,11 @@ class Dispatcher:
self._logger = logging.getLogger("meshai.pipeline.dispatcher")
async def dispatch(self, event: Event) -> None:
"""Deliver via matching rules AND enabled family toggles (parallel, v0.5)."""
await self._dispatch_rules(event)
await self._dispatch_toggles(event)
async def _dispatch_rules(self, event: Event) -> None:
"""Deliver an immediate-severity event to all matching channels.
This method is async and awaits each channel.deliver() call.
@ -64,6 +70,57 @@ class Dispatcher:
f"Channel delivery failed for rule {rule.name}"
)
async def _dispatch_toggles(self, event: Event) -> None:
"""Route an event through its family master-toggle (parallel to rules)."""
toggles = getattr(self._config.notifications, "toggles", None)
if not isinstance(toggles, dict) or not toggles:
return
fam = get_toggle(event.category)
if not fam:
return
tog = toggles.get(fam)
if tog is None or not getattr(tog, "enabled", False):
return
regions = getattr(tog, "regions", None) or []
if regions:
ev_regions = set(filter(None, [event.region, *(event.regions or [])]))
if not (set(regions) & ev_regions):
return
event_rank = self.SEVERITY_RANK.get(event.severity, 0)
if event_rank < self.SEVERITY_RANK.get(getattr(tog, "min_severity", "routine"), 0):
return
sev_channels = getattr(tog, "severity_channels", None) or {}
for ch_type in sev_channels.get(event.severity, []):
if ch_type == "digest":
continue
try:
rule = self._toggle_to_rule(tog, ch_type, event)
channel = self._channel_factory(rule, self._connector)
payload = make_payload_from_event(event)
success = await channel.deliver(payload, rule)
if success:
self._logger.info(f"Dispatched event {event.id} via toggle {fam}/{ch_type}")
else:
self._logger.warning(f"Toggle channel delivery returned False for {fam}/{ch_type}")
except Exception:
self._logger.exception(f"Toggle channel delivery failed for {fam}/{ch_type}")
def _toggle_to_rule(self, tog, ch_type: str, event: Event):
from meshai.config import NotificationRuleConfig
return NotificationRuleConfig(
name=f"toggle:{getattr(tog, 'name', '')}",
enabled=True, trigger_type="condition", delivery_type=ch_type,
broadcast_channel=(getattr(tog, "broadcast_channel", None) or 0),
node_ids=list(getattr(tog, "node_ids", []) or []),
smtp_host=getattr(tog, "smtp_host", ""), smtp_port=getattr(tog, "smtp_port", 587),
smtp_user=getattr(tog, "smtp_user", ""), smtp_password=getattr(tog, "smtp_password", ""),
smtp_tls=getattr(tog, "smtp_tls", True), from_address=getattr(tog, "from_address", ""),
recipients=list(getattr(tog, "recipients", []) or []),
webhook_url=getattr(tog, "webhook_url", ""),
webhook_headers=dict(getattr(tog, "webhook_headers", {}) or {}),
override_quiet=bool(getattr(tog, "quiet_hours_override", False) and event.severity == "immediate"),
)
def _matching_rules(self, event: Event) -> list:
"""Return enabled condition rules matching this event's category
and severity threshold."""
@ -79,5 +136,10 @@ class Dispatcher:
min_rank = self.SEVERITY_RANK.get(rule.min_severity, 0)
if event_rank < min_rank:
continue
scope = getattr(rule, "region_scope", None) or []
if scope:
ev_regions = set(filter(None, [event.region, *(event.regions or [])]))
if not (set(scope) & ev_regions):
continue
matches.append(rule)
return matches

View file

@ -0,0 +1,125 @@
"""v0.5 Section 1: NotificationToggle dispatch routing tests."""
import asyncio
from meshai.config import Config
from meshai.notifications.pipeline.dispatcher import Dispatcher
from meshai.notifications.events import make_event
class RecChannel:
def __init__(self, rec):
self.rec = rec
async def deliver(self, payload, rule):
self.rec.append({
"delivery_type": rule.delivery_type,
"name": rule.name,
"broadcast_channel": rule.broadcast_channel,
"node_ids": list(rule.node_ids),
"override_quiet": rule.override_quiet,
})
return True
def _dispatch(cfg, event):
rec = []
d = Dispatcher(cfg, lambda rule, conn: RecChannel(rec), connector=None)
asyncio.run(d.dispatch(event))
return rec
def _cfg(enable="weather", **kw):
cfg = Config()
cfg.notifications.rules = []
t = cfg.notifications.toggles[enable]
t.enabled = True
t.min_severity = kw.get("min_severity", "priority")
t.regions = kw.get("regions", [])
t.severity_channels = kw.get("severity_channels", {"priority": ["mesh_broadcast"]})
return cfg
def _ev(severity="priority", category="weather_warning", region=None, regions=None):
return make_event(source="nws", category=category, severity=severity,
region=region, regions=regions or [], title="t")
def test_disabled_toggle_no_dispatch():
cfg = Config(); cfg.notifications.rules = [] # weather disabled by default
assert _dispatch(cfg, _ev()) == []
def test_enabled_toggle_dispatches():
rec = _dispatch(_cfg(), _ev(severity="priority"))
assert len(rec) == 1 and rec[0]["delivery_type"] == "mesh_broadcast"
assert rec[0]["name"] == "toggle:weather"
def test_region_empty_allows_all():
rec = _dispatch(_cfg(regions=[]), _ev(region="Boise"))
assert len(rec) == 1
def test_region_populated_blocks_mismatch():
cfg = _cfg(regions=["Magic Valley"])
assert _dispatch(cfg, _ev(region="Boise")) == []
assert len(_dispatch(cfg, _ev(region="Magic Valley"))) == 1
def test_region_matches_via_regions_list():
cfg = _cfg(regions=["Magic Valley"])
assert len(_dispatch(cfg, _ev(region=None, regions=["Magic Valley", "X"]))) == 1
def test_severity_threshold():
cfg = _cfg(min_severity="priority",
severity_channels={"routine": ["mesh_broadcast"], "priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast"]})
assert _dispatch(cfg, _ev(severity="routine")) == [] # below threshold
assert len(_dispatch(cfg, _ev(severity="priority"))) == 1
assert len(_dispatch(cfg, _ev(severity="immediate"))) == 1
def test_per_severity_channel_routing():
cfg = _cfg(min_severity="routine",
severity_channels={"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast", "mesh_dm"]})
assert len(_dispatch(cfg, _ev(severity="priority"))) == 1
imm = _dispatch(cfg, _ev(severity="immediate"))
assert {r["delivery_type"] for r in imm} == {"mesh_broadcast", "mesh_dm"}
def test_digest_channel_skipped_in_live_dispatch():
cfg = _cfg(severity_channels={"priority": ["digest", "mesh_broadcast"]})
rec = _dispatch(cfg, _ev(severity="priority"))
assert [r["delivery_type"] for r in rec] == ["mesh_broadcast"] # digest not live-dispatched
def test_quiet_hours_override_immediate_only():
cfg = _cfg(min_severity="routine",
severity_channels={"priority": ["mesh_broadcast"], "immediate": ["mesh_broadcast"]})
cfg.notifications.toggles["weather"].quiet_hours_override = True
assert _dispatch(cfg, _ev(severity="priority"))[0]["override_quiet"] is False
assert _dispatch(cfg, _ev(severity="immediate"))[0]["override_quiet"] is True
def test_category_maps_to_correct_family():
# seismic family toggle handles earthquake_event via get_toggle fallback
cfg = Config(); cfg.notifications.rules = []
cfg.notifications.toggles["seismic"].enabled = True
cfg.notifications.toggles["seismic"].severity_channels = {"priority": ["mesh_broadcast"]}
rec = _dispatch(cfg, _ev(severity="priority", category="earthquake_event"))
assert len(rec) == 1 and rec[0]["name"] == "toggle:seismic"
def test_rules_and_toggles_both_fire():
from meshai.config import NotificationRuleConfig
cfg = _cfg()
cfg.notifications.rules = [NotificationRuleConfig(
name="legacy", enabled=True, trigger_type="condition",
categories=["weather_warning"], min_severity="routine",
delivery_type="mesh_broadcast")]
rec = _dispatch(cfg, _ev(severity="priority"))
names = {r["name"] for r in rec}
assert "legacy" in names and "toggle:weather" in names # parallel paths both fire