fix(power): add monitor fallback checks

This commit is contained in:
Mitchell R 2026-05-11 08:55:42 +02:00
parent 0d9451ae95
commit e38c92f753
No known key found for this signature in database
6 changed files with 83 additions and 11 deletions

View file

@ -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 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 5. **Rust kiosk polish** — multi-camera compositor, H264/H265 auto-detect, web cells via WebKit
6. **Node-RED bridge** — outbound HTTP forwarder + inbound callbacks 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 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) ## 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 - **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) - **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 - **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. - **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 - **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) - **Log message strings MUST be string literals** (BSB SmartLogMeta extracts placeholders from literal type)
- **Datetimes are ISO-8601 strings** stored as TEXT - **Datetimes are ISO-8601 strings** stored as TEXT

View file

@ -13,7 +13,9 @@ const CEC_DEVICE: &str = "/dev/cec0";
pub fn standby() { pub fn standby() {
info!("power: standby"); info!("power: standby");
if !cec_standby() { if !cec_standby() {
wlr_output_off(); if !wlr_output_off() {
xset_dpms_off();
}
} }
} }
@ -21,7 +23,9 @@ pub fn standby() {
pub fn wake() { pub fn wake() {
info!("power: wake"); info!("power: wake");
if !cec_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). /// 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 // Get list of outputs
let outputs = list_outputs(); let outputs = list_outputs();
if outputs.is_empty() { if outputs.is_empty() {
warn!("dpms: no outputs found"); warn!("dpms: no outputs found");
return; return false;
} }
let mut ok = false;
for output in outputs { for output in outputs {
match Command::new("wlr-randr") match Command::new("wlr-randr")
.args(["--output", &output, "--off"]) .args(["--output", &output, "--off"])
.output() .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)), Ok(out) => warn!("dpms off {output} failed: {}", String::from_utf8_lossy(&out.stderr)),
Err(e) => warn!("wlr-randr unavailable: {e}"), Err(e) => warn!("wlr-randr unavailable: {e}"),
} }
} }
ok
} }
fn wlr_output_on() { fn wlr_output_on() -> bool {
let outputs = list_outputs(); let outputs = list_outputs();
if outputs.is_empty() {
warn!("dpms: no outputs found");
return false;
}
let mut ok = false;
for output in outputs { for output in outputs {
match Command::new("wlr-randr") match Command::new("wlr-randr")
.args(["--output", &output, "--on"]) .args(["--output", &output, "--on"])
.output() .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)), Ok(out) => warn!("dpms on {output} failed: {}", String::from_utf8_lossy(&out.stderr)),
Err(e) => warn!("wlr-randr unavailable: {e}"), Err(e) => warn!("wlr-randr unavailable: {e}"),
} }
} }
ok
} }
fn list_outputs() -> Vec<String> { fn list_outputs() -> Vec<String> {
@ -99,3 +117,19 @@ fn list_outputs() -> Vec<String> {
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect() .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}"),
}
}

View file

@ -132,6 +132,17 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
// Auth-check endpoint for Angie auth_request subrequest. // Auth-check endpoint for Angie auth_request subrequest.
// Returns 200 if session cookie is valid + admin role, 401 otherwise. // Returns 200 if session cookie is valid + admin role, 401 otherwise.
app.get("/api/admin/_check", (event) => { 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 cookie = event.req.headers.get("cookie") ?? "";
const match = cookie.match(new RegExp(`${deps.cookieName}=([^;]+)`)); const match = cookie.match(new RegExp(`${deps.cookieName}=([^;]+)`));
if (!match) return new Response(null, { status: 401 }); if (!match) return new Response(null, { status: 401 });

View file

@ -22,6 +22,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
path === "/healthz" || path === "/healthz" ||
path === "/readyz" || path === "/readyz" ||
path === "/version" || path === "/version" ||
path === "/api/admin/_check" ||
path === "/" path === "/"
) { ) {
return; return;

View file

@ -108,6 +108,31 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
const app = new H3(); 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); registerPairingRoutes(app, repo, auth, secrets, codeTtl);
registerKioskRoutes(app, repo, auth, secrets, nodered); registerKioskRoutes(app, repo, auth, secrets, nodered);

View file

@ -1204,7 +1204,7 @@ export function KioskEditPage(props: KioskEditProps) {
<div>Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</div> <div>Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</div>
</div> </div>
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee"> <div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Display Power (CEC)</div> <div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Display Power</div>
<form method="post" action={`/admin/kiosks/${k.id}/power/wake`} style="display:inline"> <form method="post" action={`/admin/kiosks/${k.id}/power/wake`} style="display:inline">
<button type="submit" class="btn btn-sm">Wake</button> <button type="submit" class="btn btn-sm">Wake</button>
</form> </form>
@ -1898,7 +1898,7 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
<div class="form-group"> <div class="form-group">
<label for="sleep_timeout_seconds">Sleep Timeout (seconds)</label> <label for="sleep_timeout_seconds">Sleep Timeout (seconds)</label>
<input id="sleep_timeout_seconds" name="sleep_timeout_seconds" type="number" class="form-input" value={String(d.sleep_timeout_seconds)} min="0" /> <input id="sleep_timeout_seconds" name="sleep_timeout_seconds" type="number" class="form-input" value={String(d.sleep_timeout_seconds)} min="0" />
<div class="form-hint">Send CEC standby after this many seconds of inactivity. 0 to disable.</div> <div class="form-hint">Send display standby after this many seconds of inactivity. 0 to disable.</div>
</div> </div>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>