mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
fix(power): add monitor fallback checks
This commit is contained in:
parent
0d9451ae95
commit
e38c92f753
6 changed files with 83 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
|
|
@ -99,3 +117,19 @@ fn list_outputs() -> Vec<String> {
|
|||
.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}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,17 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, 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 });
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
|
|||
path === "/healthz" ||
|
||||
path === "/readyz" ||
|
||||
path === "/version" ||
|
||||
path === "/api/admin/_check" ||
|
||||
path === "/"
|
||||
) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -108,6 +108,31 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1204,7 +1204,7 @@ export function KioskEditPage(props: KioskEditProps) {
|
|||
<div>Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</div>
|
||||
</div>
|
||||
<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">
|
||||
<button type="submit" class="btn btn-sm">Wake</button>
|
||||
</form>
|
||||
|
|
@ -1898,7 +1898,7 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
|
|||
<div class="form-group">
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue