feat: config files, settings screen, hardware check
Build Android APK / build (push) Failing after 5m7s
Build Server .exe / build (push) Failing after 2m27s

- Add config.py with multi-source config (YAML, .env, env vars)
- Add .env.example and config.yaml.example
- Add settings API endpoints (GET/POST /api/v1/settings)
- Add status endpoint (GET /api/v1/status) with hardware subsystem check
- Add settings screen to web client (protected by API token)
- Add localization strings for settings UI
- Add tests for config module and new endpoints
- Update README with config documentation
This commit is contained in:
Imrayya
2026-07-01 13:32:08 +00:00
parent 8e2212f035
commit 871a3cc174
16 changed files with 1341 additions and 55 deletions
+59 -3
View File
@@ -66,7 +66,10 @@ flowchart TD
- **Media Playback Tracking** — Song title, artist, playback percentage, and cover art via Windows GSMTC - **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 - **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 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` - **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 - **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 - **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 │ └── paperdash_architecture_document.txt
├── server/ # Python FastAPI server ├── server/ # Python FastAPI server
│ ├── main.py # Application entry point │ ├── main.py # Application entry point
│ ├── config.py # Configuration management (YAML/.env/env vars)
│ ├── audio_mixer.py # Audio mixer abstraction (Windows + Voicemeeter) │ ├── audio_mixer.py # Audio mixer abstraction (Windows + Voicemeeter)
│ ├── media.py # Media playback tracking (GSMTC) │ ├── media.py # Media playback tracking (GSMTC)
│ ├── notifications.py # Notification system (progress + alerts) │ ├── notifications.py # Notification system (progress + alerts)
│ ├── telemetry.py # Hardware telemetry (HWiNFO) │ ├── telemetry.py # Hardware telemetry (HWiNFO)
│ ├── tray_wrapper.py # System tray .exe wrapper │ ├── tray_wrapper.py # System tray .exe wrapper
│ ├── build_exe.py # PyInstaller build script │ ├── build_exe.py # PyInstaller build script
│ ├── .env.example # Example environment file
│ ├── requirements.txt │ ├── requirements.txt
│ └── tests/ # Test suite │ └── tests/ # Test suite
├── client/ # E-Ink web client ├── 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 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 1. **Environment variables:**
- `API_TOKEN` — Bearer token for external scripts - `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 ### 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://<pc-ip>:8921/ws`. The WebView loads `file:///android_asset/index.html` and connects to the server via WebSocket at `ws://<pc-ip>: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
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PaperDash</string>
<string name="login_title">PaperDash</string>
<string name="login_subtitle">Ingrese PIN para conectar</string>
<string name="login_placeholder">****</string>
<string name="login_button">Conectar</string>
<string name="login_error_enter_pin">Ingrese un PIN</string>
<string name="login_error_disconnected">Desconectado. Reingrese PIN.</string>
<string name="connecting">Conectando…</string>
<string name="no_playback">Sin reproducción</string>
<string name="mute">SILENCIAR</string>
<string name="muted">SILENCIADO</string>
<string name="no_notifications">Sin notificaciones</string>
<string name="eta_prefix">ETA:</string>
</resources>
```
The app automatically uses the device's language. No code changes needed.
### Design Decisions ### Design Decisions
See `.pi/docs/design/` for detailed design decision records covering architecture, audio mixer, notifications, and the .exe build. See `.pi/docs/design/` for detailed design decision records covering architecture, audio mixer, notifications, and the .exe build.
+177 -10
View File
@@ -4,18 +4,92 @@
* Connects to the PaperDash server via WebSocket. * Connects to the PaperDash server via WebSocket.
* Uses vanilla JS with direct DOM manipulation (element.textContent) * Uses vanilla JS with direct DOM manipulation (element.textContent)
* to minimize E-Ink refresh flashing. * 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_WS = `ws://${window.location.host}/ws`;
const SERVER_API = `http://${window.location.host}`;
const PIN_INPUT = document.getElementById("pin-input"); const PIN_INPUT = document.getElementById("pin-input");
const CONNECT_BTN = document.getElementById("connect-btn"); const CONNECT_BTN = document.getElementById("connect-btn");
const LOGIN_ERROR = document.getElementById("login-error"); const LOGIN_ERROR = document.getElementById("login-error");
const LOGIN_SCREEN = document.getElementById("login-screen"); const LOGIN_SCREEN = document.getElementById("login-screen");
const DASHBOARD_SCREEN = document.getElementById("dashboard-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 ws = null;
let reconnectTimer = 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 // PIN Login
@@ -24,13 +98,13 @@
function connect() { function connect() {
const pin = PIN_INPUT.value.trim(); const pin = PIN_INPUT.value.trim();
if (!pin) { if (!pin) {
LOGIN_ERROR.textContent = "Enter a PIN"; LOGIN_ERROR.textContent = strings.login_error_enter_pin || "Enter a PIN";
return; return;
} }
LOGIN_ERROR.textContent = ""; LOGIN_ERROR.textContent = "";
CONNECT_BTN.disabled = true; CONNECT_BTN.disabled = true;
CONNECT_BTN.textContent = "Connecting..."; CONNECT_BTN.textContent = strings.connecting || "Connecting...";
ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`); ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`);
@@ -38,7 +112,7 @@
LOGIN_SCREEN.classList.remove("active"); LOGIN_SCREEN.classList.remove("active");
DASHBOARD_SCREEN.classList.add("active"); DASHBOARD_SCREEN.classList.add("active");
CONNECT_BTN.disabled = false; CONNECT_BTN.disabled = false;
CONNECT_BTN.textContent = "Connect"; CONNECT_BTN.textContent = strings.login_button || "Connect";
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -53,9 +127,10 @@
ws.onclose = () => { ws.onclose = () => {
DASHBOARD_SCREEN.classList.remove("active"); DASHBOARD_SCREEN.classList.remove("active");
LOGIN_SCREEN.classList.add("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.disabled = false;
CONNECT_BTN.textContent = "Connect"; CONNECT_BTN.textContent = strings.login_button || "Connect";
// Auto-reconnect after 5 seconds // Auto-reconnect after 5 seconds
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
@@ -91,7 +166,7 @@
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
function updateMedia(media) { 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-artist", media.artist || "");
setText("track-album", media.album || ""); setText("track-album", media.album || "");
@@ -127,10 +202,10 @@
const muteBtn = document.getElementById("btn-mute"); const muteBtn = document.getElementById("btn-mute");
if (audio.master_muted) { if (audio.master_muted) {
muteBtn.classList.add("muted"); muteBtn.classList.add("muted");
muteBtn.textContent = "MUTED"; muteBtn.textContent = strings.muted || "MUTED";
} else { } else {
muteBtn.classList.remove("muted"); muteBtn.classList.remove("muted");
muteBtn.textContent = "MUTE"; muteBtn.textContent = strings.mute || "MUTE";
} }
// Channels — use DOM API // Channels — use DOM API
@@ -211,7 +286,7 @@
if (!notifications || notifications.length === 0) { if (!notifications || notifications.length === 0) {
const p = document.createElement("p"); const p = document.createElement("p");
p.style.color = "#888"; p.style.color = "#888";
p.textContent = "No notifications"; p.textContent = strings.no_notifications || "No notifications";
container.appendChild(p); container.appendChild(p);
return; return;
} }
@@ -244,7 +319,7 @@
if (n.eta) { if (n.eta) {
const etaDiv = document.createElement("div"); const etaDiv = document.createElement("div");
etaDiv.className = "notification-eta"; etaDiv.className = "notification-eta";
etaDiv.textContent = "ETA: " + n.eta; etaDiv.textContent = (strings.eta_prefix || "ETA:") + " " + n.eta;
div.appendChild(etaDiv); div.appendChild(etaDiv);
} }
@@ -365,4 +440,96 @@
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
return m + ":" + (s < 10 ? "0" : "") + s; 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();
});
})(); })();
+58 -11
View File
@@ -8,34 +8,40 @@
/> />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<title>PaperDash</title> <title data-i18n="app_name">PaperDash</title>
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<!-- PIN Login Screen --> <!-- PIN Login Screen -->
<div id="login-screen" class="screen active"> <div id="login-screen" class="screen active">
<h1>PaperDash</h1> <h1 data-i18n="login_title">PaperDash</h1>
<p>Enter PIN to connect</p> <p data-i18n="login_subtitle">Enter PIN to connect</p>
<input <input
type="password" type="password"
id="pin-input" id="pin-input"
maxlength="4" maxlength="4"
data-i18n-placeholder="login_placeholder"
placeholder="****" placeholder="****"
autocomplete="off" autocomplete="off"
/> />
<button id="connect-btn">Connect</button> <button id="connect-btn" data-i18n="login_button">Connect</button>
<p id="login-error" class="error"></p> <p id="login-error" class="error"></p>
</div> </div>
<!-- Main Dashboard --> <!-- Main Dashboard -->
<div id="dashboard-screen" class="screen"> <div id="dashboard-screen" class="screen">
<!-- Settings Button -->
<button id="btn-settings" class="settings-btn"></button>
<!-- Media Section --> <!-- Media Section -->
<section id="media-section" class="panel"> <section id="media-section" class="panel">
<h2>Now Playing</h2> <h2 data-i18n="now_playing">Now Playing</h2>
<div id="artwork-container"></div> <div id="artwork-container"></div>
<div id="track-info"> <div id="track-info">
<div id="track-title" class="track-title">No playback</div> <div id="track-title" class="track-title" data-i18n="no_playback">
No playback
</div>
<div id="track-artist" class="track-artist"></div> <div id="track-artist" class="track-artist"></div>
<div id="track-album" class="track-album"></div> <div id="track-album" class="track-album"></div>
</div> </div>
@@ -57,22 +63,24 @@
<!-- Audio Mixer Section --> <!-- Audio Mixer Section -->
<section id="audio-section" class="panel"> <section id="audio-section" class="panel">
<h2>Audio Mixer</h2> <h2 data-i18n="audio_mixer">Audio Mixer</h2>
<div id="master-volume"> <div id="master-volume">
<span id="master-label">Master</span> <span id="master-label" data-i18n="master_label">Master</span>
<div class="volume-controls"> <div class="volume-controls">
<button id="btn-vol-down" class="volume-btn">-</button> <button id="btn-vol-down" class="volume-btn">-</button>
<span id="master-volume-value">50%</span> <span id="master-volume-value">50%</span>
<button id="btn-vol-up" class="volume-btn">+</button> <button id="btn-vol-up" class="volume-btn">+</button>
</div> </div>
<button id="btn-mute" class="mute-btn">MUTE</button> <button id="btn-mute" class="mute-btn" data-i18n="mute">
MUTE
</button>
</div> </div>
<div id="channels-container"></div> <div id="channels-container"></div>
</section> </section>
<!-- Telemetry Section --> <!-- Telemetry Section -->
<section id="telemetry-section" class="panel"> <section id="telemetry-section" class="panel">
<h2>System</h2> <h2 data-i18n="system">System</h2>
<div id="telemetry-grid"> <div id="telemetry-grid">
<div class="telemetry-item"> <div class="telemetry-item">
<span class="telemetry-label">CPU</span> <span class="telemetry-label">CPU</span>
@@ -95,10 +103,49 @@
<!-- Notifications Section --> <!-- Notifications Section -->
<section id="notifications-section" class="panel"> <section id="notifications-section" class="panel">
<h2>Notifications</h2> <h2 data-i18n="notifications">Notifications</h2>
<div id="notifications-container"></div> <div id="notifications-container"></div>
</section> </section>
</div> </div>
<!-- Settings Screen -->
<div id="settings-screen" class="screen">
<h1 data-i18n="settings_title">Settings</h1>
<p data-i18n="settings_subtitle">Enter API token to access settings</p>
<input
type="password"
id="settings-token-input"
placeholder="API Token"
autocomplete="off"
/>
<button id="settings-access-btn" data-i18n="access">Access</button>
<p id="settings-error" class="error"></p>
<div id="settings-content" style="display: none;">
<section class="panel">
<h2 data-i18n="dashboard_pin_label">Dashboard PIN</h2>
<input
type="text"
id="settings-pin"
maxlength="4"
placeholder="****"
/>
</section>
<section class="panel">
<h2 data-i18n="api_token_label">API Token</h2>
<input
type="password"
id="settings-token"
placeholder="API Token"
/>
</section>
<button id="settings-save-btn" data-i18n="save">Save</button>
<button id="settings-back-btn" data-i18n="back">Back</button>
<p id="settings-status" class="status"></p>
</div>
</div>
</div> </div>
<script src="app.js"></script> <script src="app.js"></script>
+101
View File
@@ -342,6 +342,107 @@ body {
overflow-x: hidden; 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 */ /* E-Ink specific: no transitions, sharp edges */
* { * {
transition: none !important; transition: none !important;
@@ -1,11 +1,14 @@
package me.kareemhorstink.paperdash package me.kareemhorstink.paperdash
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.View import android.view.View
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.webkit.JavascriptInterface
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
@@ -37,6 +40,9 @@ class MainActivity : AppCompatActivity() {
webView.isVerticalScrollBarEnabled = false webView.isVerticalScrollBarEnabled = false
webView.overScrollMode = WebView.OVER_SCROLL_NEVER 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 // Hide system bars for full-screen E-Ink display
hideSystemBars() hideSystemBars()
@@ -67,4 +73,26 @@ class MainActivity : AppCompatActivity() {
super.onWindowFocusChanged(hasFocus) super.onWindowFocusChanged(hasFocus)
if (hasFocus) hideSystemBars() 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)
""
}
}
}
} }
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PaperDash</string>
<string name="login_title">PaperDash</string>
<string name="login_subtitle">Enter PIN to connect</string>
<string name="login_placeholder">****</string>
<string name="login_button">Connect</string>
<string name="login_error_enter_pin">Enter a PIN</string>
<string name="login_error_disconnected">Disconnected. Re-enter PIN.</string>
<string name="connecting">Connecting…</string>
<string name="no_playback">No playback</string>
<string name="album_art_alt">Album art</string>
<string name="master_label">Master</string>
<string name="mute">MUTE</string>
<string name="muted">MUTED</string>
<string name="no_notifications">No notifications</string>
<string name="eta_prefix">ETA:</string>
<string name="settings_title">Settings</string>
<string name="settings_subtitle">Enter API token to access settings</string>
<string name="access">Access</string>
<string name="dashboard_pin_label">Dashboard PIN</string>
<string name="api_token_label">API Token</string>
<string name="save">Save</string>
<string name="back">Back</string>
<string name="settings_error_invalid_token">Invalid token</string>
<string name="settings_saved">Settings saved</string>
<string name="settings_error_save_failed">Failed to save settings</string>
</resources>
+177 -10
View File
@@ -4,18 +4,92 @@
* Connects to the PaperDash server via WebSocket. * Connects to the PaperDash server via WebSocket.
* Uses vanilla JS with direct DOM manipulation (element.textContent) * Uses vanilla JS with direct DOM manipulation (element.textContent)
* to minimize E-Ink refresh flashing. * 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_WS = `ws://${window.location.host}/ws`;
const SERVER_API = `http://${window.location.host}`;
const PIN_INPUT = document.getElementById("pin-input"); const PIN_INPUT = document.getElementById("pin-input");
const CONNECT_BTN = document.getElementById("connect-btn"); const CONNECT_BTN = document.getElementById("connect-btn");
const LOGIN_ERROR = document.getElementById("login-error"); const LOGIN_ERROR = document.getElementById("login-error");
const LOGIN_SCREEN = document.getElementById("login-screen"); const LOGIN_SCREEN = document.getElementById("login-screen");
const DASHBOARD_SCREEN = document.getElementById("dashboard-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 ws = null;
let reconnectTimer = 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 // PIN Login
@@ -24,13 +98,13 @@
function connect() { function connect() {
const pin = PIN_INPUT.value.trim(); const pin = PIN_INPUT.value.trim();
if (!pin) { if (!pin) {
LOGIN_ERROR.textContent = "Enter a PIN"; LOGIN_ERROR.textContent = strings.login_error_enter_pin || "Enter a PIN";
return; return;
} }
LOGIN_ERROR.textContent = ""; LOGIN_ERROR.textContent = "";
CONNECT_BTN.disabled = true; CONNECT_BTN.disabled = true;
CONNECT_BTN.textContent = "Connecting..."; CONNECT_BTN.textContent = strings.connecting || "Connecting...";
ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`); ws = new WebSocket(`${SERVER_WS}?pin=${encodeURIComponent(pin)}`);
@@ -38,7 +112,7 @@
LOGIN_SCREEN.classList.remove("active"); LOGIN_SCREEN.classList.remove("active");
DASHBOARD_SCREEN.classList.add("active"); DASHBOARD_SCREEN.classList.add("active");
CONNECT_BTN.disabled = false; CONNECT_BTN.disabled = false;
CONNECT_BTN.textContent = "Connect"; CONNECT_BTN.textContent = strings.login_button || "Connect";
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -53,9 +127,10 @@
ws.onclose = () => { ws.onclose = () => {
DASHBOARD_SCREEN.classList.remove("active"); DASHBOARD_SCREEN.classList.remove("active");
LOGIN_SCREEN.classList.add("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.disabled = false;
CONNECT_BTN.textContent = "Connect"; CONNECT_BTN.textContent = strings.login_button || "Connect";
// Auto-reconnect after 5 seconds // Auto-reconnect after 5 seconds
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
@@ -91,7 +166,7 @@
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
function updateMedia(media) { 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-artist", media.artist || "");
setText("track-album", media.album || ""); setText("track-album", media.album || "");
@@ -127,10 +202,10 @@
const muteBtn = document.getElementById("btn-mute"); const muteBtn = document.getElementById("btn-mute");
if (audio.master_muted) { if (audio.master_muted) {
muteBtn.classList.add("muted"); muteBtn.classList.add("muted");
muteBtn.textContent = "MUTED"; muteBtn.textContent = strings.muted || "MUTED";
} else { } else {
muteBtn.classList.remove("muted"); muteBtn.classList.remove("muted");
muteBtn.textContent = "MUTE"; muteBtn.textContent = strings.mute || "MUTE";
} }
// Channels — use DOM API // Channels — use DOM API
@@ -211,7 +286,7 @@
if (!notifications || notifications.length === 0) { if (!notifications || notifications.length === 0) {
const p = document.createElement("p"); const p = document.createElement("p");
p.style.color = "#888"; p.style.color = "#888";
p.textContent = "No notifications"; p.textContent = strings.no_notifications || "No notifications";
container.appendChild(p); container.appendChild(p);
return; return;
} }
@@ -244,7 +319,7 @@
if (n.eta) { if (n.eta) {
const etaDiv = document.createElement("div"); const etaDiv = document.createElement("div");
etaDiv.className = "notification-eta"; etaDiv.className = "notification-eta";
etaDiv.textContent = "ETA: " + n.eta; etaDiv.textContent = (strings.eta_prefix || "ETA:") + " " + n.eta;
div.appendChild(etaDiv); div.appendChild(etaDiv);
} }
@@ -365,4 +440,96 @@
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
return m + ":" + (s < 10 ? "0" : "") + s; 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();
});
})(); })();
+58 -11
View File
@@ -8,34 +8,40 @@
/> />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<title>PaperDash</title> <title data-i18n="app_name">PaperDash</title>
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<!-- PIN Login Screen --> <!-- PIN Login Screen -->
<div id="login-screen" class="screen active"> <div id="login-screen" class="screen active">
<h1>PaperDash</h1> <h1 data-i18n="login_title">PaperDash</h1>
<p>Enter PIN to connect</p> <p data-i18n="login_subtitle">Enter PIN to connect</p>
<input <input
type="password" type="password"
id="pin-input" id="pin-input"
maxlength="4" maxlength="4"
data-i18n-placeholder="login_placeholder"
placeholder="****" placeholder="****"
autocomplete="off" autocomplete="off"
/> />
<button id="connect-btn">Connect</button> <button id="connect-btn" data-i18n="login_button">Connect</button>
<p id="login-error" class="error"></p> <p id="login-error" class="error"></p>
</div> </div>
<!-- Main Dashboard --> <!-- Main Dashboard -->
<div id="dashboard-screen" class="screen"> <div id="dashboard-screen" class="screen">
<!-- Settings Button -->
<button id="btn-settings" class="settings-btn"></button>
<!-- Media Section --> <!-- Media Section -->
<section id="media-section" class="panel"> <section id="media-section" class="panel">
<h2>Now Playing</h2> <h2 data-i18n="now_playing">Now Playing</h2>
<div id="artwork-container"></div> <div id="artwork-container"></div>
<div id="track-info"> <div id="track-info">
<div id="track-title" class="track-title">No playback</div> <div id="track-title" class="track-title" data-i18n="no_playback">
No playback
</div>
<div id="track-artist" class="track-artist"></div> <div id="track-artist" class="track-artist"></div>
<div id="track-album" class="track-album"></div> <div id="track-album" class="track-album"></div>
</div> </div>
@@ -57,22 +63,24 @@
<!-- Audio Mixer Section --> <!-- Audio Mixer Section -->
<section id="audio-section" class="panel"> <section id="audio-section" class="panel">
<h2>Audio Mixer</h2> <h2 data-i18n="audio_mixer">Audio Mixer</h2>
<div id="master-volume"> <div id="master-volume">
<span id="master-label">Master</span> <span id="master-label" data-i18n="master_label">Master</span>
<div class="volume-controls"> <div class="volume-controls">
<button id="btn-vol-down" class="volume-btn">-</button> <button id="btn-vol-down" class="volume-btn">-</button>
<span id="master-volume-value">50%</span> <span id="master-volume-value">50%</span>
<button id="btn-vol-up" class="volume-btn">+</button> <button id="btn-vol-up" class="volume-btn">+</button>
</div> </div>
<button id="btn-mute" class="mute-btn">MUTE</button> <button id="btn-mute" class="mute-btn" data-i18n="mute">
MUTE
</button>
</div> </div>
<div id="channels-container"></div> <div id="channels-container"></div>
</section> </section>
<!-- Telemetry Section --> <!-- Telemetry Section -->
<section id="telemetry-section" class="panel"> <section id="telemetry-section" class="panel">
<h2>System</h2> <h2 data-i18n="system">System</h2>
<div id="telemetry-grid"> <div id="telemetry-grid">
<div class="telemetry-item"> <div class="telemetry-item">
<span class="telemetry-label">CPU</span> <span class="telemetry-label">CPU</span>
@@ -95,10 +103,49 @@
<!-- Notifications Section --> <!-- Notifications Section -->
<section id="notifications-section" class="panel"> <section id="notifications-section" class="panel">
<h2>Notifications</h2> <h2 data-i18n="notifications">Notifications</h2>
<div id="notifications-container"></div> <div id="notifications-container"></div>
</section> </section>
</div> </div>
<!-- Settings Screen -->
<div id="settings-screen" class="screen">
<h1 data-i18n="settings_title">Settings</h1>
<p data-i18n="settings_subtitle">Enter API token to access settings</p>
<input
type="password"
id="settings-token-input"
placeholder="API Token"
autocomplete="off"
/>
<button id="settings-access-btn" data-i18n="access">Access</button>
<p id="settings-error" class="error"></p>
<div id="settings-content" style="display: none;">
<section class="panel">
<h2 data-i18n="dashboard_pin_label">Dashboard PIN</h2>
<input
type="text"
id="settings-pin"
maxlength="4"
placeholder="****"
/>
</section>
<section class="panel">
<h2 data-i18n="api_token_label">API Token</h2>
<input
type="password"
id="settings-token"
placeholder="API Token"
/>
</section>
<button id="settings-save-btn" data-i18n="save">Save</button>
<button id="settings-back-btn" data-i18n="back">Back</button>
<p id="settings-status" class="status"></p>
</div>
</div>
</div> </div>
<script src="app.js"></script> <script src="app.js"></script>
+101
View File
@@ -342,6 +342,107 @@ body {
overflow-x: hidden; 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 */ /* E-Ink specific: no transitions, sharp edges */
* { * {
transition: none !important; transition: none !important;
+9
View File
@@ -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
+13
View File
@@ -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
+176
View File
@@ -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()
+163 -7
View File
@@ -17,6 +17,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from .audio_mixer import AudioMixerManager from .audio_mixer import AudioMixerManager
from .config import config
from .media import MediaManager from .media import MediaManager
from .notifications import NotificationManager, Notification from .notifications import NotificationManager, Notification
from .telemetry import TelemetryManager from .telemetry import TelemetryManager
@@ -24,9 +25,78 @@ from .telemetry import TelemetryManager
logger = logging.getLogger("paperdash") 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Start and stop background managers.""" """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 # Start managers
app.state.media = MediaManager() app.state.media = MediaManager()
app.state.audio = AudioMixerManager() app.state.audio = AudioMixerManager()
@@ -59,7 +129,7 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, 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_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -78,7 +148,7 @@ class WebSocketManager:
self.connections: list[WebSocket] = [] self.connections: list[WebSocket] = []
async def connect(self, ws: WebSocket, pin: str) -> bool: 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: if not expected_pin or pin != expected_pin:
await ws.close(code=4001, reason="Invalid PIN") await ws.close(code=4001, reason="Invalid PIN")
return False return False
@@ -162,7 +232,7 @@ async def handle_client_command(data: dict[str, Any]) -> None:
def _verify_token(authorization: str | None) -> bool: def _verify_token(authorization: str | None) -> bool:
"""Verify Bearer token from Authorization header.""" """Verify Bearer token from Authorization header."""
expected = os.environ.get("API_TOKEN", "") expected = config.api_token
if not expected: if not expected:
return False return False
if not authorization or not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
@@ -178,6 +248,7 @@ async def get_state():
"audio": app.state.audio.get_state(), "audio": app.state.audio.get_state(),
"telemetry": app.state.telemetry.get_state(), "telemetry": app.state.telemetry.get_state(),
"notifications": app.state.notifications.get_active(), "notifications": app.state.notifications.get_active(),
"subsystems": app.state.subsystems,
} }
@@ -195,6 +266,83 @@ async def create_notification(
return {"id": created.id, "status": "created"} 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 # 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) @app.get("/", response_class=HTMLResponse)
async def serve_client(): async def serve_client():
"""Serve the E-Ink dashboard client.""" """Serve the E-Ink dashboard client."""
client_dir = os.path.join(os.path.dirname(__file__), "..", "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") index_path = os.path.join(client_dir, "index.html")
if os.path.exists(index_path): if os.path.exists(index_path):
with open(index_path, "r") as f: with open(index_path) as f:
return f.read() return f.read()
return HTMLResponse("<h1>PaperDash</h1><p>Client files not found in client/</p>")
return HTMLResponse("<h1>PaperDash</h1><p>Client files not found.</p>")
+1
View File
@@ -2,6 +2,7 @@
fastapi>=0.100.0 fastapi>=0.100.0
uvicorn[standard]>=0.23.0 uvicorn[standard]>=0.23.0
pydantic>=2.0.0 pydantic>=2.0.0
pyyaml>=6.0
# Windows APIs # Windows APIs
winsdk>=1.0.0 winsdk>=1.0.0
+134
View File
@@ -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"
+55
View File
@@ -92,3 +92,58 @@ class TestNotifyEndpoint:
assert response.status_code == 401 assert response.status_code == 401
del os.environ["API_TOKEN"] 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"]