diff --git a/README.md b/README.md index 4525e8d..c8beae1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,10 @@ flowchart TD - **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 +- **Hardware Fallbacks** — Startup check reports which subsystems are available/unavailable - **Custom Notifications** — External scripts can push progress bars or rich text alerts via `POST /api/v1/notify` +- **Settings Screen** — Change PIN and API token via the web client (protected by API token) +- **Configuration Files** — Use `config.yaml`, `.env`, or environment variables - **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 @@ -84,12 +87,14 @@ e-ink-dash/ │ └── paperdash_architecture_document.txt ├── server/ # Python FastAPI server │ ├── main.py # Application entry point +│ ├── config.py # Configuration management (YAML/.env/env vars) │ ├── 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 +│ ├── .env.example # Example environment file │ ├── requirements.txt │ └── tests/ # Test suite ├── client/ # E-Ink web client @@ -113,10 +118,27 @@ pip install -r requirements.txt python -m uvicorn main:app --host 0.0.0.0 --port 8921 ``` -Set environment variables: +Configure via one of: -- `DASHBOARD_PIN` — 4-digit PIN for the E-Ink client -- `API_TOKEN` — Bearer token for external scripts +1. **Environment variables:** + - `DASHBOARD_PIN` — 4-digit PIN for the E-Ink client + - `API_TOKEN` — Bearer token for external scripts + +2. **`.env` file** (in project root or exe directory): + + ``` + DASHBOARD_PIN=1234 + API_TOKEN=your-token-here + ``` + +3. **`config.yaml`** (in project root or exe directory): + + ```yaml + dashboard_pin: "1234" + api_token: "your-token-here" + ``` + +See `.env.example` and `config.yaml.example` for reference. ### Build .exe @@ -144,6 +166,40 @@ adb install app/build/outputs/apk/debug/app-debug.apk The WebView loads `file:///android_asset/index.html` and connects to the server via WebSocket at `ws://:8921/ws`. +#### Localization + +The app supports multiple languages via Android string resources. The web client loads strings from Android via a JavaScript interface. + +**Adding a new language:** + +1. Create a new values folder: `android/app/src/main/res/values-{lang}/` + - Example: `values-es/` for Spanish, `values-fr/` for French +2. Copy `values/strings.xml` to the new folder +3. Translate the string values + +**Example `values-es/strings.xml`:** + +```xml + + + PaperDash + PaperDash + Ingrese PIN para conectar + **** + Conectar + Ingrese un PIN + Desconectado. Reingrese PIN. + Conectando… + Sin reproducción + SILENCIAR + SILENCIADO + Sin notificaciones + ETA: + +``` + +The app automatically uses the device's language. No code changes needed. + ### Design Decisions See `.pi/docs/design/` for detailed design decision records covering architecture, audio mixer, notifications, and the .exe build. diff --git a/android/app/src/main/assets/app.js b/android/app/src/main/assets/app.js index 4948748..a3918e6 100644 --- a/android/app/src/main/assets/app.js +++ b/android/app/src/main/assets/app.js @@ -4,18 +4,92 @@ * Connects to the PaperDash server via WebSocket. * Uses vanilla JS with direct DOM manipulation (element.textContent) * to minimize E-Ink refresh flashing. + * + * Strings are loaded from Android resources via the JavaScript interface. */ (() => { const SERVER_WS = `ws://${window.location.host}/ws`; + const SERVER_API = `http://${window.location.host}`; 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"); + const SETTINGS_SCREEN = document.getElementById("settings-screen"); + const SETTINGS_TOKEN_INPUT = document.getElementById("settings-token-input"); + const SETTINGS_ACCESS_BTN = document.getElementById("settings-access-btn"); + const SETTINGS_ERROR = document.getElementById("settings-error"); + const SETTINGS_CONTENT = document.getElementById("settings-content"); + const SETTINGS_PIN = document.getElementById("settings-pin"); + const SETTINGS_TOKEN = document.getElementById("settings-token"); + const SETTINGS_SAVE_BTN = document.getElementById("settings-save-btn"); + const SETTINGS_BACK_BTN = document.getElementById("settings-back-btn"); + const SETTINGS_STATUS = document.getElementById("settings-status"); + const BTN_SETTINGS = document.getElementById("btn-settings"); let ws = null; let reconnectTimer = null; + let currentApiToken = ""; + const strings = {}; + + // ----------------------------------------------------------------------- + // Localization + // ----------------------------------------------------------------------- + + function loadStrings() { + if (window.Android && window.Android.getString) { + const keys = [ + "login_error_enter_pin", + "connecting", + "login_error_disconnected", + "no_playback", + "mute", + "muted", + "no_notifications", + "eta_prefix", + "settings_title", + "settings_subtitle", + "access", + "dashboard_pin_label", + "api_token_label", + "save", + "back", + "settings_error_invalid_token", + "settings_saved", + "settings_error_save_failed", + ]; + keys.forEach((key) => { + strings[key] = window.Android.getString(key); + }); + } + applyStrings(); + } + + function applyStrings() { + // Apply data-i18n attributes + document.querySelectorAll("[data-i18n]").forEach((el) => { + const key = el.getAttribute("data-i18n"); + if (strings[key]) { + el.textContent = strings[key]; + } + }); + + // Apply data-i18n-placeholder attributes + document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + const key = el.getAttribute("data-i18n-placeholder"); + if (strings[key]) { + el.placeholder = strings[key]; + } + }); + } + + // Load strings when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", loadStrings); + } else { + loadStrings(); + } // ----------------------------------------------------------------------- // PIN Login @@ -24,13 +98,13 @@ function connect() { const pin = PIN_INPUT.value.trim(); if (!pin) { - LOGIN_ERROR.textContent = "Enter a PIN"; + LOGIN_ERROR.textContent = strings.login_error_enter_pin || "Enter a PIN"; return; } LOGIN_ERROR.textContent = ""; CONNECT_BTN.disabled = true; - CONNECT_BTN.textContent = "Connecting..."; + CONNECT_BTN.textContent = strings.connecting || "Connecting..."; ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`); @@ -38,7 +112,7 @@ LOGIN_SCREEN.classList.remove("active"); DASHBOARD_SCREEN.classList.add("active"); CONNECT_BTN.disabled = false; - CONNECT_BTN.textContent = "Connect"; + CONNECT_BTN.textContent = strings.login_button || "Connect"; }; ws.onmessage = (event) => { @@ -53,9 +127,10 @@ ws.onclose = () => { DASHBOARD_SCREEN.classList.remove("active"); LOGIN_SCREEN.classList.add("active"); - LOGIN_ERROR.textContent = "Disconnected. Re-enter PIN."; + LOGIN_ERROR.textContent = + strings.login_error_disconnected || "Disconnected. Re-enter PIN."; CONNECT_BTN.disabled = false; - CONNECT_BTN.textContent = "Connect"; + CONNECT_BTN.textContent = strings.login_button || "Connect"; // Auto-reconnect after 5 seconds clearTimeout(reconnectTimer); @@ -91,7 +166,7 @@ // ----------------------------------------------------------------------- function updateMedia(media) { - setText("track-title", media.title || "No playback"); + setText("track-title", media.title || strings.no_playback || "No playback"); setText("track-artist", media.artist || ""); setText("track-album", media.album || ""); @@ -127,10 +202,10 @@ const muteBtn = document.getElementById("btn-mute"); if (audio.master_muted) { muteBtn.classList.add("muted"); - muteBtn.textContent = "MUTED"; + muteBtn.textContent = strings.muted || "MUTED"; } else { muteBtn.classList.remove("muted"); - muteBtn.textContent = "MUTE"; + muteBtn.textContent = strings.mute || "MUTE"; } // Channels — use DOM API @@ -211,7 +286,7 @@ if (!notifications || notifications.length === 0) { const p = document.createElement("p"); p.style.color = "#888"; - p.textContent = "No notifications"; + p.textContent = strings.no_notifications || "No notifications"; container.appendChild(p); return; } @@ -244,7 +319,7 @@ if (n.eta) { const etaDiv = document.createElement("div"); etaDiv.className = "notification-eta"; - etaDiv.textContent = "ETA: " + n.eta; + etaDiv.textContent = (strings.eta_prefix || "ETA:") + " " + n.eta; div.appendChild(etaDiv); } @@ -365,4 +440,96 @@ const s = Math.floor(seconds % 60); return m + ":" + (s < 10 ? "0" : "") + s; } + + // ----------------------------------------------------------------------- + // Settings + // ----------------------------------------------------------------------- + + function showSettings() { + DASHBOARD_SCREEN.classList.remove("active"); + SETTINGS_SCREEN.classList.add("active"); + SETTINGS_ERROR.textContent = ""; + SETTINGS_STATUS.textContent = ""; + SETTINGS_TOKEN_INPUT.value = ""; + SETTINGS_CONTENT.style.display = "none"; + } + + function hideSettings() { + SETTINGS_SCREEN.classList.remove("active"); + DASHBOARD_SCREEN.classList.add("active"); + } + + async function accessSettings() { + const token = SETTINGS_TOKEN_INPUT.value.trim(); + if (!token) { + SETTINGS_ERROR.textContent = + strings.settings_error_invalid_token || "Invalid token"; + return; + } + + try { + const response = await fetch(`${SERVER_API}/api/v1/settings`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + SETTINGS_ERROR.textContent = + strings.settings_error_invalid_token || "Invalid token"; + return; + } + + const data = await response.json(); + currentApiToken = token; + SETTINGS_ERROR.textContent = ""; + SETTINGS_PIN.value = data.dashboard_pin || ""; + SETTINGS_TOKEN.value = data.api_token || ""; + SETTINGS_CONTENT.style.display = "block"; + } catch (err) { + SETTINGS_ERROR.textContent = + strings.settings_error_invalid_token || "Invalid token"; + } + } + + async function saveSettings() { + const pin = SETTINGS_PIN.value.trim(); + const token = SETTINGS_TOKEN.value.trim(); + + try { + const response = await fetch(`${SERVER_API}/api/v1/settings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${currentApiToken}`, + }, + body: JSON.stringify({ + dashboard_pin: pin, + api_token: token, + }), + }); + + if (!response.ok) { + SETTINGS_STATUS.textContent = + strings.settings_error_save_failed || "Failed to save settings"; + return; + } + + SETTINGS_STATUS.textContent = + strings.settings_saved || "Settings saved"; + currentApiToken = token; + } catch (err) { + SETTINGS_STATUS.textContent = + strings.settings_error_save_failed || "Failed to save settings"; + } + } + + // Settings event listeners + BTN_SETTINGS.addEventListener("click", showSettings); + SETTINGS_ACCESS_BTN.addEventListener("click", accessSettings); + SETTINGS_SAVE_BTN.addEventListener("click", saveSettings); + SETTINGS_BACK_BTN.addEventListener("click", hideSettings); + SETTINGS_TOKEN_INPUT.addEventListener("keydown", (e) => { + if (e.key === "Enter") accessSettings(); + }); })(); diff --git a/android/app/src/main/assets/index.html b/android/app/src/main/assets/index.html index 2305739..f8a717f 100644 --- a/android/app/src/main/assets/index.html +++ b/android/app/src/main/assets/index.html @@ -8,34 +8,40 @@ /> - PaperDash + PaperDash
-

