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:
@@ -1,5 +1,65 @@
|
|||||||
# LedGrab TODO
|
# 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
|
## Server shutdown action
|
||||||
|
|
||||||
Let user choose what happens to LED targets on server shutdown.
|
Let user choose what happens to LED targets on server shutdown.
|
||||||
|
|||||||
@@ -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
|
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,
|
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
|
validates it's a dict with a `version` field, and persists it under the
|
||||||
`dashboard_layout` settings key.
|
`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
|
from typing import Any
|
||||||
@@ -12,6 +16,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException
|
|||||||
|
|
||||||
from ledgrab.api.auth import AuthRequired
|
from ledgrab.api.auth import AuthRequired
|
||||||
from ledgrab.api.dependencies import get_database
|
from ledgrab.api.dependencies import get_database
|
||||||
|
from ledgrab.api.schemas.preferences import NotificationPreferences
|
||||||
from ledgrab.storage.database import Database
|
from ledgrab.storage.database import Database
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
@@ -20,6 +25,27 @@ logger = get_logger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
_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(
|
@router.get(
|
||||||
@@ -73,3 +99,48 @@ async def delete_dashboard_layout(
|
|||||||
to clear the server-side override entirely."""
|
to clear the server-side override entirely."""
|
||||||
db.set_setting(_DASHBOARD_LAYOUT_KEY, {})
|
db.set_setting(_DASHBOARD_LAYOUT_KEY, {})
|
||||||
return {"ok": True}
|
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)
|
||||||
@@ -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.devices.mqtt_client import set_mqtt_service
|
||||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||||
from ledgrab.core.processing.os_notification_listener import OsNotificationListener
|
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.update_service import UpdateService
|
||||||
from ledgrab.core.update.gitea_provider import GiteaReleaseProvider
|
from ledgrab.core.update.gitea_provider import GiteaReleaseProvider
|
||||||
from ledgrab.storage.database import Database
|
from ledgrab.storage.database import Database
|
||||||
@@ -365,6 +366,24 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
os_notif_listener.start()
|
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
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
@@ -406,6 +425,13 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping automation engine: {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
|
# Stop OS notification listener
|
||||||
try:
|
try:
|
||||||
os_notif_listener.stop()
|
os_notif_listener.stop()
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
openDashboardCustomize, closeDashboardCustomize,
|
openDashboardCustomize, closeDashboardCustomize,
|
||||||
} from './features/dashboard-customize.ts';
|
} from './features/dashboard-customize.ts';
|
||||||
import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
|
import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
|
||||||
|
import { startNotificationsWatcher } from './features/notifications-watcher.ts';
|
||||||
import { startEntityEventListeners } from './core/entity-events.ts';
|
import { startEntityEventListeners } from './core/entity-events.ts';
|
||||||
import {
|
import {
|
||||||
startPerfPolling, stopPerfPolling, setPerfMode,
|
startPerfPolling, stopPerfPolling, setPerfMode,
|
||||||
@@ -220,6 +221,7 @@ import {
|
|||||||
openLogOverlay, closeLogOverlay,
|
openLogOverlay, closeLogOverlay,
|
||||||
loadLogLevel, setLogLevel,
|
loadLogLevel, setLogLevel,
|
||||||
loadShutdownAction, setShutdownAction,
|
loadShutdownAction, setShutdownAction,
|
||||||
|
requestNotifPermissionFromSettings, testNotifFromSettings,
|
||||||
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||||
} from './features/settings.ts';
|
} from './features/settings.ts';
|
||||||
import {
|
import {
|
||||||
@@ -618,6 +620,8 @@ Object.assign(window, {
|
|||||||
setLogLevel,
|
setLogLevel,
|
||||||
loadShutdownAction,
|
loadShutdownAction,
|
||||||
setShutdownAction,
|
setShutdownAction,
|
||||||
|
requestNotifPermissionFromSettings,
|
||||||
|
testNotifFromSettings,
|
||||||
saveExternalUrl,
|
saveExternalUrl,
|
||||||
getBaseOrigin,
|
getBaseOrigin,
|
||||||
|
|
||||||
@@ -817,6 +821,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Start global events WebSocket and auto-refresh
|
// Start global events WebSocket and auto-refresh
|
||||||
startEventsWS();
|
startEventsWS();
|
||||||
startEntityEventListeners();
|
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();
|
startAutoRefresh();
|
||||||
// Perf poll starts globally so the transport-bar CPU / Mem cells stay
|
// 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
|
// 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 { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.ts';
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
import { t } from '../core/i18n.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 { IconSelect } from '../core/icon-select.ts';
|
||||||
import { openAuthedWs } from '../core/ws-auth.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) ──
|
// ─── 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') {
|
if (tabId === 'about' && typeof (window as any).renderAboutPanel === 'function') {
|
||||||
(window as any).renderAboutPanel();
|
(window as any).renderAboutPanel();
|
||||||
}
|
}
|
||||||
|
// Lazy-render the notifications panel (build IconSelects + load prefs)
|
||||||
|
if (tabId === 'notifications') {
|
||||||
|
initNotificationsPanel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Log Viewer ────────────────────────────────────────────
|
// ─── Log Viewer ────────────────────────────────────────────
|
||||||
@@ -262,6 +271,11 @@ let _logLevelIconSelect: IconSelect | null = null;
|
|||||||
let _autoBackupIntervalIconSelect: IconSelect | null = null;
|
let _autoBackupIntervalIconSelect: IconSelect | null = null;
|
||||||
let _shutdownActionIconSelect: 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';
|
type ShutdownAction = 'stop_targets' | 'nothing';
|
||||||
const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const;
|
const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const;
|
||||||
function _isShutdownAction(v: string): v is ShutdownAction {
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2229,8 +2229,49 @@
|
|||||||
"appearance.bg.mesh": "Gradient Mesh",
|
"appearance.bg.mesh": "Gradient Mesh",
|
||||||
"appearance.bg.scanlines": "Scanlines",
|
"appearance.bg.scanlines": "Scanlines",
|
||||||
"appearance.bg.applied": "Background effect applied",
|
"appearance.bg.applied": "Background effect applied",
|
||||||
|
"settings.tab.notifications": "Notifications",
|
||||||
"settings.tab.updates": "Updates",
|
"settings.tab.updates": "Updates",
|
||||||
"settings.tab.about": "About",
|
"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.status_label": "Update Status",
|
||||||
"update.current_version": "Current version:",
|
"update.current_version": "Current version:",
|
||||||
"update.badge_tooltip": "New version available — click for details",
|
"update.badge_tooltip": "New version available — click for details",
|
||||||
|
|||||||
@@ -1945,8 +1945,49 @@
|
|||||||
"appearance.bg.mesh": "Градиент",
|
"appearance.bg.mesh": "Градиент",
|
||||||
"appearance.bg.scanlines": "Развёртка",
|
"appearance.bg.scanlines": "Развёртка",
|
||||||
"appearance.bg.applied": "Фоновый эффект применён",
|
"appearance.bg.applied": "Фоновый эффект применён",
|
||||||
|
"settings.tab.notifications": "Уведомления",
|
||||||
"settings.tab.updates": "Обновления",
|
"settings.tab.updates": "Обновления",
|
||||||
"settings.tab.about": "О программе",
|
"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.status_label": "Статус обновления",
|
||||||
"update.current_version": "Текущая версия:",
|
"update.current_version": "Текущая версия:",
|
||||||
"update.badge_tooltip": "Доступна новая версия — нажмите для подробностей",
|
"update.badge_tooltip": "Доступна новая версия — нажмите для подробностей",
|
||||||
|
|||||||
@@ -1943,8 +1943,49 @@
|
|||||||
"appearance.bg.mesh": "渐变网格",
|
"appearance.bg.mesh": "渐变网格",
|
||||||
"appearance.bg.scanlines": "扫描线",
|
"appearance.bg.scanlines": "扫描线",
|
||||||
"appearance.bg.applied": "背景效果已应用",
|
"appearance.bg.applied": "背景效果已应用",
|
||||||
|
"settings.tab.notifications": "通知",
|
||||||
"settings.tab.updates": "更新",
|
"settings.tab.updates": "更新",
|
||||||
"settings.tab.about": "关于",
|
"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.status_label": "更新状态",
|
||||||
"update.current_version": "当前版本:",
|
"update.current_version": "当前版本:",
|
||||||
"update.badge_tooltip": "有新版本可用 — 点击查看详情",
|
"update.badge_tooltip": "有新版本可用 — 点击查看详情",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="settings-tab-bar">
|
<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 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="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="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="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>
|
<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>
|
||||||
</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 ═══ -->
|
<!-- ═══ Appearance tab ═══ -->
|
||||||
<div id="settings-panel-appearance" class="settings-panel">
|
<div id="settings-panel-appearance" class="settings-panel">
|
||||||
<!-- Rendered dynamically by renderAppearanceTab() -->
|
<!-- Rendered dynamically by renderAppearanceTab() -->
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Tests for the background discovery watcher.
|
||||||
|
|
||||||
|
We mock zeroconf and ``list_serial_ports`` so the tests run in milliseconds
|
||||||
|
without touching the network or hardware. The mDNS path is exercised via
|
||||||
|
the public state-change handler; the serial path is exercised via the
|
||||||
|
internal one-shot poll method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from zeroconf import ServiceStateChange
|
||||||
|
|
||||||
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
|
||||||
|
from ledgrab.core.devices.serial_transport import SerialPortInfo
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDevice:
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStore:
|
||||||
|
def __init__(self, devices: list[_FakeDevice] | None = None) -> None:
|
||||||
|
self._devices = devices or []
|
||||||
|
|
||||||
|
def get_all_devices(self):
|
||||||
|
return list(self._devices)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def captured_events():
|
||||||
|
events: list[dict] = []
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_watcher(captured_events):
|
||||||
|
def _factory(store: _FakeStore | None = None) -> DiscoveryWatcher:
|
||||||
|
return DiscoveryWatcher(
|
||||||
|
device_store=store or _FakeStore(),
|
||||||
|
fire_event=captured_events.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Serial polling
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_ports_do_not_emit_events(make_watcher, captured_events):
|
||||||
|
"""Ports already plugged in at startup must not generate notifications.
|
||||||
|
|
||||||
|
Otherwise every server start would dump a "device discovered" toast
|
||||||
|
for every COM port on the machine.
|
||||||
|
"""
|
||||||
|
watcher = make_watcher()
|
||||||
|
seed = [SerialPortInfo(device="COM3", description="USB-Serial CH340")]
|
||||||
|
|
||||||
|
# First-poll behaviour models the seed: the watcher's startup path
|
||||||
|
# populates _serial_seen without firing events. We exercise that
|
||||||
|
# behaviour by populating the dict directly and then running the
|
||||||
|
# poll, asserting no events fire because nothing changed.
|
||||||
|
from ledgrab.core.devices.discovery_watcher import _DiscoveredEntry
|
||||||
|
|
||||||
|
for p in seed:
|
||||||
|
watcher._serial_seen[p.device] = _DiscoveredEntry(
|
||||||
|
key=p.device, url=p.device, name=p.description, device_type="serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||||
|
return_value=seed,
|
||||||
|
):
|
||||||
|
watcher._poll_serial_once()
|
||||||
|
|
||||||
|
assert captured_events == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_serial_port_emits_discovered(make_watcher, captured_events):
|
||||||
|
"""A port appearing between polls fires device_discovered."""
|
||||||
|
watcher = make_watcher()
|
||||||
|
|
||||||
|
# First poll: seed COM3.
|
||||||
|
with patch(
|
||||||
|
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||||
|
return_value=[SerialPortInfo(device="COM3", description="CH340")],
|
||||||
|
):
|
||||||
|
watcher._poll_serial_once()
|
||||||
|
captured_events.clear() # discovered for COM3 above is fine; we test the next change
|
||||||
|
|
||||||
|
# Second poll: COM4 has appeared.
|
||||||
|
with patch(
|
||||||
|
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||||
|
return_value=[
|
||||||
|
SerialPortInfo(device="COM3", description="CH340"),
|
||||||
|
SerialPortInfo(device="COM4", description="CP2102"),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
watcher._poll_serial_once()
|
||||||
|
|
||||||
|
assert len(captured_events) == 1
|
||||||
|
evt = captured_events[0]
|
||||||
|
assert evt["type"] == "device_discovered"
|
||||||
|
assert evt["device_type"] == "serial"
|
||||||
|
assert evt["url"] == "COM4"
|
||||||
|
assert evt["name"] == "CP2102"
|
||||||
|
|
||||||
|
|
||||||
|
def test_disappearing_serial_port_emits_lost(make_watcher, captured_events):
|
||||||
|
"""A port that vanishes between polls fires device_lost."""
|
||||||
|
watcher = make_watcher()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||||
|
return_value=[SerialPortInfo(device="COM4", description="CP2102")],
|
||||||
|
):
|
||||||
|
watcher._poll_serial_once()
|
||||||
|
captured_events.clear()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||||
|
return_value=[],
|
||||||
|
):
|
||||||
|
watcher._poll_serial_once()
|
||||||
|
|
||||||
|
assert len(captured_events) == 1
|
||||||
|
assert captured_events[0]["type"] == "device_lost"
|
||||||
|
assert captured_events[0]["url"] == "COM4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_devices_are_filtered_from_discovery(make_watcher, captured_events):
|
||||||
|
"""If a port matches a configured device URL, suppress discovery events.
|
||||||
|
|
||||||
|
The user already gets device_health_changed for known devices; firing
|
||||||
|
a redundant 'discovered' event the first time the port re-appears
|
||||||
|
would be confusing noise.
|
||||||
|
"""
|
||||||
|
store = _FakeStore([_FakeDevice("COM5")])
|
||||||
|
watcher = make_watcher(store)
|
||||||
|
|
||||||
|
# COM5 appears for the first time — but it's already configured,
|
||||||
|
# so no event should fire.
|
||||||
|
with patch(
|
||||||
|
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||||
|
return_value=[SerialPortInfo(device="COM5", description="ESP32")],
|
||||||
|
):
|
||||||
|
watcher._poll_serial_once()
|
||||||
|
|
||||||
|
assert captured_events == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_serial_enumeration_failure_is_swallowed(make_watcher, captured_events):
|
||||||
|
"""A blown enumeration call shouldn't crash the loop or fire bogus events."""
|
||||||
|
watcher = make_watcher()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"ledgrab.core.devices.discovery_watcher.list_serial_ports",
|
||||||
|
side_effect=OSError("device busy"),
|
||||||
|
):
|
||||||
|
watcher._poll_serial_once() # must not raise
|
||||||
|
|
||||||
|
assert captured_events == []
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# mDNS state-change dispatcher
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_mdns_removed_emits_lost_only_for_unconfigured(make_watcher, captured_events):
|
||||||
|
"""ServiceStateChange.Removed → device_lost, but only when the URL
|
||||||
|
isn't already in the user's device store."""
|
||||||
|
watcher = make_watcher()
|
||||||
|
|
||||||
|
# Seed a previously-resolved entry.
|
||||||
|
from ledgrab.core.devices.discovery_watcher import _DiscoveredEntry
|
||||||
|
|
||||||
|
watcher._wled_seen["wled-foo._wled._tcp.local."] = _DiscoveredEntry(
|
||||||
|
key="wled-foo._wled._tcp.local.",
|
||||||
|
url="http://192.168.1.55",
|
||||||
|
name="wled-foo",
|
||||||
|
device_type="wled",
|
||||||
|
)
|
||||||
|
|
||||||
|
watcher._on_wled_state_change(
|
||||||
|
service_type="_wled._tcp.local.",
|
||||||
|
name="wled-foo._wled._tcp.local.",
|
||||||
|
state_change=ServiceStateChange.Removed,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(captured_events) == 1
|
||||||
|
assert captured_events[0]["type"] == "device_lost"
|
||||||
|
assert captured_events[0]["device_type"] == "wled"
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Tests for /api/v1/preferences/notifications endpoints."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgrab.config import get_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
"""TestClient with auth header — same pattern as test_preferences_api.py."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from ledgrab.main import app
|
||||||
|
|
||||||
|
api_key = next(iter(get_config().auth.api_keys.values()), "")
|
||||||
|
with TestClient(app, raise_server_exceptions=False) as c:
|
||||||
|
if api_key:
|
||||||
|
c.headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _full_prefs() -> dict:
|
||||||
|
return {
|
||||||
|
"channels": {
|
||||||
|
"device_online": "snack",
|
||||||
|
"device_offline": "both",
|
||||||
|
"device_discovered": "os",
|
||||||
|
"device_lost": "none",
|
||||||
|
},
|
||||||
|
"background_discovery_enabled": True,
|
||||||
|
"startup_grace_sec": 15,
|
||||||
|
"flap_debounce_sec": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_returns_defaults_when_unset(client):
|
||||||
|
"""When no prefs have been saved, GET returns the documented defaults."""
|
||||||
|
# Wipe via PUT to a known state to make this order-independent.
|
||||||
|
# (No DELETE endpoint — settings rows are scalar.)
|
||||||
|
resp = client.get("/api/v1/preferences/notifications")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["background_discovery_enabled"] is True
|
||||||
|
assert body["startup_grace_sec"] == 10
|
||||||
|
assert body["flap_debounce_sec"] == 5
|
||||||
|
# Default channel matrix
|
||||||
|
assert body["channels"]["device_online"] == "snack"
|
||||||
|
assert body["channels"]["device_offline"] == "both"
|
||||||
|
assert body["channels"]["device_discovered"] == "snack"
|
||||||
|
assert body["channels"]["device_lost"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_then_get_round_trips(client):
|
||||||
|
"""PUT a payload, GET it back unchanged."""
|
||||||
|
payload = _full_prefs()
|
||||||
|
put = client.put("/api/v1/preferences/notifications", json=payload)
|
||||||
|
assert put.status_code == 200
|
||||||
|
assert put.json()["startup_grace_sec"] == 15
|
||||||
|
|
||||||
|
got = client.get("/api/v1/preferences/notifications")
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert got.json() == payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_rejects_invalid_channel(client):
|
||||||
|
"""A bogus channel value (e.g. 'siren') is rejected by Pydantic."""
|
||||||
|
bad = _full_prefs()
|
||||||
|
bad["channels"]["device_offline"] = "siren"
|
||||||
|
resp = client.put("/api/v1/preferences/notifications", json=bad)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_rejects_grace_out_of_range(client):
|
||||||
|
"""startup_grace_sec is clamped to [0, 300]."""
|
||||||
|
bad = _full_prefs()
|
||||||
|
bad["startup_grace_sec"] = -5
|
||||||
|
assert client.put("/api/v1/preferences/notifications", json=bad).status_code == 422
|
||||||
|
|
||||||
|
bad["startup_grace_sec"] = 9999
|
||||||
|
assert client.put("/api/v1/preferences/notifications", json=bad).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_rejects_debounce_out_of_range(client):
|
||||||
|
"""flap_debounce_sec is clamped to [0, 60]."""
|
||||||
|
bad = _full_prefs()
|
||||||
|
bad["flap_debounce_sec"] = 999
|
||||||
|
assert client.put("/api/v1/preferences/notifications", json=bad).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_payload_uses_defaults_for_omitted_channels(client):
|
||||||
|
"""Pydantic fills in default channels when the matrix is partial.
|
||||||
|
|
||||||
|
The frontend may want to PUT only what changed; the backend should
|
||||||
|
fill in the default channel matrix for omitted rows so we don't
|
||||||
|
silently lose user preferences via partial-write.
|
||||||
|
"""
|
||||||
|
partial = {"background_discovery_enabled": False}
|
||||||
|
resp = client.put("/api/v1/preferences/notifications", json=partial)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["background_discovery_enabled"] is False
|
||||||
|
# Defaulted matrix is present
|
||||||
|
assert body["channels"]["device_offline"] == "both"
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_stored_value_falls_back_to_defaults(client):
|
||||||
|
"""If something stomps on the stored row, the GET handler must
|
||||||
|
return defaults instead of 500. Mirrors how load_shutdown_action
|
||||||
|
treats corrupt input."""
|
||||||
|
# Stuff garbage into the underlying setting via the same Database
|
||||||
|
# the route uses, then verify GET still works.
|
||||||
|
from ledgrab.api.dependencies import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
db.set_setting("notification_preferences", {"channels": "totally-wrong"})
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/preferences/notifications")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Defaults restored
|
||||||
|
assert resp.json()["channels"]["device_offline"] == "both"
|
||||||
Reference in New Issue
Block a user