331de4bc9e
- New settings page at /settings (localhost-only) accessible via tray icon - PIN manager with 6-digit codes, 5-min TTL, single-use - API token uses secrets.token_urlsafe(32) for 256-bit entropy - Config auto-creates config.yaml on first run with detected local IP - Default PIN and token generated randomly on first startup - Added pin_ttl setting (default 300s) - Updated build_exe.py for onefile exe with settings assets bundled - Added start.bat and start.sh for easy development startup
442 lines
14 KiB
Python
442 lines
14 KiB
Python
"""PaperDash — E-Ink Dashboard Ecosystem ~ Paperdash
|
|
|
|
FastAPI server that aggregates Windows desktop state and streams it
|
|
to an Onyx Boox Go 7 Color via WebSocket.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
|
|
from fastapi import (
|
|
FastAPI,
|
|
HTTPException,
|
|
Query,
|
|
Request,
|
|
WebSocket,
|
|
WebSocketDisconnect,
|
|
)
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from .audio_mixer import AudioMixerManager
|
|
from .config import config
|
|
from .media import MediaManager
|
|
from .notifications import NotificationManager, Notification
|
|
from .pin_manager import PinManager
|
|
from .telemetry import TelemetryManager
|
|
|
|
logger = logging.getLogger("paperdash")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hardware Subsystem Check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def check_subsystems() -> dict[str, bool]:
|
|
"""Check which hardware subsystems are available."""
|
|
results: dict[str, bool] = {}
|
|
|
|
# Check Voicemeeter
|
|
try:
|
|
import voicemeeter_api # noqa: F401 # type: ignore
|
|
|
|
results["voicemeeter"] = True
|
|
except ImportError:
|
|
results["voicemeeter"] = False
|
|
|
|
# Check Windows Audio (pycaw)
|
|
try:
|
|
import pycaw # noqa: F401 # type: ignore
|
|
|
|
results["windows_audio"] = True
|
|
except ImportError:
|
|
results["windows_audio"] = False
|
|
|
|
# Check Windows Media (winsdk)
|
|
try:
|
|
import winsdk # noqa: F401 # type: ignore
|
|
|
|
results["windows_media"] = True
|
|
except ImportError:
|
|
results["windows_media"] = False
|
|
|
|
# Check HWiNFO (pyhwinfo)
|
|
try:
|
|
import pyhwinfo # noqa: F401 # type: ignore
|
|
|
|
results["hwinfo"] = True
|
|
except ImportError:
|
|
results["hwinfo"] = False
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifespan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Start and stop background managers."""
|
|
# Load configuration
|
|
config.load()
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=getattr(logging, config.log_level, logging.INFO),
|
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
)
|
|
|
|
# Check hardware subsystems
|
|
subsystems = check_subsystems()
|
|
app.state.subsystems = subsystems
|
|
logger.info(f"Hardware subsystems: {subsystems}")
|
|
|
|
# Log configuration status
|
|
if config.dashboard_pin:
|
|
logger.info("Dashboard PIN is configured")
|
|
else:
|
|
logger.warning("Dashboard PIN is NOT configured — WebSocket auth disabled")
|
|
if config.api_token:
|
|
logger.info("API token is configured")
|
|
else:
|
|
logger.warning("API token is NOT configured — /api/v1/notify disabled")
|
|
|
|
# Start managers
|
|
app.state.media = MediaManager()
|
|
app.state.audio = AudioMixerManager()
|
|
app.state.notifications = NotificationManager()
|
|
app.state.telemetry = TelemetryManager()
|
|
|
|
# Initialize PIN manager
|
|
app.state.pin_manager = PinManager(ttl_seconds=config.pin_ttl)
|
|
current_pin = app.state.pin_manager.generate()
|
|
logger.info(f"Dashboard PIN: {current_pin}")
|
|
logger.info(f"PIN expires in {config.pin_ttl} seconds")
|
|
|
|
# Start background tasks
|
|
app.state.tasks = [
|
|
asyncio.create_task(app.state.media.run()),
|
|
asyncio.create_task(app.state.audio.run()),
|
|
asyncio.create_task(app.state.telemetry.run()),
|
|
]
|
|
|
|
# Start PIN cleanup loop
|
|
app.state.pin_manager.start_cleanup_loop()
|
|
|
|
logger.info("PaperDash server started")
|
|
yield
|
|
|
|
# Cancel background tasks
|
|
for task in app.state.tasks:
|
|
task.cancel()
|
|
await asyncio.gather(*app.state.tasks, return_exceptions=True)
|
|
|
|
# Stop PIN manager
|
|
if hasattr(app.state, "pin_manager"):
|
|
app.state.pin_manager.stop()
|
|
|
|
logger.info("PaperDash server stopped")
|
|
|
|
|
|
app = FastAPI(
|
|
title="PaperDash",
|
|
description="E-Ink Dashboard Ecosystem ~ Paperdash",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # Local network only — fine for this use case # noqa: B008
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket: Dashboard Client (PIN-authenticated)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class WebSocketManager:
|
|
"""Manages active WebSocket connections from the E-Ink client."""
|
|
|
|
def __init__(self) -> None:
|
|
self.connections: list[WebSocket] = []
|
|
|
|
async def connect(self, ws: WebSocket, pin: str) -> bool:
|
|
expected_pin = config.dashboard_pin
|
|
if not expected_pin or pin != expected_pin:
|
|
await ws.close(code=4001, reason="Invalid PIN")
|
|
return False
|
|
await ws.accept()
|
|
self.connections.append(ws)
|
|
logger.info(f"WebSocket client connected ({len(self.connections)} total)")
|
|
return True
|
|
|
|
def disconnect(self, ws: WebSocket) -> None:
|
|
if ws in self.connections:
|
|
self.connections.remove(ws)
|
|
logger.info(
|
|
f"WebSocket client disconnected ({len(self.connections)} total)"
|
|
)
|
|
|
|
async def broadcast(self, data: dict[str, Any]) -> None:
|
|
"""Send data to all connected clients."""
|
|
disconnected = []
|
|
for ws in self.connections:
|
|
try:
|
|
await ws.send_json(data)
|
|
except WebSocketDisconnect:
|
|
disconnected.append(ws)
|
|
except Exception:
|
|
disconnected.append(ws)
|
|
for ws in disconnected:
|
|
self.disconnect(ws)
|
|
|
|
|
|
ws_manager = WebSocketManager()
|
|
app.state.ws_manager = ws_manager
|
|
|
|
|
|
@app.websocket("/ws")
|
|
async def websocket_endpoint(
|
|
websocket: WebSocket,
|
|
pin: str = Query(..., description="Dashboard PIN"),
|
|
):
|
|
"""WebSocket endpoint for the E-Ink dashboard client."""
|
|
# Validate PIN (marks as used if valid)
|
|
if not app.state.pin_manager.validate(pin):
|
|
await websocket.close(code=4001, reason="Invalid or expired PIN")
|
|
return
|
|
|
|
if await ws_manager.connect(websocket, pin):
|
|
try:
|
|
# Keep connection alive; the server pushes state updates.
|
|
while True:
|
|
# Listen for client commands (volume, skip, etc.)
|
|
data = await websocket.receive_json()
|
|
await handle_client_command(data)
|
|
except WebSocketDisconnect:
|
|
ws_manager.disconnect(websocket)
|
|
except Exception as e:
|
|
logger.error(f"WebSocket error: {e}")
|
|
ws_manager.disconnect(websocket)
|
|
|
|
|
|
async def handle_client_command(data: dict[str, Any]) -> None:
|
|
"""Route client commands to the appropriate manager."""
|
|
action = data.get("action")
|
|
if action == "volume":
|
|
bus = data.get("bus", "master")
|
|
value = data.get("value", 0)
|
|
await app.state.audio.set_volume(bus, value)
|
|
elif action == "mute":
|
|
bus = data.get("bus", "master")
|
|
muted = data.get("muted", False)
|
|
await app.state.audio.set_mute(bus, muted)
|
|
elif action == "play":
|
|
await app.state.media.play()
|
|
elif action == "pause":
|
|
await app.state.media.pause()
|
|
elif action == "skip":
|
|
await app.state.media.skip()
|
|
elif action == "prev":
|
|
await app.state.media.previous()
|
|
else:
|
|
logger.warning(f"Unknown client command: {action}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# REST: External Scripts (Bearer Token)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _verify_token(authorization: str | None) -> bool:
|
|
"""Verify Bearer token from Authorization header."""
|
|
expected = config.api_token
|
|
if not expected:
|
|
return False
|
|
if not authorization or not authorization.startswith("Bearer "):
|
|
return False
|
|
return authorization[7:] == expected
|
|
|
|
|
|
@app.get("/api/v1/state")
|
|
async def get_state():
|
|
"""Get current aggregated dashboard state."""
|
|
return {
|
|
"media": app.state.media.get_state(),
|
|
"audio": app.state.audio.get_state(),
|
|
"telemetry": app.state.telemetry.get_state(),
|
|
"notifications": app.state.notifications.get_active(),
|
|
"subsystems": app.state.subsystems,
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/notify")
|
|
async def create_notification(
|
|
notification: Notification,
|
|
authorization: str | None = None,
|
|
):
|
|
"""Create a notification (progress or alert)."""
|
|
if not _verify_token(authorization):
|
|
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
|
created = await app.state.notifications.create(notification)
|
|
# Broadcast to connected dashboard clients
|
|
await ws_manager.broadcast({"type": "notification", "data": created})
|
|
return {"id": created.id, "status": "created"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings (Protected by API Token)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _is_localhost(request: Request) -> bool:
|
|
"""Check if the request is from localhost."""
|
|
client_host = request.client.host if request.client else ""
|
|
return client_host in ("127.0.0.1", "::1", "localhost")
|
|
|
|
|
|
class SettingsUpdate:
|
|
"""Settings update payload."""
|
|
|
|
def __init__(
|
|
self,
|
|
dashboard_pin: str | None = None,
|
|
api_token: str | None = None,
|
|
host: str | None = None,
|
|
port: int | None = None,
|
|
log_level: str | None = None,
|
|
):
|
|
self.dashboard_pin = dashboard_pin
|
|
self.api_token = api_token
|
|
self.host = host
|
|
self.port = port
|
|
self.log_level = log_level
|
|
|
|
|
|
@app.get("/api/v1/settings")
|
|
async def get_settings(
|
|
request: Request,
|
|
authorization: str | None = None,
|
|
):
|
|
"""Get current settings (requires API token or localhost)."""
|
|
if not _verify_token(authorization) and not _is_localhost(request):
|
|
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
|
return config.to_dict()
|
|
|
|
|
|
@app.post("/api/v1/settings")
|
|
async def update_settings(
|
|
request: Request,
|
|
settings: SettingsUpdate,
|
|
authorization: str | None = None,
|
|
):
|
|
"""Update settings (requires API token or localhost)."""
|
|
if not _verify_token(authorization) and not _is_localhost(request):
|
|
raise HTTPException(status_code=401, detail="Invalid or missing API token")
|
|
|
|
if settings.dashboard_pin is not None:
|
|
config.dashboard_pin = settings.dashboard_pin
|
|
if settings.api_token is not None:
|
|
config.api_token = settings.api_token
|
|
if settings.host is not None:
|
|
config.host = settings.host
|
|
if settings.port is not None:
|
|
config.port = settings.port
|
|
if settings.log_level is not None:
|
|
config.log_level = settings.log_level.upper()
|
|
|
|
# Save to config file
|
|
config.save()
|
|
|
|
logger.info("Settings updated")
|
|
return config.to_dict()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hardware Status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.get("/api/v1/status")
|
|
async def get_status():
|
|
"""Get server status including hardware subsystem availability."""
|
|
return {
|
|
"subsystems": app.state.subsystems,
|
|
"config": {
|
|
"pin_configured": bool(config.dashboard_pin),
|
|
"token_configured": bool(config.api_token),
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings Page (localhost only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.get("/settings")
|
|
async def settings_page(request: Request):
|
|
"""Serve the settings page (localhost only)."""
|
|
if not _is_localhost(request):
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
settings_html = os.path.join(os.path.dirname(__file__), "settings.html")
|
|
with open(settings_html) as f:
|
|
return HTMLResponse(f.read())
|
|
|
|
|
|
@app.get("/settings.css")
|
|
async def settings_css(request: Request):
|
|
"""Serve the settings stylesheet (localhost only)."""
|
|
if not _is_localhost(request):
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
css_path = os.path.join(os.path.dirname(__file__), "settings.css")
|
|
with open(css_path) as f:
|
|
return HTMLResponse(f.read(), media_type="text/css")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Health
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client HTML (served directly for WebView)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def serve_client():
|
|
"""Serve the E-Ink dashboard client."""
|
|
# Try multiple locations for the client files
|
|
possible_paths = [
|
|
os.path.join(os.path.dirname(__file__), "..", "client"),
|
|
os.path.join(os.path.dirname(__file__), "..", "..", "client"),
|
|
os.path.join(os.path.dirname(__file__), "client"),
|
|
]
|
|
|
|
for client_dir in possible_paths:
|
|
index_path = os.path.join(client_dir, "index.html")
|
|
if os.path.exists(index_path):
|
|
with open(index_path) as f:
|
|
return f.read()
|
|
|
|
return HTMLResponse("<h1>PaperDash</h1><p>Client files not found.</p>")
|