commit 0b4f739d12a02e3e00b037cfce89bb4bab856e21 Author: Imrayya Date: Wed Jul 1 11:19:32 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0cddf6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Dependencies +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ +*.so +*.spec + +# Virtual environments +venv/ +.venv/ +env/ +.env/ + +# Environment variables +.env +.env.local +.env.production + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project config (contains secrets, PINs, tokens) +.pi/ + +# Build artifacts +*.exe +*.pyc +*.pyo + +# Linter caches +.ruff_cache/ + +# Android +android/.gradle/ +android/build/ +android/app/build/ +*.apk +*.aab +android/local.properties diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..587dea9 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,119 @@ +# HANDOFF — PaperDash Project Setup + +**Date:** 2026-07-01 +**Status:** Initial project scaffold complete + +## What Was Done + +### 1. Repository Setup + +- Cloned empty repo from `https://gitea.kareemhorstink.me/Imrayya/e-ink-dash` +- Created `README.md`, `.gitignore`, `LICENSE` (MIT) +- Copyright: **Imrayya (2026)** +- Project name: **E-INK Dashboard Ecosystem ~ Paperdash** + +### 2. Architecture Documentation + +- Read and processed `.pi/paperdash_architecture_document.txt` +- Updated architecture doc with: + - Generic audio mixer interface (Windows Core Audio + Voicemeeter, Voicemeeter as primary focus) + - Two notification types: `progress` (with progress bar) and `alert` (rich text with priority levels) + - Updated ASCII diagram to include Windows Audio backend + +### 3. Design Decision Records (`.pi/docs/design/`) + +Created four design documents: + +- **architecture-overview.md** — Framework choices (FastAPI, Vanilla JS, WebView, WebSocket) +- **audio-mixer-design.md** — Generic audio interface with pluggable backends, data models +- **notification-system-design.md** — Two notification types (progress + alert), schemas, rationale +- **exe-tray-design.md** — PyInstaller + pystray system tray, quit-only interface + +### 4. Python Server (`server/`) + +Created the FastAPI server with these modules: + +- **main.py** — FastAPI app with WebSocket endpoint (PIN auth), REST endpoints (`/api/v1/state`, `/api/v1/notify`), client HTML serving +- **audio_mixer.py** — Pluggable audio backends (Windows Core Audio via pycaw, Voicemeeter via voicemeeter-api), unified `AudioMixerState` data model, `AudioMixerManager` for polling +- **media.py** — Windows GSMTC media tracking (title, artist, artwork, playback state), media control commands (play/pause/skip/prev) +- **notifications.py** — `NotificationManager` supporting progress and alert types, ID-based update/dismiss, auto-pruning (max 10 active) +- **telemetry.py** — HWiNFO64 Shared Memory polling for CPU/GPU temps, RAM/VRAM usage +- **tray_wrapper.py** — System tray icon with quit option, runs server in subprocess +- **build_exe.py** — PyInstaller build script producing `PaperDash.exe` in `server/dist/` +- **requirements.txt** — All Python dependencies + +### 5. Test Suite (`server/tests/`) + +Created comprehensive tests: + +- **test_notifications.py** — Notification creation, updates, pruning, dismissal, progress percentage calculation (async tests with pytest-asyncio) +- **test_audio_mixer.py** — Data model defaults, manager initialization with no backends, set_volume/set_mute graceful handling +- **test_main.py** — Health endpoint, state endpoint, notify endpoint auth (401 without token, success with valid token) +- **test_media.py** — Media state defaults, playing state, manager get_state +- **test_telemetry.py** — Sensor reading, telemetry state, manager get_state + +### 6. E-Ink Client (`client/`) + +Created the web client (source of truth for HTML/CSS/JS): + +- **index.html** — PIN login screen + dashboard with panels (media, audio mixer, telemetry, notifications) +- **style.css** — High-contrast black/white E-Ink optimized styles, no transitions, block-based progress meters +- **app.js** — Vanilla JS WebSocket client with: + - PIN authentication and auto-reconnect + - Media display (title, artist, album, artwork base64, progress bar, controls) + - Audio mixer display (master volume, mute, per-channel meters with block indicators) + - Telemetry display (CPU/GPU temp, RAM/VRAM usage) + - Notifications (progress bars as block indicators, alert priorities) + - Swipe gesture zones (70px threshold) for volume adjustment + - DOM API usage (no innerHTML for security) + +### 7. Android Client APK (`android/`) + +Created a custom Android WebView APK for the **BOOX Go 7 Color Gen II** (Android 13): + +- **MainActivity.kt** — Kotlin Single Activity that hosts a full-screen WebView, hides system bars via immersive mode, loads `file:///android_asset/index.html` +- **AndroidManifest.xml** — INTERNET permission, landscape orientation, full-screen theme +- **build.gradle.kts** — AGP 8.2.0, Kotlin 1.9.22, compileSdk 34, targetSdk 34, minSdk 33 (Android 13) +- **assets/** — Bundled index.html, style.css, app.js (copied from `client/`) +- **README.md** — Build and installation instructions + +Build: `cd android && ./gradlew assembleDebug` +APK output: `app/build/outputs/apk/debug/app-debug.apk` + +### 8. Git Ignore + +- Python artifacts (`__pycache__`, `*.pyc`, `dist/`, `build/`) +- Virtual environments (`venv/`, `.venv/`) +- Environment files (`.env`, `.env.local`) +- IDE files (`.idea/`, `.vscode/`) +- `.pi/` directory (contains secrets like PINs and tokens) +- Build artifacts (`*.exe`, `*.spec`) + +## Key Design Decisions + +1. **Audio mixer is generic** — supports both Windows default output and Voicemeeter through a pluggable backend interface. Voicemeeter is the immediate implementation focus. + +2. **Two notification types** — `progress` for long-running tasks with progress bars, `alert` for one-off messages with priority levels (info/warning/error). + +3. **E-Ink optimized client** — vanilla JS (no frameworks), DOM API instead of innerHTML, block-stepped progress meters, no CSS transitions, high-contrast black/white theme. + +4. **System tray .exe** — PyInstaller compiles the server to a standalone .exe that runs in the notification bar with a single "Quit" option. No window, no taskbar presence. + +5. **PIN auth for dashboard, Bearer token for scripts** — dual authentication model matching the architecture doc. + +## What's Next + +- Install dependencies and run tests: `cd server && pip install -r requirements.txt && pytest` +- Test on Windows with actual Voicemeeter and HWiNFO64 +- Build Android APK: `cd android && ./gradlew assembleDebug` +- Install APK on BOOX Go 7 Color via ADB: `adb install app/build/outputs/apk/debug/app-debug.apk` +- Build the .exe: `python build_exe.py` +- Fine-tune E-Ink UI layout for the 7-inch display resolution +- Sync `client/` changes to `android/app/src/main/assets/` when updating the web client + +## Environment Variables Required + +| Variable | Purpose | +|----------|---------| +| `DASHBOARD_PIN` | 4-digit PIN for E-Ink client WebSocket auth | +| `API_TOKEN` | Bearer token for `/api/v1/notify` endpoint | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fec5aa2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Imrayya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4525e8d --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# E-INK Dashboard Ecosystem ~ Paperdash + +A local network dashboard ecosystem designed for the **Onyx Boox Go 7 Color** (Android E-Ink display). It displays real-time Windows desktop states including media playback, audio mixer channels (Windows default output or Voicemeeter), system telemetry, and custom script notifications (progress bars and rich text alerts). + +**Copyright (c) Imrayya (2026)** — MIT License + +## Architecture + +Paperdash bypasses heavyweight native Android UIs in favor of a lightweight, asynchronous Python server communicating via low-overhead WebSockets to a highly optimized E-Ink web client. + +```mermaid +flowchart TD + subgraph WindowsHost["WINDOWS HOST PC"] + subgraph Subsystems["Subsystems"] + Media["WinRT Media API"] + Audio["Windows Audio / Core Audio"] + VM["Voicemeeter API"] + HW["HWiNFO Shared Memory"] + end + Server["FastAPI Server"] + StateAgg["State Aggregator"] + Auth["Token Auth Engine"] + end + + subgraph External["EXTERNAL CLIENTS"] + Script["Custom Script / CLI"] + end + + subgraph Boox["ONYX BOOX GO 7 COLOR (Android 13)"] + APK["Android WebView APK + (Kotlin)"] + style APK fill:#000,color:#fff + end + + Media --> Server + Audio --> Server + VM --> Server + HW --> Server + Server --> StateAgg + Server --> Auth + Script -->|Bearer Token| Auth + StateAgg -->|WebSocket| WebView + Auth -->|PIN Auth| WebView +``` + +**Client-side optimizations:** PIN-authenticated WebSocket connection, static DOM updates (no smooth scrolling/fades), full-screen CSS Grid layout with static tap-and-swipe gestures. + +## Tech Stack + +### Server (Python 3.11+, Windows) + +- **FastAPI + Uvicorn** — ASGI web framework with native async WebSocket handling +- **winsdk** — Windows Media API for GSMTC (Global System Media Transport Controls) +- **pysounddevice / pycaw** — Generic Windows Core Audio API for default output volume and mute +- **voicemeeter-api** — Voicemeeter Remote API wrapper for per-strip mixer controls +- **pyhwinfo** — Hardware telemetry via HWiNFO64 Shared Memory +- **PyInstaller** — Compiles the server to a standalone `.exe` with system tray icon + +### Client (BOOX Go 7 Color, Android 13) + +- **Custom Android WebView APK** — Kotlin app wrapping the web client in a full-screen WebView +- **Vanilla JavaScript (ES6+) + CSS** — configured for monochrome/high-contrast E-Ink palettes + +## Features + +- **Media Playback Tracking** — Song title, artist, playback percentage, and cover art via Windows GSMTC +- **Audio Mixer Control** — Real-time volume and mute from Windows default output or Voicemeeter per-strip gains, mutes, and track states +- **Hardware Telemetry** — CPU/GPU core temps, VRAM usage from HWiNFO64 +- **Custom Notifications** — External scripts can push progress bars or rich text alerts via `POST /api/v1/notify` +- **E-Ink Optimized UI** — High-contrast styling, discrete zone gestures, minimal refresh +- **System Tray .exe** — Runs unobtrusively in the Windows notification bar with a quit option + +## Authentication + +1. **Dashboard UI (Boox Tablet):** Shared PIN authentication via WebSocket handshake (`?pin=XXXX`) +2. **External Scripts/CLI:** Static Bearer Token via `Authorization: Bearer ` header + +## Project Structure + +``` +e-ink-dash/ +├── .pi/ +│ ├── docs/design/ # Design decision records +│ └── paperdash_architecture_document.txt +├── server/ # Python FastAPI server +│ ├── main.py # Application entry point +│ ├── audio_mixer.py # Audio mixer abstraction (Windows + Voicemeeter) +│ ├── media.py # Media playback tracking (GSMTC) +│ ├── notifications.py # Notification system (progress + alerts) +│ ├── telemetry.py # Hardware telemetry (HWiNFO) +│ ├── tray_wrapper.py # System tray .exe wrapper +│ ├── build_exe.py # PyInstaller build script +│ ├── requirements.txt +│ └── tests/ # Test suite +├── client/ # E-Ink web client +│ ├── index.html +│ ├── style.css +│ └── app.js +├── README.md +├── LICENSE +└── .gitignore +``` + +## Getting Started + +### Server + +```bash +cd server +python -m venv venv +venv\Scripts\activate # Windows +pip install -r requirements.txt +python -m uvicorn main:app --host 0.0.0.0 --port 8921 +``` + +Set environment variables: + +- `DASHBOARD_PIN` — 4-digit PIN for the E-Ink client +- `API_TOKEN` — Bearer token for external scripts + +### Build .exe + +```bash +cd server +python build_exe.py +# Output: server/dist/PaperDash.exe +``` + +### Client (Android APK) + +The web client is bundled into an Android APK. Open Android Studio, sync Gradle, and run on the BOOX Go 7 Color. + +```bash +cd android +./gradlew assembleDebug +# APK at: app/build/outputs/apk/debug/app-debug.apk +``` + +Install via ADB: + +```bash +adb install app/build/outputs/apk/debug/app-debug.apk +``` + +The WebView loads `file:///android_asset/index.html` and connects to the server via WebSocket at `ws://:8921/ws`. + +### Design Decisions + +See `.pi/docs/design/` for detailed design decision records covering architecture, audio mixer, notifications, and the .exe build. + +## License + +MIT — see [LICENSE](LICENSE) for details. diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..cb2bb69 --- /dev/null +++ b/android/README.md @@ -0,0 +1,103 @@ +# PaperDash Android Client + +WebView-based Android app for the **BOOX Go 7 Color (Gen II)** running Android 13. + +## Project Structure + +``` +android/ +├── app/ +│ ├── src/main/ +│ │ ├── assets/ # Bundled HTML/CSS/JS (served from file://) +│ │ │ ├── index.html +│ │ │ ├── style.css +│ │ │ └── app.js +│ │ ├── java/.../paperdash/ +│ │ │ └── MainActivity.kt # WebView container +│ │ ├── res/ +│ │ │ ├── layout/activity_main.xml +│ │ │ └── values/themes.xml +│ │ └── AndroidManifest.xml +│ ├── build.gradle.kts +│ └── proguard-rules.pro +├── build.gradle.kts +├── settings.gradle.kts +└── gradle.properties +``` + +## Build Requirements + +- **Android Studio** (Hedgehog or newer) with Android SDK +- **Android 13 SDK** (API 33) or newer +- **JDK 17** + +## Building + +### Option 1: Android Studio + +1. Open `android/` in Android Studio +2. Sync Gradle +3. Run on connected BOOX device or emulator + +### Option 2: Command Line + +```bash +cd android +./gradlew assembleDebug +# APK at: app/build/outputs/apk/debug/app-debug.apk +``` + +### Release Build + +```bash +./gradlew assembleRelease +# APK at: app/build/outputs/apk/release/app-release-unsigned.apk +``` + +## Installation on BOOX Go 7 Color + +1. Enable **Developer Options** on the BOOX (tap Build Number 7 times in Settings) +2. Enable **USB Debugging** in Developer Options +3. Connect via USB and run: + + ```bash + adb install app/build/outputs/apk/debug/app-debug.apk + ``` + +4. Or transfer the APK via file manager and install directly + +## Architecture + +The app is a thin WebView container: + +1. **MainActivity** loads `file:///android_asset/index.html` +2. **WebView** connects to the PaperDash server via WebSocket (`ws://:8921/ws`) +3. The server pushes real-time state (media, audio, telemetry, notifications) +4. System bars are hidden for full-screen E-Ink display + +## Key Configuration + +- **minSdk: 33** (Android 13) — matches BOOX Go 7 Color Gen II +- **targetSdk: 34** (Android 14) +- **compileSdk: 34** +- **Orientation: landscape** (optimized for the 7" display) +- **Full-screen immersive mode** — hides status and navigation bars +- **JavaScript enabled** — required for WebSocket connection +- **No smooth scrolling** — prevents E-Ink refresh artifacts + +## Updating the Client + +The HTML/CSS/JS files live in `android/app/src/main/assets/`. To update: + +1. Edit files in `client/` (source of truth) +2. Copy to `android/app/src/main/assets/`: + + ```bash + cp client/index.html android/app/src/main/assets/ + cp client/style.css android/app/src/main/assets/ + cp client/app.js android/app/src/main/assets/ + ``` + +3. Rebuild the APK + +Alternatively, the WebView can be configured to load from the server URL instead of local assets — see `MainActivity.kt` line where `loadUrl` is called. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..4b49dab --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "me.kareemhorstink.paperdash" + compileSdk = 34 + + defaultConfig { + applicationId = "me.kareemhorstink.paperdash" + minSdk = 33 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..42c3fdf --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# PaperDash ProGuard Rules +# Add project specific ProGuard rules here. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d67c856 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/app.js b/android/app/src/main/assets/app.js new file mode 100644 index 0000000..4948748 --- /dev/null +++ b/android/app/src/main/assets/app.js @@ -0,0 +1,368 @@ +/** + * PaperDash — E-Ink Dashboard Client + * + * Connects to the PaperDash server via WebSocket. + * Uses vanilla JS with direct DOM manipulation (element.textContent) + * to minimize E-Ink refresh flashing. + */ + +(() => { + const SERVER_WS = `ws://${window.location.host}/ws`; + const PIN_INPUT = document.getElementById("pin-input"); + const CONNECT_BTN = document.getElementById("connect-btn"); + const LOGIN_ERROR = document.getElementById("login-error"); + const LOGIN_SCREEN = document.getElementById("login-screen"); + const DASHBOARD_SCREEN = document.getElementById("dashboard-screen"); + + let ws = null; + let reconnectTimer = null; + + // ----------------------------------------------------------------------- + // PIN Login + // ----------------------------------------------------------------------- + + function connect() { + const pin = PIN_INPUT.value.trim(); + if (!pin) { + LOGIN_ERROR.textContent = "Enter a PIN"; + return; + } + + LOGIN_ERROR.textContent = ""; + CONNECT_BTN.disabled = true; + CONNECT_BTN.textContent = "Connecting..."; + + ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`); + + ws.onopen = () => { + LOGIN_SCREEN.classList.remove("active"); + DASHBOARD_SCREEN.classList.add("active"); + CONNECT_BTN.disabled = false; + CONNECT_BTN.textContent = "Connect"; + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleStateUpdate(data); + } catch (err) { + console.error("Failed to parse WebSocket message:", err); + } + }; + + ws.onclose = () => { + DASHBOARD_SCREEN.classList.remove("active"); + LOGIN_SCREEN.classList.add("active"); + LOGIN_ERROR.textContent = "Disconnected. Re-enter PIN."; + CONNECT_BTN.disabled = false; + CONNECT_BTN.textContent = "Connect"; + + // Auto-reconnect after 5 seconds + clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + PIN_INPUT.value = pin; + connect(); + }, 5000); + }; + + ws.onerror = () => { + ws.close(); + }; + } + + CONNECT_BTN.addEventListener("click", connect); + PIN_INPUT.addEventListener("keydown", (e) => { + if (e.key === "Enter") connect(); + }); + + // ----------------------------------------------------------------------- + // State Updates + // ----------------------------------------------------------------------- + + function handleStateUpdate(data) { + if (data.media) updateMedia(data.media); + if (data.audio) updateAudio(data.audio); + if (data.telemetry) updateTelemetry(data.telemetry); + if (data.notifications) updateNotifications(data.notifications); + } + + // ----------------------------------------------------------------------- + // Media + // ----------------------------------------------------------------------- + + function updateMedia(media) { + setText("track-title", media.title || "No playback"); + setText("track-artist", media.artist || ""); + setText("track-album", media.album || ""); + + // Artwork — use DOM API to avoid innerHTML + const artworkContainer = document.getElementById("artwork-container"); + artworkContainer.replaceChildren(); + if (media.artwork_base64) { + const img = document.createElement("img"); + img.src = "data:image/jpeg;base64," + media.artwork_base64; + img.alt = "Album art"; + artworkContainer.appendChild(img); + } + + // Progress + const pct = media.playback_percentage || 0; + setStyle("progress-fill", "width", pct + "%"); + setText("progress-current", formatTime(media.playback_position || 0)); + setText("progress-duration", formatTime(media.playback_duration || 0)); + + // Play/pause button + const btn = document.getElementById("btn-playpause"); + btn.textContent = media.is_playing ? "\u23F8" : "\u25B6"; + } + + // ----------------------------------------------------------------------- + // Audio Mixer + // ----------------------------------------------------------------------- + + function updateAudio(audio) { + const vol = Math.round((audio.master_volume || 0) * 100); + setText("master-volume-value", vol + "%"); + + const muteBtn = document.getElementById("btn-mute"); + if (audio.master_muted) { + muteBtn.classList.add("muted"); + muteBtn.textContent = "MUTED"; + } else { + muteBtn.classList.remove("muted"); + muteBtn.textContent = "MUTE"; + } + + // Channels — use DOM API + const container = document.getElementById("channels-container"); + container.replaceChildren(); + if (!audio.channels || audio.channels.length === 0) { + return; + } + + const fragment = document.createDocumentFragment(); + for (const ch of audio.channels) { + const volPct = Math.round((ch.volume || 0) * 100); + const div = document.createElement("div"); + div.className = "channel"; + if (ch.muted) { + div.style.opacity = "0.4"; + } + + const nameSpan = document.createElement("span"); + nameSpan.className = "channel-name"; + nameSpan.textContent = ch.name; + + const meterDiv = document.createElement("div"); + meterDiv.className = "channel-meter"; + meterDiv.textContent = renderBlockMeter(ch.volume || 0); + + const volSpan = document.createElement("span"); + volSpan.className = "channel-volume"; + volSpan.textContent = volPct + "%"; + + div.appendChild(nameSpan); + div.appendChild(meterDiv); + div.appendChild(volSpan); + fragment.appendChild(div); + } + container.appendChild(fragment); + } + + function renderBlockMeter(volume) { + const blocks = 10; + const filled = Math.round(volume * blocks); + let result = ""; + for (let i = 0; i < blocks; i++) { + result += i < filled ? "\u2588" : "\u2591"; + } + return result; + } + + // ----------------------------------------------------------------------- + // Telemetry + // ----------------------------------------------------------------------- + + function updateTelemetry(tel) { + setText("cpu-temp", tel.cpu_temp ? tel.cpu_temp + "C" : "--"); + setText("gpu-temp", tel.gpu_temp ? tel.gpu_temp + "C" : "--"); + setText( + "ram-usage", + tel.ram_usage && tel.ram_total + ? Math.round((tel.ram_usage / tel.ram_total) * 100) + "%" + : "--", + ); + setText( + "vram-usage", + tel.vram_usage && tel.vram_total + ? Math.round((tel.vram_usage / tel.vram_total) * 100) + "%" + : "--", + ); + } + + // ----------------------------------------------------------------------- + // Notifications + // ----------------------------------------------------------------------- + + function updateNotifications(notifications) { + const container = document.getElementById("notifications-container"); + container.replaceChildren(); + + if (!notifications || notifications.length === 0) { + const p = document.createElement("p"); + p.style.color = "#888"; + p.textContent = "No notifications"; + container.appendChild(p); + return; + } + + const fragment = document.createDocumentFragment(); + for (const n of notifications) { + if (n.type === "progress") { + const pct = n.progress ? n.progress.percentage : 0; + const blocks = renderBlockMeter(pct / 100); + + const div = document.createElement("div"); + div.className = "notification"; + + const titleDiv = document.createElement("div"); + titleDiv.className = "notification-title"; + titleDiv.textContent = n.title; + + const msgDiv = document.createElement("div"); + msgDiv.className = "notification-message"; + msgDiv.textContent = n.message; + + const progDiv = document.createElement("div"); + progDiv.className = "notification-progress"; + progDiv.textContent = blocks + " " + Math.round(pct) + "%"; + + div.appendChild(titleDiv); + div.appendChild(msgDiv); + div.appendChild(progDiv); + + if (n.eta) { + const etaDiv = document.createElement("div"); + etaDiv.className = "notification-eta"; + etaDiv.textContent = "ETA: " + n.eta; + div.appendChild(etaDiv); + } + + fragment.appendChild(div); + } else { + const cls = n.priority || "info"; + const div = document.createElement("div"); + div.className = "notification alert " + cls; + + const titleDiv = document.createElement("div"); + titleDiv.className = "notification-title"; + titleDiv.textContent = n.title; + + const msgDiv = document.createElement("div"); + msgDiv.className = "notification-message"; + msgDiv.textContent = n.message; + + div.appendChild(titleDiv); + div.appendChild(msgDiv); + fragment.appendChild(div); + } + } + container.appendChild(fragment); + } + + // ----------------------------------------------------------------------- + // Controls + // ----------------------------------------------------------------------- + + function sendCommand(action, extra) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(Object.assign({ action: action }, extra || {}))); + } + } + + document.getElementById("btn-vol-up").addEventListener("click", () => { + sendCommand("volume", { bus: "master", value: 0.05 }); + }); + + document.getElementById("btn-vol-down").addEventListener("click", () => { + sendCommand("volume", { bus: "master", value: -0.05 }); + }); + + document.getElementById("btn-mute").addEventListener("click", function () { + const isMuted = this.classList.contains("muted"); + sendCommand("mute", { bus: "master", muted: !isMuted }); + }); + + document.getElementById("btn-playpause").addEventListener("click", () => { + sendCommand( + ws && ws.readyState === WebSocket.OPEN + ? document.getElementById("btn-playpause").textContent === "\u23F8" + ? "pause" + : "play" + : "play", + ); + }); + + document.getElementById("btn-prev").addEventListener("click", () => { + sendCommand("prev"); + }); + + document.getElementById("btn-next").addEventListener("click", () => { + sendCommand("skip"); + }); + + // ----------------------------------------------------------------------- + // Swipe gesture zones (E-Ink optimized) + // ----------------------------------------------------------------------- + + let touchStartY = 0; + const SWIPE_THRESHOLD = 70; + + document.addEventListener( + "touchstart", + (e) => { + touchStartY = e.touches[0].clientY; + }, + { passive: true }, + ); + + document.addEventListener( + "touchend", + (e) => { + const deltaY = e.changedTouches[0].clientY - touchStartY; + if (Math.abs(deltaY) > SWIPE_THRESHOLD) { + if (deltaY < 0) { + sendCommand("volume", { bus: "master", value: 0.1 }); + } else { + sendCommand("volume", { bus: "master", value: -0.1 }); + } + } + }, + { passive: true }, + ); + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + function setText(id, text) { + const el = document.getElementById(id); + if (el && el.textContent !== text) { + el.textContent = text; + } + } + + function setStyle(id, prop, value) { + const el = document.getElementById(id); + if (el) { + el.style[prop] = value; + } + } + + function formatTime(seconds) { + if (!seconds || isNaN(seconds)) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return m + ":" + (s < 10 ? "0" : "") + s; + } +})(); diff --git a/android/app/src/main/assets/index.html b/android/app/src/main/assets/index.html new file mode 100644 index 0000000..2305739 --- /dev/null +++ b/android/app/src/main/assets/index.html @@ -0,0 +1,106 @@ + + + + + + + + PaperDash + + + +
+ +
+