PaperDash

-

Enter PIN to connect

+

PaperDash

+

Enter PIN to connect

- +

+ + +
-

Now Playing

+

Now Playing

-
No playback
+
+ No playback +
@@ -57,22 +63,24 @@
-

Audio Mixer

+

Audio Mixer

- Master + Master
50%
- +
-

System

+

System

CPU @@ -95,10 +103,49 @@
-

Notifications

+

Notifications

+ + +
+

Settings

+

Enter API token to access settings

+ + +

+ + +
diff --git a/android/app/src/main/assets/style.css b/android/app/src/main/assets/style.css index 87226aa..33af1cc 100644 --- a/android/app/src/main/assets/style.css +++ b/android/app/src/main/assets/style.css @@ -342,6 +342,107 @@ body { overflow-x: hidden; } +/* Settings button */ +.settings-btn { + position: absolute; + top: 10px; + right: 10px; + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + width: 40px; + height: 40px; + font-size: 1.2em; + cursor: pointer; + z-index: 100; + font-family: inherit; +} + +/* Settings screen */ +#settings-screen { + justify-content: flex-start; + padding-top: 40px; +} + +#settings-screen h1 { + font-size: 1.5em; + border-bottom: 2px solid var(--border); + padding-bottom: 8px; + margin-bottom: 12px; +} + +#settings-screen p { + margin-bottom: 12px; + color: var(--dim); +} + +#settings-token-input { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 10px; + width: 100%; + margin-bottom: 10px; + font-family: inherit; + font-size: 1em; +} + +#settings-access-btn, +#settings-save-btn, +#settings-back-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 10px 20px; + font-size: 1em; + cursor: pointer; + margin-right: 10px; + margin-bottom: 10px; + font-family: inherit; +} + +#settings-content { + margin-top: 20px; +} + +#settings-content .panel { + margin-bottom: 15px; +} + +#settings-content h2 { + font-size: 0.9em; + margin-bottom: 8px; + color: var(--dim); +} + +#settings-pin, +#settings-token { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 10px; + width: 100%; + font-family: inherit; + font-size: 1em; +} + +#settings-status { + margin-top: 15px; + padding: 10px; + border: 2px solid var(--border); + background: var(--panel-bg); +} + +.status { + color: var(--fg); +} + +.error { + color: var(--dim); + font-size: 0.9em; + margin-top: 10px; +} + /* E-Ink specific: no transitions, sharp edges */ * { transition: none !important; diff --git a/android/app/src/main/java/me/kareemhorstink/paperdash/MainActivity.kt b/android/app/src/main/java/me/kareemhorstink/paperdash/MainActivity.kt index 4ef8b79..ab55962 100644 --- a/android/app/src/main/java/me/kareemhorstink/paperdash/MainActivity.kt +++ b/android/app/src/main/java/me/kareemhorstink/paperdash/MainActivity.kt @@ -1,11 +1,14 @@ package me.kareemhorstink.paperdash import android.annotation.SuppressLint +import android.content.Context import android.os.Build import android.os.Bundle +import android.util.Log import android.view.View import android.view.WindowInsets import android.view.WindowInsetsController +import android.webkit.JavascriptInterface import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient @@ -37,6 +40,9 @@ class MainActivity : AppCompatActivity() { webView.isVerticalScrollBarEnabled = false webView.overScrollMode = WebView.OVER_SCROLL_NEVER + // Add JavaScript interface for localized strings + webView.addJavascriptInterface(LocalizationInterface(this), "Android") + // Hide system bars for full-screen E-Ink display hideSystemBars() @@ -67,4 +73,26 @@ class MainActivity : AppCompatActivity() { super.onWindowFocusChanged(hasFocus) if (hasFocus) hideSystemBars() } + + /** + * JavaScript interface that provides localized strings to the web client. + * Call from JavaScript: window.Android.getString("key") + */ + inner class LocalizationInterface(context: Context) { + @JavascriptInterface + fun getString(key: String): String { + return try { + val resourceId = context.resources.getIdentifier(key, "string", context.packageName) + if (resourceId != 0) { + context.resources.getString(resourceId) + } else { + Log.w("PaperDash", "String resource not found: $key") + "" + } + } catch (e: Exception) { + Log.e("PaperDash", "Error getting string: $key", e) + "" + } + } + } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d956be4 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,28 @@ + + + PaperDash + PaperDash + Enter PIN to connect + **** + Connect + Enter a PIN + Disconnected. Re-enter PIN. + Connecting… + No playback + Album art + Master + MUTE + MUTED + No notifications + ETA: + Settings + Enter API token to access settings + Access + Dashboard PIN + API Token + Save + Back + Invalid token + Settings saved + Failed to save settings + diff --git a/client/app.js b/client/app.js index 4948748..a3918e6 100644 --- a/client/app.js +++ b/client/app.js @@ -4,18 +4,92 @@ * Connects to the PaperDash server via WebSocket. * Uses vanilla JS with direct DOM manipulation (element.textContent) * to minimize E-Ink refresh flashing. + * + * Strings are loaded from Android resources via the JavaScript interface. */ (() => { const SERVER_WS = `ws://${window.location.host}/ws`; + const SERVER_API = `http://${window.location.host}`; 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"); + const SETTINGS_SCREEN = document.getElementById("settings-screen"); + const SETTINGS_TOKEN_INPUT = document.getElementById("settings-token-input"); + const SETTINGS_ACCESS_BTN = document.getElementById("settings-access-btn"); + const SETTINGS_ERROR = document.getElementById("settings-error"); + const SETTINGS_CONTENT = document.getElementById("settings-content"); + const SETTINGS_PIN = document.getElementById("settings-pin"); + const SETTINGS_TOKEN = document.getElementById("settings-token"); + const SETTINGS_SAVE_BTN = document.getElementById("settings-save-btn"); + const SETTINGS_BACK_BTN = document.getElementById("settings-back-btn"); + const SETTINGS_STATUS = document.getElementById("settings-status"); + const BTN_SETTINGS = document.getElementById("btn-settings"); let ws = null; let reconnectTimer = null; + let currentApiToken = ""; + const strings = {}; + + // ----------------------------------------------------------------------- + // Localization + // ----------------------------------------------------------------------- + + function loadStrings() { + if (window.Android && window.Android.getString) { + const keys = [ + "login_error_enter_pin", + "connecting", + "login_error_disconnected", + "no_playback", + "mute", + "muted", + "no_notifications", + "eta_prefix", + "settings_title", + "settings_subtitle", + "access", + "dashboard_pin_label", + "api_token_label", + "save", + "back", + "settings_error_invalid_token", + "settings_saved", + "settings_error_save_failed", + ]; + keys.forEach((key) => { + strings[key] = window.Android.getString(key); + }); + } + applyStrings(); + } + + function applyStrings() { + // Apply data-i18n attributes + document.querySelectorAll("[data-i18n]").forEach((el) => { + const key = el.getAttribute("data-i18n"); + if (strings[key]) { + el.textContent = strings[key]; + } + }); + + // Apply data-i18n-placeholder attributes + document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + const key = el.getAttribute("data-i18n-placeholder"); + if (strings[key]) { + el.placeholder = strings[key]; + } + }); + } + + // Load strings when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", loadStrings); + } else { + loadStrings(); + } // ----------------------------------------------------------------------- // PIN Login @@ -24,13 +98,13 @@ function connect() { const pin = PIN_INPUT.value.trim(); if (!pin) { - LOGIN_ERROR.textContent = "Enter a PIN"; + LOGIN_ERROR.textContent = strings.login_error_enter_pin || "Enter a PIN"; return; } LOGIN_ERROR.textContent = ""; CONNECT_BTN.disabled = true; - CONNECT_BTN.textContent = "Connecting..."; + CONNECT_BTN.textContent = strings.connecting || "Connecting..."; ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`); @@ -38,7 +112,7 @@ LOGIN_SCREEN.classList.remove("active"); DASHBOARD_SCREEN.classList.add("active"); CONNECT_BTN.disabled = false; - CONNECT_BTN.textContent = "Connect"; + CONNECT_BTN.textContent = strings.login_button || "Connect"; }; ws.onmessage = (event) => { @@ -53,9 +127,10 @@ ws.onclose = () => { DASHBOARD_SCREEN.classList.remove("active"); LOGIN_SCREEN.classList.add("active"); - LOGIN_ERROR.textContent = "Disconnected. Re-enter PIN."; + LOGIN_ERROR.textContent = + strings.login_error_disconnected || "Disconnected. Re-enter PIN."; CONNECT_BTN.disabled = false; - CONNECT_BTN.textContent = "Connect"; + CONNECT_BTN.textContent = strings.login_button || "Connect"; // Auto-reconnect after 5 seconds clearTimeout(reconnectTimer); @@ -91,7 +166,7 @@ // ----------------------------------------------------------------------- function updateMedia(media) { - setText("track-title", media.title || "No playback"); + setText("track-title", media.title || strings.no_playback || "No playback"); setText("track-artist", media.artist || ""); setText("track-album", media.album || ""); @@ -127,10 +202,10 @@ const muteBtn = document.getElementById("btn-mute"); if (audio.master_muted) { muteBtn.classList.add("muted"); - muteBtn.textContent = "MUTED"; + muteBtn.textContent = strings.muted || "MUTED"; } else { muteBtn.classList.remove("muted"); - muteBtn.textContent = "MUTE"; + muteBtn.textContent = strings.mute || "MUTE"; } // Channels — use DOM API @@ -211,7 +286,7 @@ if (!notifications || notifications.length === 0) { const p = document.createElement("p"); p.style.color = "#888"; - p.textContent = "No notifications"; + p.textContent = strings.no_notifications || "No notifications"; container.appendChild(p); return; } @@ -244,7 +319,7 @@ if (n.eta) { const etaDiv = document.createElement("div"); etaDiv.className = "notification-eta"; - etaDiv.textContent = "ETA: " + n.eta; + etaDiv.textContent = (strings.eta_prefix || "ETA:") + " " + n.eta; div.appendChild(etaDiv); } @@ -365,4 +440,96 @@ const s = Math.floor(seconds % 60); return m + ":" + (s < 10 ? "0" : "") + s; } + + // ----------------------------------------------------------------------- + // Settings + // ----------------------------------------------------------------------- + + function showSettings() { + DASHBOARD_SCREEN.classList.remove("active"); + SETTINGS_SCREEN.classList.add("active"); + SETTINGS_ERROR.textContent = ""; + SETTINGS_STATUS.textContent = ""; + SETTINGS_TOKEN_INPUT.value = ""; + SETTINGS_CONTENT.style.display = "none"; + } + + function hideSettings() { + SETTINGS_SCREEN.classList.remove("active"); + DASHBOARD_SCREEN.classList.add("active"); + } + + async function accessSettings() { + const token = SETTINGS_TOKEN_INPUT.value.trim(); + if (!token) { + SETTINGS_ERROR.textContent = + strings.settings_error_invalid_token || "Invalid token"; + return; + } + + try { + const response = await fetch(`${SERVER_API}/api/v1/settings`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + SETTINGS_ERROR.textContent = + strings.settings_error_invalid_token || "Invalid token"; + return; + } + + const data = await response.json(); + currentApiToken = token; + SETTINGS_ERROR.textContent = ""; + SETTINGS_PIN.value = data.dashboard_pin || ""; + SETTINGS_TOKEN.value = data.api_token || ""; + SETTINGS_CONTENT.style.display = "block"; + } catch (err) { + SETTINGS_ERROR.textContent = + strings.settings_error_invalid_token || "Invalid token"; + } + } + + async function saveSettings() { + const pin = SETTINGS_PIN.value.trim(); + const token = SETTINGS_TOKEN.value.trim(); + + try { + const response = await fetch(`${SERVER_API}/api/v1/settings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${currentApiToken}`, + }, + body: JSON.stringify({ + dashboard_pin: pin, + api_token: token, + }), + }); + + if (!response.ok) { + SETTINGS_STATUS.textContent = + strings.settings_error_save_failed || "Failed to save settings"; + return; + } + + SETTINGS_STATUS.textContent = + strings.settings_saved || "Settings saved"; + currentApiToken = token; + } catch (err) { + SETTINGS_STATUS.textContent = + strings.settings_error_save_failed || "Failed to save settings"; + } + } + + // Settings event listeners + BTN_SETTINGS.addEventListener("click", showSettings); + SETTINGS_ACCESS_BTN.addEventListener("click", accessSettings); + SETTINGS_SAVE_BTN.addEventListener("click", saveSettings); + SETTINGS_BACK_BTN.addEventListener("click", hideSettings); + SETTINGS_TOKEN_INPUT.addEventListener("keydown", (e) => { + if (e.key === "Enter") accessSettings(); + }); })(); diff --git a/client/index.html b/client/index.html index 2305739..f8a717f 100644 --- a/client/index.html +++ b/client/index.html @@ -8,34 +8,40 @@ /> - PaperDash + PaperDash
-

