feat: initial project scaffold — server, client, Android APK
- FastAPI server with audio mixer (Windows + Voicemeeter), media tracking, notifications (progress + alerts), telemetry, and system tray .exe build - Test suite covering notifications, audio mixer, media, telemetry, and API - E-Ink web client (vanilla JS, DOM API, block meters, swipe gestures) - Android WebView APK for BOOX Go 7 Color Gen II (Android 13, Kotlin) - Design decision records in .pi/docs/design/ - PyInstaller build script for server .exe
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""Notification system supporting progress bars and rich text alerts.
|
||||
|
||||
External scripts push notifications via POST /api/v1/notify.
|
||||
Two types are supported:
|
||||
- "progress": long-running tasks with a measurable progress bar
|
||||
- "alert": one-off informational/warning/error messages
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
logger = logging.getLogger("paperdash.notifications")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgressState:
|
||||
"""Progress tracking for long-running tasks."""
|
||||
|
||||
current: float = 0.0
|
||||
min: float = 0.0
|
||||
max: float = 100.0
|
||||
unit: str = ""
|
||||
|
||||
@property
|
||||
def percentage(self) -> float:
|
||||
if self.max <= self.min:
|
||||
return 0.0
|
||||
return max(
|
||||
0.0, min(100.0, (self.current - self.min) / (self.max - self.min) * 100)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notification:
|
||||
"""A notification to display on the dashboard."""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
type: Literal["progress", "alert"] = "alert"
|
||||
title: str = ""
|
||||
message: str = ""
|
||||
progress: ProgressState | None = None
|
||||
eta: str | None = None
|
||||
priority: Literal["info", "warning", "error"] = "info"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification Manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
"""Manages active notifications and their lifecycle."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._notifications: dict[str, Notification] = {}
|
||||
self._max_active: int = 10 # Keep at most 10 active at a time
|
||||
|
||||
async def create(self, notification: Notification) -> Notification:
|
||||
"""Create a new notification or update an existing one by ID."""
|
||||
# If a notification with this ID already exists, update it
|
||||
if notification.id in self._notifications:
|
||||
existing = self._notifications[notification.id]
|
||||
# Preserve type if not explicitly set on update
|
||||
if not notification.type:
|
||||
notification.type = existing.type
|
||||
self._notifications[notification.id] = notification
|
||||
logger.debug(f"Notification updated: {notification.id}")
|
||||
else:
|
||||
self._notifications[notification.id] = notification
|
||||
logger.info(
|
||||
f"Notification created: {notification.id} ({notification.type})"
|
||||
)
|
||||
|
||||
# Prune old notifications if we exceed the limit
|
||||
self._prune_old()
|
||||
|
||||
return notification
|
||||
|
||||
def _prune_old(self) -> None:
|
||||
"""Remove oldest notifications if we exceed the limit."""
|
||||
if len(self._notifications) <= self._max_active:
|
||||
return
|
||||
|
||||
# Remove oldest first (by insertion order)
|
||||
ids_to_remove = list(self._notifications.keys())[
|
||||
: len(self._notifications) - self._max_active
|
||||
]
|
||||
for nid in ids_to_remove:
|
||||
del self._notifications[nid]
|
||||
logger.debug(f"Notification pruned: {nid}")
|
||||
|
||||
def get_active(self) -> list[dict]:
|
||||
"""Return all active notifications as serializable dicts."""
|
||||
result = []
|
||||
for n in self._notifications.values():
|
||||
entry: dict = {
|
||||
"id": n.id,
|
||||
"type": n.type,
|
||||
"title": n.title,
|
||||
"message": n.message,
|
||||
"priority": n.priority,
|
||||
}
|
||||
if n.type == "progress" and n.progress:
|
||||
entry["progress"] = {
|
||||
"current": n.progress.current,
|
||||
"min": n.progress.min,
|
||||
"max": n.progress.max,
|
||||
"unit": n.progress.unit,
|
||||
"percentage": n.progress.percentage,
|
||||
}
|
||||
if n.eta:
|
||||
entry["eta"] = n.eta
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
async def dismiss(self, notification_id: str) -> bool:
|
||||
"""Dismiss (remove) a notification by ID."""
|
||||
if notification_id in self._notifications:
|
||||
del self._notifications[notification_id]
|
||||
logger.debug(f"Notification dismissed: {notification_id}")
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user