mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 09:21:33 +00:00
feat(reminders): route fire reminders through per-region fire routing (#106)
Fire (wfigs) reminders previously dispatched via the generic scheduled path, which hardcoded the rf_propagation toggle (Meshtastic ch4 / MeshCore #aida) and ignored the fire's region. They now route through a new dispatch_scheduled_fire_broadcast() that builds a synthetic fire event from the fire's lat/lon, derives its region the same way the live fire event path does, and routes per region_routes.cells['fire'] (per-region MT/MC channels), falling back to the fire toggle's own defaults when a transport isn't matrix-owned -- never rf_propagation. rf_propagation and 511 reminders are unchanged. Reminders remain disabled (reminders_wfigs.enabled stays false); this only fixes routing for when they are enabled. 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
fed63b2344
commit
af826319c8
4 changed files with 597 additions and 5 deletions
|
|
@ -924,6 +924,203 @@ class Dispatcher:
|
|||
"scheduled-broadcast: audit row insert failed for %s", ch_type)
|
||||
return delivered_any
|
||||
|
||||
async def dispatch_scheduled_fire_broadcast(
|
||||
self, text: str, *,
|
||||
source_event_pk: str,
|
||||
lat=None, lon=None, county=None, state=None,
|
||||
) -> bool:
|
||||
"""Region-aware scheduled broadcast for a FIRE reminder.
|
||||
|
||||
Unlike dispatch_scheduled_broadcast (which hardcodes the
|
||||
rf_propagation toggle -> band-conditions channels), a fire reminder
|
||||
must land on the SAME channels a live New/Update fire alert for the
|
||||
SAME fire would: routed through the `fire` toggle + region_routes
|
||||
matrix, with the fire's region derived from its lat/lon exactly the
|
||||
way the event path derives it (CoverageFilter.event_region_names over
|
||||
the configured coverage areas).
|
||||
|
||||
Resolution (mirrors _dispatch_toggles Section 1.5 semantics, minus the
|
||||
event-driven dedup/cooldown/freshness gates that reminders intentionally
|
||||
bypass — reminders are clock-driven, not event-driven):
|
||||
|
||||
* derive regions from (lat, lon) via the coverage areas,
|
||||
* for each ENABLED transport (mt/mc) whose region_routes cell matches
|
||||
a derived region, route to that cell's channel (per-cell `enabled`
|
||||
respected); a matched+enabled transport is matrix-OWNED even if its
|
||||
column is null, so it does NOT fall back to the toggle default,
|
||||
* any transport NOT owned by the matrix (matrix disabled for it, no
|
||||
cell for any derived region, or no derived region at all) falls back
|
||||
to the `fire` toggle's own default channel(s).
|
||||
|
||||
This guarantees a SW-Idaho fire reminder goes to MT ch3 / MC
|
||||
#sw-id-aida (etc.), and an unlocatable / unmatched fire falls back to
|
||||
the fire toggle default — never to rf_propagation / band-conditions.
|
||||
|
||||
Cold-start grace still applies (consistent with the other scheduled
|
||||
broadcasts). Returns True on at least one successful mesh delivery.
|
||||
"""
|
||||
# Cold-start grace (mirrors dispatch_scheduled_broadcast).
|
||||
grace_s = int(getattr(self._config.notifications,
|
||||
"cold_start_grace_seconds", 60) or 0)
|
||||
if grace_s > 0:
|
||||
now_anchor = time.time()
|
||||
if self._first_event_at is None:
|
||||
self._first_event_at = now_anchor
|
||||
self._persist_state()
|
||||
if (now_anchor - self._first_event_at) < grace_s:
|
||||
self._cold_start_dropped += 1
|
||||
self._persist_state()
|
||||
self._logger.info(
|
||||
"cold-start grace: dropping scheduled fire broadcast "
|
||||
"(pk=%s)", source_event_pk)
|
||||
return False
|
||||
|
||||
toggles = getattr(self._config.notifications, "toggles", None) or {}
|
||||
fire_tog = toggles.get("fire") if isinstance(toggles, dict) else None
|
||||
if fire_tog is None:
|
||||
self._logger.info(
|
||||
"scheduled-fire-broadcast: fire toggle not found; dropping "
|
||||
"(pk=%s)", source_event_pk)
|
||||
return False
|
||||
|
||||
# Build a synthetic fire Event purely to (a) run region derivation and
|
||||
# (b) reuse make_payload_from_event. category wildfire_declared maps to
|
||||
# the `fire` family; severity priority mirrors the live fire path.
|
||||
from meshai.notifications.events import make_event, make_payload_from_event
|
||||
ev = make_event(
|
||||
source="wfigs", category="wildfire_declared",
|
||||
severity="priority", title=text,
|
||||
lat=(float(lat) if lat is not None else None),
|
||||
lon=(float(lon) if lon is not None else None),
|
||||
)
|
||||
ev.data["_meshai_precomposed"] = True
|
||||
|
||||
# Derive regions from the fire's location the SAME way CoverageFilter
|
||||
# does for a live event (named coverage areas -> region names). Never
|
||||
# raises: an unlocatable fire simply yields no regions -> toggle default.
|
||||
derived_regions: list = []
|
||||
try:
|
||||
from meshai.coverage_area import (
|
||||
areas_from_config, event_region_names,
|
||||
)
|
||||
_areas = areas_from_config(getattr(self._config, "coverage", None))
|
||||
if _areas and ev.lat is not None and ev.lon is not None:
|
||||
derived_regions = event_region_names(ev, _areas) or []
|
||||
except Exception:
|
||||
self._logger.exception(
|
||||
"scheduled-fire-broadcast: region derivation failed; "
|
||||
"falling back to toggle default (pk=%s)", source_event_pk)
|
||||
if derived_regions:
|
||||
ev.regions = list(derived_regions)
|
||||
ev.region = derived_regions[0]
|
||||
|
||||
# Resolve the channel plan. Each entry: (ch_type, chan_val).
|
||||
# ch_type in {"mesh_broadcast", "meshcore_broadcast"}
|
||||
# chan_val = Meshtastic channel index or MeshCore channel name.
|
||||
# _mt_owned / _mc_owned track per-transport matrix authority so an
|
||||
# owned transport does NOT also emit the toggle default.
|
||||
plan: list = []
|
||||
_mt_owned = False
|
||||
_mc_owned = False
|
||||
rr = getattr(self._config.notifications, "region_routes", None)
|
||||
_mt_on = bool(getattr(rr, "mt_enabled", False)) if rr is not None else False
|
||||
_mc_on = bool(getattr(rr, "mc_enabled", False)) if rr is not None else False
|
||||
if rr is not None and (_mt_on or _mc_on) and derived_regions:
|
||||
fam_cells = (getattr(rr, "cells", None) or {}).get("fire") or {}
|
||||
_seen: set = set()
|
||||
for _rg in derived_regions:
|
||||
if _rg in _seen or _rg not in fam_cells:
|
||||
continue
|
||||
_seen.add(_rg)
|
||||
_cell = fam_cells[_rg]
|
||||
# A matched region marks each ENABLED transport as matrix-owned
|
||||
# (mirrors the event path's authoritative-suppress), so it does
|
||||
# NOT fall back to the toggle default even if the column is null.
|
||||
if _mt_on:
|
||||
_mt_owned = True
|
||||
if _mc_on:
|
||||
_mc_owned = True
|
||||
_cell_enabled = (_cell.get("enabled", True) if isinstance(_cell, dict)
|
||||
else getattr(_cell, "enabled", True))
|
||||
if not _cell_enabled:
|
||||
continue
|
||||
_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_on and _mt is not None:
|
||||
plan.append(("mesh_broadcast", _mt))
|
||||
if _mc_on and _mc: # truthy: non-empty string
|
||||
plan.append(("meshcore_broadcast", _mc))
|
||||
|
||||
# Toggle-default fallback for any transport the matrix did NOT own.
|
||||
# Uses the fire toggle's priority severity_channels (band-conditions is
|
||||
# NOT consulted). A transport already owned by the matrix is skipped.
|
||||
sev_channels = getattr(fire_tog, "severity_channels", {}) or {}
|
||||
default_ch_types = [
|
||||
c for c in sev_channels.get("priority", ["mesh_broadcast"])
|
||||
if c in ("mesh_broadcast", "meshcore_broadcast")
|
||||
]
|
||||
for ct in default_ch_types:
|
||||
if ct == "mesh_broadcast" and not _mt_owned:
|
||||
plan.append(("mesh_broadcast",
|
||||
getattr(fire_tog, "broadcast_channel", None) or 0))
|
||||
elif ct == "meshcore_broadcast" and not _mc_owned:
|
||||
_mcn = getattr(fire_tog, "meshcore_channel", None)
|
||||
if _mcn:
|
||||
plan.append(("meshcore_broadcast", _mcn))
|
||||
|
||||
if not plan:
|
||||
self._logger.info(
|
||||
"scheduled-fire-broadcast: no channels resolved for pk=%s "
|
||||
"(regions=%s); dropping", source_event_pk, derived_regions)
|
||||
return False
|
||||
|
||||
delivered_any = False
|
||||
for ch_type, chan_val in plan:
|
||||
rule = self._toggle_to_rule(
|
||||
fire_tog, ch_type, ev,
|
||||
mt_override=(chan_val if ch_type == "mesh_broadcast" else None),
|
||||
mc_override=(chan_val if ch_type == "meshcore_broadcast" else None),
|
||||
)
|
||||
try:
|
||||
channel = self._channel_factory(rule, self._connector)
|
||||
payload = make_payload_from_event(ev, message=text)
|
||||
success = await channel.deliver(payload, rule)
|
||||
except Exception:
|
||||
self._logger.exception(
|
||||
"scheduled-fire-broadcast: delivery raised for %s", ch_type)
|
||||
success = False
|
||||
|
||||
if success:
|
||||
delivered_any = True
|
||||
self._logger.info(
|
||||
"scheduled-fire-broadcast: dispatched pk=%s via %s ch=%s "
|
||||
"regions=%s", source_event_pk, ch_type, chan_val,
|
||||
derived_regions or "DEFAULT")
|
||||
|
||||
# v20 per-mesh audit row (one per channel; mirrors
|
||||
# dispatch_scheduled_broadcast).
|
||||
try:
|
||||
from meshai.persistence import get_db
|
||||
conn = get_db()
|
||||
bytes_sent = len(text.encode("utf-8")) if text else 0
|
||||
transport, channel_id, recipient = self._audit_route(rule, ch_type)
|
||||
conn.execute(
|
||||
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, "
|
||||
"channel, text, source_event_table, source_event_pk, "
|
||||
"bytes_sent, ack_received, transport, success) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(int(time.time()), recipient, channel_id, text,
|
||||
"fires", str(source_event_pk), bytes_sent, 0,
|
||||
transport, 1 if success else 0),
|
||||
)
|
||||
except Exception:
|
||||
self._logger.exception(
|
||||
"scheduled-fire-broadcast: audit row insert failed for %s",
|
||||
ch_type)
|
||||
return delivered_any
|
||||
|
||||
@staticmethod
|
||||
def _audit_route(rule, ch_type: str):
|
||||
"""Resolve (transport, channel_id, recipient) for a mesh delivery.
|
||||
|
|
|
|||
|
|
@ -308,10 +308,29 @@ class ReminderScheduler:
|
|||
return ""
|
||||
|
||||
async def _dispatch(self, adapter: str, row, wire: str) -> bool:
|
||||
"""Send via dispatcher.dispatch_scheduled_broadcast (cold-start
|
||||
grace honored, no toggle-path freshness gating)."""
|
||||
"""Send via the dispatcher (cold-start grace honored, no toggle-path
|
||||
freshness gating).
|
||||
|
||||
Fire (wfigs) reminders route through the REGION-AWARE fire path
|
||||
(dispatch_scheduled_fire_broadcast) so an "Active:" reminder lands on
|
||||
the SAME channels a live New/Update fire alert for that fire would —
|
||||
the `fire` toggle + region_routes matrix, region derived from the
|
||||
fire's lat/lon. Every OTHER adapter (rf_propagation band conditions,
|
||||
511 work zones) keeps the existing dispatch_scheduled_broadcast path
|
||||
(rf_propagation -> band-conditions channels), unchanged."""
|
||||
table, pk = self._row_pk(adapter, row)
|
||||
try:
|
||||
if adapter == "wfigs":
|
||||
return bool(
|
||||
await self._dispatcher.dispatch_scheduled_fire_broadcast(
|
||||
text=wire,
|
||||
source_event_pk=pk,
|
||||
lat=_safe_row(row, "lat"),
|
||||
lon=_safe_row(row, "lon"),
|
||||
county=_safe_row(row, "county"),
|
||||
state=_safe_row(row, "state"),
|
||||
)
|
||||
)
|
||||
return bool(await self._dispatcher.dispatch_scheduled_broadcast(
|
||||
text=wire, source_event_table=table, source_event_pk=pk,
|
||||
))
|
||||
|
|
@ -393,3 +412,14 @@ def _safe_get(adapter_config, adapter: str, key: str) -> Any:
|
|||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_row(row, key: str) -> Any:
|
||||
"""Read a column from a sqlite3.Row without raising when it's absent.
|
||||
|
||||
The wfigs reminder rows come from `SELECT * FROM fires`, so lat/lon/
|
||||
county/state are present; this stays defensive against schema drift."""
|
||||
try:
|
||||
return row[key]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return None
|
||||
|
|
|
|||
358
work/tests/test_fire_reminder_region_routing.py
Normal file
358
work/tests/test_fire_reminder_region_routing.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
"""Fire-reminder region routing tests.
|
||||
|
||||
A wfigs "Active:" reminder must land on the SAME channels a live New/Update
|
||||
fire alert for that fire would — routed through the `fire` toggle +
|
||||
region_routes matrix, with the fire's region derived from its lat/lon exactly
|
||||
the way the event path derives it (coverage areas -> region names). Before this
|
||||
change, fire reminders went through dispatch_scheduled_broadcast(), which
|
||||
hardcodes the rf_propagation toggle -> Meshtastic ch4 / MeshCore #aida
|
||||
(band-conditions), ignoring the fire's region entirely.
|
||||
|
||||
These tests drive the FULL path: ReminderScheduler.tick_once() reads the seeded
|
||||
`fires` row, the wfigs branch calls dispatch_scheduled_fire_broadcast(), which
|
||||
derives the region from the fire's lat/lon over the configured coverage areas
|
||||
and routes via the matrix. Delivery channels are asserted against a real
|
||||
Dispatcher + a recording channel (no MagicMock) so the exact channel is checked.
|
||||
|
||||
Region layout (coverage areas named to match the matrix cell keys):
|
||||
SW Idaho ~ Boise (43.6, -116.2) -> MT ch3 / MC #sw-id-aida
|
||||
SC Idaho ~ Twin Falls (42.5, -114.5) -> MT ch2 / MC #sc-id-aida
|
||||
East Idaho ~ Idaho Falls (43.5, -112.0) -> MT ch5 / MC #e-id-aida
|
||||
Toggle default (fire) ~ MT ch9 / MC #aida (never rf_propagation/ch4)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.config import Config, RegionRouteMatrix
|
||||
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
||||
from meshai.notifications.reminders import ReminderScheduler
|
||||
from meshai.persistence import get_db
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- recorder
|
||||
|
||||
|
||||
class RecChannel:
|
||||
"""Records each delivery's transport + channel value + message."""
|
||||
|
||||
def __init__(self, rec: list, succeed: bool = True):
|
||||
self.rec = rec
|
||||
self.succeed = succeed
|
||||
|
||||
async def deliver(self, payload, rule):
|
||||
self.rec.append({
|
||||
"delivery_type": rule.delivery_type,
|
||||
"broadcast_channel": getattr(rule, "broadcast_channel", None),
|
||||
"meshcore_channel": getattr(rule, "meshcore_channel", None),
|
||||
"message": payload.message if payload else None,
|
||||
})
|
||||
return self.succeed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- fixtures
|
||||
|
||||
|
||||
# Coverage areas (name -> small bbox around the reference point). The name is
|
||||
# what event_region_names() returns AND what the matrix cell is keyed on.
|
||||
_COVERAGE_AREAS = [
|
||||
{"name": "SW", "west": -117.0, "south": 43.0, "east": -115.5, "north": 44.2},
|
||||
{"name": "SC", "west": -115.2, "south": 42.0, "east": -113.8, "north": 43.0},
|
||||
{"name": "East", "west": -112.8, "south": 43.0, "east": -111.2, "north": 44.2},
|
||||
]
|
||||
|
||||
# Reference points that fall inside exactly one named area above.
|
||||
_PT = {
|
||||
"SW": (43.6, -116.2),
|
||||
"SC": (42.5, -114.5),
|
||||
"East": (43.5, -112.0),
|
||||
}
|
||||
|
||||
|
||||
def _fire_cfg(*, with_matrix=True, mt_enabled=True, mc_enabled=True,
|
||||
cold_start_grace=0):
|
||||
"""Config: fire toggle enabled, coverage areas for region tagging, and the
|
||||
per-region fire matrix (SW/SC/East -> MT/MC channels)."""
|
||||
cfg = Config()
|
||||
cfg.notifications.rules = []
|
||||
cfg.notifications.cold_start_grace_seconds = cold_start_grace
|
||||
|
||||
# Fire toggle: default channels DISTINCT from every matrix cell + from
|
||||
# rf_propagation (ch4) so a mis-route is unambiguous. MT default = ch9,
|
||||
# MC default = #aida (the same MC default the legacy path used — proving
|
||||
# the fallback is the FIRE toggle default, not rf_propagation's).
|
||||
fire = cfg.notifications.toggles["fire"]
|
||||
fire.enabled = True
|
||||
fire.min_severity = "routine"
|
||||
fire.regions = []
|
||||
fire.freshness_seconds = 0
|
||||
fire.cooldown_seconds = 0
|
||||
fire.broadcast_channel = 9
|
||||
fire.meshcore_channel = "#aida"
|
||||
fire.severity_channels = {
|
||||
"routine": ["mesh_broadcast", "meshcore_broadcast"],
|
||||
"priority": ["mesh_broadcast", "meshcore_broadcast"],
|
||||
"immediate": ["mesh_broadcast", "meshcore_broadcast"],
|
||||
}
|
||||
|
||||
# rf_propagation toggle configured too, so a mis-route to the OLD path
|
||||
# would land on ch4/#aida-band and be caught by the assertions.
|
||||
rf = cfg.notifications.toggles["rf_propagation"]
|
||||
rf.enabled = True
|
||||
rf.broadcast_channel = 4
|
||||
rf.meshcore_channel = "#aida-band"
|
||||
rf.severity_channels = {
|
||||
"priority": ["mesh_broadcast", "meshcore_broadcast"],
|
||||
}
|
||||
|
||||
cfg.coverage.enabled = True
|
||||
cfg.coverage.areas = list(_COVERAGE_AREAS)
|
||||
|
||||
if with_matrix:
|
||||
cfg.notifications.region_routes = RegionRouteMatrix(
|
||||
mt_enabled=mt_enabled, mc_enabled=mc_enabled,
|
||||
cells={
|
||||
"fire": {
|
||||
"SW": {"mt": 3, "mc": "#sw-id-aida",
|
||||
"min_severity": "routine", "enabled": True},
|
||||
"SC": {"mt": 2, "mc": "#sc-id-aida",
|
||||
"min_severity": "routine", "enabled": True},
|
||||
"East": {"mt": 5, "mc": "#e-id-aida",
|
||||
"min_severity": "routine", "enabled": True},
|
||||
},
|
||||
},
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _dispatcher(cfg, succeed=True):
|
||||
rec: list = []
|
||||
d = Dispatcher(cfg, lambda rule, conn: RecChannel(rec, succeed), connector=None)
|
||||
return d, rec
|
||||
|
||||
|
||||
def _seed_fire(conn, *, irwin_id, lat, lon, last_broadcast_at,
|
||||
current_contained_pct=10, name="Test Fire",
|
||||
county="Ada", state="ID"):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fires(irwin_id, incident_name, incident_type, "
|
||||
"current_acres, current_contained_pct, lat, lon, county, state, "
|
||||
"declared_at, last_event_at, first_broadcast_at, last_broadcast_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(irwin_id, name, "WF", 500, current_contained_pct, lat, lon,
|
||||
county, state, last_broadcast_at, now,
|
||||
last_broadcast_at, last_broadcast_at),
|
||||
)
|
||||
|
||||
|
||||
def _enable_wfigs_reminders():
|
||||
"""Flip the reminders_wfigs.enabled kill switch on IN THE TEST DB ONLY.
|
||||
|
||||
This mutates the per-test isolated sqlite adapter_config seed (conftest
|
||||
points MESHAI_DB_PATH at a tmp file). It does NOT touch /data/config or
|
||||
the container's adapter_config — the production kill switch stays False.
|
||||
"""
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE adapter_config SET default_json='true' "
|
||||
"WHERE adapter='reminders_wfigs' AND key='enabled'"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE adapter_config SET value_json='true' "
|
||||
"WHERE adapter='reminders_wfigs' AND key='enabled'"
|
||||
)
|
||||
from meshai.adapter_config import adapter_config as _ac
|
||||
_ac.invalidate()
|
||||
|
||||
|
||||
def _tick(cfg, *, irwin_id, region, succeed=True, **fire_kw):
|
||||
"""Seed one fire in `region`, run one reminder tick, return (fired, rec)."""
|
||||
conn = get_db()
|
||||
_enable_wfigs_reminders()
|
||||
lat, lon = _PT[region]
|
||||
_seed_fire(conn, irwin_id=irwin_id, lat=lat, lon=lon,
|
||||
last_broadcast_at=int(time.time()) - 9 * 3600, **fire_kw)
|
||||
d, rec = _dispatcher(cfg, succeed=succeed)
|
||||
sch = ReminderScheduler(d, clock=time.time)
|
||||
fired = asyncio.run(sch.tick_once())
|
||||
return fired, rec
|
||||
|
||||
|
||||
# ============================================================ SW / SC / East
|
||||
|
||||
|
||||
def test_sw_idaho_fire_reminder_routes_ch3_and_sw_mc():
|
||||
"""SW Idaho fire reminder -> MT ch3 + MC #sw-id-aida (NOT ch4, NOT #aida)."""
|
||||
cfg = _fire_cfg()
|
||||
fired, rec = _tick(cfg, irwin_id="F-SW", region="SW")
|
||||
assert fired == 1
|
||||
|
||||
mt = [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(mt) == 1 and mt[0]["broadcast_channel"] == 3, \
|
||||
"SW fire reminder must go to MT ch3, got %r" % (mt,)
|
||||
assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#sw-id-aida", \
|
||||
"SW fire reminder must go to MC #sw-id-aida, got %r" % (mc,)
|
||||
|
||||
# Explicitly rule out the OLD hardcoded rf_propagation route.
|
||||
assert all(r["broadcast_channel"] != 4 for r in mt), "must NOT hit ch4"
|
||||
assert all(r["meshcore_channel"] not in ("#aida", "#aida-band") for r in mc)
|
||||
# And it must be an "Active:" reminder.
|
||||
assert any("Active" in (r["message"] or "") for r in rec)
|
||||
|
||||
|
||||
def test_sc_idaho_fire_reminder_routes_ch2_and_sc_mc():
|
||||
"""SC Idaho fire reminder -> MT ch2 + MC #sc-id-aida."""
|
||||
cfg = _fire_cfg()
|
||||
fired, rec = _tick(cfg, irwin_id="F-SC", region="SC")
|
||||
assert fired == 1
|
||||
|
||||
mt = [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(mt) == 1 and mt[0]["broadcast_channel"] == 2
|
||||
assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#sc-id-aida"
|
||||
|
||||
|
||||
def test_east_idaho_fire_reminder_routes_ch5_and_east_mc():
|
||||
"""East Idaho fire reminder -> MT ch5 + MC #e-id-aida."""
|
||||
cfg = _fire_cfg()
|
||||
fired, rec = _tick(cfg, irwin_id="F-E", region="East")
|
||||
assert fired == 1
|
||||
|
||||
mt = [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(mt) == 1 and mt[0]["broadcast_channel"] == 5
|
||||
assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#e-id-aida"
|
||||
|
||||
|
||||
# ============================================================ unresolved region
|
||||
|
||||
|
||||
def test_unresolvable_region_falls_back_to_fire_toggle_default():
|
||||
"""A fire whose lat/lon lands in NO named coverage area falls back to the
|
||||
FIRE toggle default (MT ch9 / MC #aida) — never rf_propagation (ch4)."""
|
||||
cfg = _fire_cfg()
|
||||
conn = get_db()
|
||||
_enable_wfigs_reminders()
|
||||
# Point in the middle of the ocean — inside no named area.
|
||||
_seed_fire(conn, irwin_id="F-NONE", lat=0.0, lon=0.0,
|
||||
last_broadcast_at=int(time.time()) - 9 * 3600)
|
||||
d, rec = _dispatcher(cfg)
|
||||
fired = asyncio.run(ReminderScheduler(d, clock=time.time).tick_once())
|
||||
assert fired == 1
|
||||
|
||||
mt = [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(mt) == 1 and mt[0]["broadcast_channel"] == 9, \
|
||||
"unresolved fire must fall back to fire toggle default MT ch9, got %r" % (mt,)
|
||||
assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida", \
|
||||
"unresolved fire must fall back to fire toggle default MC #aida, got %r" % (mc,)
|
||||
# NOT the rf_propagation band-conditions channels.
|
||||
assert all(r["broadcast_channel"] != 4 for r in mt)
|
||||
assert all(r["meshcore_channel"] != "#aida-band" for r in mc)
|
||||
|
||||
|
||||
def test_region_matched_but_not_in_matrix_falls_back_to_default():
|
||||
"""A fire whose region IS derived (SW) but has NO cell in the matrix falls
|
||||
back to the fire toggle default, still NOT rf_propagation."""
|
||||
cfg = _fire_cfg()
|
||||
# Drop the SW cell so the derived 'SW' region has no matrix entry.
|
||||
del cfg.notifications.region_routes.cells["fire"]["SW"]
|
||||
fired, rec = _tick(cfg, irwin_id="F-SW2", region="SW")
|
||||
assert fired == 1
|
||||
|
||||
mt = [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(mt) == 1 and mt[0]["broadcast_channel"] == 9
|
||||
assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida"
|
||||
|
||||
|
||||
def test_matrix_disabled_falls_back_to_fire_toggle_default():
|
||||
"""region_routes disabled for both transports -> fire toggle default
|
||||
channels (ch9/#aida), never rf_propagation."""
|
||||
cfg = _fire_cfg(mt_enabled=False, mc_enabled=False)
|
||||
fired, rec = _tick(cfg, irwin_id="F-SW3", region="SW")
|
||||
assert fired == 1
|
||||
|
||||
mt = [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(mt) == 1 and mt[0]["broadcast_channel"] == 9
|
||||
assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida"
|
||||
|
||||
|
||||
# ============================================================ regressions
|
||||
|
||||
|
||||
def test_rf_propagation_reminder_still_routes_ch4_and_aida():
|
||||
"""REGRESSION: a band-conditions (rf_propagation) reminder still uses the
|
||||
UNCHANGED dispatch_scheduled_broadcast path -> MT ch4 / MC #aida.
|
||||
|
||||
Driven via the same _dispatch entry the scheduler uses, with adapter=swpc
|
||||
standing in for a scheduled non-fire broadcaster: it must NOT touch the new
|
||||
fire path and must land on rf_propagation's configured channels."""
|
||||
cfg = _fire_cfg()
|
||||
d, rec = _dispatcher(cfg)
|
||||
sch = ReminderScheduler(d, clock=time.time)
|
||||
|
||||
# A minimal fake row is fine — _dispatch(adapter="swpc", ...) only needs
|
||||
# _row_pk to resolve, which reads row["event_id"].
|
||||
row = {"event_id": "S-RF"}
|
||||
ok = asyncio.run(sch._dispatch("swpc", row, "🌌 Active: ongoing space weather"))
|
||||
assert ok is True
|
||||
|
||||
# rf_propagation is priority-class: severity_channels['priority'] =
|
||||
# [mesh_broadcast, meshcore_broadcast] -> ch4 + #aida-band (its config).
|
||||
mt = [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(mt) == 1 and mt[0]["broadcast_channel"] == 4, \
|
||||
"rf_propagation reminder must still use ch4, got %r" % (mt,)
|
||||
assert len(mc) == 1 and mc[0]["meshcore_channel"] == "#aida-band"
|
||||
# It must NOT have used any fire matrix / fire-default channel.
|
||||
assert all(r["broadcast_channel"] not in (2, 3, 5, 9) for r in mt)
|
||||
|
||||
|
||||
def test_fire_reminder_does_not_touch_rf_propagation_path():
|
||||
"""The wfigs branch must call dispatch_scheduled_fire_broadcast and NOT
|
||||
dispatch_scheduled_broadcast (the rf_propagation path). Verified by spying
|
||||
on both dispatcher methods with a matched-region fire."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
cfg = _fire_cfg()
|
||||
conn = get_db()
|
||||
_enable_wfigs_reminders()
|
||||
lat, lon = _PT["SW"]
|
||||
_seed_fire(conn, irwin_id="F-SPY", lat=lat, lon=lon,
|
||||
last_broadcast_at=int(time.time()) - 9 * 3600)
|
||||
|
||||
d, _rec = _dispatcher(cfg)
|
||||
d.dispatch_scheduled_broadcast = AsyncMock(return_value=True)
|
||||
fire_spy = AsyncMock(return_value=True)
|
||||
d.dispatch_scheduled_fire_broadcast = fire_spy
|
||||
|
||||
fired = asyncio.run(ReminderScheduler(d, clock=time.time).tick_once())
|
||||
assert fired == 1
|
||||
fire_spy.assert_called_once()
|
||||
kwargs = fire_spy.call_args.kwargs
|
||||
assert kwargs["source_event_pk"] == "F-SPY"
|
||||
assert kwargs["lat"] == pytest.approx(lat)
|
||||
assert kwargs["lon"] == pytest.approx(lon)
|
||||
d.dispatch_scheduled_broadcast.assert_not_called()
|
||||
|
||||
|
||||
def test_disabled_wfigs_reminders_send_nothing():
|
||||
"""Sanity: with the kill switch OFF (default), no fire reminder fires even
|
||||
when a stale fire exists."""
|
||||
cfg = _fire_cfg()
|
||||
conn = get_db()
|
||||
# NOTE: intentionally do NOT call _enable_wfigs_reminders().
|
||||
lat, lon = _PT["SW"]
|
||||
_seed_fire(conn, irwin_id="F-OFF", lat=lat, lon=lon,
|
||||
last_broadcast_at=int(time.time()) - 9 * 3600)
|
||||
d, rec = _dispatcher(cfg)
|
||||
fired = asyncio.run(ReminderScheduler(d, clock=time.time).tick_once())
|
||||
assert fired == 0
|
||||
assert rec == []
|
||||
|
|
@ -71,6 +71,11 @@ def _enable_wfigs_reminders():
|
|||
def mock_dispatcher():
|
||||
d = MagicMock()
|
||||
d.dispatch_scheduled_broadcast = AsyncMock(return_value=True)
|
||||
# wfigs reminders route through the region-aware fire path (see
|
||||
# dispatch_scheduled_fire_broadcast); make it awaitable too so the
|
||||
# non-fire path tests (rf/511 via dispatch_scheduled_broadcast) and the
|
||||
# fire path tests share one fixture.
|
||||
d.dispatch_scheduled_fire_broadcast = AsyncMock(return_value=True)
|
||||
return d
|
||||
|
||||
|
||||
|
|
@ -89,10 +94,12 @@ def test_wfigs_reminder_fires_past_cadence(mock_dispatcher):
|
|||
sch = ReminderScheduler(mock_dispatcher, clock=lambda: now)
|
||||
fired = asyncio.run(sch.tick_once())
|
||||
assert fired == 1
|
||||
mock_dispatcher.dispatch_scheduled_broadcast.assert_called_once()
|
||||
args = mock_dispatcher.dispatch_scheduled_broadcast.call_args.kwargs
|
||||
# wfigs now routes through the region-aware fire path, NOT the hardcoded
|
||||
# rf_propagation dispatch_scheduled_broadcast.
|
||||
mock_dispatcher.dispatch_scheduled_broadcast.assert_not_called()
|
||||
mock_dispatcher.dispatch_scheduled_fire_broadcast.assert_called_once()
|
||||
args = mock_dispatcher.dispatch_scheduled_fire_broadcast.call_args.kwargs
|
||||
assert "Active" in args["text"]
|
||||
assert args["source_event_table"] == "fires"
|
||||
assert args["source_event_pk"] == "F1"
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue