diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f12b077..af0b467 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -490,6 +490,19 @@ dashboard: Access at `http://:8080`. Bind to `127.0.0.1` to restrict to local access only. +### Browser notifications + +**Settings → System → Browser notifications** enables LAN push alerts while any authenticated dashboard tab is open. Preferences are stored in the browser (`localStorage`), not on the device. + +Supported alert types: + +- **Node offline** — not heard for 2+ hours (matches the node-card online dot) +- **Node back online** — heard again after being offline +- **Low battery** — telemetry reports ≤20% (one alert per node per hour) +- **Storm guard** — reserved for a future release; toggle is present but inactive until storm-guard quarantine ships + +Grant notification permission when prompted. Use **Suppress when this tab is focused** to avoid duplicate toasts while you are actively watching the dashboard. + --- ## Device Identity diff --git a/frontend/index.html b/frontend/index.html index 239398c..1e6a384 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -656,6 +656,42 @@

Display units

+
+

Browser notifications

+

LAN push alerts while any dashboard tab is open. Preferences are saved in this browser only. Storm-guard alerts require the storm-guard feature (future release).

+

+ +
+ +
+
+ Alert types + + + + +
+ +

+

Service actions

Each requires confirmation and is recorded in the audit log.

@@ -753,6 +789,8 @@

Service actions

