From e38c92f753ac7ac05f640facc3f1c901442df179 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Mon, 11 May 2026 08:55:42 +0200 Subject: [PATCH] fix(power): add monitor fallback checks --- CLAUDE.md | 5 +- kiosk/src/cec.rs | 48 ++++++++++++++++--- .../src/plugins/service-admin-http/index.ts | 11 +++++ .../plugins/service-admin-http/middleware.ts | 1 + server/src/plugins/service-api-http/index.ts | 25 ++++++++++ server/src/web-templates/admin-pages.tsx | 4 +- 6 files changed, 83 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 98dbd2a..e0663da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,9 +234,9 @@ Everything else is a shared module (plain TS, no BSB lifecycle). 4. **coordinator-ws WebSocket** — install crossws, implement kiosk connections + layout-switch commands 5. **Rust kiosk polish** — multi-camera compositor, H264/H265 auto-detect, web cells via WebKit 6. **Node-RED bridge** — outbound HTTP forwarder + inbound callbacks -7. **CEC relay** — cec-ctl subprocess + ws command translation +7. **Display power relay** — kiosk handles CEC first, then monitor DPMS fallback (`wlr-randr`, then `xset`). Server/admin should keep using generic wake/standby commands, not CEC-only naming. 8. **Angie config** + systemd units + Dockerfile -9. **Auth-check endpoints** — for proxy `auth_request` +9. **Auth-check endpoints** — ✅ admin session/API-key, kiosk key, and API-key checks added for proxy `auth_request` ## conventions (additions discovered while building) - **BSB build needs tsx**: `cross-env NODE_OPTIONS="--import tsx" bsb-plugin-cli build` in package.json scripts. Required because BSB's schema extractor doesn't resolve .js→.ts imports in multi-file plugins @@ -246,6 +246,7 @@ Everything else is a shared module (plain TS, no BSB lifecycle). - **Cookie signing uses HKDF-derived key** (deterministic). NOT encryptString (random IV = non-deterministic = broken) - **RTSP URLs with special chars** in password: URL-encode user/pass components. Camera form splits into host/port/path/user/pass fields, builds URL server-side - **ONVIF discovery import**: ONVIF profiles are streams, not cameras. Group profiles by VideoSourceConfiguration/SourceToken (fallback to channel-ish URI/name), assign largest stream `main`, next `sub`, rest `other`, and import one camera with multiple `camera_streams`. If RTSP URIs omit userinfo, inject the ONVIF username/password before storing so kiosk playback avoids RTSP 401. +- **Display power**: TVs may support CEC, monitors usually won't. Kiosk power commands should try `cec-ctl`, then standard output sleep/wake (`wlr-randr`, then `xset dpms`) so monitor installs still work. - **GStreamer on Pi5**: hw H265 decoder rejects non-standard resolutions (960x1080). Use avdec_h265 (sw) as fallback - **Log message strings MUST be string literals** (BSB SmartLogMeta extracts placeholders from literal type) - **Datetimes are ISO-8601 strings** stored as TEXT diff --git a/kiosk/src/cec.rs b/kiosk/src/cec.rs index f394f54..796d84c 100644 --- a/kiosk/src/cec.rs +++ b/kiosk/src/cec.rs @@ -13,7 +13,9 @@ const CEC_DEVICE: &str = "/dev/cec0"; pub fn standby() { info!("power: standby"); if !cec_standby() { - wlr_output_off(); + if !wlr_output_off() { + xset_dpms_off(); + } } } @@ -21,7 +23,9 @@ pub fn standby() { pub fn wake() { info!("power: wake"); if !cec_wake() { - wlr_output_on(); + if !wlr_output_on() { + xset_dpms_on(); + } } } @@ -54,37 +58,51 @@ fn run_cec(args: &[&str]) -> bool { } /// Turn off all outputs via wlr-randr (Wayland compositors: labwc, wayfire, sway). -fn wlr_output_off() { +fn wlr_output_off() -> bool { // Get list of outputs let outputs = list_outputs(); if outputs.is_empty() { warn!("dpms: no outputs found"); - return; + return false; } + let mut ok = false; for output in outputs { match Command::new("wlr-randr") .args(["--output", &output, "--off"]) .output() { - Ok(out) if out.status.success() => info!("dpms: {output} off"), + Ok(out) if out.status.success() => { + info!("dpms: {output} off"); + ok = true; + } Ok(out) => warn!("dpms off {output} failed: {}", String::from_utf8_lossy(&out.stderr)), Err(e) => warn!("wlr-randr unavailable: {e}"), } } + ok } -fn wlr_output_on() { +fn wlr_output_on() -> bool { let outputs = list_outputs(); + if outputs.is_empty() { + warn!("dpms: no outputs found"); + return false; + } + let mut ok = false; for output in outputs { match Command::new("wlr-randr") .args(["--output", &output, "--on"]) .output() { - Ok(out) if out.status.success() => info!("dpms: {output} on"), + Ok(out) if out.status.success() => { + info!("dpms: {output} on"); + ok = true; + } Ok(out) => warn!("dpms on {output} failed: {}", String::from_utf8_lossy(&out.stderr)), Err(e) => warn!("wlr-randr unavailable: {e}"), } } + ok } fn list_outputs() -> Vec { @@ -99,3 +117,19 @@ fn list_outputs() -> Vec { .filter(|s| !s.is_empty()) .collect() } + +fn xset_dpms_off() { + match Command::new("xset").args(["dpms", "force", "off"]).output() { + Ok(out) if out.status.success() => info!("xset: dpms off"), + Ok(out) => warn!("xset dpms off failed: {}", String::from_utf8_lossy(&out.stderr)), + Err(e) => warn!("xset unavailable: {e}"), + } +} + +fn xset_dpms_on() { + match Command::new("xset").args(["dpms", "force", "on"]).output() { + Ok(out) if out.status.success() => info!("xset: dpms on"), + Ok(out) => warn!("xset dpms on failed: {}", String::from_utf8_lossy(&out.stderr)), + Err(e) => warn!("xset unavailable: {e}"), + } +} diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 4247dda..85fc39b 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -132,6 +132,17 @@ export class Plugin extends BSBService, typeof Event // Auth-check endpoint for Angie auth_request subrequest. // Returns 200 if session cookie is valid + admin role, 401 otherwise. app.get("/api/admin/_check", (event) => { + const authz = event.req.headers.get("authorization"); + if (authz?.startsWith("Bearer ")) { + return deps.auth.verifyApiKey(authz.slice(7), event.req.headers.get("x-real-ip")).then((key) => { + if (!key || !key.scopes.includes("admin")) return new Response(null, { status: 401 }); + return new Response(null, { + status: 200, + headers: { "x-betterframe-api-key": key.key_prefix }, + }); + }); + } + const cookie = event.req.headers.get("cookie") ?? ""; const match = cookie.match(new RegExp(`${deps.cookieName}=([^;]+)`)); if (!match) return new Response(null, { status: 401 }); diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index b29e40e..25b6593 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -22,6 +22,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { path === "/healthz" || path === "/readyz" || path === "/version" || + path === "/api/admin/_check" || path === "/" ) { return; diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 1e9a4c3..3e3e47b 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -108,6 +108,31 @@ export class Plugin extends BSBService, typeof Event const app = new H3(); + app.get("/api/kiosk/_check", async (event) => { + const token = extractBearerToken(event); + if (!token) return new Response(null, { status: 401 }); + const kiosk = await auth.verifyKioskKey(token); + if (!kiosk) return new Response(null, { status: 401 }); + return new Response(null, { + status: 200, + headers: { "x-betterframe-kiosk-id": String(kiosk.id) }, + }); + }); + + app.get("/api/key/_check", async (event) => { + const token = extractBearerToken(event); + if (!token) return new Response(null, { status: 401 }); + const key = await auth.verifyApiKey(token, getRequestHeader(event, "x-real-ip") ?? null); + if (!key) return new Response(null, { status: 401 }); + return new Response(null, { + status: 200, + headers: { + "x-betterframe-api-key": key.key_prefix, + "x-betterframe-scopes": key.scopes.join(","), + }, + }); + }); + registerPairingRoutes(app, repo, auth, secrets, codeTtl); registerKioskRoutes(app, repo, auth, secrets, nodered); diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index bd3754c..5303f92 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1204,7 +1204,7 @@ export function KioskEditPage(props: KioskEditProps) {
Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}
-
Display Power (CEC)
+
Display Power
@@ -1898,7 +1898,7 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
-
Send CEC standby after this many seconds of inactivity. 0 to disable.
+
Send display standby after this many seconds of inactivity. 0 to disable.