PaperDash

+

Enter PIN to connect

+ + +

+
+ + +
+ +
+

Now Playing

+
+
+
No playback
+
+
+
+
+
+
+
+
+ 0:00 + 0:00 +
+
+
+ + + +
+
+ + +
+

Audio Mixer

+
+ Master +
+ + 50% + +
+ +
+
+
+ + +
+

System

+
+
+ CPU + -- +
+
+ GPU + -- +
+
+ RAM + -- +
+
+ VRAM + -- +
+
+
+ + +
+

Notifications

+
+
+
+
+ + + + diff --git a/android/app/src/main/assets/style.css b/android/app/src/main/assets/style.css new file mode 100644 index 0000000..87226aa --- /dev/null +++ b/android/app/src/main/assets/style.css @@ -0,0 +1,349 @@ +/* PaperDash — E-Ink optimized styles */ +/* High contrast, no smooth transitions, block-based UI */ + +:root { + --bg: #000000; + --fg: #ffffff; + --border: #ffffff; + --dim: #888888; + --panel-bg: #111111; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: "Courier New", monospace; + font-size: 16px; + overflow-x: hidden; + -webkit-user-select: none; + user-select: none; +} + +#app { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +/* Screens */ +.screen { + display: none; + flex-direction: column; + height: 100%; + padding: 16px; +} + +.screen.active { + display: flex; +} + +/* Login */ +#login-screen { + justify-content: center; + align-items: center; + text-align: center; + gap: 16px; +} + +#login-screen h1 { + font-size: 2em; + border-bottom: 2px solid var(--border); + padding-bottom: 8px; +} + +#pin-input { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 12px 24px; + font-size: 1.5em; + text-align: center; + letter-spacing: 8px; + width: 200px; + font-family: inherit; +} + +#connect-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 10px 32px; + font-size: 1.1em; + cursor: pointer; + font-family: inherit; +} + +.error { + color: var(--dim); + font-size: 0.9em; +} + +/* Panels */ +.panel { + border: 2px solid var(--border); + padding: 12px; + margin-bottom: 12px; + flex-shrink: 0; +} + +.panel h2 { + font-size: 1em; + border-bottom: 1px solid var(--dim); + padding-bottom: 4px; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 2px; +} + +/* Media */ +#artwork-container { + width: 100%; + max-height: 120px; + overflow: hidden; + margin-bottom: 8px; + border: 1px solid var(--dim); +} + +#artwork-container img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.track-title { + font-size: 1.2em; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.track-artist, +.track-album { + color: var(--dim); + font-size: 0.9em; +} + +/* Progress bar — block stepped for E-Ink */ +#progress-container { + margin: 8px 0; +} + +#progress-bar { + width: 100%; + height: 20px; + border: 2px solid var(--border); + background: var(--bg); + overflow: hidden; +} + +#progress-fill { + height: 100%; + background: var(--fg); + width: 0%; +} + +#progress-text { + display: flex; + justify-content: space-between; + font-size: 0.8em; + color: var(--dim); + margin-top: 2px; +} + +/* Media controls */ +#media-controls { + display: flex; + gap: 8px; + justify-content: center; + margin-top: 8px; +} + +.control-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + width: 50px; + height: 50px; + font-size: 1.3em; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-family: inherit; +} + +/* Audio mixer */ +#master-volume { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +#master-label { + font-weight: bold; + min-width: 60px; +} + +.volume-controls { + display: flex; + align-items: center; + gap: 4px; + flex: 1; +} + +.volume-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + width: 40px; + height: 40px; + font-size: 1.2em; + cursor: pointer; + font-family: inherit; +} + +#master-volume-value { + min-width: 45px; + text-align: center; +} + +.mute-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 6px 12px; + font-size: 0.8em; + cursor: pointer; + font-family: inherit; + text-transform: uppercase; +} + +.mute-btn.muted { + background: var(--fg); + color: var(--bg); +} + +/* Channels */ +.channel { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + border-top: 1px solid var(--dim); +} + +.channel-name { + min-width: 30px; + font-weight: bold; +} + +.channel-meter { + flex: 1; + height: 16px; + border: 1px solid var(--dim); + overflow: hidden; + font-family: monospace; + font-size: 14px; + line-height: 16px; +} + +.channel-meter-fill { + height: 100%; + background: var(--fg); +} + +.channel-volume { + min-width: 35px; + text-align: right; + font-size: 0.85em; +} + +/* Telemetry */ +#telemetry-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.telemetry-item { + display: flex; + justify-content: space-between; + padding: 4px 8px; + border: 1px solid var(--dim); +} + +.telemetry-label { + color: var(--dim); + text-transform: uppercase; + font-size: 0.85em; +} + +.telemetry-value { + font-weight: bold; +} + +/* Notifications */ +.notification { + border: 2px solid var(--border); + padding: 8px; + margin-bottom: 6px; +} + +.notification.alert { + border-color: var(--dim); +} + +.notification.alert.warning { + border-style: double; +} + +.notification.alert.error { + border-width: 3px; +} + +.notification-title { + font-weight: bold; + margin-bottom: 4px; +} + +.notification-message { + font-size: 0.9em; + color: var(--dim); +} + +.notification-progress { + margin-top: 6px; + font-family: monospace; + font-size: 14px; +} + +.notification-eta { + font-size: 0.8em; + color: var(--dim); + margin-top: 2px; +} + +/* Scrollable dashboard */ +#dashboard-screen { + overflow-y: auto; + overflow-x: hidden; +} + +/* E-Ink specific: no transitions, sharp edges */ +* { + transition: none !important; + -webkit-transition: none !important; +} diff --git a/android/app/src/main/java/me/kareemhorstink/paperdash/MainActivity.kt b/android/app/src/main/java/me/kareemhorstink/paperdash/MainActivity.kt new file mode 100644 index 0000000..4ef8b79 --- /dev/null +++ b/android/app/src/main/java/me/kareemhorstink/paperdash/MainActivity.kt @@ -0,0 +1,70 @@ +package me.kareemhorstink.paperdash + +import android.annotation.SuppressLint +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.WindowInsets +import android.view.WindowInsetsController +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AppCompatActivity +import me.kareemhorstink.paperdash.databinding.ActivityMainBinding + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + val webView = binding.webview + val settings = webView.settings + + // E-Ink optimized: no smooth scrolling, no scaling + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + settings.cacheMode = WebSettings.LOAD_DEFAULT + + // Disable gestures that cause unwanted E-Ink refreshes + webView.isHorizontalScrollBarEnabled = false + webView.isVerticalScrollBarEnabled = false + webView.overScrollMode = WebView.OVER_SCROLL_NEVER + + // Hide system bars for full-screen E-Ink display + hideSystemBars() + + // Load from assets (bundled HTML/CSS/JS) + webView.webViewClient = WebViewClient() + webView.loadUrl("file:///android_asset/index.html") + } + + @SuppressLint("NewApi") + private fun hideSystemBars() { + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_FULLSCREEN + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + window.insetsController?.systemBarsBehavior = + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) hideSystemBars() + } +} diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..6516926 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..e43c05c --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..0836ea8 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.2.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c0235ff --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "PaperDash" +include(":app") diff --git a/client/app.js b/client/app.js new file mode 100644 index 0000000..4948748 --- /dev/null +++ b/client/app.js @@ -0,0 +1,368 @@ +/** + * PaperDash — E-Ink Dashboard Client + * + * Connects to the PaperDash server via WebSocket. + * Uses vanilla JS with direct DOM manipulation (element.textContent) + * to minimize E-Ink refresh flashing. + */ + +(() => { + const SERVER_WS = `ws://${window.location.host}/ws`; + const PIN_INPUT = document.getElementById("pin-input"); + const CONNECT_BTN = document.getElementById("connect-btn"); + const LOGIN_ERROR = document.getElementById("login-error"); + const LOGIN_SCREEN = document.getElementById("login-screen"); + const DASHBOARD_SCREEN = document.getElementById("dashboard-screen"); + + let ws = null; + let reconnectTimer = null; + + // ----------------------------------------------------------------------- + // PIN Login + // ----------------------------------------------------------------------- + + function connect() { + const pin = PIN_INPUT.value.trim(); + if (!pin) { + LOGIN_ERROR.textContent = "Enter a PIN"; + return; + } + + LOGIN_ERROR.textContent = ""; + CONNECT_BTN.disabled = true; + CONNECT_BTN.textContent = "Connecting..."; + + ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`); + + ws.onopen = () => { + LOGIN_SCREEN.classList.remove("active"); + DASHBOARD_SCREEN.classList.add("active"); + CONNECT_BTN.disabled = false; + CONNECT_BTN.textContent = "Connect"; + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleStateUpdate(data); + } catch (err) { + console.error("Failed to parse WebSocket message:", err); + } + }; + + ws.onclose = () => { + DASHBOARD_SCREEN.classList.remove("active"); + LOGIN_SCREEN.classList.add("active"); + LOGIN_ERROR.textContent = "Disconnected. Re-enter PIN."; + CONNECT_BTN.disabled = false; + CONNECT_BTN.textContent = "Connect"; + + // Auto-reconnect after 5 seconds + clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + PIN_INPUT.value = pin; + connect(); + }, 5000); + }; + + ws.onerror = () => { + ws.close(); + }; + } + + CONNECT_BTN.addEventListener("click", connect); + PIN_INPUT.addEventListener("keydown", (e) => { + if (e.key === "Enter") connect(); + }); + + // ----------------------------------------------------------------------- + // State Updates + // ----------------------------------------------------------------------- + + function handleStateUpdate(data) { + if (data.media) updateMedia(data.media); + if (data.audio) updateAudio(data.audio); + if (data.telemetry) updateTelemetry(data.telemetry); + if (data.notifications) updateNotifications(data.notifications); + } + + // ----------------------------------------------------------------------- + // Media + // ----------------------------------------------------------------------- + + function updateMedia(media) { + setText("track-title", media.title || "No playback"); + setText("track-artist", media.artist || ""); + setText("track-album", media.album || ""); + + // Artwork — use DOM API to avoid innerHTML + const artworkContainer = document.getElementById("artwork-container"); + artworkContainer.replaceChildren(); + if (media.artwork_base64) { + const img = document.createElement("img"); + img.src = "data:image/jpeg;base64," + media.artwork_base64; + img.alt = "Album art"; + artworkContainer.appendChild(img); + } + + // Progress + const pct = media.playback_percentage || 0; + setStyle("progress-fill", "width", pct + "%"); + setText("progress-current", formatTime(media.playback_position || 0)); + setText("progress-duration", formatTime(media.playback_duration || 0)); + + // Play/pause button + const btn = document.getElementById("btn-playpause"); + btn.textContent = media.is_playing ? "\u23F8" : "\u25B6"; + } + + // ----------------------------------------------------------------------- + // Audio Mixer + // ----------------------------------------------------------------------- + + function updateAudio(audio) { + const vol = Math.round((audio.master_volume || 0) * 100); + setText("master-volume-value", vol + "%"); + + const muteBtn = document.getElementById("btn-mute"); + if (audio.master_muted) { + muteBtn.classList.add("muted"); + muteBtn.textContent = "MUTED"; + } else { + muteBtn.classList.remove("muted"); + muteBtn.textContent = "MUTE"; + } + + // Channels — use DOM API + const container = document.getElementById("channels-container"); + container.replaceChildren(); + if (!audio.channels || audio.channels.length === 0) { + return; + } + + const fragment = document.createDocumentFragment(); + for (const ch of audio.channels) { + const volPct = Math.round((ch.volume || 0) * 100); + const div = document.createElement("div"); + div.className = "channel"; + if (ch.muted) { + div.style.opacity = "0.4"; + } + + const nameSpan = document.createElement("span"); + nameSpan.className = "channel-name"; + nameSpan.textContent = ch.name; + + const meterDiv = document.createElement("div"); + meterDiv.className = "channel-meter"; + meterDiv.textContent = renderBlockMeter(ch.volume || 0); + + const volSpan = document.createElement("span"); + volSpan.className = "channel-volume"; + volSpan.textContent = volPct + "%"; + + div.appendChild(nameSpan); + div.appendChild(meterDiv); + div.appendChild(volSpan); + fragment.appendChild(div); + } + container.appendChild(fragment); + } + + function renderBlockMeter(volume) { + const blocks = 10; + const filled = Math.round(volume * blocks); + let result = ""; + for (let i = 0; i < blocks; i++) { + result += i < filled ? "\u2588" : "\u2591"; + } + return result; + } + + // ----------------------------------------------------------------------- + // Telemetry + // ----------------------------------------------------------------------- + + function updateTelemetry(tel) { + setText("cpu-temp", tel.cpu_temp ? tel.cpu_temp + "C" : "--"); + setText("gpu-temp", tel.gpu_temp ? tel.gpu_temp + "C" : "--"); + setText( + "ram-usage", + tel.ram_usage && tel.ram_total + ? Math.round((tel.ram_usage / tel.ram_total) * 100) + "%" + : "--", + ); + setText( + "vram-usage", + tel.vram_usage && tel.vram_total + ? Math.round((tel.vram_usage / tel.vram_total) * 100) + "%" + : "--", + ); + } + + // ----------------------------------------------------------------------- + // Notifications + // ----------------------------------------------------------------------- + + function updateNotifications(notifications) { + const container = document.getElementById("notifications-container"); + container.replaceChildren(); + + if (!notifications || notifications.length === 0) { + const p = document.createElement("p"); + p.style.color = "#888"; + p.textContent = "No notifications"; + container.appendChild(p); + return; + } + + const fragment = document.createDocumentFragment(); + for (const n of notifications) { + if (n.type === "progress") { + const pct = n.progress ? n.progress.percentage : 0; + const blocks = renderBlockMeter(pct / 100); + + const div = document.createElement("div"); + div.className = "notification"; + + const titleDiv = document.createElement("div"); + titleDiv.className = "notification-title"; + titleDiv.textContent = n.title; + + const msgDiv = document.createElement("div"); + msgDiv.className = "notification-message"; + msgDiv.textContent = n.message; + + const progDiv = document.createElement("div"); + progDiv.className = "notification-progress"; + progDiv.textContent = blocks + " " + Math.round(pct) + "%"; + + div.appendChild(titleDiv); + div.appendChild(msgDiv); + div.appendChild(progDiv); + + if (n.eta) { + const etaDiv = document.createElement("div"); + etaDiv.className = "notification-eta"; + etaDiv.textContent = "ETA: " + n.eta; + div.appendChild(etaDiv); + } + + fragment.appendChild(div); + } else { + const cls = n.priority || "info"; + const div = document.createElement("div"); + div.className = "notification alert " + cls; + + const titleDiv = document.createElement("div"); + titleDiv.className = "notification-title"; + titleDiv.textContent = n.title; + + const msgDiv = document.createElement("div"); + msgDiv.className = "notification-message"; + msgDiv.textContent = n.message; + + div.appendChild(titleDiv); + div.appendChild(msgDiv); + fragment.appendChild(div); + } + } + container.appendChild(fragment); + } + + // ----------------------------------------------------------------------- + // Controls + // ----------------------------------------------------------------------- + + function sendCommand(action, extra) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(Object.assign({ action: action }, extra || {}))); + } + } + + document.getElementById("btn-vol-up").addEventListener("click", () => { + sendCommand("volume", { bus: "master", value: 0.05 }); + }); + + document.getElementById("btn-vol-down").addEventListener("click", () => { + sendCommand("volume", { bus: "master", value: -0.05 }); + }); + + document.getElementById("btn-mute").addEventListener("click", function () { + const isMuted = this.classList.contains("muted"); + sendCommand("mute", { bus: "master", muted: !isMuted }); + }); + + document.getElementById("btn-playpause").addEventListener("click", () => { + sendCommand( + ws && ws.readyState === WebSocket.OPEN + ? document.getElementById("btn-playpause").textContent === "\u23F8" + ? "pause" + : "play" + : "play", + ); + }); + + document.getElementById("btn-prev").addEventListener("click", () => { + sendCommand("prev"); + }); + + document.getElementById("btn-next").addEventListener("click", () => { + sendCommand("skip"); + }); + + // ----------------------------------------------------------------------- + // Swipe gesture zones (E-Ink optimized) + // ----------------------------------------------------------------------- + + let touchStartY = 0; + const SWIPE_THRESHOLD = 70; + + document.addEventListener( + "touchstart", + (e) => { + touchStartY = e.touches[0].clientY; + }, + { passive: true }, + ); + + document.addEventListener( + "touchend", + (e) => { + const deltaY = e.changedTouches[0].clientY - touchStartY; + if (Math.abs(deltaY) > SWIPE_THRESHOLD) { + if (deltaY < 0) { + sendCommand("volume", { bus: "master", value: 0.1 }); + } else { + sendCommand("volume", { bus: "master", value: -0.1 }); + } + } + }, + { passive: true }, + ); + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + function setText(id, text) { + const el = document.getElementById(id); + if (el && el.textContent !== text) { + el.textContent = text; + } + } + + function setStyle(id, prop, value) { + const el = document.getElementById(id); + if (el) { + el.style[prop] = value; + } + } + + function formatTime(seconds) { + if (!seconds || isNaN(seconds)) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return m + ":" + (s < 10 ? "0" : "") + s; + } +})(); diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..2305739 --- /dev/null +++ b/client/index.html @@ -0,0 +1,106 @@ + + + + + + + + PaperDash + + + +
+ +
+

PaperDash

+

Enter PIN to connect

+ + +

+
+ + +
+ +
+

Now Playing

+
+
+
No playback
+
+
+
+
+
+
+
+
+ 0:00 + 0:00 +
+
+
+ + + +
+
+ + +
+

Audio Mixer

+
+ Master +
+ + 50% + +
+ +
+
+
+ + +
+

System

+
+
+ CPU + -- +
+
+ GPU + -- +
+
+ RAM + -- +
+
+ VRAM + -- +
+
+
+ + +
+

Notifications

+
+
+
+
+ + + + diff --git a/client/style.css b/client/style.css new file mode 100644 index 0000000..87226aa --- /dev/null +++ b/client/style.css @@ -0,0 +1,349 @@ +/* PaperDash — E-Ink optimized styles */ +/* High contrast, no smooth transitions, block-based UI */ + +:root { + --bg: #000000; + --fg: #ffffff; + --border: #ffffff; + --dim: #888888; + --panel-bg: #111111; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: "Courier New", monospace; + font-size: 16px; + overflow-x: hidden; + -webkit-user-select: none; + user-select: none; +} + +#app { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +/* Screens */ +.screen { + display: none; + flex-direction: column; + height: 100%; + padding: 16px; +} + +.screen.active { + display: flex; +} + +/* Login */ +#login-screen { + justify-content: center; + align-items: center; + text-align: center; + gap: 16px; +} + +#login-screen h1 { + font-size: 2em; + border-bottom: 2px solid var(--border); + padding-bottom: 8px; +} + +#pin-input { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 12px 24px; + font-size: 1.5em; + text-align: center; + letter-spacing: 8px; + width: 200px; + font-family: inherit; +} + +#connect-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 10px 32px; + font-size: 1.1em; + cursor: pointer; + font-family: inherit; +} + +.error { + color: var(--dim); + font-size: 0.9em; +} + +/* Panels */ +.panel { + border: 2px solid var(--border); + padding: 12px; + margin-bottom: 12px; + flex-shrink: 0; +} + +.panel h2 { + font-size: 1em; + border-bottom: 1px solid var(--dim); + padding-bottom: 4px; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 2px; +} + +/* Media */ +#artwork-container { + width: 100%; + max-height: 120px; + overflow: hidden; + margin-bottom: 8px; + border: 1px solid var(--dim); +} + +#artwork-container img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.track-title { + font-size: 1.2em; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.track-artist, +.track-album { + color: var(--dim); + font-size: 0.9em; +} + +/* Progress bar — block stepped for E-Ink */ +#progress-container { + margin: 8px 0; +} + +#progress-bar { + width: 100%; + height: 20px; + border: 2px solid var(--border); + background: var(--bg); + overflow: hidden; +} + +#progress-fill { + height: 100%; + background: var(--fg); + width: 0%; +} + +#progress-text { + display: flex; + justify-content: space-between; + font-size: 0.8em; + color: var(--dim); + margin-top: 2px; +} + +/* Media controls */ +#media-controls { + display: flex; + gap: 8px; + justify-content: center; + margin-top: 8px; +} + +.control-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + width: 50px; + height: 50px; + font-size: 1.3em; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-family: inherit; +} + +/* Audio mixer */ +#master-volume { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +#master-label { + font-weight: bold; + min-width: 60px; +} + +.volume-controls { + display: flex; + align-items: center; + gap: 4px; + flex: 1; +} + +.volume-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + width: 40px; + height: 40px; + font-size: 1.2em; + cursor: pointer; + font-family: inherit; +} + +#master-volume-value { + min-width: 45px; + text-align: center; +} + +.mute-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 6px 12px; + font-size: 0.8em; + cursor: pointer; + font-family: inherit; + text-transform: uppercase; +} + +.mute-btn.muted { + background: var(--fg); + color: var(--bg); +} + +/* Channels */ +.channel { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + border-top: 1px solid var(--dim); +} + +.channel-name { + min-width: 30px; + font-weight: bold; +} + +.channel-meter { + flex: 1; + height: 16px; + border: 1px solid var(--dim); + overflow: hidden; + font-family: monospace; + font-size: 14px; + line-height: 16px; +} + +.channel-meter-fill { + height: 100%; + background: var(--fg); +} + +.channel-volume { + min-width: 35px; + text-align: right; + font-size: 0.85em; +} + +/* Telemetry */ +#telemetry-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.telemetry-item { + display: flex; + justify-content: space-between; + padding: 4px 8px; + border: 1px solid var(--dim); +} + +.telemetry-label { + color: var(--dim); + text-transform: uppercase; + font-size: 0.85em; +} + +.telemetry-value { + font-weight: bold; +} + +/* Notifications */ +.notification { + border: 2px solid var(--border); + padding: 8px; + margin-bottom: 6px; +} + +.notification.alert { + border-color: var(--dim); +} + +.notification.alert.warning { + border-style: double; +} + +.notification.alert.error { + border-width: 3px; +} + +.notification-title { + font-weight: bold; + margin-bottom: 4px; +} + +.notification-message { + font-size: 0.9em; + color: var(--dim); +} + +.notification-progress { + margin-top: 6px; + font-family: monospace; + font-size: 14px; +} + +.notification-eta { + font-size: 0.8em; + color: var(--dim); + margin-top: 2px; +} + +/* Scrollable dashboard */ +#dashboard-screen { + overflow-y: auto; + overflow-x: hidden; +} + +/* E-Ink specific: no transitions, sharp edges */ +* { + transition: none !important; + -webkit-transition: none !important; +} diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/audio_mixer.py b/server/audio_mixer.py new file mode 100644 index 0000000..21fc6a4 --- /dev/null +++ b/server/audio_mixer.py @@ -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 diff --git a/server/build_exe.py b/server/build_exe.py new file mode 100644 index 0000000..8c4685c --- /dev/null +++ b/server/build_exe.py @@ -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) diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..2c1fc25 --- /dev/null +++ b/server/main.py @@ -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("

PaperDash

Client files not found in client/

") diff --git a/server/media.py b/server/media.py new file mode 100644 index 0000000..5d30b0a --- /dev/null +++ b/server/media.py @@ -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}") diff --git a/server/notifications.py b/server/notifications.py new file mode 100644 index 0000000..205eae4 --- /dev/null +++ b/server/notifications.py @@ -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 diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..988f98f --- /dev/null +++ b/server/requirements.txt @@ -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 diff --git a/server/telemetry.py b/server/telemetry.py new file mode 100644 index 0000000..f87bdba --- /dev/null +++ b/server/telemetry.py @@ -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 + ], + } diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..9199b1c --- /dev/null +++ b/server/tests/conftest.py @@ -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__), "..")) diff --git a/server/tests/test_audio_mixer.py b/server/tests/test_audio_mixer.py new file mode 100644 index 0000000..46d5a74 --- /dev/null +++ b/server/tests/test_audio_mixer.py @@ -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 diff --git a/server/tests/test_main.py b/server/tests/test_main.py new file mode 100644 index 0000000..7be529e --- /dev/null +++ b/server/tests/test_main.py @@ -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"] diff --git a/server/tests/test_media.py b/server/tests/test_media.py new file mode 100644 index 0000000..a347f41 --- /dev/null +++ b/server/tests/test_media.py @@ -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"] diff --git a/server/tests/test_notifications.py b/server/tests/test_notifications.py new file mode 100644 index 0000000..0fea77e --- /dev/null +++ b/server/tests/test_notifications.py @@ -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 diff --git a/server/tests/test_telemetry.py b/server/tests/test_telemetry.py new file mode 100644 index 0000000..b81dec8 --- /dev/null +++ b/server/tests/test_telemetry.py @@ -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"] == [] diff --git a/server/tray_wrapper.py b/server/tray_wrapper.py new file mode 100644 index 0000000..18c5337 --- /dev/null +++ b/server/tray_wrapper.py @@ -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()