feat(notifications): device event notifications (snack + Web Notifications)
Surface device connection state changes (configured target online/offline) and discovery events (new WLED on LAN, new serial port, devices that disappear) through a configurable per-event channel matrix: none / snack / OS / both. - Backend: long-running mDNS browser + 10 s serial poller in core/devices/discovery_watcher.py, gated by user pref. Reuses the existing device_health_changed event for online/offline transitions. New GET/PUT /api/v1/preferences/notifications endpoint with Pydantic v2 schema (channel matrix + background-discovery flag + grace/debounce). 13 new tests, full suite still 899 passing. - Frontend: features/notifications-watcher.ts with startup-grace + flap-debounce + bulk-coalesce pipeline. Web Notifications API for the OS channel (no platform-specific code, works in PWA shell). New "Notifications" tab in Settings with 4 IconSelect rows + bg toggle + permission row + test button. en/ru/zh translations. Defaults: device_offline=both (urgent), online/discovered=snack, lost=none, background discovery on. Already-configured devices are filtered from discovery events to avoid double-notifications.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""Tests for the background discovery watcher.
|
||||
|
||||
We mock zeroconf and ``list_serial_ports`` so the tests run in milliseconds
|
||||
without touching the network or hardware. The mDNS path is exercised via
|
||||
the public state-change handler; the serial path is exercised via the
|
||||
internal one-shot poll method.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from zeroconf import ServiceStateChange
|
||||
|
||||
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
|
||||
from ledgrab.core.devices.serial_transport import SerialPortInfo
|
||||
|
||||
|
||||
class _FakeDevice:
|
||||
def __init__(self, url: str) -> None:
|
||||
self.url = url
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self, devices: list[_FakeDevice] | None = None) -> None:
|
||||
self._devices = devices or []
|
||||
|
||||
def get_all_devices(self):
|
||||
return list(self._devices)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured_events():
|
||||
events: list[dict] = []
|
||||
return events
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_watcher(captured_events):
|
||||
def _factory(store: _FakeStore | None = None) -> DiscoveryWatcher:
|
||||
return DiscoveryWatcher(
|
||||
device_store=store or _FakeStore(),
|
||||
fire_event=captured_events.append,
|
||||
)
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Serial polling
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_seed_ports_do_not_emit_events(make_watcher, captured_events):
|
||||
"""Ports already plugged in at startup must not generate notifications.
|
||||
|
||||
Otherwise every server start would dump a "device discovered" toast
|
||||
for every COM port on the machine.
|
||||
"""
|
||||
watcher = make_watcher()
|
||||
seed = [SerialPortInfo(device="COM3", description="USB-Serial CH340")]
|
||||
|
||||
# First-poll behaviour models the seed: the watcher's startup path
|
||||
# populates _serial_seen without firing events. We exercise that
|
||||
# behaviour by populating the dict directly and then running the
|
||||
# poll, asserting no events fire because nothing changed.
|
||||
from ledgrab.core.devices.discovery_watcher import _DiscoveredEntry
|
||||
|
||||
for p in seed:
|
||||
watcher._serial_seen[p.device] = _DiscoveredEntry(
|
||||
key=p.device, url=p.device, name=p.description, device_type="serial"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||
return_value=seed,
|
||||
):
|
||||
watcher._poll_serial_once()
|
||||
|
||||
assert captured_events == []
|
||||
|
||||
|
||||
def test_new_serial_port_emits_discovered(make_watcher, captured_events):
|
||||
"""A port appearing between polls fires device_discovered."""
|
||||
watcher = make_watcher()
|
||||
|
||||
# First poll: seed COM3.
|
||||
with patch(
|
||||
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||
return_value=[SerialPortInfo(device="COM3", description="CH340")],
|
||||
):
|
||||
watcher._poll_serial_once()
|
||||
captured_events.clear() # discovered for COM3 above is fine; we test the next change
|
||||
|
||||
# Second poll: COM4 has appeared.
|
||||
with patch(
|
||||
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||
return_value=[
|
||||
SerialPortInfo(device="COM3", description="CH340"),
|
||||
SerialPortInfo(device="COM4", description="CP2102"),
|
||||
],
|
||||
):
|
||||
watcher._poll_serial_once()
|
||||
|
||||
assert len(captured_events) == 1
|
||||
evt = captured_events[0]
|
||||
assert evt["type"] == "device_discovered"
|
||||
assert evt["device_type"] == "serial"
|
||||
assert evt["url"] == "COM4"
|
||||
assert evt["name"] == "CP2102"
|
||||
|
||||
|
||||
def test_disappearing_serial_port_emits_lost(make_watcher, captured_events):
|
||||
"""A port that vanishes between polls fires device_lost."""
|
||||
watcher = make_watcher()
|
||||
|
||||
with patch(
|
||||
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||
return_value=[SerialPortInfo(device="COM4", description="CP2102")],
|
||||
):
|
||||
watcher._poll_serial_once()
|
||||
captured_events.clear()
|
||||
|
||||
with patch(
|
||||
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||
return_value=[],
|
||||
):
|
||||
watcher._poll_serial_once()
|
||||
|
||||
assert len(captured_events) == 1
|
||||
assert captured_events[0]["type"] == "device_lost"
|
||||
assert captured_events[0]["url"] == "COM4"
|
||||
|
||||
|
||||
def test_configured_devices_are_filtered_from_discovery(make_watcher, captured_events):
|
||||
"""If a port matches a configured device URL, suppress discovery events.
|
||||
|
||||
The user already gets device_health_changed for known devices; firing
|
||||
a redundant 'discovered' event the first time the port re-appears
|
||||
would be confusing noise.
|
||||
"""
|
||||
store = _FakeStore([_FakeDevice("COM5")])
|
||||
watcher = make_watcher(store)
|
||||
|
||||
# COM5 appears for the first time — but it's already configured,
|
||||
# so no event should fire.
|
||||
with patch(
|
||||
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||
return_value=[SerialPortInfo(device="COM5", description="ESP32")],
|
||||
):
|
||||
watcher._poll_serial_once()
|
||||
|
||||
assert captured_events == []
|
||||
|
||||
|
||||
def test_serial_enumeration_failure_is_swallowed(make_watcher, captured_events):
|
||||
"""A blown enumeration call shouldn't crash the loop or fire bogus events."""
|
||||
watcher = make_watcher()
|
||||
|
||||
with patch(
|
||||
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||
side_effect=OSError("device busy"),
|
||||
):
|
||||
watcher._poll_serial_once() # must not raise
|
||||
|
||||
assert captured_events == []
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# mDNS state-change dispatcher
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mdns_removed_emits_lost_only_for_unconfigured(make_watcher, captured_events):
|
||||
"""ServiceStateChange.Removed → device_lost, but only when the URL
|
||||
isn't already in the user's device store."""
|
||||
watcher = make_watcher()
|
||||
|
||||
# Seed a previously-resolved entry.
|
||||
from ledgrab.core.devices.discovery_watcher import _DiscoveredEntry
|
||||
|
||||
watcher._wled_seen["wled-foo._wled._tcp.local."] = _DiscoveredEntry(
|
||||
key="wled-foo._wled._tcp.local.",
|
||||
url="http://192.168.1.55",
|
||||
name="wled-foo",
|
||||
device_type="wled",
|
||||
)
|
||||
|
||||
watcher._on_wled_state_change(
|
||||
service_type="_wled._tcp.local.",
|
||||
name="wled-foo._wled._tcp.local.",
|
||||
state_change=ServiceStateChange.Removed,
|
||||
)
|
||||
|
||||
assert len(captured_events) == 1
|
||||
assert captured_events[0]["type"] == "device_lost"
|
||||
assert captured_events[0]["device_type"] == "wled"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for /api/v1/preferences/notifications endpoints."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.config import get_config
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""TestClient with auth header — same pattern as test_preferences_api.py."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ledgrab.main import app
|
||||
|
||||
api_key = next(iter(get_config().auth.api_keys.values()), "")
|
||||
with TestClient(app, raise_server_exceptions=False) as c:
|
||||
if api_key:
|
||||
c.headers["Authorization"] = f"Bearer {api_key}"
|
||||
yield c
|
||||
|
||||
|
||||
def _full_prefs() -> dict:
|
||||
return {
|
||||
"channels": {
|
||||
"device_online": "snack",
|
||||
"device_offline": "both",
|
||||
"device_discovered": "os",
|
||||
"device_lost": "none",
|
||||
},
|
||||
"background_discovery_enabled": True,
|
||||
"startup_grace_sec": 15,
|
||||
"flap_debounce_sec": 7,
|
||||
}
|
||||
|
||||
|
||||
def test_get_returns_defaults_when_unset(client):
|
||||
"""When no prefs have been saved, GET returns the documented defaults."""
|
||||
# Wipe via PUT to a known state to make this order-independent.
|
||||
# (No DELETE endpoint — settings rows are scalar.)
|
||||
resp = client.get("/api/v1/preferences/notifications")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["background_discovery_enabled"] is True
|
||||
assert body["startup_grace_sec"] == 10
|
||||
assert body["flap_debounce_sec"] == 5
|
||||
# Default channel matrix
|
||||
assert body["channels"]["device_online"] == "snack"
|
||||
assert body["channels"]["device_offline"] == "both"
|
||||
assert body["channels"]["device_discovered"] == "snack"
|
||||
assert body["channels"]["device_lost"] == "none"
|
||||
|
||||
|
||||
def test_put_then_get_round_trips(client):
|
||||
"""PUT a payload, GET it back unchanged."""
|
||||
payload = _full_prefs()
|
||||
put = client.put("/api/v1/preferences/notifications", json=payload)
|
||||
assert put.status_code == 200
|
||||
assert put.json()["startup_grace_sec"] == 15
|
||||
|
||||
got = client.get("/api/v1/preferences/notifications")
|
||||
assert got.status_code == 200
|
||||
assert got.json() == payload
|
||||
|
||||
|
||||
def test_put_rejects_invalid_channel(client):
|
||||
"""A bogus channel value (e.g. 'siren') is rejected by Pydantic."""
|
||||
bad = _full_prefs()
|
||||
bad["channels"]["device_offline"] = "siren"
|
||||
resp = client.put("/api/v1/preferences/notifications", json=bad)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_put_rejects_grace_out_of_range(client):
|
||||
"""startup_grace_sec is clamped to [0, 300]."""
|
||||
bad = _full_prefs()
|
||||
bad["startup_grace_sec"] = -5
|
||||
assert client.put("/api/v1/preferences/notifications", json=bad).status_code == 422
|
||||
|
||||
bad["startup_grace_sec"] = 9999
|
||||
assert client.put("/api/v1/preferences/notifications", json=bad).status_code == 422
|
||||
|
||||
|
||||
def test_put_rejects_debounce_out_of_range(client):
|
||||
"""flap_debounce_sec is clamped to [0, 60]."""
|
||||
bad = _full_prefs()
|
||||
bad["flap_debounce_sec"] = 999
|
||||
assert client.put("/api/v1/preferences/notifications", json=bad).status_code == 422
|
||||
|
||||
|
||||
def test_partial_payload_uses_defaults_for_omitted_channels(client):
|
||||
"""Pydantic fills in default channels when the matrix is partial.
|
||||
|
||||
The frontend may want to PUT only what changed; the backend should
|
||||
fill in the default channel matrix for omitted rows so we don't
|
||||
silently lose user preferences via partial-write.
|
||||
"""
|
||||
partial = {"background_discovery_enabled": False}
|
||||
resp = client.put("/api/v1/preferences/notifications", json=partial)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["background_discovery_enabled"] is False
|
||||
# Defaulted matrix is present
|
||||
assert body["channels"]["device_offline"] == "both"
|
||||
|
||||
|
||||
def test_corrupt_stored_value_falls_back_to_defaults(client):
|
||||
"""If something stomps on the stored row, the GET handler must
|
||||
return defaults instead of 500. Mirrors how load_shutdown_action
|
||||
treats corrupt input."""
|
||||
# Stuff garbage into the underlying setting via the same Database
|
||||
# the route uses, then verify GET still works.
|
||||
from ledgrab.api.dependencies import get_database
|
||||
|
||||
db = get_database()
|
||||
db.set_setting("notification_preferences", {"channels": "totally-wrong"})
|
||||
|
||||
resp = client.get("/api/v1/preferences/notifications")
|
||||
assert resp.status_code == 200
|
||||
# Defaults restored
|
||||
assert resp.json()["channels"]["device_offline"] == "both"
|
||||
Reference in New Issue
Block a user