feat: initial project scaffold — server, client, Android APK

- FastAPI server with audio mixer (Windows + Voicemeeter), media tracking,
  notifications (progress + alerts), telemetry, and system tray .exe build
- Test suite covering notifications, audio mixer, media, telemetry, and API
- E-Ink web client (vanilla JS, DOM API, block meters, swipe gestures)
- Android WebView APK for BOOX Go 7 Color Gen II (Android 13, Kotlin)
- Design decision records in .pi/docs/design/
- PyInstaller build script for server .exe
This commit is contained in:
Imrayya
2026-07-01 11:19:32 +00:00
commit 0b4f739d12
36 changed files with 3916 additions and 0 deletions
+50
View File
@@ -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
+119
View File
@@ -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 |
+21
View File
@@ -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.
+153
View File
@@ -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 <SECRET_TOKEN>` 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://<pc-ip>: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.
+103
View File
@@ -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://<pc-ip>: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.
+46
View File
@@ -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")
}
+2
View File
@@ -0,0 +1,2 @@
# PaperDash ProGuard Rules
# Add project specific ProGuard rules here.
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="PaperDash"
android:supportsRtl="true"
android:theme="@style/Theme.PaperDash">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+368
View File
@@ -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;
}
})();
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<title>PaperDash</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<!-- PIN Login Screen -->
<div id="login-screen" class="screen active">
<h1>PaperDash</h1>
<p>Enter PIN to connect</p>
<input
type="password"
id="pin-input"
maxlength="4"
placeholder="****"
autocomplete="off"
/>
<button id="connect-btn">Connect</button>
<p id="login-error" class="error"></p>
</div>
<!-- Main Dashboard -->
<div id="dashboard-screen" class="screen">
<!-- Media Section -->
<section id="media-section" class="panel">
<h2>Now Playing</h2>
<div id="artwork-container"></div>
<div id="track-info">
<div id="track-title" class="track-title">No playback</div>
<div id="track-artist" class="track-artist"></div>
<div id="track-album" class="track-album"></div>
</div>
<div id="progress-container">
<div id="progress-bar">
<div id="progress-fill"></div>
</div>
<div id="progress-text">
<span id="progress-current">0:00</span>
<span id="progress-duration">0:00</span>
</div>
</div>
<div id="media-controls">
<button id="btn-prev" class="control-btn">&#9198;</button>
<button id="btn-playpause" class="control-btn">&#9208;</button>
<button id="btn-next" class="control-btn">&#9197;</button>
</div>
</section>
<!-- Audio Mixer Section -->
<section id="audio-section" class="panel">
<h2>Audio Mixer</h2>
<div id="master-volume">
<span id="master-label">Master</span>
<div class="volume-controls">
<button id="btn-vol-down" class="volume-btn">-</button>
<span id="master-volume-value">50%</span>
<button id="btn-vol-up" class="volume-btn">+</button>
</div>
<button id="btn-mute" class="mute-btn">MUTE</button>
</div>
<div id="channels-container"></div>
</section>
<!-- Telemetry Section -->
<section id="telemetry-section" class="panel">
<h2>System</h2>
<div id="telemetry-grid">
<div class="telemetry-item">
<span class="telemetry-label">CPU</span>
<span id="cpu-temp" class="telemetry-value">--</span>
</div>
<div class="telemetry-item">
<span class="telemetry-label">GPU</span>
<span id="gpu-temp" class="telemetry-value">--</span>
</div>
<div class="telemetry-item">
<span class="telemetry-label">RAM</span>
<span id="ram-usage" class="telemetry-value">--</span>
</div>
<div class="telemetry-item">
<span class="telemetry-label">VRAM</span>
<span id="vram-usage" class="telemetry-value">--</span>
</div>
</div>
</section>
<!-- Notifications Section -->
<section id="notifications-section" class="panel">
<h2>Notifications</h2>
<div id="notifications-container"></div>
</section>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+349
View File
@@ -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;
}
@@ -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()
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PaperDash" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:statusBarColor">#000000</item>
<item name="android:navigationBarColor">#000000</item>
</style>
</resources>
+4
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+18
View File
@@ -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")
+368
View File
@@ -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;
}
})();
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<title>PaperDash</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<!-- PIN Login Screen -->
<div id="login-screen" class="screen active">
<h1>PaperDash</h1>
<p>Enter PIN to connect</p>
<input
type="password"
id="pin-input"
maxlength="4"
placeholder="****"
autocomplete="off"
/>
<button id="connect-btn">Connect</button>
<p id="login-error" class="error"></p>
</div>
<!-- Main Dashboard -->
<div id="dashboard-screen" class="screen">
<!-- Media Section -->
<section id="media-section" class="panel">
<h2>Now Playing</h2>
<div id="artwork-container"></div>
<div id="track-info">
<div id="track-title" class="track-title">No playback</div>
<div id="track-artist" class="track-artist"></div>
<div id="track-album" class="track-album"></div>
</div>
<div id="progress-container">
<div id="progress-bar">
<div id="progress-fill"></div>
</div>
<div id="progress-text">
<span id="progress-current">0:00</span>
<span id="progress-duration">0:00</span>
</div>
</div>
<div id="media-controls">
<button id="btn-prev" class="control-btn">&#9198;</button>
<button id="btn-playpause" class="control-btn">&#9208;</button>
<button id="btn-next" class="control-btn">&#9197;</button>
</div>
</section>
<!-- Audio Mixer Section -->
<section id="audio-section" class="panel">
<h2>Audio Mixer</h2>
<div id="master-volume">
<span id="master-label">Master</span>
<div class="volume-controls">
<button id="btn-vol-down" class="volume-btn">-</button>
<span id="master-volume-value">50%</span>
<button id="btn-vol-up" class="volume-btn">+</button>
</div>
<button id="btn-mute" class="mute-btn">MUTE</button>
</div>
<div id="channels-container"></div>
</section>
<!-- Telemetry Section -->
<section id="telemetry-section" class="panel">
<h2>System</h2>
<div id="telemetry-grid">
<div class="telemetry-item">
<span class="telemetry-label">CPU</span>
<span id="cpu-temp" class="telemetry-value">--</span>
</div>
<div class="telemetry-item">
<span class="telemetry-label">GPU</span>
<span id="gpu-temp" class="telemetry-value">--</span>
</div>
<div class="telemetry-item">
<span class="telemetry-label">RAM</span>
<span id="ram-usage" class="telemetry-value">--</span>
</div>
<div class="telemetry-item">
<span class="telemetry-label">VRAM</span>
<span id="vram-usage" class="telemetry-value">--</span>
</div>
</div>
</section>
<!-- Notifications Section -->
<section id="notifications-section" class="panel">
<h2>Notifications</h2>
<div id="notifications-container"></div>
</section>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+349
View File
@@ -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;
}
View File
+323
View File
@@ -0,0 +1,323 @@
"""Audio mixer abstraction with pluggable backends.
Supports Windows Core Audio (default output) and Voicemeeter as backends.
Both expose the same interface so the client sees a unified audio view.
"""
from __future__ import annotations
import asyncio
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Literal
logger = logging.getLogger("paperdash.audio")
# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------
@dataclass
class AudioChannel:
"""A single audio channel/strip."""
name: str
volume: float = 0.0 # 0.0 - 1.0
muted: bool = False
peak_left: float = 0.0
peak_right: float = 0.0
@dataclass
class AudioMixerState:
"""Unified audio mixer state from any backend."""
backend: Literal["windows", "voicemeeter"]
master_volume: float = 0.0
master_muted: bool = False
channels: list[AudioChannel] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Backend Interface
# ---------------------------------------------------------------------------
class AudioMixerBackend(ABC):
"""Abstract interface for audio mixer backends."""
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
async def get_state(self) -> AudioMixerState: ...
@abstractmethod
async def set_volume(self, channel: str, value: float) -> None: ...
@abstractmethod
async def set_mute(self, channel: str, muted: bool) -> None: ...
# ---------------------------------------------------------------------------
# Windows Core Audio Backend
# ---------------------------------------------------------------------------
class WindowsAudioBackend(AudioMixerBackend):
"""Backend for the default Windows audio output device."""
@property
def name(self) -> str:
return "windows"
async def get_state(self) -> AudioMixerState:
"""Read master volume and mute from Windows Core Audio."""
try:
import pycaw # type: ignore
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume # type: ignore
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(IAudioEndpointVolume._iid_, 0, None)
volume = interface.QueryInterface(IAudioEndpointVolume)
master_volume = volume.MasterVolumeLevelScalar
master_muted = volume.Mute
return AudioMixerState(
backend="windows",
master_volume=master_volume,
master_muted=master_muted,
channels=[
AudioChannel(
name="Master",
volume=master_volume,
muted=master_muted,
)
],
)
except ImportError:
logger.warning("pycaw not installed — Windows audio unavailable")
return AudioMixerState(backend="windows")
except Exception as e:
logger.error(f"Windows audio error: {e}")
return AudioMixerState(backend="windows")
async def set_volume(self, channel: str, value: float) -> None:
try:
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(IAudioEndpointVolume._iid_, 0, None)
volume = interface.QueryInterface(IAudioEndpointVolume)
volume.MasterVolumeLevelScalar = max(0.0, min(1.0, value))
except Exception as e:
logger.error(f"Failed to set Windows volume: {e}")
async def set_mute(self, channel: str, muted: bool) -> None:
try:
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(IAudioEndpointVolume._iid_, 0, None)
volume = interface.QueryInterface(IAudioEndpointVolume)
volume.Mute = muted
except Exception as e:
logger.error(f"Failed to set Windows mute: {e}")
# ---------------------------------------------------------------------------
# Voicemeeter Backend
# ---------------------------------------------------------------------------
class VoicemeeterBackend(AudioMixerBackend):
"""Backend for Voicemeeter Remote API."""
@property
def name(self) -> str:
return "voicemeeter"
async def get_state(self) -> AudioMixerState:
"""Read Voicemeeter strip states."""
try:
import voicemeeter_api # type: ignore
vm = voicemeeter_api.VoicemeeterRemote()
vm.connect()
channels = []
# Read hardware inputs (A1, A2, A3)
for i in range(1, 4):
gain = vm.get_input_gain(i)
mute = bool(vm.get_input_mute(i))
channels.append(
AudioChannel(
name=f"A{i}",
volume=max(0.0, min(1.0, (gain + 40) / 40)),
muted=mute,
)
)
# Read virtual outputs (B1, B2, B3, B4, B5)
for i in range(1, 6):
gain = vm.get_output_gain(i)
mute = bool(vm.get_output_mute(i))
channels.append(
AudioChannel(
name=f"B{i}",
volume=max(0.0, min(1.0, (gain + 40) / 40)),
muted=mute,
)
)
vm.disconnect()
return AudioMixerState(
backend="voicemeeter",
master_volume=sum(c.volume for c in channels) / max(len(channels), 1),
master_muted=all(c.muted for c in channels) if channels else False,
channels=channels,
)
except ImportError:
logger.warning("voicemeeter-api not installed — Voicemeeter unavailable")
return AudioMixerState(backend="voicemeeter")
except Exception as e:
logger.error(f"Voicemeeter error: {e}")
return AudioMixerState(backend="voicemeeter")
async def set_volume(self, channel: str, value: float) -> None:
try:
import voicemeeter_api
vm = voicemeeter_api.VoicemeeterRemote()
vm.connect()
# Map channel names to Voicemeeter strip indices
if channel.startswith("A"):
strip = int(channel[1])
# Convert 0-1 to Voicemeeter gain (-40 to 0 dB)
gain = (value * -40) + 40
vm.set_input_gain(strip, gain)
elif channel.startswith("B"):
strip = int(channel[1])
gain = (value * -40) + 40
vm.set_output_gain(strip, gain)
vm.disconnect()
except Exception as e:
logger.error(f"Failed to set Voicemeeter volume for {channel}: {e}")
async def set_mute(self, channel: str, muted: bool) -> None:
try:
import voicemeeter_api
vm = voicemeeter_api.VoicemeeterRemote()
vm.connect()
if channel.startswith("A"):
strip = int(channel[1])
vm.set_input_mute(strip, int(muted))
elif channel.startswith("B"):
strip = int(channel[1])
vm.set_output_mute(strip, int(muted))
vm.disconnect()
except Exception as e:
logger.error(f"Failed to set Voicemeeter mute for {channel}: {e}")
# ---------------------------------------------------------------------------
# Manager (orchestrates backends and polling)
# ---------------------------------------------------------------------------
class AudioMixerManager:
"""Manages audio mixer backends and periodic state polling."""
def __init__(self) -> None:
self._backends: list[AudioMixerBackend] = []
self._current_state: AudioMixerState = AudioMixerState(backend="windows")
self._poll_interval: float = 2.0
# Initialize available backends
self._init_backends()
def _init_backends(self) -> None:
"""Try to initialize backends in priority order."""
# Voicemeeter first (primary focus)
try:
self._backends.append(VoicemeeterBackend())
logger.info("Voicemeeter backend initialized")
except Exception:
logger.debug("Voicemeeter not available")
# Windows Core Audio as fallback
try:
self._backends.append(WindowsAudioBackend())
logger.info("Windows audio backend initialized")
except Exception:
logger.debug("Windows audio not available")
if not self._backends:
logger.warning("No audio backends available")
async def run(self) -> None:
"""Periodically poll audio state."""
while True:
try:
self._current_state = await self._get_best_state()
except Exception as e:
logger.error(f"Audio poll error: {e}")
await asyncio.sleep(self._poll_interval)
async def _get_best_state(self) -> AudioMixerState:
"""Get state from the best available backend."""
for backend in self._backends:
try:
state = await backend.get_state()
if state.channels or state.master_volume > 0 or state.master_muted:
return state
except Exception:
continue
return self._current_state
def get_state(self) -> dict:
"""Return current state as a serializable dict."""
return {
"backend": self._current_state.backend,
"master_volume": self._current_state.master_volume,
"master_muted": self._current_state.master_muted,
"channels": [
{
"name": ch.name,
"volume": ch.volume,
"muted": ch.muted,
"peak_left": ch.peak_left,
"peak_right": ch.peak_right,
}
for ch in self._current_state.channels
],
}
async def set_volume(self, bus: str, value: float) -> None:
"""Set volume on the active backend."""
for backend in self._backends:
try:
await backend.set_volume(bus, value)
return
except Exception:
continue
async def set_mute(self, bus: str, muted: bool) -> None:
"""Set mute on the active backend."""
for backend in self._backends:
try:
await backend.set_mute(bus, muted)
return
except Exception:
continue
+129
View File
@@ -0,0 +1,129 @@
"""Build script: compile PaperDash server to a standalone .exe with system tray.
Usage:
python build_exe.py # Build in release mode
python build_exe.py --debug # Build in debug mode (with console)
Output: server/dist/PaperDash.exe
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DIST_DIR = os.path.join(SCRIPT_DIR, "dist")
def build(debug: bool = False) -> None:
"""Compile the server to an .exe using PyInstaller."""
os.makedirs(DIST_DIR, exist_ok=True)
# PyInstaller spec content — wraps the server with system tray
spec_content = f'''
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec for PaperDash with system tray."""
import os
from pathlib import Path
block_cipher = None
a = Analysis(
["tray_wrapper.py"],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[
"fastapi",
"uvicorn",
"pydantic",
"pystray",
"PIL",
"audio_mixer",
"media",
"notifications",
"telemetry",
"main",
],
hookspath=[],
hooksconfig={{}},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="PaperDash",
debug={"True": True, "False": False}[str(debug)],
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False if not debug else True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
)
'''
spec_path = os.path.join(SCRIPT_DIR, "PaperDash.spec")
with open(spec_path, "w") as f:
f.write(spec_content)
try:
cmd = [
sys.executable,
"-m",
"PyInstaller",
spec_path,
"--clean",
]
if debug:
cmd.append("--debug=all")
print(f"Building PaperDash.exe (debug={debug})...")
print(f" Spec: {spec_path}")
print(f" Output: {DIST_DIR}/PaperDash.exe")
print()
subprocess.run(cmd, check=True)
exe_path = os.path.join(DIST_DIR, "PaperDash.exe")
if os.path.exists(exe_path):
size_mb = os.path.getsize(exe_path) / (1024 * 1024)
print(f"\nBuild complete: {exe_path} ({size_mb:.1f} MB)")
else:
print("\nBuild completed but .exe not found in dist/")
except subprocess.CalledProcessError as e:
print(f"\nBuild failed: {e}", file=sys.stderr)
sys.exit(1)
finally:
# Clean up spec file
if os.path.exists(spec_path):
os.remove(spec_path)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Build PaperDash .exe")
parser.add_argument("--debug", action="store_true", help="Build with debug console")
args = parser.parse_args()
build(debug=args.debug)
+221
View File
@@ -0,0 +1,221 @@
"""PaperDash — E-Ink Dashboard Ecosystem ~ Paperdash
FastAPI server that aggregates Windows desktop state and streams it
to an Onyx Boox Go 7 Color via WebSocket.
"""
from __future__ import annotations
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from .audio_mixer import AudioMixerManager
from .media import MediaManager
from .notifications import NotificationManager, Notification
from .telemetry import TelemetryManager
logger = logging.getLogger("paperdash")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Start and stop background managers."""
# Start managers
app.state.media = MediaManager()
app.state.audio = AudioMixerManager()
app.state.notifications = NotificationManager()
app.state.telemetry = TelemetryManager()
# Start background tasks
app.state.tasks = [
asyncio.create_task(app.state.media.run()),
asyncio.create_task(app.state.audio.run()),
asyncio.create_task(app.state.telemetry.run()),
]
logger.info("PaperDash server started")
yield
# Cancel background tasks
for task in app.state.tasks:
task.cancel()
await asyncio.gather(*app.state.tasks, return_exceptions=True)
logger.info("PaperDash server stopped")
app = FastAPI(
title="PaperDash",
description="E-Ink Dashboard Ecosystem ~ Paperdash",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Local network only — fine for this use case
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# WebSocket: Dashboard Client (PIN-authenticated)
# ---------------------------------------------------------------------------
class WebSocketManager:
"""Manages active WebSocket connections from the E-Ink client."""
def __init__(self) -> None:
self.connections: list[WebSocket] = []
async def connect(self, ws: WebSocket, pin: str) -> bool:
expected_pin = os.environ.get("DASHBOARD_PIN", "")
if not expected_pin or pin != expected_pin:
await ws.close(code=4001, reason="Invalid PIN")
return False
await ws.accept()
self.connections.append(ws)
logger.info(f"WebSocket client connected ({len(self.connections)} total)")
return True
def disconnect(self, ws: WebSocket) -> None:
if ws in self.connections:
self.connections.remove(ws)
logger.info(
f"WebSocket client disconnected ({len(self.connections)} total)"
)
async def broadcast(self, data: dict[str, Any]) -> None:
"""Send data to all connected clients."""
disconnected = []
for ws in self.connections:
try:
await ws.send_json(data)
except WebSocketDisconnect:
disconnected.append(ws)
except Exception:
disconnected.append(ws)
for ws in disconnected:
self.disconnect(ws)
ws_manager = WebSocketManager()
app.state.ws_manager = ws_manager
@app.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
pin: str = Query(..., description="Dashboard PIN"),
):
"""WebSocket endpoint for the E-Ink dashboard client."""
if await ws_manager.connect(websocket, pin):
try:
# Keep connection alive; the server pushes state updates.
while True:
# Listen for client commands (volume, skip, etc.)
data = await websocket.receive_json()
await handle_client_command(data)
except WebSocketDisconnect:
ws_manager.disconnect(websocket)
except Exception as e:
logger.error(f"WebSocket error: {e}")
ws_manager.disconnect(websocket)
async def handle_client_command(data: dict[str, Any]) -> None:
"""Route client commands to the appropriate manager."""
action = data.get("action")
if action == "volume":
bus = data.get("bus", "master")
value = data.get("value", 0)
await app.state.audio.set_volume(bus, value)
elif action == "mute":
bus = data.get("bus", "master")
muted = data.get("muted", False)
await app.state.audio.set_mute(bus, muted)
elif action == "play":
await app.state.media.play()
elif action == "pause":
await app.state.media.pause()
elif action == "skip":
await app.state.media.skip()
elif action == "prev":
await app.state.media.previous()
else:
logger.warning(f"Unknown client command: {action}")
# ---------------------------------------------------------------------------
# REST: External Scripts (Bearer Token)
# ---------------------------------------------------------------------------
def _verify_token(authorization: str | None) -> bool:
"""Verify Bearer token from Authorization header."""
expected = os.environ.get("API_TOKEN", "")
if not expected:
return False
if not authorization or not authorization.startswith("Bearer "):
return False
return authorization[7:] == expected
@app.get("/api/v1/state")
async def get_state():
"""Get current aggregated dashboard state."""
return {
"media": app.state.media.get_state(),
"audio": app.state.audio.get_state(),
"telemetry": app.state.telemetry.get_state(),
"notifications": app.state.notifications.get_active(),
}
@app.post("/api/v1/notify")
async def create_notification(
notification: Notification,
authorization: str | None = None,
):
"""Create a notification (progress or alert)."""
if not _verify_token(authorization):
raise HTTPException(status_code=401, detail="Invalid or missing API token")
created = await app.state.notifications.create(notification)
# Broadcast to connected dashboard clients
await ws_manager.broadcast({"type": "notification", "data": created})
return {"id": created.id, "status": "created"}
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "ok"}
# ---------------------------------------------------------------------------
# Client HTML (served directly for Hermit WebView)
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def serve_client():
"""Serve the E-Ink dashboard client."""
client_dir = os.path.join(os.path.dirname(__file__), "..", "client")
index_path = os.path.join(client_dir, "index.html")
if os.path.exists(index_path):
with open(index_path, "r") as f:
return f.read()
return HTMLResponse("<h1>PaperDash</h1><p>Client files not found in client/</p>")
+166
View File
@@ -0,0 +1,166 @@
"""Windows Media playback tracking via GSMTC (Global System Media Transport Controls).
Uses the winsdk library to access the Windows Media API without polling.
"""
from __future__ import annotations
import asyncio
import base64
import io
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("paperdash.media")
# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------
@dataclass
class MediaState:
"""Current media playback state."""
title: str = ""
artist: str = ""
album: str = ""
artwork_base64: str = ""
playback_position: float = 0.0 # seconds
playback_duration: float = 0.0 # seconds
playback_percentage: float = 0.0 # 0.0 - 1.0
is_playing: bool = False
is_paused: bool = False
is_stopped: bool = True
# ---------------------------------------------------------------------------
# Media Manager
# ---------------------------------------------------------------------------
class MediaManager:
"""Tracks Windows media playback via GSMTC."""
def __init__(self) -> None:
self._state = MediaState()
self._poll_interval: float = 2.0
async def run(self) -> None:
"""Periodically poll media state from GSMTC."""
while True:
try:
self._state = await self._poll_gsmtc()
except Exception as e:
logger.error(f"Media poll error: {e}")
await asyncio.sleep(self._poll_interval)
async def _poll_gsmtc(self) -> MediaState:
"""Read current media state from Windows GSMTC."""
try:
from winsdk.windows.media.control import ( # type: ignore
GlobalSystemMediaTransportControlsSessionManager as Manager,
)
session_manager = await Manager.request_async()
session = session_manager.get_current_session()
if session is None:
return MediaState()
info = await session.try_get_media_properties_async()
# Convert artwork to base64
artwork_b64 = ""
if info.thumbnail:
try:
stream = await info.thumbnail.open_async()
reader = await asyncio.to_thread(
__import__("Windows.Storage.Streams").DataTypes.DataReader,
stream,
)
buffer = await asyncio.to_thread(reader.load_async, stream.size)
data = bytearray(buffer)
buf = io.BytesIO(data)
artwork_b64 = base64.b64encode(buf.read()).decode("ascii")
except Exception:
artwork_b64 = ""
# Compute percentage
props = session.get_playback_info()
pos = props.position.total_seconds() if props.position else 0.0
duration = info.duration.total_seconds() if info.duration else 0.0
percentage = (pos / duration * 100) if duration > 0 else 0.0
return MediaState(
title=info.title or "",
artist=info.artist or "",
album=info.album or "",
artwork_base64=artwork_b64,
playback_position=pos,
playback_duration=duration,
playback_percentage=percentage,
is_playing=session.playback_status.value == 0, # Playing
is_paused=session.playback_status.value == 1, # Paused
is_stopped=session.playback_status.value == 2, # Stopped
)
except ImportError:
logger.warning("winsdk not installed — media tracking unavailable")
return MediaState()
except Exception as e:
logger.error(f"GSMTC error: {e}")
return MediaState()
def get_state(self) -> dict[str, Any]:
"""Return current media state as a serializable dict."""
return {
"title": self._state.title,
"artist": self._state.artist,
"album": self._state.album,
"artwork_base64": self._state.artwork_base64,
"playback_position": self._state.playback_position,
"playback_duration": self._state.playback_duration,
"playback_percentage": self._state.playback_percentage,
"is_playing": self._state.is_playing,
"is_paused": self._state.is_paused,
"is_stopped": self._state.is_stopped,
}
async def play(self) -> None:
"""Send play command to current media session."""
await self._send_command("play")
async def pause(self) -> None:
"""Send pause command to current media session."""
await self._send_command("pause")
async def skip(self) -> None:
"""Send next track command."""
await self._send_command("next")
async def previous(self) -> None:
"""Send previous track command."""
await self._send_command("previous")
async def _send_command(self, command: str) -> None:
"""Send a media transport command via GSMTC."""
try:
from winsdk.windows.media.control import ( # type: ignore
GlobalSystemMediaTransportControlsSessionManager as Manager,
)
session_manager = await Manager.request_async()
session = session_manager.get_current_session()
if session:
if command == "play":
await session.try_play_async()
elif command == "pause":
await session.try_pause_async()
elif command == "next":
await session.try_skip_next_async()
elif command == "previous":
await session.try_skip_previous_async()
except Exception as e:
logger.error(f"Media command '{command}' failed: {e}")
+131
View File
@@ -0,0 +1,131 @@
"""Notification system supporting progress bars and rich text alerts.
External scripts push notifications via POST /api/v1/notify.
Two types are supported:
- "progress": long-running tasks with a measurable progress bar
- "alert": one-off informational/warning/error messages
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from typing import Literal
logger = logging.getLogger("paperdash.notifications")
# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------
@dataclass
class ProgressState:
"""Progress tracking for long-running tasks."""
current: float = 0.0
min: float = 0.0
max: float = 100.0
unit: str = ""
@property
def percentage(self) -> float:
if self.max <= self.min:
return 0.0
return max(
0.0, min(100.0, (self.current - self.min) / (self.max - self.min) * 100)
)
@dataclass
class Notification:
"""A notification to display on the dashboard."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
type: Literal["progress", "alert"] = "alert"
title: str = ""
message: str = ""
progress: ProgressState | None = None
eta: str | None = None
priority: Literal["info", "warning", "error"] = "info"
# ---------------------------------------------------------------------------
# Notification Manager
# ---------------------------------------------------------------------------
class NotificationManager:
"""Manages active notifications and their lifecycle."""
def __init__(self) -> None:
self._notifications: dict[str, Notification] = {}
self._max_active: int = 10 # Keep at most 10 active at a time
async def create(self, notification: Notification) -> Notification:
"""Create a new notification or update an existing one by ID."""
# If a notification with this ID already exists, update it
if notification.id in self._notifications:
existing = self._notifications[notification.id]
# Preserve type if not explicitly set on update
if not notification.type:
notification.type = existing.type
self._notifications[notification.id] = notification
logger.debug(f"Notification updated: {notification.id}")
else:
self._notifications[notification.id] = notification
logger.info(
f"Notification created: {notification.id} ({notification.type})"
)
# Prune old notifications if we exceed the limit
self._prune_old()
return notification
def _prune_old(self) -> None:
"""Remove oldest notifications if we exceed the limit."""
if len(self._notifications) <= self._max_active:
return
# Remove oldest first (by insertion order)
ids_to_remove = list(self._notifications.keys())[
: len(self._notifications) - self._max_active
]
for nid in ids_to_remove:
del self._notifications[nid]
logger.debug(f"Notification pruned: {nid}")
def get_active(self) -> list[dict]:
"""Return all active notifications as serializable dicts."""
result = []
for n in self._notifications.values():
entry: dict = {
"id": n.id,
"type": n.type,
"title": n.title,
"message": n.message,
"priority": n.priority,
}
if n.type == "progress" and n.progress:
entry["progress"] = {
"current": n.progress.current,
"min": n.progress.min,
"max": n.progress.max,
"unit": n.progress.unit,
"percentage": n.progress.percentage,
}
if n.eta:
entry["eta"] = n.eta
result.append(entry)
return result
async def dismiss(self, notification_id: str) -> bool:
"""Dismiss (remove) a notification by ID."""
if notification_id in self._notifications:
del self._notifications[notification_id]
logger.debug(f"Notification dismissed: {notification_id}")
return True
return False
+17
View File
@@ -0,0 +1,17 @@
# Core
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
pydantic>=2.0.0
# Windows APIs
winsdk>=1.0.0
pycaw>=20230407
voicemeeter-api>=1.0.0
# Hardware telemetry
pyhwinfo>=0.1.0
# Build & packaging
pyinstaller>=6.0.0
pystray>=0.19.0
Pillow>=10.0.0
+136
View File
@@ -0,0 +1,136 @@
"""Hardware telemetry via HWiNFO64 Shared Memory.
Reads CPU/GPU core temperatures, VRAM usage, and other hardware metrics
from HWiNFO's shared memory interface.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger("paperdash.telemetry")
# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------
@dataclass
class SensorReading:
"""A single hardware sensor reading."""
name: str
value: float
unit: str
category: str = "" # e.g., "CPU", "GPU", "System"
@dataclass
class TelemetryState:
"""Aggregated hardware telemetry state."""
sensors: list[SensorReading] = field(default_factory=list)
cpu_temp: float = 0.0
gpu_temp: float = 0.0
cpu_usage: float = 0.0
gpu_usage: float = 0.0
vram_usage: float = 0.0
vram_total: float = 0.0
ram_usage: float = 0.0
ram_total: float = 0.0
# ---------------------------------------------------------------------------
# Telemetry Manager
# ---------------------------------------------------------------------------
class TelemetryManager:
"""Reads hardware telemetry from HWiNFO64 Shared Memory."""
def __init__(self) -> None:
self._state = TelemetryState()
self._poll_interval: float = 5.0
async def run(self) -> None:
"""Periodically poll hardware telemetry."""
while True:
try:
self._state = await self._poll_hwinfo()
except Exception as e:
logger.error(f"Telemetry poll error: {e}")
await asyncio.sleep(self._poll_interval)
async def _poll_hwinfo(self) -> TelemetryState:
"""Read hardware state from HWiNFO64 Shared Memory."""
try:
import pyhwinfo # type: ignore
data = pyhwinfo.read_all()
sensors = []
state = TelemetryState()
for sensor in data:
reading = SensorReading(
name=sensor.get("name", ""),
value=sensor.get("value", 0.0),
unit=sensor.get("unit", ""),
category=sensor.get("category", ""),
)
sensors.append(reading)
# Extract key metrics
name_lower = reading.name.lower()
if "cpu core" in name_lower and "temp" in name_lower:
state.cpu_temp = max(state.cpu_temp, reading.value)
elif "gpu" in name_lower and "temp" in name_lower:
state.gpu_temp = max(state.gpu_temp, reading.value)
elif "cpu" in name_lower and "usage" in name_lower:
state.cpu_usage = max(state.cpu_usage, reading.value)
elif "gpu" in name_lower and "usage" in name_lower:
state.gpu_usage = max(state.gpu_usage, reading.value)
elif "dedicated" in name_lower and "memory" in name_lower:
if "used" in name_lower:
state.vram_usage = reading.value
elif "total" in name_lower:
state.vram_total = reading.value
elif "memory" in name_lower and "usage" in name_lower:
state.ram_usage = reading.value
elif "memory" in name_lower and "total" in name_lower:
state.ram_total = reading.value
state.sensors = sensors
return state
except ImportError:
logger.warning("pyhwinfo not installed — telemetry unavailable")
return TelemetryState()
except Exception as e:
logger.error(f"HWiNFO read error: {e}")
return TelemetryState()
def get_state(self) -> dict[str, Any]:
"""Return current telemetry state as a serializable dict."""
return {
"cpu_temp": self._state.cpu_temp,
"gpu_temp": self._state.gpu_temp,
"cpu_usage": self._state.cpu_usage,
"gpu_usage": self._state.gpu_usage,
"vram_usage": self._state.vram_usage,
"vram_total": self._state.vram_total,
"ram_usage": self._state.ram_usage,
"ram_total": self._state.ram_total,
"sensors": [
{
"name": s.name,
"value": s.value,
"unit": s.unit,
"category": s.category,
}
for s in self._state.sensors
],
}
View File
+9
View File
@@ -0,0 +1,9 @@
"""Shared test configuration."""
from __future__ import annotations
import sys
import os
# Ensure the server package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+73
View File
@@ -0,0 +1,73 @@
"""Tests for the audio mixer data models and backend interface."""
from __future__ import annotations
import pytest
from ..audio_mixer import (
AudioChannel,
AudioMixerManager,
AudioMixerState,
)
class TestDataModels:
def test_audio_channel_defaults(self) -> None:
ch = AudioChannel(name="Master")
assert ch.name == "Master"
assert ch.volume == 0.0
assert ch.muted == False
assert ch.peak_left == 0.0
def test_audio_mixer_state_defaults(self) -> None:
state = AudioMixerState(backend="windows")
assert state.backend == "windows"
assert state.master_volume == 0.0
assert state.master_muted == False
assert state.channels == []
def test_audio_mixer_state_with_channels(self) -> None:
channels = [
AudioChannel(name="A1", volume=0.8, muted=False),
AudioChannel(name="B1", volume=0.5, muted=True),
]
state = AudioMixerState(
backend="voicemeeter",
master_volume=0.65,
master_muted=False,
channels=channels,
)
assert len(state.channels) == 2
assert state.channels[0].name == "A1"
assert state.channels[1].muted == True
class TestAudioMixerManager:
def test_init_with_no_backends(self) -> None:
"""Manager should initialize even with no backends available."""
manager = AudioMixerManager()
# Should not raise
state = manager.get_state()
assert isinstance(state, dict)
assert "backend" in state
assert "master_volume" in state
assert "channels" in state
def test_get_state_returns_dict(self) -> None:
manager = AudioMixerManager()
state = manager.get_state()
assert isinstance(state, dict)
assert state["backend"] in ("windows", "voicemeeter")
assert isinstance(state["channels"], list)
@pytest.mark.asyncio
async def test_set_volume_no_backends(self) -> None:
"""set_volume should not raise when no backends are available."""
manager = AudioMixerManager()
await manager.set_volume("master", 0.5) # Should not raise
@pytest.mark.asyncio
async def test_set_mute_no_backends(self) -> None:
"""set_mute should not raise when no backends are available."""
manager = AudioMixerManager()
await manager.set_mute("master", True) # Should not raise
+94
View File
@@ -0,0 +1,94 @@
"""Tests for the FastAPI application endpoints."""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from ..main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
class TestHealth:
def test_health_returns_ok(self, client: TestClient) -> None:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
class TestGetState:
def test_get_state_returns_dict(self, client: TestClient) -> None:
with (
patch.object(app.state, "media", create=True),
patch.object(app.state, "audio", create=True),
patch.object(app.state, "notifications", create=True),
patch.object(app.state, "telemetry", create=True),
):
app.state.media.get_state = lambda: {"title": "Test"}
app.state.audio.get_state = lambda: {"backend": "windows"}
app.state.notifications.get_active = list
app.state.telemetry.get_state = dict
response = client.get("/api/v1/state")
assert response.status_code == 200
data = response.json()
assert "media" in data
assert "audio" in data
assert "notifications" in data
assert "telemetry" in data
class TestNotifyEndpoint:
def test_notify_without_token_returns_401(self, client: TestClient) -> None:
response = client.post(
"/api/v1/notify",
json={
"type": "alert",
"title": "Test",
"message": "Hello",
},
)
assert response.status_code == 401
def test_notify_with_valid_token(self, client: TestClient) -> None:
os.environ["API_TOKEN"] = "test-token-123"
with patch.object(app.state, "notifications", create=True) as mock_notif:
mock_notif.create = AsyncMock()
mock_notif.create.return_value = type(
"Notification", (), {"id": "test-id"}
)()
response = client.post(
"/api/v1/notify",
json={
"type": "alert",
"title": "Build Complete",
"message": "Success",
},
headers={"Authorization": "Bearer test-token-123"},
)
assert response.status_code == 200
assert response.json()["status"] == "created"
# Cleanup
del os.environ["API_TOKEN"]
def test_notify_with_wrong_token_returns_401(self, client: TestClient) -> None:
os.environ["API_TOKEN"] = "correct-token"
response = client.post(
"/api/v1/notify",
json={"type": "alert", "title": "Test", "message": "Hello"},
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401
del os.environ["API_TOKEN"]
+51
View File
@@ -0,0 +1,51 @@
"""Tests for the media tracking module."""
from __future__ import annotations
from ..media import MediaManager, MediaState
class TestMediaState:
def test_defaults(self) -> None:
state = MediaState()
assert state.title == ""
assert state.artist == ""
assert not state.is_playing
assert state.is_stopped
assert state.playback_percentage == 0.0
def test_playing_state(self) -> None:
state = MediaState(
title="Test Song",
artist="Test Artist",
is_playing=True,
is_paused=False,
is_stopped=False,
playback_percentage=45.5,
)
assert state.title == "Test Song"
assert state.is_playing
assert state.playback_percentage == 45.5
class TestMediaManager:
def test_init(self) -> None:
manager = MediaManager()
assert manager._state is not None
def test_get_state_returns_dict(self) -> None:
manager = MediaManager()
state = manager.get_state()
assert isinstance(state, dict)
assert "title" in state
assert "artist" in state
assert "is_playing" in state
assert "playback_percentage" in state
def test_get_state_empty_when_nothing_playing(self) -> None:
manager = MediaManager()
state = manager.get_state()
assert state["title"] == ""
assert not state["is_playing"]
assert state["is_stopped"]
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the notification system."""
from __future__ import annotations
import pytest
from ..notifications import Notification, NotificationManager, ProgressState
@pytest.fixture
def manager() -> NotificationManager:
return NotificationManager()
class TestNotificationCreation:
@pytest.mark.asyncio
async def test_create_alert(self, manager: NotificationManager) -> None:
notif = Notification(
type="alert",
title="Build Complete",
message="Deployment succeeded.",
priority="info",
)
result = await manager.create(notif)
assert result.id
assert result.type == "alert"
assert result.title == "Build Complete"
@pytest.mark.asyncio
async def test_create_progress(self, manager: NotificationManager) -> None:
notif = Notification(
type="progress",
title="Backup",
message="Uploading...",
progress=ProgressState(current=50, min=0, max=100, unit="MB"),
)
result = await manager.create(notif)
assert result.type == "progress"
assert result.progress is not None
progress = result.progress
assert progress.percentage == 50.0
@pytest.mark.asyncio
async def test_update_existing_notification(
self, manager: NotificationManager
) -> None:
notif = Notification(
id="test_01",
type="progress",
title="Backup",
progress=ProgressState(current=10, max=100),
)
await manager.create(notif)
notif.progress = ProgressState(current=50, max=100)
result = await manager.create(notif)
assert result.progress is not None
assert result.progress.percentage == 50.0
@pytest.mark.asyncio
async def test_get_active_returns_list(self, manager: NotificationManager) -> None:
await manager.create(Notification(title="Test"))
active = manager.get_active()
assert len(active) == 1
assert active[0]["title"] == "Test"
class TestNotificationPruning:
@pytest.mark.asyncio
async def test_prunes_old_notifications(self) -> None:
manager = NotificationManager()
manager._max_active = 3
for i in range(5):
await manager.create(Notification(id=f"n{i}", title=f"Msg {i}"))
active = manager.get_active()
assert len(active) == 3
ids = [n["id"] for n in active]
assert "n0" not in ids
assert "n4" in ids
class TestNotificationDismiss:
@pytest.mark.asyncio
async def test_dismiss_removes_notification(
self, manager: NotificationManager
) -> None:
notif = Notification(id="dismiss_me", title="Temp")
await manager.create(notif)
assert len(manager.get_active()) == 1
result = await manager.dismiss("dismiss_me")
assert result
assert len(manager.get_active()) == 0
@pytest.mark.asyncio
async def test_dismiss_nonexistent_returns_false(
self, manager: NotificationManager
) -> None:
result = await manager.dismiss("does_not_exist")
assert not result
class TestProgressState:
def test_percentage_calculation(self) -> None:
p = ProgressState(current=75, min=0, max=100)
assert p.percentage == 75.0
def test_percentage_clamped_at_zero(self) -> None:
p = ProgressState(current=-10, min=0, max=100)
assert p.percentage == 0.0
def test_percentage_clamped_at_hundred(self) -> None:
p = ProgressState(current=150, min=0, max=100)
assert p.percentage == 100.0
def test_zero_range_returns_zero(self) -> None:
p = ProgressState(current=50, min=0, max=0)
assert p.percentage == 0.0
+59
View File
@@ -0,0 +1,59 @@
"""Tests for the telemetry module."""
from __future__ import annotations
from ..telemetry import SensorReading, TelemetryManager, TelemetryState
class TestSensorReading:
def test_defaults(self) -> None:
reading = SensorReading(name="CPU Temp", value=65.0, unit="C")
assert reading.name == "CPU Temp"
assert reading.value == 65.0
assert reading.unit == "C"
assert reading.category == ""
def test_with_category(self) -> None:
reading = SensorReading(name="GPU Usage", value=72.0, unit="%", category="GPU")
assert reading.category == "GPU"
class TestTelemetryState:
def test_defaults(self) -> None:
state = TelemetryState()
assert state.cpu_temp == 0.0
assert state.gpu_temp == 0.0
assert state.sensors == []
def test_with_values(self) -> None:
state = TelemetryState(
cpu_temp=72.5,
gpu_temp=68.0,
cpu_usage=45.0,
sensors=[
SensorReading(name="CPU", value=72.5, unit="C"),
],
)
assert state.cpu_temp == 72.5
assert len(state.sensors) == 1
class TestTelemetryManager:
def test_init(self) -> None:
manager = TelemetryManager()
assert manager._state is not None
def test_get_state_returns_dict(self) -> None:
manager = TelemetryManager()
state = manager.get_state()
assert isinstance(state, dict)
assert "cpu_temp" in state
assert "gpu_temp" in state
assert "sensors" in state
def test_get_state_empty(self) -> None:
manager = TelemetryManager()
state = manager.get_state()
assert state["cpu_temp"] == 0.0
assert state["sensors"] == []
+104
View File
@@ -0,0 +1,104 @@
"""System tray wrapper for PaperDash.
Runs the FastAPI server in a background thread and displays a tray icon
with a quit option. This is the entry point for the compiled .exe.
"""
from __future__ import annotations
import logging
import multiprocessing
import signal
import time
import pystray
from PIL import Image, ImageDraw
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
logger = logging.getLogger("paperdash.tray")
def create_icon() -> Image.Image:
"""Create a simple 16x16 tray icon."""
img = Image.new("RGB", (16, 16), color=(0, 0, 0))
draw = ImageDraw.Draw(img)
# Dashboard frame
draw.rectangle([1, 1, 15, 15], outline=(255, 255, 255), width=1)
# Screen lines
draw.line([3, 3, 13, 3], fill=(255, 255, 255), width=1)
draw.line([3, 5, 13, 5], fill=(255, 255, 255), width=1)
draw.line([3, 7, 8, 7], fill=(255, 255, 255), width=1)
# Stand
draw.line([6, 15, 10, 15], fill=(255, 255, 255), width=1)
return img
def _run_server() -> None:
"""Run the FastAPI server in this process (for multiprocessing)."""
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8921,
log_level="info",
reload=False,
)
def _on_quit(icon: pystray.Icon, item: pystray.MenuItem) -> None:
"""Handle quit from tray menu."""
logger.info("Quit requested from tray")
icon.stop()
def main() -> None:
"""Entry point for the compiled .exe."""
logger.info("PaperDash starting...")
# Start server in a subprocess
server_process = multiprocessing.Process(target=_run_server, daemon=True)
server_process.start()
logger.info(f"Server process started (PID {server_process.pid})")
# Wait briefly for server to be ready
time.sleep(2)
# Create and run tray icon
icon_image = create_icon()
menu = pystray.Menu(
pystray.MenuItem("PaperDash", pystray.MenuItem.default, enabled=False),
pystray.Menu.Separator(),
pystray.MenuItem("Quit", _on_quit),
)
icon = pystray.Icon(
name="PaperDash",
image=icon_image,
title="PaperDash",
menu=menu,
)
# Handle OS exit signals
def _signal_handler(signum, frame):
logger.info(f"Received signal {signum}, shutting down")
icon.stop()
server_process.terminate()
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
logger.info("PaperDash running in system tray. Quit from notification area.")
icon.run()
# Cleanup
logger.info("Shutting down server process")
server_process.terminate()
server_process.join(timeout=5)
if __name__ == "__main__":
main()