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:
2026-04-25 17:49:20 +03:00
parent 8e109f32b9
commit 8aa3a323d6
14 changed files with 1451 additions and 2 deletions
+60
View File
@@ -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.
+72 -1
View File
@@ -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
@@ -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."
),
)
@@ -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)
+26
View File
@@ -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()
+8
View File
@@ -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
@@ -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:<type>`):
* - 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<typeof setTimeout>;
}
const _pending = new Map<string, PendingState>();
/** Timestamp window for bulk coalescing. */
let _coalesceTimer: ReturnType<typeof setTimeout> | 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<void> {
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:<type>` 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<NotificationPreferences> {
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<NotificationPreferences> {
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<NotificationPermission> {
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<DeviceEventPayload>): 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<DeviceEventPayload>): void {
_scheduleEvent('device_discovered', evt.detail || {}, evt.detail?.url || 'unknown');
}
function _onLost(evt: CustomEvent<DeviceEventPayload>): 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<NotificationChannel, number> = {
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);
}
}
}
@@ -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<Record<keyof NotificationPreferences['channels'], IconSelect>> = {};
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<void> {
}
}
// ─── 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<void> {
// 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<void> {
const bgInput = document.getElementById('settings-notif-background') as HTMLInputElement | null;
const channels: Partial<NotificationPreferences['channels']> = {};
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<void> {
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();
}
+41
View File
@@ -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",
+41
View File
@@ -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": "Доступна новая версия — нажмите для подробностей",
+41
View File
@@ -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": "有新版本可用 — 点击查看详情",
@@ -10,6 +10,7 @@
<div class="settings-tab-bar">
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
<button class="settings-tab-btn" data-settings-tab="notifications" onclick="switchSettingsTab('notifications')" data-i18n="settings.tab.notifications">Notifications</button>
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" data-i18n="settings.tab.appearance">Appearance</button>
<button class="settings-tab-btn" data-settings-tab="updates" onclick="switchSettingsTab('updates')" data-i18n="settings.tab.updates">Updates</button>
<button class="settings-tab-btn" data-settings-tab="about" onclick="switchSettingsTab('about')" data-i18n="settings.tab.about">About</button>
@@ -161,6 +162,71 @@
</div>
</div>
<!-- ═══ Notifications tab ═══ -->
<div id="settings-panel-notifications" class="settings-panel">
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.intro_label">Device events</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="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.</small>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.row.online">Device came online</label>
</div>
<select id="settings-notif-device-online"></select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.row.offline">Device went offline</label>
</div>
<select id="settings-notif-device-offline"></select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.row.discovered">New device found</label>
</div>
<select id="settings-notif-device-discovered"></select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.row.lost">Discovered device lost</label>
</div>
<select id="settings-notif-device-lost"></select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.background.label">Background discovery</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="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.</small>
<label class="toggle-row">
<input type="checkbox" id="settings-notif-background" checked>
<span data-i18n="settings.notifications.background.toggle">Enable background discovery</span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.permission.label">OS notification permission</label>
</div>
<div id="settings-notif-permission-row" style="display:flex;gap:0.5rem;align-items:center;">
<span id="settings-notif-permission-state" style="flex:1;font-size:0.9rem;color:var(--text-muted);"></span>
<button class="btn btn-secondary" onclick="requestNotifPermissionFromSettings()" data-i18n="settings.notifications.permission.grant">Grant permission</button>
</div>
</div>
<div class="form-group">
<button class="btn btn-secondary" onclick="testNotifFromSettings()" style="width:100%" data-i18n="settings.notifications.test_button">Send a test notification</button>
</div>
</div>
<!-- ═══ Appearance tab ═══ -->
<div id="settings-panel-appearance" class="settings-panel">
<!-- Rendered dynamically by renderAppearanceTab() -->
+198
View File
@@ -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"