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