+ + diff --git a/frontend/js/app.js b/frontend/js/app.js index b6b237e..9ef63c2 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -119,6 +119,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (window.themeController) window.themeController.init(); _bootCommandPaletteAndKeymap(router); _wireSoundEvents(); + _wirePushNotifications(); new SignOutController('signout-btn').bind(); @@ -198,6 +199,10 @@ function _bootDangerousPanel(router) { if (prefsRoot && window.MeshpointDisplayForm) { new window.MeshpointDisplayForm(prefsRoot); } + const pushRoot = document.getElementById('push-notifications-prefs'); + if (pushRoot && window.PushNotificationsForm) { + new window.PushNotificationsForm(pushRoot); + } const controller = new window.DangerousPanelController(root); controller.bind(); let primed = false; @@ -575,3 +580,15 @@ function _wireSoundEvents() { ws.on('disconnected', () => window.soundEngine.play('disconnect')); } } + +function _wirePushNotifications() { + if (!window.pushNotifications || !window.concentratorWS) return; + const pn = window.pushNotifications; + const ws = window.concentratorWS; + if (pn.isEnabled() && pn.permissionState() === 'granted') { + pn.registerServiceWorker(); + } + if (typeof ws.on === 'function') { + ws.on('alert', (data) => pn.handleAlert(data)); + } +} diff --git a/frontend/js/push_notifications.js b/frontend/js/push_notifications.js new file mode 100644 index 0000000..a36f8aa --- /dev/null +++ b/frontend/js/push_notifications.js @@ -0,0 +1,150 @@ +/** + * LAN browser push notifications for mesh alerts (PR 04). + * + * Preferences live in localStorage. Alerts arrive on the existing + * WebSocket as ``type: "alert"`` with ``event_type: "alert"`` in the + * payload. Works while any authenticated dashboard tab is open. + */ +class PushNotifications { + static STORAGE_KEY = 'meshpoint:push-notifications:v1'; + static DEFAULT_PREFS = { + enabled: false, + node_offline: true, + node_online: true, + battery_low: true, + storm_guard: true, + suppress_when_focused: true, + }; + + constructor() { + this._prefs = this._readPrefs(); + this._swReady = null; + } + + getPrefs() { + return { ...this._prefs }; + } + + savePrefs(next) { + this._prefs = { ...PushNotifications.DEFAULT_PREFS, ...next }; + try { + localStorage.setItem( + PushNotifications.STORAGE_KEY, + JSON.stringify(this._prefs), + ); + } catch (_e) { /* ignore quota errors */ } + } + + isEnabled() { + return !!this._prefs.enabled; + } + + permissionState() { + if (!('Notification' in window)) return 'unsupported'; + return Notification.permission; + } + + async registerServiceWorker() { + if (!('serviceWorker' in navigator)) return null; + try { + const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' }); + this._swReady = navigator.serviceWorker.ready; + return reg; + } catch (err) { + console.warn('Service worker registration failed:', err); + return null; + } + } + + async requestPermission() { + if (!('Notification' in window)) { + return 'unsupported'; + } + if (Notification.permission === 'granted') { + await this.registerServiceWorker(); + return 'granted'; + } + if (Notification.permission === 'denied') { + return 'denied'; + } + const result = await Notification.requestPermission(); + if (result === 'granted') { + await this.registerServiceWorker(); + } + return result; + } + + async enable() { + const perm = await this.requestPermission(); + if (perm !== 'granted') return perm; + this.savePrefs({ ...this._prefs, enabled: true }); + return perm; + } + + disable() { + this.savePrefs({ ...this._prefs, enabled: false }); + } + + handleAlert(data) { + if (!this.isEnabled() || !data) return; + const kind = data.alert_kind || ''; + if (!this._isKindEnabled(kind)) return; + if (this._prefs.suppress_when_focused && document.visibilityState === 'visible') { + return; + } + const title = data.title || 'Meshpoint alert'; + const body = data.body || ''; + this._show(title, body, data); + } + + _isKindEnabled(kind) { + const map = { + node_offline: this._prefs.node_offline, + node_online: this._prefs.node_online, + battery_low: this._prefs.battery_low, + storm_guard: this._prefs.storm_guard, + }; + return map[kind] !== false; + } + + async _show(title, body, payload) { + if (!('Notification' in window) || Notification.permission !== 'granted') { + return; + } + const tag = `meshpoint:${payload.alert_kind || 'alert'}:${payload.node_id || 'system'}`; + try { + if ('serviceWorker' in navigator) { + const reg = this._swReady + ? await this._swReady + : await navigator.serviceWorker.ready; + const active = reg.active || navigator.serviceWorker.controller; + if (active) { + active.postMessage({ + type: 'show-notification', + title, + body, + tag, + payload, + }); + return; + } + } + new Notification(title, { body, tag, renotify: true }); + } catch (err) { + console.warn('Notification failed:', err); + } + } + + _readPrefs() { + try { + const raw = localStorage.getItem(PushNotifications.STORAGE_KEY); + if (!raw) return { ...PushNotifications.DEFAULT_PREFS }; + return { ...PushNotifications.DEFAULT_PREFS, ...JSON.parse(raw) }; + } catch (_e) { + return { ...PushNotifications.DEFAULT_PREFS }; + } + } +} + +window.PushNotifications = PushNotifications; +window.pushNotifications = new PushNotifications(); diff --git a/frontend/js/settings/push_notifications_form.js b/frontend/js/settings/push_notifications_form.js new file mode 100644 index 0000000..6ea0882 --- /dev/null +++ b/frontend/js/settings/push_notifications_form.js @@ -0,0 +1,109 @@ +/** + * Settings → System: browser push notification preferences. + */ + +class PushNotificationsForm { + constructor(rootEl) { + this.root = rootEl; + this._statusEl = rootEl.querySelector('[data-push-notifications-status]'); + this._masterToggle = rootEl.querySelector('[data-push-enabled]'); + this._kindInputs = Array.from(rootEl.querySelectorAll('[data-push-kind]')); + this._focusToggle = rootEl.querySelector('[data-push-suppress-focused]'); + this._enableBtn = rootEl.querySelector('[data-push-enable-btn]'); + this._bind(); + this._syncFromStorage(); + } + + _bind() { + if (this._enableBtn) { + this._enableBtn.addEventListener('click', () => this._onEnableClick()); + } + if (this._masterToggle) { + this._masterToggle.addEventListener('change', () => this._onMasterChange()); + } + this._kindInputs.forEach((el) => { + el.addEventListener('change', () => this._saveKinds()); + }); + if (this._focusToggle) { + this._focusToggle.addEventListener('change', () => this._saveKinds()); + } + } + + _syncFromStorage() { + const prefs = window.pushNotifications.getPrefs(); + if (this._masterToggle) { + this._masterToggle.checked = prefs.enabled; + } + this._kindInputs.forEach((el) => { + const key = el.dataset.pushKind; + el.checked = prefs[key] !== false; + el.disabled = !prefs.enabled; + }); + if (this._focusToggle) { + this._focusToggle.checked = prefs.suppress_when_focused !== false; + this._focusToggle.disabled = !prefs.enabled; + } + this._updatePermissionHint(); + } + + async _onEnableClick() { + const perm = await window.pushNotifications.enable(); + if (perm === 'granted') { + this._setStatus('success', 'Notifications enabled for this browser.'); + } else if (perm === 'denied') { + this._setStatus('error', 'Permission denied. Allow notifications in browser settings.'); + } else if (perm === 'unsupported') { + this._setStatus('error', 'This browser does not support notifications.'); + } else { + this._setStatus('error', 'Permission not granted.'); + } + this._syncFromStorage(); + } + + _onMasterChange() { + const enabled = !!(this._masterToggle && this._masterToggle.checked); + if (enabled) { + this._onEnableClick(); + return; + } + window.pushNotifications.disable(); + this._setStatus('success', 'Notifications disabled.'); + this._syncFromStorage(); + } + + _saveKinds() { + const prefs = window.pushNotifications.getPrefs(); + const next = { ...prefs }; + this._kindInputs.forEach((el) => { + next[el.dataset.pushKind] = el.checked; + }); + if (this._focusToggle) { + next.suppress_when_focused = this._focusToggle.checked; + } + window.pushNotifications.savePrefs(next); + this._setStatus('success', 'Alert preferences saved.'); + } + + _updatePermissionHint() { + const hint = this.root.querySelector('[data-push-permission-hint]'); + if (!hint) return; + const perm = window.pushNotifications.permissionState(); + if (perm === 'unsupported') { + hint.textContent = 'Notifications are not supported in this browser.'; + } else if (perm === 'denied') { + hint.textContent = 'Blocked — reset site permissions to enable alerts.'; + } else if (perm === 'granted') { + hint.textContent = 'Permission granted.'; + } else { + hint.textContent = 'Click Enable to request browser permission.'; + } + } + + _setStatus(kind, message) { + if (!this._statusEl) return; + this._statusEl.dataset.kind = kind; + this._statusEl.textContent = message; + } +} + +window.PushNotificationsForm = PushNotificationsForm; diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..488939f --- /dev/null +++ b/frontend/sw.js @@ -0,0 +1,37 @@ +/** + * Minimal service worker for LAN dashboard push notifications. + * The main app posts show-notification messages while a tab is open. + */ +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', (event) => { + const data = event.data || {}; + if (data.type !== 'show-notification') return; + const title = data.title || 'Meshpoint'; + const options = { + body: data.body || '', + tag: data.tag || 'meshpoint-alert', + renotify: true, + data: data.payload || {}, + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if ('focus' in client) return client.focus(); + } + if (self.clients.openWindow) return self.clients.openWindow('/'); + return undefined; + }), + ); +}); diff --git a/src/api/alert_emitter.py b/src/api/alert_emitter.py new file mode 100644 index 0000000..0249472 --- /dev/null +++ b/src/api/alert_emitter.py @@ -0,0 +1,212 @@ +"""LAN dashboard alert broadcaster for browser push notifications (PR 04). + +Emits additive WebSocket messages with ``type: "alert"`` and a payload +that includes ``event_type: "alert"`` so the frontend can show native +notifications while any authenticated dashboard tab is open. +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any, Optional + +from src.models.packet import Packet, PacketType + +if TYPE_CHECKING: + from src.api.websocket_manager import WebSocketManager + from src.storage.node_repository import NodeRepository + +logger = logging.getLogger(__name__) + +# Match the dashboard node-card "recently heard" window (2 hours). +ONLINE_THRESHOLD = timedelta(hours=2) +POLL_INTERVAL_SECONDS = 60.0 +BATTERY_LOW_PERCENT = 20.0 +BATTERY_ALERT_COOLDOWN = timedelta(hours=1) + + +def build_alert_payload( + alert_kind: str, + *, + node_id: str = "", + title: str = "", + body: str = "", + extra: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Shape a WS alert payload (additive, non-breaking for other clients).""" + payload: dict[str, Any] = { + "event_type": "alert", + "alert_kind": alert_kind, + "node_id": node_id, + "title": title, + "body": body, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + if extra: + payload.update(extra) + return payload + + +def _parse_last_heard(raw: str | datetime | None) -> Optional[datetime]: + if raw is None: + return None + if isinstance(raw, datetime): + return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc) + try: + text = raw.replace(" ", "T") + if not text.endswith("Z") and "+" not in text: + text += "Z" + return datetime.fromisoformat(text.replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + + +def is_node_online(last_heard: str | datetime | None, *, now: datetime | None = None) -> bool: + """True when the node was heard within ONLINE_THRESHOLD.""" + heard = _parse_last_heard(last_heard) + if heard is None: + return False + ref = now or datetime.now(timezone.utc) + return (ref - heard) < ONLINE_THRESHOLD + + +class AlertEmitter: + """Tracks node presence and emits alert WS events for the dashboard.""" + + def __init__( + self, + node_repo: NodeRepository, + ws_manager: WebSocketManager, + *, + poll_interval_seconds: float = POLL_INTERVAL_SECONDS, + ) -> None: + self._node_repo = node_repo + self._ws = ws_manager + self._poll_interval = poll_interval_seconds + self._online_state: dict[str, bool] = {} + self._battery_cooldown: dict[str, datetime] = {} + self._poll_task: asyncio.Task | None = None + self._started = False + + async def start(self) -> None: + if self._started: + return + self._started = True + await self._refresh_online_state() + self._poll_task = asyncio.get_running_loop().create_task(self._poll_loop()) + logger.info("Alert emitter started (poll every %.0fs)", self._poll_interval) + + async def stop(self) -> None: + self._started = False + if self._poll_task is not None: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + + def on_packet(self, packet: Packet) -> None: + """Sync hook from the packet pipeline; schedules async broadcasts.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.create_task(self._handle_packet(packet)) + + async def _handle_packet(self, packet: Packet) -> None: + source = (packet.source_id or "").strip() + if not source: + return + + was_online = self._online_state.get(source) + if was_online is False: + name = self._node_display_name(packet, source) + await self._emit( + build_alert_payload( + "node_online", + node_id=source, + title="Node back online", + body=f"{name} was heard again on the mesh.", + ) + ) + self._online_state[source] = True + + if packet.packet_type == PacketType.TELEMETRY and packet.decoded_payload: + battery = packet.decoded_payload.get("battery_level") + if battery is not None and float(battery) <= BATTERY_LOW_PERCENT: + await self._maybe_emit_battery_low(source, float(battery), packet) + + async def _maybe_emit_battery_low( + self, node_id: str, battery: float, packet: Packet + ) -> None: + now = datetime.now(timezone.utc) + last = self._battery_cooldown.get(node_id) + if last and (now - last) < BATTERY_ALERT_COOLDOWN: + return + self._battery_cooldown[node_id] = now + name = self._node_display_name(packet, node_id) + pct = int(round(battery)) + await self._emit( + build_alert_payload( + "battery_low", + node_id=node_id, + title="Low battery", + body=f"{name} reported {pct}% battery.", + extra={"battery_level": pct}, + ) + ) + + async def _poll_loop(self) -> None: + try: + while self._started: + await asyncio.sleep(self._poll_interval) + await self._check_offline_transitions() + except asyncio.CancelledError: + pass + + async def _check_offline_transitions(self) -> None: + nodes = await self._node_repo.get_all() + now = datetime.now(timezone.utc) + for node in nodes: + online = is_node_online(node.last_heard, now=now) + was_online = self._online_state.get(node.node_id) + if was_online is True and not online: + name = ( + node.long_name + or node.short_name + or node.node_id + ) + await self._emit( + build_alert_payload( + "node_offline", + node_id=node.node_id, + title="Node offline", + body=f"{name} has not been heard for 2+ hours.", + ) + ) + self._online_state[node.node_id] = online + + async def _refresh_online_state(self) -> None: + nodes = await self._node_repo.get_all() + now = datetime.now(timezone.utc) + for node in nodes: + self._online_state[node.node_id] = is_node_online( + node.last_heard, now=now + ) + + async def _emit(self, payload: dict[str, Any]) -> None: + try: + await self._ws.broadcast("alert", payload) + except Exception: + logger.exception("Failed to broadcast alert %s", payload.get("alert_kind")) + + @staticmethod + def _node_display_name(packet: Packet, node_id: str) -> str: + payload = packet.decoded_payload or {} + return ( + payload.get("long_name") + or payload.get("short_name") + or node_id + ) diff --git a/src/api/server.py b/src/api/server.py index 3bd4fb0..398cb79 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -63,6 +63,7 @@ from src.api.update import ReleaseChannelRegistry, UpdateApplier from src.api.update.rollback_state import resolve_rollback_state_path from src.api.upstream_client import UpstreamClient +from src.api.alert_emitter import AlertEmitter from src.api.websocket_manager import WebSocketManager from src.config import AppConfig, load_config, validate_activation from src.coordinator import PipelineCoordinator @@ -94,6 +95,7 @@ noise_floor_tracker = NoiseFloorTracker() _noise_floor_emitter_task = None _spectral_scan_service: SpectralScanService | None = None +_alert_emitter: AlertEmitter | None = None def create_app(config: AppConfig | None = None) -> FastAPI: @@ -209,11 +211,13 @@ async def lifespan(app: FastAPI): _wire_native_relay(pipeline, tx_service) - global _noise_floor_emitter_task + global _noise_floor_emitter_task, _alert_emitter import asyncio _noise_floor_emitter_task = asyncio.get_running_loop().create_task( _noise_floor_emitter_loop(noise_floor_tracker, ws_manager) ) + _alert_emitter = AlertEmitter(pipeline.node_repo, ws_manager) + await _alert_emitter.start() global _spectral_scan_service _spectral_scan_service = _build_spectral_scan_service( @@ -231,6 +235,8 @@ async def lifespan(app: FastAPI): yield if _spectral_scan_service is not None: await _spectral_scan_service.stop() + if _alert_emitter is not None: + await _alert_emitter.stop() if _noise_floor_emitter_task is not None: _noise_floor_emitter_task.cancel() try: @@ -1405,6 +1411,8 @@ def _on_packet_received(packet: Packet) -> None: snr_db=packet.signal.snr, bandwidth_khz=packet.signal.bandwidth_khz, ) + if _alert_emitter is not None: + _alert_emitter.on_packet(packet) try: loop = asyncio.get_running_loop() loop.create_task(ws_manager.broadcast("packet", packet.to_dict())) diff --git a/tests/test_alert_emitter.py b/tests/test_alert_emitter.py new file mode 100644 index 0000000..01404a2 --- /dev/null +++ b/tests/test_alert_emitter.py @@ -0,0 +1,135 @@ +"""Unit tests for src/api/alert_emitter.py.""" +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +from src.api.alert_emitter import ( + BATTERY_LOW_PERCENT, + ONLINE_THRESHOLD, + AlertEmitter, + build_alert_payload, + is_node_online, +) +from src.models.packet import Packet, PacketType, Protocol + + +class TestBuildAlertPayload(unittest.TestCase): + + def test_includes_event_type_alert(self) -> None: + payload = build_alert_payload( + "node_offline", + node_id="abc123", + title="Node offline", + body="Node abc123 has not been heard for 2+ hours.", + ) + self.assertEqual(payload["event_type"], "alert") + self.assertEqual(payload["alert_kind"], "node_offline") + self.assertEqual(payload["node_id"], "abc123") + self.assertIn("timestamp", payload) + + +class TestIsNodeOnline(unittest.TestCase): + + def test_recent_heard_is_online(self) -> None: + now = datetime(2026, 6, 2, 12, 0, tzinfo=timezone.utc) + heard = (now - timedelta(hours=1)).isoformat() + self.assertTrue(is_node_online(heard, now=now)) + + def test_stale_heard_is_offline(self) -> None: + now = datetime(2026, 6, 2, 12, 0, tzinfo=timezone.utc) + heard = (now - ONLINE_THRESHOLD - timedelta(minutes=1)).isoformat() + self.assertFalse(is_node_online(heard, now=now)) + + def test_missing_heard_is_offline(self) -> None: + self.assertFalse(is_node_online(None)) + + +class TestAlertEmitterBattery(unittest.IsolatedAsyncioTestCase): + + async def test_emits_battery_low_for_telemetry(self) -> None: + ws = MagicMock() + ws.broadcast = AsyncMock() + repo = MagicMock() + emitter = AlertEmitter(repo, ws) + + packet = Packet( + packet_id="p1", + source_id="node1", + destination_id="broadcast", + protocol=Protocol.MESHTASTIC, + packet_type=PacketType.TELEMETRY, + decoded_payload={ + "battery_level": BATTERY_LOW_PERCENT, + "long_name": "Test Node", + }, + ) + await emitter._handle_packet(packet) + + ws.broadcast.assert_awaited_once() + args = ws.broadcast.await_args.args + self.assertEqual(args[0], "alert") + self.assertEqual(args[1]["alert_kind"], "battery_low") + self.assertEqual(args[1]["battery_level"], 20) + + async def test_battery_alert_respects_cooldown(self) -> None: + ws = MagicMock() + ws.broadcast = AsyncMock() + repo = MagicMock() + emitter = AlertEmitter(repo, ws) + + packet = Packet( + packet_id="p1", + source_id="node1", + destination_id="broadcast", + protocol=Protocol.MESHTASTIC, + packet_type=PacketType.TELEMETRY, + decoded_payload={"battery_level": 10}, + ) + await emitter._handle_packet(packet) + await emitter._handle_packet(packet) + self.assertEqual(ws.broadcast.await_count, 1) + + +class TestAlertEmitterOnlineTransition(unittest.IsolatedAsyncioTestCase): + + async def test_node_online_only_after_explicit_offline(self) -> None: + ws = MagicMock() + ws.broadcast = AsyncMock() + repo = MagicMock() + emitter = AlertEmitter(repo, ws) + emitter._online_state["node1"] = False + + packet = Packet( + packet_id="p1", + source_id="node1", + destination_id="broadcast", + protocol=Protocol.MESHTASTIC, + packet_type=PacketType.TEXT, + decoded_payload={"long_name": "Alpha"}, + ) + await emitter._handle_packet(packet) + + ws.broadcast.assert_awaited_once() + self.assertEqual(ws.broadcast.await_args.args[1]["alert_kind"], "node_online") + + async def test_new_node_does_not_emit_online_alert(self) -> None: + ws = MagicMock() + ws.broadcast = AsyncMock() + repo = MagicMock() + emitter = AlertEmitter(repo, ws) + + packet = Packet( + packet_id="p1", + source_id="newnode", + destination_id="broadcast", + protocol=Protocol.MESHTASTIC, + packet_type=PacketType.TEXT, + ) + await emitter._handle_packet(packet) + ws.broadcast.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main()