From 8aa3a323d69251d3aedf339c216699e93bee5714 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 17:49:20 +0300 Subject: [PATCH] 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. --- TODO.md | 60 ++++ server/src/ledgrab/api/routes/preferences.py | 73 +++- server/src/ledgrab/api/schemas/preferences.py | 66 ++++ .../ledgrab/core/devices/discovery_watcher.py | 273 +++++++++++++++ server/src/ledgrab/main.py | 26 ++ server/src/ledgrab/static/js/app.ts | 8 + .../js/features/notifications-watcher.ts | 311 ++++++++++++++++++ .../ledgrab/static/js/features/settings.ts | 129 +++++++- server/src/ledgrab/static/locales/en.json | 41 +++ server/src/ledgrab/static/locales/ru.json | 41 +++ server/src/ledgrab/static/locales/zh.json | 41 +++ .../ledgrab/templates/modals/settings.html | 66 ++++ server/tests/test_discovery_watcher.py | 198 +++++++++++ .../test_preferences_notifications_api.py | 120 +++++++ 14 files changed, 1451 insertions(+), 2 deletions(-) create mode 100644 server/src/ledgrab/api/schemas/preferences.py create mode 100644 server/src/ledgrab/core/devices/discovery_watcher.py create mode 100644 server/src/ledgrab/static/js/features/notifications-watcher.ts create mode 100644 server/tests/test_discovery_watcher.py create mode 100644 server/tests/test_preferences_notifications_api.py diff --git a/TODO.md b/TODO.md index ddd01e9..c352057 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,65 @@ # LedGrab TODO +## Device Event Notifications + +Notify the user when LED devices come online/go offline (configured targets), and when new +WLED/serial devices are discovered or disappear from the LAN/USB. Each event class has a +configurable channel: `none` | `snack` | `os` | `both`. OS channel uses Web Notifications +(works in any browser tab and in the PWA shell — no platform-specific Python). + +Branch: `feat/device-event-notifications`. Default ON. + +### Backend + +- [x] `core/devices/discovery_watcher.py` — long-running mDNS browser + (`AsyncServiceBrowser` kept alive for the process lifetime) + 10 s serial-port + poller. Fires `device_discovered`/`device_lost` via `processor_manager.fire_event`, + suppresses events for URLs already in `device_store`. Seeded ports do NOT generate + startup-time toasts. +- [x] Wired into `lifespan` (`main.py`). Gated by `notification_preferences. + background_discovery_enabled`. Default True. Stops before health monitor stop. +- [x] `api/schemas/preferences.py` — `NotificationPreferences` Pydantic v2 model with + the 4-event channel matrix, `background_discovery_enabled`, `startup_grace_sec` + (0..300), `flap_debounce_sec` (0..60). +- [x] `api/routes/preferences.py` — `GET/PUT /api/v1/preferences/notifications`, + persisted under `db.set_setting("notification_preferences", …)`. Corrupt stored + values fall back to defaults instead of 500. +- [x] Reuses existing `device_health_changed` event from `device_health.py` (already + fires online/offline transitions on the same event bus). +- [x] Tests: 7 in `tests/test_preferences_notifications_api.py`, 6 in + `tests/test_discovery_watcher.py`. Full pytest suite still 899 passing. + +### Frontend + +- [x] `js/features/notifications-watcher.ts` — listens to the three `server:*` DOM + events. Applies user prefs. Pipeline: startup grace → flap debounce → bulk + coalesce (≥3 events / 800 ms collapse to one summary). +- [x] Web Notification permission requested from the Settings → Notifications panel + via a user-gesture button. State chip reflects granted/denied/default. +- [x] Settings panel — new "Notifications" subtab between Backup and Appearance. + 4 IconSelects (`none`/`snack`/`os`/`both`) + background-discovery toggle + + permission row + Test-notification button. +- [x] i18n: `settings.notifications.*` and `notifications.*` keys in en/ru/zh. + +### Verification + +- [x] `npx tsc --noEmit` clean, `npm run build` produces 2.5 MB bundle. +- [x] `ruff check src/ tests/` clean. 899/899 pytest pass. +- [x] App import smoke-test (`from ledgrab.main import app`) loads 233 routes + without errors. +- [ ] Real-hardware test pending — verify on user's network: + (1) plug a fresh WLED in → snack toast appears, (2) configure it → next + offline transition fires both snack + OS toast, (3) Background-discovery + toggle off → no more discovered/lost events. + +### Out of scope for v1 + +- Per-device-type granularity (we ship one matrix per event-type, no device-type split) +- Per-device mute list (deferred — user can globally toggle off if noisy) +- Native OS toast via Windows winrt API (Web Notifications cover the use case; + also avoids the `os_notification_listener` feedback loop) +- Notification history panel — could land later as the reserved `alerts` dashboard cell + ## Server shutdown action Let user choose what happens to LED targets on server shutdown. diff --git a/server/src/ledgrab/api/routes/preferences.py b/server/src/ledgrab/api/routes/preferences.py index 70a9b81..1bcb613 100644 --- a/server/src/ledgrab/api/routes/preferences.py +++ b/server/src/ledgrab/api/routes/preferences.py @@ -1,9 +1,13 @@ -"""User preferences routes — currently dashboard layout only. +"""User preferences routes — dashboard layout + notification settings. The dashboard layout schema is owned by the frontend (open registry of section/cell keys); the backend treats the value as an opaque JSON blob, validates it's a dict with a `version` field, and persists it under the `dashboard_layout` settings key. + +Notification preferences are validated server-side via Pydantic so the +backend can read them when deciding whether to start the background +discovery watcher. """ from typing import Any @@ -12,6 +16,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException from ledgrab.api.auth import AuthRequired from ledgrab.api.dependencies import get_database +from ledgrab.api.schemas.preferences import NotificationPreferences from ledgrab.storage.database import Database from ledgrab.utils import get_logger @@ -20,6 +25,27 @@ logger = get_logger(__name__) router = APIRouter() _DASHBOARD_LAYOUT_KEY = "dashboard_layout" +_NOTIFICATION_PREFS_KEY = "notification_preferences" + + +def load_notification_preferences(db: Database | None = None) -> NotificationPreferences: + """Read notification prefs, returning defaults when unset or corrupt. + + Used by both the route handler and `main.lifespan` (so the discovery + watcher can decide whether to start without going through HTTP). + """ + if db is None: + from ledgrab.api.dependencies import get_database as _get_db + + db = _get_db() + raw = db.get_setting(_NOTIFICATION_PREFS_KEY) + if not raw: + return NotificationPreferences() + try: + return NotificationPreferences.model_validate(raw) + except Exception as e: + logger.warning("Stored notification preferences invalid (%s); using defaults", e) + return NotificationPreferences() @router.get( @@ -73,3 +99,48 @@ async def delete_dashboard_layout( to clear the server-side override entirely.""" db.set_setting(_DASHBOARD_LAYOUT_KEY, {}) return {"ok": True} + + +# --------------------------------------------------------------------------- +# Notification preferences +# --------------------------------------------------------------------------- + + +@router.get( + "/api/v1/preferences/notifications", + response_model=NotificationPreferences, + tags=["Preferences"], +) +async def get_notification_preferences( + _: AuthRequired, + db: Database = Depends(get_database), +) -> NotificationPreferences: + """Read notification prefs, returning defaults when unset. + + Defaults: device_offline=both, device_online/discovered=snack, + device_lost=none, background discovery on, 10 s startup grace, + 5 s flap debounce. + """ + return load_notification_preferences(db) + + +@router.put( + "/api/v1/preferences/notifications", + response_model=NotificationPreferences, + tags=["Preferences"], +) +async def put_notification_preferences( + _: AuthRequired, + body: NotificationPreferences, + db: Database = Depends(get_database), +) -> NotificationPreferences: + """Persist the notification prefs. Pydantic enforces channel + enum + grace/debounce ranges so a bad client cannot poison + the stored value.""" + db.set_setting(_NOTIFICATION_PREFS_KEY, body.model_dump()) + logger.info( + "Notification preferences updated (background_discovery=%s, " "channels=%s)", + body.background_discovery_enabled, + body.channels.model_dump(), + ) + return body diff --git a/server/src/ledgrab/api/schemas/preferences.py b/server/src/ledgrab/api/schemas/preferences.py new file mode 100644 index 0000000..a548b94 --- /dev/null +++ b/server/src/ledgrab/api/schemas/preferences.py @@ -0,0 +1,66 @@ +"""User-preference schemas (notifications, future per-user settings).""" + +from typing import Literal + +from pydantic import BaseModel, Field + +NotificationChannel = Literal["none", "snack", "os", "both"] + + +class NotificationChannelMatrix(BaseModel): + """Channel selection per device-event type.""" + + device_online: NotificationChannel = Field( + default="snack", + description="Configured device transitioned from offline to online", + ) + device_offline: NotificationChannel = Field( + default="both", + description="Configured device went offline (urgent — likely user wants OS toast)", + ) + device_discovered: NotificationChannel = Field( + default="snack", + description="A new WLED/serial device appeared on the LAN/USB", + ) + device_lost: NotificationChannel = Field( + default="none", + description=( + "Previously discovered (but never configured) device disappeared. " + "Default off — usually noise unless the user is actively pairing." + ), + ) + + +class NotificationPreferences(BaseModel): + """User-level notification preferences.""" + + channels: NotificationChannelMatrix = Field( + default_factory=NotificationChannelMatrix, + description="Per-event-type channel selection", + ) + background_discovery_enabled: bool = Field( + default=True, + description=( + "Run the continuous mDNS browser + serial-port poller while the server " + "is up. Required for device_discovered/device_lost notifications. " + "Disable to silence all discovery-driven events at the source." + ), + ) + startup_grace_sec: int = Field( + default=10, + ge=0, + le=300, + description=( + "Seconds after each event-WS connect during which device_offline " + "notifications are suppressed (devices boot at different speeds)." + ), + ) + flap_debounce_sec: int = Field( + default=5, + ge=0, + le=60, + description=( + "A device must hold a new state for at least this many seconds before " + "the corresponding notification is fired. Filters out single-packet drops." + ), + ) diff --git a/server/src/ledgrab/core/devices/discovery_watcher.py b/server/src/ledgrab/core/devices/discovery_watcher.py new file mode 100644 index 0000000..394beca --- /dev/null +++ b/server/src/ledgrab/core/devices/discovery_watcher.py @@ -0,0 +1,273 @@ +"""Background discovery watcher — long-running mDNS browser + serial port poller. + +Existing per-target health monitoring (``DeviceHealthMixin``) already fires +``device_health_changed`` events when *configured* devices flip online/offline. +This module is the complementary half: it watches for *new* devices appearing +on the LAN/USB (and old discovered-but-never-configured ones disappearing) and +emits ``device_discovered`` / ``device_lost`` events on the same event bus. + +Design notes +------------ +- The mDNS browser is kept alive for the process lifetime (one ``AsyncZeroconf`` + + ``AsyncServiceBrowser``), which is the entire point of "background discovery". + The on-demand scan in ``WLEDDeviceProvider.discover`` is unchanged — that one + spins up its own short-lived browser for the Add Device modal. +- Serial-port hotplug has no equivalent of mDNS push, so we poll + :func:`list_serial_ports` every 10 s. Cheap on desktop (one Windows API call), + no-op on Android (handled separately by Kotlin USB receivers). +- Already-configured devices (matched by URL or MAC against ``device_store``) + are intentionally suppressed from the discovery stream — those are covered by + the health-monitor's online/offline events and would otherwise notify twice + per device on startup. + +The watcher is purely an event source; pref-driven snack/OS-toast routing +happens client-side in ``features/notifications-watcher.ts``. +""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable, Dict, Optional + +from zeroconf import ServiceStateChange +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf + +from ledgrab.core.devices.serial_transport import list_serial_ports +from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE +from ledgrab.utils import get_logger +from ledgrab.utils.platform import is_android + +if TYPE_CHECKING: + from ledgrab.storage.device_store import DeviceStore + +logger = get_logger(__name__) + +# Poll interval for serial-port enumeration. Cheap on desktop; skipped on Android. +_SERIAL_POLL_INTERVAL_SEC = 10.0 + + +@dataclass(frozen=True) +class _DiscoveredEntry: + """A device the watcher has seen at least once. + + Two snapshots are compared each cycle (mDNS by service name, serial by + device path) to detect appearances vs. disappearances; the URL is what + we cross-reference against ``device_store`` to know if the device is + already configured. + """ + + key: str + url: str + name: str + device_type: str # "wled" | "serial" + + +FireEvent = Callable[[dict], None] + + +class DiscoveryWatcher: + """Continuously scan for new WLED/serial devices and emit events.""" + + def __init__(self, device_store: "DeviceStore", fire_event: FireEvent) -> None: + self._device_store = device_store + self._fire_event = fire_event + + self._aiozc: Optional[AsyncZeroconf] = None + self._browser: Optional[AsyncServiceBrowser] = None + self._serial_task: Optional[asyncio.Task] = None + self._running = False + self._started_at: float = 0.0 + + # service-name -> entry. mDNS state-change callback runs on the + # asyncio thread so no lock is needed; Python attr writes are atomic. + self._wled_seen: Dict[str, _DiscoveredEntry] = {} + # device-path -> entry. Only the serial poller mutates this. + self._serial_seen: Dict[str, _DiscoveredEntry] = {} + + # --- lifecycle -------------------------------------------------------- + + async def start(self) -> None: + if self._running: + return + self._running = True + self._started_at = time.monotonic() + + # mDNS browser — kept alive for the whole process. The handler is sync + # (zeroconf calls it via call_soon on our loop), but resolves run in a + # short-lived task to avoid blocking the dispatcher. + try: + self._aiozc = AsyncZeroconf() + self._browser = AsyncServiceBrowser( + self._aiozc.zeroconf, + WLED_MDNS_TYPE, + handlers=[self._on_wled_state_change], + ) + logger.info("Discovery watcher: mDNS browser started for %s", WLED_MDNS_TYPE) + except Exception as e: + # Don't let a zeroconf failure (firewall, multiple-host, etc.) + # prevent the rest of the server from coming up. + logger.error("Discovery watcher: failed to start mDNS browser: %s", e) + self._aiozc = None + self._browser = None + + # Serial poller — only on desktop. On Android, USB hotplug is delivered + # through Kotlin receivers, not by polling pyserial. + if not is_android(): + self._serial_task = asyncio.create_task(self._serial_poll_loop()) + + async def stop(self) -> None: + self._running = False + + if self._serial_task is not None: + self._serial_task.cancel() + try: + await self._serial_task + except (asyncio.CancelledError, Exception): + pass + self._serial_task = None + + if self._browser is not None: + try: + await self._browser.async_cancel() + except Exception as e: + logger.debug("Discovery watcher: browser cancel error: %s", e) + self._browser = None + + if self._aiozc is not None: + try: + await self._aiozc.async_close() + except Exception as e: + logger.debug("Discovery watcher: zeroconf close error: %s", e) + self._aiozc = None + + logger.info("Discovery watcher stopped") + + # --- mDNS ------------------------------------------------------------- + + def _on_wled_state_change(self, **kwargs) -> None: + """zeroconf state-change handler. Runs on the asyncio thread.""" + state_change = kwargs.get("state_change") + service_type = kwargs.get("service_type", "") + name = kwargs.get("name", "") + + if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated): + # Resolve in a task — async_request blocks the handler if awaited + # synchronously and we don't want to stall mDNS dispatch. + asyncio.create_task(self._resolve_wled(service_type, name)) + elif state_change == ServiceStateChange.Removed: + entry = self._wled_seen.pop(name, None) + if entry is not None and not self._is_configured(entry.url): + self._emit("device_lost", entry) + + async def _resolve_wled(self, service_type: str, name: str) -> None: + if self._aiozc is None: + return + info = AsyncServiceInfo(service_type, name) + try: + await info.async_request(self._aiozc.zeroconf, timeout=2000) + except Exception as e: + logger.debug("Discovery watcher: resolve failed for %s: %s", name, e) + return + + addrs = info.parsed_addresses() + if not addrs: + return + ip = addrs[0] + port = info.port or 80 + url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}" + service_name = name.replace(f".{service_type}", "") + + entry = _DiscoveredEntry( + key=name, + url=url, + name=service_name, + device_type="wled", + ) + + first_sight = name not in self._wled_seen + self._wled_seen[name] = entry + + if first_sight and not self._is_configured(url): + self._emit("device_discovered", entry) + + # --- serial ----------------------------------------------------------- + + async def _serial_poll_loop(self) -> None: + """Detect serial-port appearances/disappearances on a fixed interval.""" + try: + # Seed without notifying — ports already plugged in when the server + # starts shouldn't generate "new device" toasts on every boot. + for port in list_serial_ports(): + url = port.device + self._serial_seen[url] = _DiscoveredEntry( + key=url, + url=url, + name=port.description, + device_type="serial", + ) + + while self._running: + await asyncio.sleep(_SERIAL_POLL_INTERVAL_SEC) + if not self._running: + break + self._poll_serial_once() + except asyncio.CancelledError: + pass + except Exception as e: + logger.error("Discovery watcher: serial loop crashed: %s", e) + + def _poll_serial_once(self) -> None: + try: + current = {p.device: p for p in list_serial_ports()} + except Exception as e: + logger.debug("Discovery watcher: serial enumeration failed: %s", e) + return + + # Appeared + for device, port in current.items(): + if device in self._serial_seen: + continue + entry = _DiscoveredEntry( + key=device, + url=device, + name=port.description, + device_type="serial", + ) + self._serial_seen[device] = entry + if not self._is_configured(device): + self._emit("device_discovered", entry) + + # Disappeared + for device in list(self._serial_seen.keys()): + if device in current: + continue + entry = self._serial_seen.pop(device) + if not self._is_configured(entry.url): + self._emit("device_lost", entry) + + # --- helpers ---------------------------------------------------------- + + def _is_configured(self, url: str) -> bool: + """True when the URL matches a device already in the user's store.""" + try: + for device in self._device_store.get_all_devices(): + if device.url and device.url.rstrip("/") == url.rstrip("/"): + return True + except Exception as e: + logger.debug("Discovery watcher: device store lookup failed: %s", e) + return False + + def _emit(self, event_type: str, entry: _DiscoveredEntry) -> None: + try: + self._fire_event( + { + "type": event_type, + "device_type": entry.device_type, + "url": entry.url, + "name": entry.name, + } + ) + except Exception as e: + logger.debug("Discovery watcher: fire_event failed: %s", e) diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index 12c8823..5a39a60 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -57,6 +57,7 @@ import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-re from ledgrab.core.devices.mqtt_client import set_mqtt_service from ledgrab.core.backup.auto_backup import AutoBackupEngine from ledgrab.core.processing.os_notification_listener import OsNotificationListener +from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher from ledgrab.core.update.update_service import UpdateService from ledgrab.core.update.gitea_provider import GiteaReleaseProvider from ledgrab.storage.database import Database @@ -365,6 +366,24 @@ async def lifespan(app: FastAPI): ) os_notif_listener.start() + # Start background discovery watcher (mDNS + serial polling). + # Gated by user pref; default is on. The watcher emits + # device_discovered/device_lost events through the same fire_event + # bus that the health monitor uses for device_health_changed. + from ledgrab.api.routes.preferences import load_notification_preferences + + discovery_watcher: DiscoveryWatcher | None = None + try: + notif_prefs = load_notification_preferences(db) + if notif_prefs.background_discovery_enabled: + discovery_watcher = DiscoveryWatcher( + device_store=device_store, + fire_event=processor_manager.fire_event, + ) + await discovery_watcher.start() + except Exception as e: + logger.error(f"Failed to start discovery watcher: {e}") + yield # Shutdown @@ -406,6 +425,13 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Error stopping automation engine: {e}") + # Stop discovery watcher (before health monitor stop so events still flow) + if discovery_watcher is not None: + try: + await discovery_watcher.stop() + except Exception as e: + logger.error(f"Error stopping discovery watcher: {e}") + # Stop OS notification listener try: os_notif_listener.stop() diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 0c4348f..c5eb96f 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -55,6 +55,7 @@ import { openDashboardCustomize, closeDashboardCustomize, } from './features/dashboard-customize.ts'; import { startEventsWS, stopEventsWS } from './core/events-ws.ts'; +import { startNotificationsWatcher } from './features/notifications-watcher.ts'; import { startEntityEventListeners } from './core/entity-events.ts'; import { startPerfPolling, stopPerfPolling, setPerfMode, @@ -220,6 +221,7 @@ import { openLogOverlay, closeLogOverlay, loadLogLevel, setLogLevel, loadShutdownAction, setShutdownAction, + requestNotifPermissionFromSettings, testNotifFromSettings, saveExternalUrl, getBaseOrigin, loadExternalUrl, } from './features/settings.ts'; import { @@ -618,6 +620,8 @@ Object.assign(window, { setLogLevel, loadShutdownAction, setShutdownAction, + requestNotifPermissionFromSettings, + testNotifFromSettings, saveExternalUrl, getBaseOrigin, @@ -817,6 +821,10 @@ document.addEventListener('DOMContentLoaded', async () => { // Start global events WebSocket and auto-refresh startEventsWS(); startEntityEventListeners(); + // Device-event notifications (snack/Web Notification per user prefs). + // Must start *after* startEventsWS so its DOM listeners catch the + // first events fired during the startup grace window. + startNotificationsWatcher().catch(() => {}); startAutoRefresh(); // Perf poll starts globally so the transport-bar CPU / Mem cells stay // live regardless of which tab is active. Tab-hidden pauses it via the diff --git a/server/src/ledgrab/static/js/features/notifications-watcher.ts b/server/src/ledgrab/static/js/features/notifications-watcher.ts new file mode 100644 index 0000000..d92f46f --- /dev/null +++ b/server/src/ledgrab/static/js/features/notifications-watcher.ts @@ -0,0 +1,311 @@ +/** + * Device-event notifications — listens to the global events WS and renders + * snackbar / Web Notification / both per the user's preference matrix. + * + * Events handled (dispatched by core/events-ws.ts as `server:`): + * - device_health_changed (online/offline of a configured target) + * - device_discovered (new WLED on LAN / serial port appeared) + * - device_lost (untargeted device disappeared) + * + * Anti-spam: + * - 10 s startup grace period (no offline toasts during boot — devices + * come up at different speeds, the user doesn't need a flurry). + * - 5 s flap debounce (state must hold N seconds before notifying). + * - bulk coalescing: ≥3 simultaneous events within 800 ms collapse into + * one "N devices changed" toast (most likely a network blip, not three + * independent failures). + * + * The OS channel uses the Web Notifications API (works in any browser tab + * + the PWA shell, no platform-specific code). Permission is requested + * lazily — on the first event that wants it, or via the Test button in + * settings. + */ + +import { fetchWithAuth } from '../core/api.ts'; +import { showToast } from '../core/ui.ts'; +import { t } from '../core/i18n.ts'; +import { logError } from '../core/log.ts'; + +// ─── Types (mirror server/src/ledgrab/api/schemas/preferences.py) ───── + +export type NotificationChannel = 'none' | 'snack' | 'os' | 'both'; + +export interface NotificationChannelMatrix { + device_online: NotificationChannel; + device_offline: NotificationChannel; + device_discovered: NotificationChannel; + device_lost: NotificationChannel; +} + +export interface NotificationPreferences { + channels: NotificationChannelMatrix; + background_discovery_enabled: boolean; + startup_grace_sec: number; + flap_debounce_sec: number; +} + +const DEFAULT_PREFS: NotificationPreferences = { + channels: { + device_online: 'snack', + device_offline: 'both', + device_discovered: 'snack', + device_lost: 'none', + }, + background_discovery_enabled: true, + startup_grace_sec: 10, + flap_debounce_sec: 5, +}; + +// ─── State ──────────────────────────────────────────────────────────── + +let _prefs: NotificationPreferences = { ...DEFAULT_PREFS }; +let _bootedAt = 0; +let _started = false; + +/** device-id (or url) -> { lastEventType, deadline ms } pending until debounce expires */ +interface PendingState { + eventType: keyof NotificationChannelMatrix; + payload: DeviceEventPayload; + fireAt: number; + timer: ReturnType; +} +const _pending = new Map(); + +/** Timestamp window for bulk coalescing. */ +let _coalesceTimer: ReturnType | null = null; +const _coalesceQueue: { eventType: keyof NotificationChannelMatrix; payload: DeviceEventPayload }[] = []; +const COALESCE_WINDOW_MS = 800; +const COALESCE_THRESHOLD = 3; + +/** Cached Web Notification permission (mirrors `Notification.permission`). */ +let _osPermission: NotificationPermission = + typeof Notification !== 'undefined' ? Notification.permission : 'denied'; + +// ─── Public API ─────────────────────────────────────────────────────── + +export async function startNotificationsWatcher(): Promise { + if (_started) return; + _started = true; + _bootedAt = Date.now(); + + await refreshNotificationPreferences().catch((err) => + logError('notifications.load_prefs', err), + ); + + // Subscribe to the three event types. The events-ws module dispatches + // them as `server:` DOM custom events. + document.addEventListener('server:device_health_changed', _onHealthChanged as EventListener); + document.addEventListener('server:device_discovered', _onDiscovered as EventListener); + document.addEventListener('server:device_lost', _onLost as EventListener); +} + +/** Pull the latest prefs from the server and cache them. */ +export async function refreshNotificationPreferences(): Promise { + try { + const resp = await fetchWithAuth('/preferences/notifications'); + if (!resp.ok) return _prefs; + const data = await resp.json(); + _prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } }; + } catch (err) { + logError('notifications.fetch', err); + } + return _prefs; +} + +/** Save prefs to the server and update the cache. Used by the settings UI. */ +export async function saveNotificationPreferences( + next: NotificationPreferences, +): Promise { + const resp = await fetchWithAuth('/preferences/notifications', { + method: 'PUT', + body: JSON.stringify(next), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + const saved = await resp.json(); + _prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } }; + return _prefs; +} + +export function getNotificationPreferences(): NotificationPreferences { + return _prefs; +} + +export function getOsPermission(): NotificationPermission { + return _osPermission; +} + +/** + * Request OS-toast permission. Returns the new permission state. + * Lazy — only call from a user gesture, otherwise the prompt is suppressed + * by browsers that require user interaction (Safari, Firefox). + */ +export async function requestOsNotificationPermission(): Promise { + if (typeof Notification === 'undefined') return 'denied'; + if (Notification.permission === 'granted' || Notification.permission === 'denied') { + _osPermission = Notification.permission; + return _osPermission; + } + try { + _osPermission = await Notification.requestPermission(); + } catch (err) { + logError('notifications.permission', err); + _osPermission = 'denied'; + } + return _osPermission; +} + +/** Emit a sample notification through both channels — used by the Test button. */ +export function fireTestNotification(): void { + _render('snack', t('notifications.test.title'), t('notifications.test.body')); + _render('os', t('notifications.test.title'), t('notifications.test.body')); +} + +// ─── Event handlers ────────────────────────────────────────────────── + +interface DeviceEventPayload { + type: string; + device_id?: string; + device_type?: string; + url?: string; + name?: string; + online?: boolean; +} + +function _onHealthChanged(evt: CustomEvent): void { + const detail = evt.detail || ({} as DeviceEventPayload); + const eventType: keyof NotificationChannelMatrix = detail.online + ? 'device_online' + : 'device_offline'; + _scheduleEvent(eventType, detail, detail.device_id || detail.url || 'unknown'); +} + +function _onDiscovered(evt: CustomEvent): void { + _scheduleEvent('device_discovered', evt.detail || {}, evt.detail?.url || 'unknown'); +} + +function _onLost(evt: CustomEvent): void { + _scheduleEvent('device_lost', evt.detail || {}, evt.detail?.url || 'unknown'); +} + +// ─── Pipeline: grace -> debounce -> coalesce -> render ──────────────── + +function _scheduleEvent( + eventType: keyof NotificationChannelMatrix, + payload: DeviceEventPayload, + deviceKey: string, +): void { + const channel = _prefs.channels[eventType]; + if (channel === 'none') return; + + // Startup grace: drop offline events during boot to avoid the + // "everything offline → online again 200 ms later" flicker some + // devices show right after the page loads. + const inGrace = Date.now() - _bootedAt < _prefs.startup_grace_sec * 1000; + if (inGrace && eventType === 'device_offline') return; + + // Cancel any opposing pending state for this device — if it just went + // offline-then-online inside the debounce window, neither toast fires. + const existing = _pending.get(deviceKey); + if (existing) { + clearTimeout(existing.timer); + const opposing = + (existing.eventType === 'device_online' && eventType === 'device_offline') || + (existing.eventType === 'device_offline' && eventType === 'device_online'); + if (opposing) { + _pending.delete(deviceKey); + return; + } + } + + const debounceMs = _prefs.flap_debounce_sec * 1000; + const timer = setTimeout(() => { + _pending.delete(deviceKey); + _enqueueForCoalesce(eventType, payload); + }, debounceMs); + + _pending.set(deviceKey, { + eventType, + payload, + fireAt: Date.now() + debounceMs, + timer, + }); +} + +function _enqueueForCoalesce( + eventType: keyof NotificationChannelMatrix, + payload: DeviceEventPayload, +): void { + _coalesceQueue.push({ eventType, payload }); + if (_coalesceTimer) return; + _coalesceTimer = setTimeout(() => { + const drained = _coalesceQueue.splice(0); + _coalesceTimer = null; + + if (drained.length >= COALESCE_THRESHOLD) { + _renderBulk(drained); + return; + } + + for (const item of drained) { + _renderSingle(item.eventType, item.payload); + } + }, COALESCE_WINDOW_MS); +} + +function _renderSingle( + eventType: keyof NotificationChannelMatrix, + payload: DeviceEventPayload, +): void { + const channel = _prefs.channels[eventType]; + if (channel === 'none') return; + + const label = payload.name || payload.url || t('notifications.unknown_device'); + const title = t(`notifications.${eventType}.title`); + const body = t(`notifications.${eventType}.body`).replace('{device}', label); + + _render(channel, title, body); +} + +function _renderBulk( + drained: { eventType: keyof NotificationChannelMatrix; payload: DeviceEventPayload }[], +): void { + // Use the loudest channel selected across the batch. + // Order: none < snack < os < both. Picking the first 'both' wins. + const channelRank: Record = { + none: 0, snack: 1, os: 2, both: 3, + }; + let loudest: NotificationChannel = 'none'; + for (const item of drained) { + const ch = _prefs.channels[item.eventType]; + if (channelRank[ch] > channelRank[loudest]) loudest = ch; + if (loudest === 'both') break; + } + if (loudest === 'none') return; + + const title = t('notifications.bulk.title'); + const body = t('notifications.bulk.body').replace('{count}', String(drained.length)); + _render(loudest, title, body); +} + +function _render(channel: NotificationChannel, title: string, body: string): void { + if (channel === 'none') return; + + if (channel === 'snack' || channel === 'both') { + showToast(`${title}: ${body}`, 'info'); + } + if (channel === 'os' || channel === 'both') { + if (typeof Notification === 'undefined') return; + if (Notification.permission !== 'granted') { + // Don't auto-prompt mid-event; user grants via the Settings button. + return; + } + try { + new Notification(title, { body, icon: '/static/icons/icon-192.png', tag: title }); + } catch (err) { + logError('notifications.render', err); + } + } +} diff --git a/server/src/ledgrab/static/js/features/settings.ts b/server/src/ledgrab/static/js/features/settings.ts index 443ddd0..0867d89 100644 --- a/server/src/ledgrab/static/js/features/settings.ts +++ b/server/src/ledgrab/static/js/features/settings.ts @@ -7,9 +7,14 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { t } from '../core/i18n.ts'; -import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE } from '../core/icons.ts'; +import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE, ICON_BELL, ICON_MONITOR, ICON_X, ICON_LIGHTBULB } from '../core/icons.ts'; import { IconSelect } from '../core/icon-select.ts'; import { openAuthedWs } from '../core/ws-auth.ts'; +import { + NotificationChannel, NotificationPreferences, + refreshNotificationPreferences, saveNotificationPreferences, + requestOsNotificationPermission, fireTestNotification, getOsPermission, +} from './notifications-watcher.ts'; // ─── External URL (used by other modules for user-visible URLs) ── @@ -90,6 +95,10 @@ export function switchSettingsTab(tabId: string): void { if (tabId === 'about' && typeof (window as any).renderAboutPanel === 'function') { (window as any).renderAboutPanel(); } + // Lazy-render the notifications panel (build IconSelects + load prefs) + if (tabId === 'notifications') { + initNotificationsPanel(); + } } // ─── Log Viewer ──────────────────────────────────────────── @@ -262,6 +271,11 @@ let _logLevelIconSelect: IconSelect | null = null; let _autoBackupIntervalIconSelect: IconSelect | null = null; let _shutdownActionIconSelect: IconSelect | null = null; +// Notification matrix: one IconSelect per event type. Constructed lazily +// when the Notifications tab is first opened so the icon palette and i18n +// strings have a chance to load. +const _notifIconSelects: Partial> = {}; + type ShutdownAction = 'stop_targets' | 'nothing'; const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const; function _isShutdownAction(v: string): v is ShutdownAction { @@ -748,3 +762,116 @@ export async function setShutdownAction(): Promise { } } +// ─── Notifications tab ──────────────────────────────────────── + +const _NOTIF_EVENT_KEYS = [ + 'device_online', 'device_offline', 'device_discovered', 'device_lost', +] as const; +type NotifEventKey = typeof _NOTIF_EVENT_KEYS[number]; + +function _getNotifChannelItems(): { value: string; icon: string; label: string; desc: string }[] { + return [ + { value: 'none', icon: ICON_X, label: t('settings.notifications.channel.none.label'), desc: t('settings.notifications.channel.none.desc') }, + { value: 'snack', icon: ICON_BELL, label: t('settings.notifications.channel.snack.label'), desc: t('settings.notifications.channel.snack.desc') }, + { value: 'os', icon: ICON_MONITOR, label: t('settings.notifications.channel.os.label'), desc: t('settings.notifications.channel.os.desc') }, + { value: 'both', icon: ICON_LIGHTBULB, label: t('settings.notifications.channel.both.label'), desc: t('settings.notifications.channel.both.desc') }, + ]; +} + +function _isNotifChannel(v: string): v is NotificationChannel { + return v === 'none' || v === 'snack' || v === 'os' || v === 'both'; +} + +let _notifPrefsLoaded = false; + +export async function initNotificationsPanel(): Promise { + // Build IconSelects (idempotent). + for (const key of _NOTIF_EVENT_KEYS) { + if (_notifIconSelects[key]) continue; + const sel = document.getElementById(`settings-notif-${key.replace(/_/g, '-')}`) as HTMLSelectElement | null; + if (!sel) continue; + _notifIconSelects[key] = new IconSelect({ + target: sel, + items: _getNotifChannelItems(), + columns: 2, + onChange: () => saveNotifPreferencesFromUi(), + }); + } + + const bgInput = document.getElementById('settings-notif-background') as HTMLInputElement | null; + if (bgInput && !bgInput.dataset.wired) { + bgInput.dataset.wired = '1'; + bgInput.addEventListener('change', () => saveNotifPreferencesFromUi()); + } + + if (!_notifPrefsLoaded) { + try { + const prefs = await refreshNotificationPreferences(); + for (const key of _NOTIF_EVENT_KEYS) { + _notifIconSelects[key]?.setValue(prefs.channels[key]); + } + if (bgInput) bgInput.checked = prefs.background_discovery_enabled; + _notifPrefsLoaded = true; + } catch (err) { + console.error('Failed to load notification preferences:', err); + } + } + + _refreshNotifPermissionState(); +} + +function _refreshNotifPermissionState(): void { + const stateEl = document.getElementById('settings-notif-permission-state'); + if (!stateEl) return; + const perm = getOsPermission(); + const key = perm === 'granted' + ? 'settings.notifications.permission.state.granted' + : perm === 'denied' + ? 'settings.notifications.permission.state.denied' + : 'settings.notifications.permission.state.default'; + stateEl.textContent = t(key); + const grantBtn = document.querySelector( + '#settings-notif-permission-row button', + ) as HTMLButtonElement | null; + if (grantBtn) grantBtn.disabled = perm === 'granted' || perm === 'denied'; +} + +async function saveNotifPreferencesFromUi(): Promise { + const bgInput = document.getElementById('settings-notif-background') as HTMLInputElement | null; + const channels: Partial = {}; + for (const key of _NOTIF_EVENT_KEYS) { + const sel = document.getElementById(`settings-notif-${key.replace(/_/g, '-')}`) as HTMLSelectElement | null; + const value = sel?.value; + if (value && _isNotifChannel(value)) { + channels[key] = value; + } + } + const next: NotificationPreferences = { + channels: channels as NotificationPreferences['channels'], + background_discovery_enabled: bgInput ? bgInput.checked : true, + startup_grace_sec: 10, + flap_debounce_sec: 5, + }; + try { + await saveNotificationPreferences(next); + showToast(t('settings.notifications.saved'), 'success'); + } catch (err) { + console.error('Failed to save notification preferences:', err); + showToast(t('settings.notifications.save_error') + ': ' + (err as Error).message, 'error'); + } +} + +export async function requestNotifPermissionFromSettings(): Promise { + const perm = await requestOsNotificationPermission(); + _refreshNotifPermissionState(); + if (perm === 'granted') { + showToast(t('settings.notifications.permission.granted'), 'success'); + } else if (perm === 'denied') { + showToast(t('settings.notifications.permission.denied'), 'error'); + } +} + +export function testNotifFromSettings(): void { + fireTestNotification(); +} + diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 88ff655..59747c3 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -2229,8 +2229,49 @@ "appearance.bg.mesh": "Gradient Mesh", "appearance.bg.scanlines": "Scanlines", "appearance.bg.applied": "Background effect applied", + "settings.tab.notifications": "Notifications", "settings.tab.updates": "Updates", "settings.tab.about": "About", + "settings.notifications.intro_label": "Device events", + "settings.notifications.intro_hint": "Pick how each device event reaches you. \"Snack\" shows an in-app toast, \"OS\" shows a system notification (browser must be granted permission), \"Both\" shows both, \"None\" silences the event entirely.", + "settings.notifications.row.online": "Device came online", + "settings.notifications.row.offline": "Device went offline", + "settings.notifications.row.discovered": "New device found", + "settings.notifications.row.lost": "Discovered device lost", + "settings.notifications.background.label": "Background discovery", + "settings.notifications.background.hint": "Continuously scan the LAN (mDNS) and serial bus for new LED devices. Disable to silence \"device discovered/lost\" events at the source. Restart the server to apply.", + "settings.notifications.background.toggle": "Enable background discovery", + "settings.notifications.permission.label": "OS notification permission", + "settings.notifications.permission.grant": "Grant permission", + "settings.notifications.permission.granted": "OS notifications enabled", + "settings.notifications.permission.denied": "OS notifications blocked — change in browser settings", + "settings.notifications.permission.state.granted": "Granted — OS toasts will appear", + "settings.notifications.permission.state.denied": "Denied — change in browser settings", + "settings.notifications.permission.state.default": "Not yet requested", + "settings.notifications.test_button": "Send a test notification", + "settings.notifications.saved": "Notification preferences saved", + "settings.notifications.save_error": "Failed to save notification preferences", + "settings.notifications.channel.none.label": "Off", + "settings.notifications.channel.none.desc": "Silence this event", + "settings.notifications.channel.snack.label": "Snack", + "settings.notifications.channel.snack.desc": "In-app toast at the bottom of the page", + "settings.notifications.channel.os.label": "OS", + "settings.notifications.channel.os.desc": "System notification (works while the browser is in the background)", + "settings.notifications.channel.both.label": "Both", + "settings.notifications.channel.both.desc": "In-app toast and system notification", + "notifications.unknown_device": "Unknown device", + "notifications.device_online.title": "Device online", + "notifications.device_online.body": "{device} is back online", + "notifications.device_offline.title": "Device offline", + "notifications.device_offline.body": "{device} stopped responding", + "notifications.device_discovered.title": "New device found", + "notifications.device_discovered.body": "{device} appeared on your network", + "notifications.device_lost.title": "Device lost", + "notifications.device_lost.body": "{device} disappeared", + "notifications.bulk.title": "Multiple devices changed", + "notifications.bulk.body": "{count} device events fired at once", + "notifications.test.title": "LedGrab test notification", + "notifications.test.body": "Notifications are wired up correctly.", "update.status_label": "Update Status", "update.current_version": "Current version:", "update.badge_tooltip": "New version available — click for details", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 21c643e..e8c21b9 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -1945,8 +1945,49 @@ "appearance.bg.mesh": "Градиент", "appearance.bg.scanlines": "Развёртка", "appearance.bg.applied": "Фоновый эффект применён", + "settings.tab.notifications": "Уведомления", "settings.tab.updates": "Обновления", "settings.tab.about": "О программе", + "settings.notifications.intro_label": "События устройств", + "settings.notifications.intro_hint": "Выберите способ оповещения для каждого события. «Снэк» — всплывающее сообщение внутри приложения, «ОС» — системное уведомление (требуется разрешение в браузере), «Оба» — оба варианта, «Выкл» — событие игнорируется.", + "settings.notifications.row.online": "Устройство вернулось в сеть", + "settings.notifications.row.offline": "Устройство пропало", + "settings.notifications.row.discovered": "Новое устройство найдено", + "settings.notifications.row.lost": "Найденное устройство исчезло", + "settings.notifications.background.label": "Фоновое обнаружение", + "settings.notifications.background.hint": "Постоянно сканировать локальную сеть (mDNS) и последовательные порты на новые LED-устройства. Отключите, чтобы убрать события «найдено/потеряно» в источнике. Перезапустите сервер, чтобы применить.", + "settings.notifications.background.toggle": "Включить фоновое обнаружение", + "settings.notifications.permission.label": "Разрешение на уведомления ОС", + "settings.notifications.permission.grant": "Разрешить", + "settings.notifications.permission.granted": "Системные уведомления включены", + "settings.notifications.permission.denied": "Системные уведомления заблокированы — измените в настройках браузера", + "settings.notifications.permission.state.granted": "Разрешено — будут показываться уведомления ОС", + "settings.notifications.permission.state.denied": "Запрещено — измените в настройках браузера", + "settings.notifications.permission.state.default": "Разрешение ещё не запрошено", + "settings.notifications.test_button": "Отправить тестовое уведомление", + "settings.notifications.saved": "Настройки уведомлений сохранены", + "settings.notifications.save_error": "Не удалось сохранить настройки уведомлений", + "settings.notifications.channel.none.label": "Выкл", + "settings.notifications.channel.none.desc": "Не уведомлять об этом событии", + "settings.notifications.channel.snack.label": "Снэк", + "settings.notifications.channel.snack.desc": "Всплывающее сообщение внизу страницы", + "settings.notifications.channel.os.label": "ОС", + "settings.notifications.channel.os.desc": "Системное уведомление (работает в фоне браузера)", + "settings.notifications.channel.both.label": "Оба", + "settings.notifications.channel.both.desc": "Снэк и системное уведомление", + "notifications.unknown_device": "Неизвестное устройство", + "notifications.device_online.title": "Устройство в сети", + "notifications.device_online.body": "{device} снова в сети", + "notifications.device_offline.title": "Устройство недоступно", + "notifications.device_offline.body": "{device} перестало отвечать", + "notifications.device_discovered.title": "Новое устройство", + "notifications.device_discovered.body": "{device} появилось в сети", + "notifications.device_lost.title": "Устройство пропало", + "notifications.device_lost.body": "{device} исчезло", + "notifications.bulk.title": "Изменилось несколько устройств", + "notifications.bulk.body": "Одновременно произошло {count} событий устройств", + "notifications.test.title": "Тестовое уведомление LedGrab", + "notifications.test.body": "Уведомления работают корректно.", "update.status_label": "Статус обновления", "update.current_version": "Текущая версия:", "update.badge_tooltip": "Доступна новая версия — нажмите для подробностей", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 07298c8..8c15112 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -1943,8 +1943,49 @@ "appearance.bg.mesh": "渐变网格", "appearance.bg.scanlines": "扫描线", "appearance.bg.applied": "背景效果已应用", + "settings.tab.notifications": "通知", "settings.tab.updates": "更新", "settings.tab.about": "关于", + "settings.notifications.intro_label": "设备事件", + "settings.notifications.intro_hint": "选择每类事件的通知方式。「内置」在页面底部弹出提示,「系统」显示系统通知(需要浏览器授权),「两者」同时显示,「关闭」则不通知。", + "settings.notifications.row.online": "设备重新上线", + "settings.notifications.row.offline": "设备离线", + "settings.notifications.row.discovered": "发现新设备", + "settings.notifications.row.lost": "已发现的设备消失", + "settings.notifications.background.label": "后台发现", + "settings.notifications.background.hint": "持续扫描局域网(mDNS)和串口以发现新的 LED 设备。关闭后将不再产生「发现/消失」事件。重启服务器后生效。", + "settings.notifications.background.toggle": "启用后台发现", + "settings.notifications.permission.label": "系统通知权限", + "settings.notifications.permission.grant": "授权", + "settings.notifications.permission.granted": "系统通知已启用", + "settings.notifications.permission.denied": "系统通知被拒绝 — 请在浏览器设置中修改", + "settings.notifications.permission.state.granted": "已授权 — 系统通知将会显示", + "settings.notifications.permission.state.denied": "已拒绝 — 请在浏览器设置中修改", + "settings.notifications.permission.state.default": "尚未请求授权", + "settings.notifications.test_button": "发送测试通知", + "settings.notifications.saved": "通知偏好已保存", + "settings.notifications.save_error": "保存通知偏好失败", + "settings.notifications.channel.none.label": "关闭", + "settings.notifications.channel.none.desc": "屏蔽此事件", + "settings.notifications.channel.snack.label": "内置", + "settings.notifications.channel.snack.desc": "页面底部的弹出提示", + "settings.notifications.channel.os.label": "系统", + "settings.notifications.channel.os.desc": "系统通知(浏览器在后台时也能收到)", + "settings.notifications.channel.both.label": "两者", + "settings.notifications.channel.both.desc": "弹出提示与系统通知同时显示", + "notifications.unknown_device": "未知设备", + "notifications.device_online.title": "设备已上线", + "notifications.device_online.body": "{device} 已重新上线", + "notifications.device_offline.title": "设备离线", + "notifications.device_offline.body": "{device} 没有响应", + "notifications.device_discovered.title": "发现新设备", + "notifications.device_discovered.body": "{device} 出现在网络中", + "notifications.device_lost.title": "设备消失", + "notifications.device_lost.body": "{device} 已消失", + "notifications.bulk.title": "多个设备状态变化", + "notifications.bulk.body": "同时发生 {count} 个设备事件", + "notifications.test.title": "LedGrab 测试通知", + "notifications.test.body": "通知功能已正常工作。", "update.status_label": "更新状态", "update.current_version": "当前版本:", "update.badge_tooltip": "有新版本可用 — 点击查看详情", diff --git a/server/src/ledgrab/templates/modals/settings.html b/server/src/ledgrab/templates/modals/settings.html index 13872cb..7b79502 100644 --- a/server/src/ledgrab/templates/modals/settings.html +++ b/server/src/ledgrab/templates/modals/settings.html @@ -10,6 +10,7 @@
+ @@ -161,6 +162,71 @@
+ +
+
+
+ + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+ + +
+ +
+
+ +
+
+ + +
+
+ +
+ +
+
+
diff --git a/server/tests/test_discovery_watcher.py b/server/tests/test_discovery_watcher.py new file mode 100644 index 0000000..276fdbc --- /dev/null +++ b/server/tests/test_discovery_watcher.py @@ -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" diff --git a/server/tests/test_preferences_notifications_api.py b/server/tests/test_preferences_notifications_api.py new file mode 100644 index 0000000..b617899 --- /dev/null +++ b/server/tests/test_preferences_notifications_api.py @@ -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"