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:
+221
@@ -0,0 +1,221 @@
|
||||
"""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, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from .audio_mixer import AudioMixerManager
|
||||
from .media import MediaManager
|
||||
from .notifications import NotificationManager, Notification
|
||||
from .telemetry import TelemetryManager
|
||||
|
||||
logger = logging.getLogger("paperdash")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Start and stop background managers."""
|
||||
# Start managers
|
||||
app.state.media = MediaManager()
|
||||
app.state.audio = AudioMixerManager()
|
||||
app.state.notifications = NotificationManager()
|
||||
app.state.telemetry = TelemetryManager()
|
||||
|
||||
# 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()),
|
||||
]
|
||||
|
||||
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)
|
||||
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
|
||||
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 = os.environ.get("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."""
|
||||
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 = os.environ.get("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(),
|
||||
}
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client HTML (served directly for Hermit WebView)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def serve_client():
|
||||
"""Serve the E-Ink dashboard client."""
|
||||
client_dir = os.path.join(os.path.dirname(__file__), "..", "client")
|
||||
index_path = os.path.join(client_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
with open(index_path, "r") as f:
|
||||
return f.read()
|
||||
return HTMLResponse("<h1>PaperDash</h1><p>Client files not found in client/</p>")
|
||||
Reference in New Issue
Block a user