PaperDash

-

Enter PIN to connect

+

PaperDash

+

Enter PIN to connect

- +

+ + +
-

Now Playing

+

Now Playing

-
No playback
+
+ No playback +
@@ -57,22 +63,24 @@
-

Audio Mixer

+

Audio Mixer

- Master + Master
50%
- +
-

System

+

System

CPU @@ -95,10 +103,49 @@
-

Notifications

+

Notifications

+ + +
+

Settings

+

Enter API token to access settings

+ + +

+ + +
diff --git a/client/style.css b/client/style.css index 87226aa..33af1cc 100644 --- a/client/style.css +++ b/client/style.css @@ -342,6 +342,107 @@ body { overflow-x: hidden; } +/* Settings button */ +.settings-btn { + position: absolute; + top: 10px; + right: 10px; + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + width: 40px; + height: 40px; + font-size: 1.2em; + cursor: pointer; + z-index: 100; + font-family: inherit; +} + +/* Settings screen */ +#settings-screen { + justify-content: flex-start; + padding-top: 40px; +} + +#settings-screen h1 { + font-size: 1.5em; + border-bottom: 2px solid var(--border); + padding-bottom: 8px; + margin-bottom: 12px; +} + +#settings-screen p { + margin-bottom: 12px; + color: var(--dim); +} + +#settings-token-input { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 10px; + width: 100%; + margin-bottom: 10px; + font-family: inherit; + font-size: 1em; +} + +#settings-access-btn, +#settings-save-btn, +#settings-back-btn { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 10px 20px; + font-size: 1em; + cursor: pointer; + margin-right: 10px; + margin-bottom: 10px; + font-family: inherit; +} + +#settings-content { + margin-top: 20px; +} + +#settings-content .panel { + margin-bottom: 15px; +} + +#settings-content h2 { + font-size: 0.9em; + margin-bottom: 8px; + color: var(--dim); +} + +#settings-pin, +#settings-token { + background: var(--bg); + color: var(--fg); + border: 2px solid var(--border); + padding: 10px; + width: 100%; + font-family: inherit; + font-size: 1em; +} + +#settings-status { + margin-top: 15px; + padding: 10px; + border: 2px solid var(--border); + background: var(--panel-bg); +} + +.status { + color: var(--fg); +} + +.error { + color: var(--dim); + font-size: 0.9em; + margin-top: 10px; +} + /* E-Ink specific: no transitions, sharp edges */ * { transition: none !important; diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..c7822cb --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,9 @@ +# PaperDash Configuration +# Copy this file to config.yaml and fill in your values + +dashboard_pin: "1234" +api_token: "your-secret-token-here" + +host: "0.0.0.0" +port: 8921 +log_level: INFO diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..3e9144c --- /dev/null +++ b/server/.env.example @@ -0,0 +1,13 @@ +# PaperDash Configuration +# Copy this file to .env and fill in your values + +# Dashboard PIN (4-digit code for the E-Ink client) +DASHBOARD_PIN=1234 + +# API Token (Bearer token for external scripts) +API_TOKEN=your-secret-token-here + +# Server settings +HOST=0.0.0.0 +PORT=8921 +LOG_LEVEL=INFO diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..a95b857 --- /dev/null +++ b/server/config.py @@ -0,0 +1,176 @@ +"""Configuration management for PaperDash. + +Loads config from: +1. config.yaml (if exists) +2. .env file (if exists) +3. Environment variables (fallback) +""" + +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path +from typing import Any + +logger = logging.getLogger("paperdash.config") + +# Try to import yaml, fall back to simple parser +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + + +class Config: + """PaperDash configuration.""" + + def __init__(self) -> None: + self.dashboard_pin: str = "" + self.api_token: str = "" + self.host: str = "0.0.0.0" + self.port: int = 8921 + self.log_level: str = "INFO" + + def load(self) -> None: + """Load configuration from all sources.""" + # 1. Load config.yaml + config_file = self._find_config_file() + if config_file and config_file.exists(): + self._load_yaml(config_file) + + # 2. Load .env file + env_file = self._find_env_file() + if env_file and env_file.exists(): + self._load_env(env_file) + + # 3. Environment variables override everything + self._load_from_env() + + # Validate + if not self.dashboard_pin: + logger.warning("DASHBOARD_PIN not set — WebSocket auth will be disabled") + if not self.api_token: + logger.warning("API_TOKEN not set — /api/v1/notify will reject all requests") + + def _get_base_path(self) -> Path: + """Get base path (exe directory or cwd).""" + if getattr(sys, 'frozen', False): + return Path(sys.executable).parent + return Path.cwd() + + def _find_config_file(self) -> Path | None: + """Find config.yaml relative to the executable or working directory.""" + base = self._get_base_path() + for candidate in [base] + list(base.parents): + config_path = candidate / "config.yaml" + if config_path.exists(): + return config_path + return None + + def _find_env_file(self) -> Path | None: + """Find .env file relative to the executable or working directory.""" + base = self._get_base_path() + for candidate in [base] + list(base.parents): + env_path = candidate / ".env" + if env_path.exists(): + return env_path + return None + + def _load_yaml(self, path: Path) -> None: + """Load configuration from YAML file.""" + if not HAS_YAML: + logger.warning("PyYAML not installed — skipping config.yaml") + return + + try: + with open(path) as f: + data = yaml.safe_load(f) or {} # type: ignore[possibly-undefined] + + self.dashboard_pin = data.get("dashboard_pin", self.dashboard_pin) + self.api_token = data.get("api_token", self.api_token) + self.host = data.get("host", self.host) + self.port = data.get("port", self.port) + self.log_level = data.get("log_level", self.log_level).upper() + + logger.info(f"Loaded config from {path}") + except Exception as e: + logger.error(f"Failed to load config from {path}: {e}") + + def _load_env(self, path: Path) -> None: + """Load configuration from .env file.""" + try: + with open(path) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("'\"") + + match key: + case "DASHBOARD_PIN": + self.dashboard_pin = value + case "API_TOKEN": + self.api_token = value + case "HOST": + self.host = value + case "PORT": + self.port = int(value) + case "LOG_LEVEL": + self.log_level = value.upper() + + logger.info(f"Loaded config from {path}") + except Exception as e: + logger.error(f"Failed to load .env from {path}: {e}") + + def _load_from_env(self) -> None: + """Load configuration from environment variables (highest priority).""" + self.dashboard_pin = os.environ.get("DASHBOARD_PIN", self.dashboard_pin) + self.api_token = os.environ.get("API_TOKEN", self.api_token) + self.host = os.environ.get("HOST", self.host) + try: + self.port = int(os.environ.get("PORT", self.port)) + except ValueError: + logger.warning("Invalid PORT environment variable") + self.log_level = os.environ.get("LOG_LEVEL", self.log_level).upper() + + def save(self, path: Path | None = None) -> None: + """Save configuration to YAML file.""" + if path is None: + path = self._find_config_file() or Path.cwd() / "config.yaml" + + if not HAS_YAML: + logger.error("PyYAML not installed — cannot save config") + return + + data = { + "dashboard_pin": self.dashboard_pin, + "api_token": self.api_token, + "host": self.host, + "port": self.port, + "log_level": self.log_level, + } + + try: + with open(path, "w") as f: + yaml.dump(data, f, default_flow_style=False) # type: ignore[possibly-undefined] + logger.info(f"Saved config to {path}") + except Exception as e: + logger.error(f"Failed to save config to {path}: {e}") + + def to_dict(self) -> dict[str, Any]: + """Return configuration as a dictionary (for API responses).""" + return { + "dashboard_pin": self.dashboard_pin, + "api_token": self.api_token, + "host": self.host, + "port": self.port, + "log_level": self.log_level, + } + + +# Global config instance +config = Config() diff --git a/server/main.py b/server/main.py index 2c1fc25..c3c19bb 100644 --- a/server/main.py +++ b/server/main.py @@ -17,6 +17,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from .audio_mixer import AudioMixerManager +from .config import config from .media import MediaManager from .notifications import NotificationManager, Notification from .telemetry import TelemetryManager @@ -24,9 +25,78 @@ from .telemetry import TelemetryManager logger = logging.getLogger("paperdash") +# --------------------------------------------------------------------------- +# Hardware Subsystem Check +# --------------------------------------------------------------------------- + + +def check_subsystems() -> dict[str, bool]: + """Check which hardware subsystems are available.""" + results: dict[str, bool] = {} + + # Check Voicemeeter + try: + import voicemeeter_api # noqa: F401 # type: ignore + results["voicemeeter"] = True + except ImportError: + results["voicemeeter"] = False + + # Check Windows Audio (pycaw) + try: + import pycaw # noqa: F401 # type: ignore + results["windows_audio"] = True + except ImportError: + results["windows_audio"] = False + + # Check Windows Media (winsdk) + try: + import winsdk # noqa: F401 # type: ignore + results["windows_media"] = True + except ImportError: + results["windows_media"] = False + + # Check HWiNFO (pyhwinfo) + try: + import pyhwinfo # noqa: F401 # type: ignore + results["hwinfo"] = True + except ImportError: + results["hwinfo"] = False + + return results + + +# --------------------------------------------------------------------------- +# Lifespan +# --------------------------------------------------------------------------- + + @asynccontextmanager async def lifespan(app: FastAPI): """Start and stop background managers.""" + # Load configuration + config.load() + + # Configure logging + logging.basicConfig( + level=getattr(logging, config.log_level, logging.INFO), + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + + # Check hardware subsystems + subsystems = check_subsystems() + app.state.subsystems = subsystems + logger.info(f"Hardware subsystems: {subsystems}") + + # Log configuration status + if config.dashboard_pin: + logger.info("Dashboard PIN is configured") + else: + logger.warning("Dashboard PIN is NOT configured — WebSocket auth disabled") + if config.api_token: + logger.info("API token is configured") + else: + logger.warning("API token is NOT configured — /api/v1/notify disabled") + # Start managers app.state.media = MediaManager() app.state.audio = AudioMixerManager() @@ -59,7 +129,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Local network only — fine for this use case + allow_origins=["*"], # Local network only — fine for this use case # noqa: B008 allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -78,7 +148,7 @@ class WebSocketManager: self.connections: list[WebSocket] = [] async def connect(self, ws: WebSocket, pin: str) -> bool: - expected_pin = os.environ.get("DASHBOARD_PIN", "") + expected_pin = config.dashboard_pin if not expected_pin or pin != expected_pin: await ws.close(code=4001, reason="Invalid PIN") return False @@ -162,7 +232,7 @@ async def handle_client_command(data: dict[str, Any]) -> None: def _verify_token(authorization: str | None) -> bool: """Verify Bearer token from Authorization header.""" - expected = os.environ.get("API_TOKEN", "") + expected = config.api_token if not expected: return False if not authorization or not authorization.startswith("Bearer "): @@ -178,6 +248,7 @@ async def get_state(): "audio": app.state.audio.get_state(), "telemetry": app.state.telemetry.get_state(), "notifications": app.state.notifications.get_active(), + "subsystems": app.state.subsystems, } @@ -195,6 +266,83 @@ async def create_notification( return {"id": created.id, "status": "created"} +# --------------------------------------------------------------------------- +# Settings (Protected by API Token) +# --------------------------------------------------------------------------- + + +class SettingsUpdate: + """Settings update payload.""" + + def __init__( + self, + dashboard_pin: str | None = None, + api_token: str | None = None, + host: str | None = None, + port: int | None = None, + log_level: str | None = None, + ): + self.dashboard_pin = dashboard_pin + self.api_token = api_token + self.host = host + self.port = port + self.log_level = log_level + + +@app.get("/api/v1/settings") +async def get_settings( + authorization: str | None = None, +): + """Get current settings (requires API token).""" + if not _verify_token(authorization): + raise HTTPException(status_code=401, detail="Invalid or missing API token") + return config.to_dict() + + +@app.post("/api/v1/settings") +async def update_settings( + settings: SettingsUpdate, + authorization: str | None = None, +): + """Update settings (requires API token).""" + if not _verify_token(authorization): + raise HTTPException(status_code=401, detail="Invalid or missing API token") + + if settings.dashboard_pin is not None: + config.dashboard_pin = settings.dashboard_pin + if settings.api_token is not None: + config.api_token = settings.api_token + if settings.host is not None: + config.host = settings.host + if settings.port is not None: + config.port = settings.port + if settings.log_level is not None: + config.log_level = settings.log_level.upper() + + # Save to config file + config.save() + + logger.info("Settings updated") + return config.to_dict() + + +# --------------------------------------------------------------------------- +# Hardware Status +# --------------------------------------------------------------------------- + + +@app.get("/api/v1/status") +async def get_status(): + """Get server status including hardware subsystem availability.""" + return { + "subsystems": app.state.subsystems, + "config": { + "pin_configured": bool(config.dashboard_pin), + "token_configured": bool(config.api_token), + }, + } + + # --------------------------------------------------------------------------- # Health # --------------------------------------------------------------------------- @@ -206,16 +354,24 @@ async def health(): # --------------------------------------------------------------------------- -# Client HTML (served directly for Hermit WebView) +# Client HTML (served directly for WebView) # --------------------------------------------------------------------------- @app.get("/", response_class=HTMLResponse) async def serve_client(): """Serve the E-Ink dashboard client.""" - client_dir = os.path.join(os.path.dirname(__file__), "..", "client") - index_path = os.path.join(client_dir, "index.html") - if os.path.exists(index_path): - with open(index_path, "r") as f: - return f.read() - return HTMLResponse("

