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:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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__), ".."))
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user