"""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("

PaperDash

Client files not found.

")