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:
Imrayya
2026-07-01 11:19:32 +00:00
commit 0b4f739d12
36 changed files with 3916 additions and 0 deletions
+221
View File
@@ -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>")