feat: config files, settings screen, hardware check
- 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:
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
+166
-10
@@ -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
|
||||||
index_path = os.path.join(client_dir, "index.html")
|
possible_paths = [
|
||||||
if os.path.exists(index_path):
|
os.path.join(os.path.dirname(__file__), "..", "client"),
|
||||||
with open(index_path, "r") as f:
|
os.path.join(os.path.dirname(__file__), "..", "..", "client"),
|
||||||
return f.read()
|
os.path.join(os.path.dirname(__file__), "client"),
|
||||||
return HTMLResponse("<h1>PaperDash</h1><p>Client files not found in client/</p>")
|
]
|
||||||
|
|
||||||
|
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("<h1>PaperDash</h1><p>Client files not found.</p>")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user