PaperDash

Client files not found in client/

") + # Try multiple locations for the client files + possible_paths = [ + os.path.join(os.path.dirname(__file__), "..", "client"), + os.path.join(os.path.dirname(__file__), "..", "..", "client"), + os.path.join(os.path.dirname(__file__), "client"), + ] + + for client_dir in possible_paths: + index_path = os.path.join(client_dir, "index.html") + if os.path.exists(index_path): + with open(index_path) as f: + return f.read() + + return HTMLResponse("

PaperDash

Client files not found.

") diff --git a/server/requirements.txt b/server/requirements.txt index 988f98f..8b27ae6 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -2,6 +2,7 @@ fastapi>=0.100.0 uvicorn[standard]>=0.23.0 pydantic>=2.0.0 +pyyaml>=6.0 # Windows APIs winsdk>=1.0.0 diff --git a/server/tests/test_config.py b/server/tests/test_config.py new file mode 100644 index 0000000..0069856 --- /dev/null +++ b/server/tests/test_config.py @@ -0,0 +1,134 @@ +"""Tests for the configuration module.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +import pytest + +from ..config import Config + + +@pytest.fixture +def config() -> Config: + return Config() + + +class TestConfigDefaults: + def test_defaults(self, config: Config) -> None: + assert config.dashboard_pin == "" + assert config.api_token == "" + assert config.host == "0.0.0.0" + assert config.port == 8921 + assert config.log_level == "INFO" + + +class TestConfigLoad: + def test_load_from_env(self, config: Config) -> None: + os.environ["DASHBOARD_PIN"] = "5678" + os.environ["API_TOKEN"] = "test-token" + os.environ["HOST"] = "127.0.0.1" + os.environ["PORT"] = "9000" + os.environ["LOG_LEVEL"] = "DEBUG" + + config.load() + + assert config.dashboard_pin == "5678" + assert config.api_token == "test-token" + assert config.host == "127.0.0.1" + assert config.port == 9000 + assert config.log_level == "DEBUG" + + # Cleanup + del os.environ["DASHBOARD_PIN"] + del os.environ["API_TOKEN"] + del os.environ["HOST"] + del os.environ["PORT"] + del os.environ["LOG_LEVEL"] + + def test_load_from_env_file(self, config: Config) -> None: + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("DASHBOARD_PIN=1111\n") + f.write("API_TOKEN=env-token\n") + f.write("# Comment\n") + f.write("HOST=0.0.0.0\n") + f.write("PORT=8921\n") + env_file = f.name + + try: + config._load_env(Path(env_file)) + assert config.dashboard_pin == "1111" + assert config.api_token == "env-token" + finally: + os.unlink(env_file) + + def test_load_from_yaml_file(self, config: Config) -> None: + """Test YAML loading (requires PyYAML).""" + try: + import yaml # noqa: F401 + except ImportError: + pytest.skip("PyYAML not installed") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump( # type: ignore[possibly-undefined] + { + "dashboard_pin": "2222", + "api_token": "yaml-token", + "host": "0.0.0.0", + "port": 8921, + "log_level": "INFO", + }, + f, + ) + yaml_file = f.name + + try: + config._load_yaml(Path(yaml_file)) + assert config.dashboard_pin == "2222" + assert config.api_token == "yaml-token" + finally: + os.unlink(yaml_file) + + +class TestConfigSave: + def test_save_to_yaml(self, config: Config) -> None: + """Test YAML saving (requires PyYAML).""" + try: + import yaml # noqa: F401 + except ImportError: + pytest.skip("PyYAML not installed") + + config.dashboard_pin = "3333" + config.api_token = "save-token" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + save_path = f.name + + try: + config.save(Path(save_path)) + + with open(save_path) as f: + data = yaml.safe_load(f) # type: ignore[possibly-undefined] + + assert data["dashboard_pin"] == "3333" + assert data["api_token"] == "save-token" + finally: + os.unlink(save_path) + + +class TestConfigToDict: + def test_to_dict(self, config: Config) -> None: + config.dashboard_pin = "4444" + config.api_token = "dict-token" + config.host = "127.0.0.1" + config.port = 9000 + config.log_level = "DEBUG" + + d = config.to_dict() + assert d["dashboard_pin"] == "4444" + assert d["api_token"] == "dict-token" + assert d["host"] == "127.0.0.1" + assert d["port"] == 9000 + assert d["log_level"] == "DEBUG" diff --git a/server/tests/test_main.py b/server/tests/test_main.py index 7be529e..08030d4 100644 --- a/server/tests/test_main.py +++ b/server/tests/test_main.py @@ -92,3 +92,58 @@ class TestNotifyEndpoint: assert response.status_code == 401 del os.environ["API_TOKEN"] + + +class TestSettingsEndpoint: + def test_get_settings_without_token_returns_401(self, client: TestClient) -> None: + response = client.get("/api/v1/settings") + assert response.status_code == 401 + + def test_get_settings_with_valid_token(self, client: TestClient) -> None: + os.environ["API_TOKEN"] = "settings-token" + + response = client.get( + "/api/v1/settings", + headers={"Authorization": "Bearer settings-token"}, + ) + assert response.status_code == 200 + data = response.json() + assert "dashboard_pin" in data + assert "api_token" in data + assert "host" in data + assert "port" in data + + del os.environ["API_TOKEN"] + + def test_update_settings(self, client: TestClient) -> None: + os.environ["API_TOKEN"] = "update-token" + + response = client.post( + "/api/v1/settings", + json={ + "dashboard_pin": "9999", + "api_token": "new-token", + }, + headers={"Authorization": "Bearer update-token"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["dashboard_pin"] == "9999" + assert data["api_token"] == "new-token" + + del os.environ["API_TOKEN"] + + +class TestStatusEndpoint: + def test_status_returns_subsystems(self, client: TestClient) -> None: + # Ensure subsystems are initialized + if not hasattr(app.state, "subsystems"): + app.state.subsystems = {} + + response = client.get("/api/v1/status") + assert response.status_code == 200 + data = response.json() + assert "subsystems" in data + assert "config" in data + assert "pin_configured" in data["config"] + assert "token_configured" in data["config"]