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