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
View File
+323
View File
@@ -0,0 +1,323 @@
"""Audio mixer abstraction with pluggable backends.
Supports Windows Core Audio (default output) and Voicemeeter as backends.
Both expose the same interface so the client sees a unified audio view.
"""
from __future__ import annotations
import asyncio
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Literal
logger = logging.getLogger("paperdash.audio")
# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------
@dataclass
class AudioChannel:
"""A single audio channel/strip."""
name: str
volume: float = 0.0 # 0.0 - 1.0
muted: bool = False
peak_left: float = 0.0
peak_right: float = 0.0
@dataclass
class AudioMixerState:
"""Unified audio mixer state from any backend."""
backend: Literal["windows", "voicemeeter"]
master_volume: float = 0.0
master_muted: bool = False
channels: list[AudioChannel] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Backend Interface
# ---------------------------------------------------------------------------
class AudioMixerBackend(ABC):
"""Abstract interface for audio mixer backends."""
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
async def get_state(self) -> AudioMixerState: ...
@abstractmethod
async def set_volume(self, channel: str, value: float) -> None: ...
@abstractmethod
async def set_mute(self, channel: str, muted: bool) -> None: ...
# ---------------------------------------------------------------------------
# Windows Core Audio Backend
# ---------------------------------------------------------------------------
class WindowsAudioBackend(AudioMixerBackend):
"""Backend for the default Windows audio output device."""
@property
def name(self) -> str:
return "windows"
async def get_state(self) -> AudioMixerState:
"""Read master volume and mute from Windows Core Audio."""
try:
import pycaw # type: ignore
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume # type: ignore
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(IAudioEndpointVolume._iid_, 0, None)
volume = interface.QueryInterface(IAudioEndpointVolume)
master_volume = volume.MasterVolumeLevelScalar
master_muted = volume.Mute
return AudioMixerState(
backend="windows",
master_volume=master_volume,
master_muted=master_muted,
channels=[
AudioChannel(
name="Master",
volume=master_volume,
muted=master_muted,
)
],
)
except ImportError:
logger.warning("pycaw not installed — Windows audio unavailable")
return AudioMixerState(backend="windows")
except Exception as e:
logger.error(f"Windows audio error: {e}")
return AudioMixerState(backend="windows")
async def set_volume(self, channel: str, value: float) -> None:
try:
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(IAudioEndpointVolume._iid_, 0, None)
volume = interface.QueryInterface(IAudioEndpointVolume)
volume.MasterVolumeLevelScalar = max(0.0, min(1.0, value))
except Exception as e:
logger.error(f"Failed to set Windows volume: {e}")
async def set_mute(self, channel: str, muted: bool) -> None:
try:
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(IAudioEndpointVolume._iid_, 0, None)
volume = interface.QueryInterface(IAudioEndpointVolume)
volume.Mute = muted
except Exception as e:
logger.error(f"Failed to set Windows mute: {e}")
# ---------------------------------------------------------------------------
# Voicemeeter Backend
# ---------------------------------------------------------------------------
class VoicemeeterBackend(AudioMixerBackend):
"""Backend for Voicemeeter Remote API."""
@property
def name(self) -> str:
return "voicemeeter"
async def get_state(self) -> AudioMixerState:
"""Read Voicemeeter strip states."""
try:
import voicemeeter_api # type: ignore
vm = voicemeeter_api.VoicemeeterRemote()
vm.connect()
channels = []
# Read hardware inputs (A1, A2, A3)
for i in range(1, 4):
gain = vm.get_input_gain(i)
mute = bool(vm.get_input_mute(i))
channels.append(
AudioChannel(
name=f"A{i}",
volume=max(0.0, min(1.0, (gain + 40) / 40)),
muted=mute,
)
)
# Read virtual outputs (B1, B2, B3, B4, B5)
for i in range(1, 6):
gain = vm.get_output_gain(i)
mute = bool(vm.get_output_mute(i))
channels.append(
AudioChannel(
name=f"B{i}",
volume=max(0.0, min(1.0, (gain + 40) / 40)),
muted=mute,
)
)
vm.disconnect()
return AudioMixerState(
backend="voicemeeter",
master_volume=sum(c.volume for c in channels) / max(len(channels), 1),
master_muted=all(c.muted for c in channels) if channels else False,
channels=channels,
)
except ImportError:
logger.warning("voicemeeter-api not installed — Voicemeeter unavailable")
return AudioMixerState(backend="voicemeeter")
except Exception as e:
logger.error(f"Voicemeeter error: {e}")
return AudioMixerState(backend="voicemeeter")
async def set_volume(self, channel: str, value: float) -> None:
try:
import voicemeeter_api
vm = voicemeeter_api.VoicemeeterRemote()
vm.connect()
# Map channel names to Voicemeeter strip indices
if channel.startswith("A"):
strip = int(channel[1])
# Convert 0-1 to Voicemeeter gain (-40 to 0 dB)
gain = (value * -40) + 40
vm.set_input_gain(strip, gain)
elif channel.startswith("B"):
strip = int(channel[1])
gain = (value * -40) + 40
vm.set_output_gain(strip, gain)
vm.disconnect()
except Exception as e:
logger.error(f"Failed to set Voicemeeter volume for {channel}: {e}")
async def set_mute(self, channel: str, muted: bool) -> None:
try:
import voicemeeter_api
vm = voicemeeter_api.VoicemeeterRemote()
vm.connect()
if channel.startswith("A"):
strip = int(channel[1])
vm.set_input_mute(strip, int(muted))
elif channel.startswith("B"):
strip = int(channel[1])
vm.set_output_mute(strip, int(muted))
vm.disconnect()
except Exception as e:
logger.error(f"Failed to set Voicemeeter mute for {channel}: {e}")
# ---------------------------------------------------------------------------
# Manager (orchestrates backends and polling)
# ---------------------------------------------------------------------------
class AudioMixerManager:
"""Manages audio mixer backends and periodic state polling."""
def __init__(self) -> None:
self._backends: list[AudioMixerBackend] = []
self._current_state: AudioMixerState = AudioMixerState(backend="windows")
self._poll_interval: float = 2.0
# Initialize available backends
self._init_backends()
def _init_backends(self) -> None:
"""Try to initialize backends in priority order."""
# Voicemeeter first (primary focus)
try:
self._backends.append(VoicemeeterBackend())
logger.info("Voicemeeter backend initialized")
except Exception:
logger.debug("Voicemeeter not available")
# Windows Core Audio as fallback
try:
self._backends.append(WindowsAudioBackend())
logger.info("Windows audio backend initialized")
except Exception:
logger.debug("Windows audio not available")
if not self._backends:
logger.warning("No audio backends available")
async def run(self) -> None:
"""Periodically poll audio state."""
while True:
try:
self._current_state = await self._get_best_state()
except Exception as e:
logger.error(f"Audio poll error: {e}")
await asyncio.sleep(self._poll_interval)
async def _get_best_state(self) -> AudioMixerState:
"""Get state from the best available backend."""
for backend in self._backends:
try:
state = await backend.get_state()
if state.channels or state.master_volume > 0 or state.master_muted:
return state
except Exception:
continue
return self._current_state
def get_state(self) -> dict:
"""Return current state as a serializable dict."""
return {
"backend": self._current_state.backend,
"master_volume": self._current_state.master_volume,
"master_muted": self._current_state.master_muted,
"channels": [
{
"name": ch.name,
"volume": ch.volume,
"muted": ch.muted,
"peak_left": ch.peak_left,
"peak_right": ch.peak_right,
}
for ch in self._current_state.channels
],
}
async def set_volume(self, bus: str, value: float) -> None:
"""Set volume on the active backend."""
for backend in self._backends:
try:
await backend.set_volume(bus, value)
return
except Exception:
continue
async def set_mute(self, bus: str, muted: bool) -> None:
"""Set mute on the active backend."""
for backend in self._backends:
try:
await backend.set_mute(bus, muted)
return
except Exception:
continue
+129
View File
@@ -0,0 +1,129 @@
"""Build script: compile PaperDash server to a standalone .exe with system tray.
Usage:
python build_exe.py # Build in release mode
python build_exe.py --debug # Build in debug mode (with console)
Output: server/dist/PaperDash.exe
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DIST_DIR = os.path.join(SCRIPT_DIR, "dist")
def build(debug: bool = False) -> None:
"""Compile the server to an .exe using PyInstaller."""
os.makedirs(DIST_DIR, exist_ok=True)
# PyInstaller spec content — wraps the server with system tray
spec_content = f'''
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec for PaperDash with system tray."""
import os
from pathlib import Path
block_cipher = None
a = Analysis(
["tray_wrapper.py"],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[
"fastapi",
"uvicorn",
"pydantic",
"pystray",
"PIL",
"audio_mixer",
"media",
"notifications",
"telemetry",
"main",
],
hookspath=[],
hooksconfig={{}},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="PaperDash",
debug={"True": True, "False": False}[str(debug)],
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False if not debug else True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
)
'''
spec_path = os.path.join(SCRIPT_DIR, "PaperDash.spec")
with open(spec_path, "w") as f:
f.write(spec_content)
try:
cmd = [
sys.executable,
"-m",
"PyInstaller",
spec_path,
"--clean",
]
if debug:
cmd.append("--debug=all")
print(f"Building PaperDash.exe (debug={debug})...")
print(f" Spec: {spec_path}")
print(f" Output: {DIST_DIR}/PaperDash.exe")
print()
subprocess.run(cmd, check=True)
exe_path = os.path.join(DIST_DIR, "PaperDash.exe")
if os.path.exists(exe_path):
size_mb = os.path.getsize(exe_path) / (1024 * 1024)
print(f"\nBuild complete: {exe_path} ({size_mb:.1f} MB)")
else:
print("\nBuild completed but .exe not found in dist/")
except subprocess.CalledProcessError as e:
print(f"\nBuild failed: {e}", file=sys.stderr)
sys.exit(1)
finally:
# Clean up spec file
if os.path.exists(spec_path):
os.remove(spec_path)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Build PaperDash .exe")
parser.add_argument("--debug", action="store_true", help="Build with debug console")
args = parser.parse_args()
build(debug=args.debug)
+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>")
+166
View File
@@ -0,0 +1,166 @@
"""Windows Media playback tracking via GSMTC (Global System Media Transport Controls).
Uses the winsdk library to access the Windows Media API without polling.
"""
from __future__ import annotations
import asyncio
import base64
import io
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("paperdash.media")
# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------
@dataclass
class MediaState:
"""Current media playback state."""
title: str = ""
artist: str = ""
album: str = ""
artwork_base64: str = ""
playback_position: float = 0.0 # seconds
playback_duration: float = 0.0 # seconds
playback_percentage: float = 0.0 # 0.0 - 1.0
is_playing: bool = False
is_paused: bool = False
is_stopped: bool = True
# ---------------------------------------------------------------------------
# Media Manager
# ---------------------------------------------------------------------------
class MediaManager:
"""Tracks Windows media playback via GSMTC."""
def __init__(self) -> None:
self._state = MediaState()
self._poll_interval: float = 2.0
async def run(self) -> None:
"""Periodically poll media state from GSMTC."""
while True:
try:
self._state = await self._poll_gsmtc()
except Exception as e:
logger.error(f"Media poll error: {e}")
await asyncio.sleep(self._poll_interval)
async def _poll_gsmtc(self) -> MediaState:
"""Read current media state from Windows GSMTC."""
try:
from winsdk.windows.media.control import ( # type: ignore
GlobalSystemMediaTransportControlsSessionManager as Manager,
)
session_manager = await Manager.request_async()
session = session_manager.get_current_session()
if session is None:
return MediaState()
info = await session.try_get_media_properties_async()
# Convert artwork to base64
artwork_b64 = ""
if info.thumbnail:
try:
stream = await info.thumbnail.open_async()
reader = await asyncio.to_thread(
__import__("Windows.Storage.Streams").DataTypes.DataReader,
stream,
)
buffer = await asyncio.to_thread(reader.load_async, stream.size)
data = bytearray(buffer)
buf = io.BytesIO(data)
artwork_b64 = base64.b64encode(buf.read()).decode("ascii")
except Exception:
artwork_b64 = ""
# Compute percentage
props = session.get_playback_info()
pos = props.position.total_seconds() if props.position else 0.0
duration = info.duration.total_seconds() if info.duration else 0.0
percentage = (pos / duration * 100) if duration > 0 else 0.0
return MediaState(
title=info.title or "",
artist=info.artist or "",
album=info.album or "",
artwork_base64=artwork_b64,
playback_position=pos,
playback_duration=duration,
playback_percentage=percentage,
is_playing=session.playback_status.value == 0, # Playing
is_paused=session.playback_status.value == 1, # Paused
is_stopped=session.playback_status.value == 2, # Stopped
)
except ImportError:
logger.warning("winsdk not installed — media tracking unavailable")
return MediaState()
except Exception as e:
logger.error(f"GSMTC error: {e}")
return MediaState()
def get_state(self) -> dict[str, Any]:
"""Return current media state as a serializable dict."""
return {
"title": self._state.title,
"artist": self._state.artist,
"album": self._state.album,
"artwork_base64": self._state.artwork_base64,
"playback_position": self._state.playback_position,
"playback_duration": self._state.playback_duration,
"playback_percentage": self._state.playback_percentage,
"is_playing": self._state.is_playing,
"is_paused": self._state.is_paused,
"is_stopped": self._state.is_stopped,
}
async def play(self) -> None:
"""Send play command to current media session."""
await self._send_command("play")
async def pause(self) -> None:
"""Send pause command to current media session."""
await self._send_command("pause")
async def skip(self) -> None:
"""Send next track command."""
await self._send_command("next")
async def previous(self) -> None:
"""Send previous track command."""
await self._send_command("previous")
async def _send_command(self, command: str) -> None:
"""Send a media transport command via GSMTC."""
try:
from winsdk.windows.media.control import ( # type: ignore
GlobalSystemMediaTransportControlsSessionManager as Manager,
)
session_manager = await Manager.request_async()
session = session_manager.get_current_session()
if session:
if command == "play":
await session.try_play_async()
elif command == "pause":
await session.try_pause_async()
elif command == "next":
await session.try_skip_next_async()
elif command == "previous":
await session.try_skip_previous_async()
except Exception as e:
logger.error(f"Media command '{command}' failed: {e}")
+131
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
# Core
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
pydantic>=2.0.0
# Windows APIs
winsdk>=1.0.0
pycaw>=20230407
voicemeeter-api>=1.0.0
# Hardware telemetry
pyhwinfo>=0.1.0
# Build & packaging
pyinstaller>=6.0.0
pystray>=0.19.0
Pillow>=10.0.0
+136
View File
@@ -0,0 +1,136 @@
"""Hardware telemetry via HWiNFO64 Shared Memory.
Reads CPU/GPU core temperatures, VRAM usage, and other hardware metrics
from HWiNFO's shared memory interface.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger("paperdash.telemetry")
# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------
@dataclass
class SensorReading:
"""A single hardware sensor reading."""
name: str
value: float
unit: str
category: str = "" # e.g., "CPU", "GPU", "System"
@dataclass
class TelemetryState:
"""Aggregated hardware telemetry state."""
sensors: list[SensorReading] = field(default_factory=list)
cpu_temp: float = 0.0
gpu_temp: float = 0.0
cpu_usage: float = 0.0
gpu_usage: float = 0.0
vram_usage: float = 0.0
vram_total: float = 0.0
ram_usage: float = 0.0
ram_total: float = 0.0
# ---------------------------------------------------------------------------
# Telemetry Manager
# ---------------------------------------------------------------------------
class TelemetryManager:
"""Reads hardware telemetry from HWiNFO64 Shared Memory."""
def __init__(self) -> None:
self._state = TelemetryState()
self._poll_interval: float = 5.0
async def run(self) -> None:
"""Periodically poll hardware telemetry."""
while True:
try:
self._state = await self._poll_hwinfo()
except Exception as e:
logger.error(f"Telemetry poll error: {e}")
await asyncio.sleep(self._poll_interval)
async def _poll_hwinfo(self) -> TelemetryState:
"""Read hardware state from HWiNFO64 Shared Memory."""
try:
import pyhwinfo # type: ignore
data = pyhwinfo.read_all()
sensors = []
state = TelemetryState()
for sensor in data:
reading = SensorReading(
name=sensor.get("name", ""),
value=sensor.get("value", 0.0),
unit=sensor.get("unit", ""),
category=sensor.get("category", ""),
)
sensors.append(reading)
# Extract key metrics
name_lower = reading.name.lower()
if "cpu core" in name_lower and "temp" in name_lower:
state.cpu_temp = max(state.cpu_temp, reading.value)
elif "gpu" in name_lower and "temp" in name_lower:
state.gpu_temp = max(state.gpu_temp, reading.value)
elif "cpu" in name_lower and "usage" in name_lower:
state.cpu_usage = max(state.cpu_usage, reading.value)
elif "gpu" in name_lower and "usage" in name_lower:
state.gpu_usage = max(state.gpu_usage, reading.value)
elif "dedicated" in name_lower and "memory" in name_lower:
if "used" in name_lower:
state.vram_usage = reading.value
elif "total" in name_lower:
state.vram_total = reading.value
elif "memory" in name_lower and "usage" in name_lower:
state.ram_usage = reading.value
elif "memory" in name_lower and "total" in name_lower:
state.ram_total = reading.value
state.sensors = sensors
return state
except ImportError:
logger.warning("pyhwinfo not installed — telemetry unavailable")
return TelemetryState()
except Exception as e:
logger.error(f"HWiNFO read error: {e}")
return TelemetryState()
def get_state(self) -> dict[str, Any]:
"""Return current telemetry state as a serializable dict."""
return {
"cpu_temp": self._state.cpu_temp,
"gpu_temp": self._state.gpu_temp,
"cpu_usage": self._state.cpu_usage,
"gpu_usage": self._state.gpu_usage,
"vram_usage": self._state.vram_usage,
"vram_total": self._state.vram_total,
"ram_usage": self._state.ram_usage,
"ram_total": self._state.ram_total,
"sensors": [
{
"name": s.name,
"value": s.value,
"unit": s.unit,
"category": s.category,
}
for s in self._state.sensors
],
}
View File
+9
View File
@@ -0,0 +1,9 @@
"""Shared test configuration."""
from __future__ import annotations
import sys
import os
# Ensure the server package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+73
View File
@@ -0,0 +1,73 @@
"""Tests for the audio mixer data models and backend interface."""
from __future__ import annotations
import pytest
from ..audio_mixer import (
AudioChannel,
AudioMixerManager,
AudioMixerState,
)
class TestDataModels:
def test_audio_channel_defaults(self) -> None:
ch = AudioChannel(name="Master")
assert ch.name == "Master"
assert ch.volume == 0.0
assert ch.muted == False
assert ch.peak_left == 0.0
def test_audio_mixer_state_defaults(self) -> None:
state = AudioMixerState(backend="windows")
assert state.backend == "windows"
assert state.master_volume == 0.0
assert state.master_muted == False
assert state.channels == []
def test_audio_mixer_state_with_channels(self) -> None:
channels = [
AudioChannel(name="A1", volume=0.8, muted=False),
AudioChannel(name="B1", volume=0.5, muted=True),
]
state = AudioMixerState(
backend="voicemeeter",
master_volume=0.65,
master_muted=False,
channels=channels,
)
assert len(state.channels) == 2
assert state.channels[0].name == "A1"
assert state.channels[1].muted == True
class TestAudioMixerManager:
def test_init_with_no_backends(self) -> None:
"""Manager should initialize even with no backends available."""
manager = AudioMixerManager()
# Should not raise
state = manager.get_state()
assert isinstance(state, dict)
assert "backend" in state
assert "master_volume" in state
assert "channels" in state
def test_get_state_returns_dict(self) -> None:
manager = AudioMixerManager()
state = manager.get_state()
assert isinstance(state, dict)
assert state["backend"] in ("windows", "voicemeeter")
assert isinstance(state["channels"], list)
@pytest.mark.asyncio
async def test_set_volume_no_backends(self) -> None:
"""set_volume should not raise when no backends are available."""
manager = AudioMixerManager()
await manager.set_volume("master", 0.5) # Should not raise
@pytest.mark.asyncio
async def test_set_mute_no_backends(self) -> None:
"""set_mute should not raise when no backends are available."""
manager = AudioMixerManager()
await manager.set_mute("master", True) # Should not raise
+94
View File
@@ -0,0 +1,94 @@
"""Tests for the FastAPI application endpoints."""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from ..main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
class TestHealth:
def test_health_returns_ok(self, client: TestClient) -> None:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
class TestGetState:
def test_get_state_returns_dict(self, client: TestClient) -> None:
with (
patch.object(app.state, "media", create=True),
patch.object(app.state, "audio", create=True),
patch.object(app.state, "notifications", create=True),
patch.object(app.state, "telemetry", create=True),
):
app.state.media.get_state = lambda: {"title": "Test"}
app.state.audio.get_state = lambda: {"backend": "windows"}
app.state.notifications.get_active = list
app.state.telemetry.get_state = dict
response = client.get("/api/v1/state")
assert response.status_code == 200
data = response.json()
assert "media" in data
assert "audio" in data
assert "notifications" in data
assert "telemetry" in data
class TestNotifyEndpoint:
def test_notify_without_token_returns_401(self, client: TestClient) -> None:
response = client.post(
"/api/v1/notify",
json={
"type": "alert",
"title": "Test",
"message": "Hello",
},
)
assert response.status_code == 401
def test_notify_with_valid_token(self, client: TestClient) -> None:
os.environ["API_TOKEN"] = "test-token-123"
with patch.object(app.state, "notifications", create=True) as mock_notif:
mock_notif.create = AsyncMock()
mock_notif.create.return_value = type(
"Notification", (), {"id": "test-id"}
)()
response = client.post(
"/api/v1/notify",
json={
"type": "alert",
"title": "Build Complete",
"message": "Success",
},
headers={"Authorization": "Bearer test-token-123"},
)
assert response.status_code == 200
assert response.json()["status"] == "created"
# Cleanup
del os.environ["API_TOKEN"]
def test_notify_with_wrong_token_returns_401(self, client: TestClient) -> None:
os.environ["API_TOKEN"] = "correct-token"
response = client.post(
"/api/v1/notify",
json={"type": "alert", "title": "Test", "message": "Hello"},
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401
del os.environ["API_TOKEN"]
+51
View File
@@ -0,0 +1,51 @@
"""Tests for the media tracking module."""
from __future__ import annotations
from ..media import MediaManager, MediaState
class TestMediaState:
def test_defaults(self) -> None:
state = MediaState()
assert state.title == ""
assert state.artist == ""
assert not state.is_playing
assert state.is_stopped
assert state.playback_percentage == 0.0
def test_playing_state(self) -> None:
state = MediaState(
title="Test Song",
artist="Test Artist",
is_playing=True,
is_paused=False,
is_stopped=False,
playback_percentage=45.5,
)
assert state.title == "Test Song"
assert state.is_playing
assert state.playback_percentage == 45.5
class TestMediaManager:
def test_init(self) -> None:
manager = MediaManager()
assert manager._state is not None
def test_get_state_returns_dict(self) -> None:
manager = MediaManager()
state = manager.get_state()
assert isinstance(state, dict)
assert "title" in state
assert "artist" in state
assert "is_playing" in state
assert "playback_percentage" in state
def test_get_state_empty_when_nothing_playing(self) -> None:
manager = MediaManager()
state = manager.get_state()
assert state["title"] == ""
assert not state["is_playing"]
assert state["is_stopped"]
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the notification system."""
from __future__ import annotations
import pytest
from ..notifications import Notification, NotificationManager, ProgressState
@pytest.fixture
def manager() -> NotificationManager:
return NotificationManager()
class TestNotificationCreation:
@pytest.mark.asyncio
async def test_create_alert(self, manager: NotificationManager) -> None:
notif = Notification(
type="alert",
title="Build Complete",
message="Deployment succeeded.",
priority="info",
)
result = await manager.create(notif)
assert result.id
assert result.type == "alert"
assert result.title == "Build Complete"
@pytest.mark.asyncio
async def test_create_progress(self, manager: NotificationManager) -> None:
notif = Notification(
type="progress",
title="Backup",
message="Uploading...",
progress=ProgressState(current=50, min=0, max=100, unit="MB"),
)
result = await manager.create(notif)
assert result.type == "progress"
assert result.progress is not None
progress = result.progress
assert progress.percentage == 50.0
@pytest.mark.asyncio
async def test_update_existing_notification(
self, manager: NotificationManager
) -> None:
notif = Notification(
id="test_01",
type="progress",
title="Backup",
progress=ProgressState(current=10, max=100),
)
await manager.create(notif)
notif.progress = ProgressState(current=50, max=100)
result = await manager.create(notif)
assert result.progress is not None
assert result.progress.percentage == 50.0
@pytest.mark.asyncio
async def test_get_active_returns_list(self, manager: NotificationManager) -> None:
await manager.create(Notification(title="Test"))
active = manager.get_active()
assert len(active) == 1
assert active[0]["title"] == "Test"
class TestNotificationPruning:
@pytest.mark.asyncio
async def test_prunes_old_notifications(self) -> None:
manager = NotificationManager()
manager._max_active = 3
for i in range(5):
await manager.create(Notification(id=f"n{i}", title=f"Msg {i}"))
active = manager.get_active()
assert len(active) == 3
ids = [n["id"] for n in active]
assert "n0" not in ids
assert "n4" in ids
class TestNotificationDismiss:
@pytest.mark.asyncio
async def test_dismiss_removes_notification(
self, manager: NotificationManager
) -> None:
notif = Notification(id="dismiss_me", title="Temp")
await manager.create(notif)
assert len(manager.get_active()) == 1
result = await manager.dismiss("dismiss_me")
assert result
assert len(manager.get_active()) == 0
@pytest.mark.asyncio
async def test_dismiss_nonexistent_returns_false(
self, manager: NotificationManager
) -> None:
result = await manager.dismiss("does_not_exist")
assert not result
class TestProgressState:
def test_percentage_calculation(self) -> None:
p = ProgressState(current=75, min=0, max=100)
assert p.percentage == 75.0
def test_percentage_clamped_at_zero(self) -> None:
p = ProgressState(current=-10, min=0, max=100)
assert p.percentage == 0.0
def test_percentage_clamped_at_hundred(self) -> None:
p = ProgressState(current=150, min=0, max=100)
assert p.percentage == 100.0
def test_zero_range_returns_zero(self) -> None:
p = ProgressState(current=50, min=0, max=0)
assert p.percentage == 0.0
+59
View File
@@ -0,0 +1,59 @@
"""Tests for the telemetry module."""
from __future__ import annotations
from ..telemetry import SensorReading, TelemetryManager, TelemetryState
class TestSensorReading:
def test_defaults(self) -> None:
reading = SensorReading(name="CPU Temp", value=65.0, unit="C")
assert reading.name == "CPU Temp"
assert reading.value == 65.0
assert reading.unit == "C"
assert reading.category == ""
def test_with_category(self) -> None:
reading = SensorReading(name="GPU Usage", value=72.0, unit="%", category="GPU")
assert reading.category == "GPU"
class TestTelemetryState:
def test_defaults(self) -> None:
state = TelemetryState()
assert state.cpu_temp == 0.0
assert state.gpu_temp == 0.0
assert state.sensors == []
def test_with_values(self) -> None:
state = TelemetryState(
cpu_temp=72.5,
gpu_temp=68.0,
cpu_usage=45.0,
sensors=[
SensorReading(name="CPU", value=72.5, unit="C"),
],
)
assert state.cpu_temp == 72.5
assert len(state.sensors) == 1
class TestTelemetryManager:
def test_init(self) -> None:
manager = TelemetryManager()
assert manager._state is not None
def test_get_state_returns_dict(self) -> None:
manager = TelemetryManager()
state = manager.get_state()
assert isinstance(state, dict)
assert "cpu_temp" in state
assert "gpu_temp" in state
assert "sensors" in state
def test_get_state_empty(self) -> None:
manager = TelemetryManager()
state = manager.get_state()
assert state["cpu_temp"] == 0.0
assert state["sensors"] == []
+104
View File
@@ -0,0 +1,104 @@
"""System tray wrapper for PaperDash.
Runs the FastAPI server in a background thread and displays a tray icon
with a quit option. This is the entry point for the compiled .exe.
"""
from __future__ import annotations
import logging
import multiprocessing
import signal
import time
import pystray
from PIL import Image, ImageDraw
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
logger = logging.getLogger("paperdash.tray")
def create_icon() -> Image.Image:
"""Create a simple 16x16 tray icon."""
img = Image.new("RGB", (16, 16), color=(0, 0, 0))
draw = ImageDraw.Draw(img)
# Dashboard frame
draw.rectangle([1, 1, 15, 15], outline=(255, 255, 255), width=1)
# Screen lines
draw.line([3, 3, 13, 3], fill=(255, 255, 255), width=1)
draw.line([3, 5, 13, 5], fill=(255, 255, 255), width=1)
draw.line([3, 7, 8, 7], fill=(255, 255, 255), width=1)
# Stand
draw.line([6, 15, 10, 15], fill=(255, 255, 255), width=1)
return img
def _run_server() -> None:
"""Run the FastAPI server in this process (for multiprocessing)."""
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8921,
log_level="info",
reload=False,
)
def _on_quit(icon: pystray.Icon, item: pystray.MenuItem) -> None:
"""Handle quit from tray menu."""
logger.info("Quit requested from tray")
icon.stop()
def main() -> None:
"""Entry point for the compiled .exe."""
logger.info("PaperDash starting...")
# Start server in a subprocess
server_process = multiprocessing.Process(target=_run_server, daemon=True)
server_process.start()
logger.info(f"Server process started (PID {server_process.pid})")
# Wait briefly for server to be ready
time.sleep(2)
# Create and run tray icon
icon_image = create_icon()
menu = pystray.Menu(
pystray.MenuItem("PaperDash", pystray.MenuItem.default, enabled=False),
pystray.Menu.Separator(),
pystray.MenuItem("Quit", _on_quit),
)
icon = pystray.Icon(
name="PaperDash",
image=icon_image,
title="PaperDash",
menu=menu,
)
# Handle OS exit signals
def _signal_handler(signum, frame):
logger.info(f"Received signal {signum}, shutting down")
icon.stop()
server_process.terminate()
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
logger.info("PaperDash running in system tray. Quit from notification area.")
icon.run()
# Cleanup
logger.info("Shutting down server process")
server_process.terminate()
server_process.join(timeout=5)
if __name__ == "__main__":
main()