diff --git a/kiosk/src/cec.rs b/kiosk/src/cec.rs index e3d307b..ec5171a 100644 --- a/kiosk/src/cec.rs +++ b/kiosk/src/cec.rs @@ -20,6 +20,13 @@ pub fn standby() { } } +pub fn standby_output(output: &str) { + info!("power: standby output {output}"); + if !wlr_output_set(output, false) { + standby(); + } +} + /// Wake the display — fire both CEC and DPMS. pub fn wake() { info!("power: wake"); @@ -29,6 +36,13 @@ pub fn wake() { } } +pub fn wake_output(output: &str) { + info!("power: wake output {output}"); + if !wlr_output_set(output, true) { + wake(); + } +} + fn cec_standby() -> bool { run_cec(&["--standby", "--to", "0"]) } @@ -105,6 +119,27 @@ fn wlr_output_on() -> bool { ok } +fn wlr_output_set(output: &str, on: bool) -> bool { + let state = if on { "--on" } else { "--off" }; + match Command::new("wlr-randr") + .args(["--output", output, state]) + .output() + { + Ok(out) if out.status.success() => { + info!("dpms: {output} {state}"); + true + } + Ok(out) => { + warn!("dpms {output} {state} failed: {}", String::from_utf8_lossy(&out.stderr)); + false + } + Err(e) => { + warn!("wlr-randr unavailable: {e}"); + false + } + } +} + fn list_outputs() -> Vec { let out = match Command::new("wlr-randr").output() { Ok(out) => out, diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index 3057a15..97bba57 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -13,8 +13,8 @@ pub use ui::WorkerMsg; pub enum ServerMsg { ReloadBundle, - Standby, - Wake, + Standby(Option), + Wake(Option), /// Some(0..=255) = manual PWM. None = restore auto. Fan(Option), /// Switch to a specific layout by ID, optionally scoped to one display. diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 72f34be..c5acc39 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -7,6 +7,14 @@ use tracing::info; use crate::bundle::KioskBundle; +pub struct DisplayReport { + pub index: usize, + pub name: String, + pub width_px: u32, + pub height_px: u32, + pub power_state: String, +} + fn kiosk_app_version() -> &'static str { option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) } @@ -258,17 +266,19 @@ pub fn report_layout_change( pub fn heartbeat( server: &str, key: &str, - displays: &[(String, u32, u32)], + displays: &[DisplayReport], hw: &crate::hwmon::HwInfo, ) -> bool { let client = reqwest::blocking::Client::new(); - let display_info: Vec<_> = displays - .iter() - .enumerate() - .map(|(index, (name, w, h))| { - serde_json::json!({ "index": index, "name": name, "width_px": w, "height_px": h }) + let display_info: Vec<_> = displays.iter().map(|d| { + serde_json::json!({ + "index": d.index, + "name": &d.name, + "width_px": d.width_px, + "height_px": d.height_px, + "power_state": &d.power_state, }) - .collect(); + }).collect(); // Surface the LAN-side local key + port to admin so the UI can show a // copy-paste URL for bookmark-style layout switches. let local_key = load_or_create_local_key(); diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index aa289c8..269e1ae 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -238,9 +238,11 @@ fn activate(app: &Application) { None => warn!("reload-bundle: fetch failed, keeping current render"), } } - ServerMsg::Standby => cec::standby(), - ServerMsg::Wake => { - let _ = tx_for_reload.send(WorkerMsg::Wake); + ServerMsg::Standby(display_id) => { + let _ = tx_for_reload.send(WorkerMsg::Standby(display_id)); + } + ServerMsg::Wake(display_id) => { + let _ = tx_for_reload.send(WorkerMsg::Wake(display_id)); } ServerMsg::Fan(pwm) => { if !hwmon::set_fan(pwm) { @@ -303,15 +305,8 @@ fn activate(app: &Application) { switch_layout_anywhere(layout_id); } } - WorkerMsg::Wake => { - cec::wake(); - DISPLAYS.with(|ds| { - for st in ds.borrow_mut().values_mut() { - st.is_asleep = false; - st.last_activity = Instant::now(); - } - }); - } + WorkerMsg::Standby(display_id) => standby_display(display_id), + WorkerMsg::Wake(display_id) => wake_display(display_id), } } gtk::glib::ControlFlow::Continue @@ -325,7 +320,68 @@ pub enum WorkerMsg { display_id: Option, layout_id: u32, }, - Wake, + Standby(Option), + Wake(Option), +} + +fn output_name_for_display(display_id: u32) -> Option { + CURRENT_BUNDLE.with(|b| { + b.borrow() + .as_ref() + .and_then(|bundle| { + bundle + .normalized_displays() + .into_iter() + .find(|d| d.id == display_id) + }) + .map(|d| d.name) + }) +} + +fn standby_display(display_id: Option) { + if let Some(display_id) = display_id { + if let Some(output_name) = output_name_for_display(display_id) { + cec::standby_output(&output_name); + } else { + cec::standby(); + } + DISPLAYS.with(|ds| { + if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + st.is_asleep = true; + } + }); + } else { + cec::standby(); + DISPLAYS.with(|ds| { + for st in ds.borrow_mut().values_mut() { + st.is_asleep = true; + } + }); + } +} + +fn wake_display(display_id: Option) { + if let Some(display_id) = display_id { + if let Some(output_name) = output_name_for_display(display_id) { + cec::wake_output(&output_name); + } else { + cec::wake(); + } + DISPLAYS.with(|ds| { + if let Some(st) = ds.borrow_mut().get_mut(&display_id) { + st.is_asleep = false; + st.last_activity = Instant::now(); + } + }); + } else { + cec::wake(); + DISPLAYS.with(|ds| { + for st in ds.borrow_mut().values_mut() { + st.is_asleep = false; + st.last_activity = Instant::now(); + } + }); + } } /// Reset activity timer for one display. If asleep, wake it. @@ -335,7 +391,11 @@ fn mark_activity(display_id: u32) { st.last_activity = Instant::now(); if st.is_asleep { info!("activity while asleep → waking display {display_id}"); - cec::wake(); + if let Some(output_name) = output_name_for_display(display_id) { + cec::wake_output(&output_name); + } else { + cec::wake(); + } st.is_asleep = false; } } @@ -343,7 +403,34 @@ fn mark_activity(display_id: u32) { } fn send_heartbeat_now(server_url: &str, kiosk_key: &str) -> bool { - let displays = query_displays(); + let raw_displays = query_displays(); + let bundle_displays = CURRENT_BUNDLE + .with(|b| b.borrow().as_ref().map(|b| b.normalized_displays())) + .unwrap_or_default(); + let displays: Vec = raw_displays + .into_iter() + .enumerate() + .map(|(index, (name, width_px, height_px))| { + let bundle_id = bundle_displays + .get(index) + .map(|d| d.id) + .or_else(|| bundle_displays.iter().find(|d| d.name == name).map(|d| d.id)); + let power_state = bundle_id + .and_then(|id| { + DISPLAYS.with(|ds| ds.borrow().get(&id).map(|st| st.is_asleep)) + }) + .map(|is_asleep| if is_asleep { "standby" } else { "awake" }) + .unwrap_or("unknown") + .to_string(); + server::DisplayReport { + index, + name, + width_px, + height_px, + power_state, + } + }) + .collect(); let hw = hwmon::read(); server::heartbeat(server_url, kiosk_key, &displays, &hw) } @@ -362,7 +449,7 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) { if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") { return; } - let current = env!("CARGO_PKG_VERSION"); + let current = option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")); let Some(info) = firmware::check(server_url, kiosk_key, current) else { return; }; @@ -454,10 +541,19 @@ fn install_idle_watchdog() { } if a.sleep { info!( - "sleep timeout reached on display {} → CEC standby", + "sleep timeout reached on display {}", a.display_id ); - cec::standby(); + let output_name = bundle + .normalized_displays() + .into_iter() + .find(|d| d.id == a.display_id) + .map(|d| d.name); + if let Some(output_name) = output_name { + cec::standby_output(&output_name); + } else { + cec::standby(); + } DISPLAYS.with(|ds| { if let Some(st) = ds.borrow_mut().get_mut(&a.display_id) { st.is_asleep = true; @@ -591,8 +687,8 @@ fn render_bundle( let mut new_state: HashMap = HashMap::new(); for (i, bd) in displays.iter().enumerate() { let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id)); - let window = match existing { - Some(st) => st.window, + let (window, was_asleep) = match existing { + Some(st) => (st.window, st.is_asleep), None => { let w = ApplicationWindow::builder() .application(app) @@ -611,7 +707,7 @@ fn render_bundle( if let Some(monitor) = gdk_monitors.get(i) { w.fullscreen_on_monitor(monitor); } - w + (w, false) } }; new_state.insert( @@ -620,7 +716,7 @@ fn render_bundle( window, current_layout_id: None, last_activity: Instant::now(), - is_asleep: false, + is_asleep: was_asleep, }, ); } diff --git a/kiosk/src/ws_client.rs b/kiosk/src/ws_client.rs index c34fe20..e93264e 100644 --- a/kiosk/src/ws_client.rs +++ b/kiosk/src/ws_client.rs @@ -67,10 +67,16 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { let _ = tx.send(ServerMsg::ReloadBundle); } else if text.contains("\"type\":\"standby\"") { info!("ws: standby received"); - let _ = tx.send(ServerMsg::Standby); + let display_id = serde_json::from_str::(&text) + .ok() + .and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32)); + let _ = tx.send(ServerMsg::Standby(display_id)); } else if text.contains("\"type\":\"wake\"") { info!("ws: wake received"); - let _ = tx.send(ServerMsg::Wake); + let display_id = serde_json::from_str::(&text) + .ok() + .and_then(|m| m.get("display_id").and_then(|v| v.as_u64()).map(|v| v as u32)); + let _ = tx.send(ServerMsg::Wake(display_id)); } else if text.contains("\"type\":\"layout-switch\"") { info!("ws: layout-switch received: {text}"); let msg = serde_json::from_str::(&text).ok(); diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index ed80348..c161fa7 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1524,6 +1524,29 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); + const displayPower = (event: any, state: "on" | "standby") => { + const id = Number(getRouterParam(event, "id")); + const display = deps.repo.getDisplayById(id); + if (display?.kiosk_id) { + getCoordinator().sendToKiosk(display.kiosk_id, { + type: state === "on" ? "wake" : "standby", + display_id: id, + }); + deps.repo.updateDisplay(id, { + actual_power_state: state === "on" ? "awake" : "standby", + actual_power_state_at: new Date().toISOString(), + } as any); + deps.nodered.forward("display.power.changed", { + display_id: id, + kiosk_id: display.kiosk_id, + state, + }); + } + return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } }); + }; + app.post("/admin/displays/:id/power/standby", (event) => displayPower(event, "standby")); + app.post("/admin/displays/:id/power/wake", (event) => displayPower(event, "on")); + // Node-RED embedded page app.get("/admin/nodered", (event) => { const user = event.context.user!; @@ -1534,6 +1557,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const emitDisplayPower = (kioskId: number, state: "on" | "standby") => { const displays = deps.repo.listDisplaysForKiosk(kioskId); const displayId = displays[0]?.id ?? null; + const actual = state === "on" ? "awake" : "standby"; + const at = new Date().toISOString(); + for (const display of displays) { + deps.repo.updateDisplay(display.id, { + actual_power_state: actual, + actual_power_state_at: at, + } as any); + } deps.nodered.forward("display.power.changed", { display_id: displayId, kiosk_id: kioskId, diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 51735ce..fb32749 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -302,7 +302,13 @@ function registerKioskRoutes( bundle_version?: string; kiosk_app_version?: string; os_version?: string; - displays?: Array<{ index?: number; name: string; width_px: number; height_px: number }>; + displays?: Array<{ + index?: number; + name: string; + width_px: number; + height_px: number; + power_state?: "awake" | "standby" | "unknown"; + }>; cpu_temp_c?: number | null; cpu_load_percent?: number | null; fan_rpm?: number | null; @@ -389,17 +395,27 @@ function registerKioskRoutes( ?? existing.find((d) => d.index === reportedIndex); if (match) { seenDisplayIds.add(match.id); + const powerState = reported.power_state === "awake" || reported.power_state === "standby" + ? reported.power_state + : reported.power_state === "unknown" + ? "unknown" + : null; if ( match.name !== reported.name || match.index !== reportedIndex || match.width_px !== reported.width_px || match.height_px !== reported.height_px + || (powerState != null && match.actual_power_state !== powerState) ) { repo.updateDisplay(match.id, { name: reported.name, index: reportedIndex, width_px: reported.width_px, height_px: reported.height_px, + ...(powerState != null ? { + actual_power_state: powerState, + actual_power_state_at: new Date().toISOString(), + } : {}), } as any); } } else { @@ -410,6 +426,17 @@ function registerKioskRoutes( width_px: reported.width_px, height_px: reported.height_px, }); + const powerState = reported.power_state === "awake" || reported.power_state === "standby" + ? reported.power_state + : reported.power_state === "unknown" + ? "unknown" + : null; + if (powerState != null) { + repo.updateDisplay(created.id, { + actual_power_state: powerState, + actual_power_state_at: new Date().toISOString(), + } as any); + } seenDisplayIds.add(created.id); } } diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index 9b27e94..8685c33 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -16,6 +16,7 @@ import type { CameraType, CellContentType, DesiredPowerState, + ActualPowerState, Display, Entity, EntityType, @@ -135,6 +136,8 @@ export function rowToDisplay(r: Row): Display { cec_device_path: sn(r["cec_device_path"]), cec_logical_address: nn(r["cec_logical_address"]), desired_power_state: s(r["desired_power_state"]) as DesiredPowerState, + actual_power_state: s(r["actual_power_state"] ?? "unknown") as ActualPowerState, + actual_power_state_at: sn(r["actual_power_state_at"]), state_check_enabled: b(r["state_check_enabled"]), state_check_interval_seconds: n(r["state_check_interval_seconds"]), is_enabled: b(r["is_enabled"]), diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 7359ad0..0a9b392 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -111,6 +111,9 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ cec_logical_address INTEGER, desired_power_state TEXT NOT NULL DEFAULT 'follow_layout' CHECK(desired_power_state IN ('follow_layout', 'on', 'standby')), + actual_power_state TEXT NOT NULL DEFAULT 'unknown' + CHECK(actual_power_state IN ('awake', 'standby', 'unknown')), + actual_power_state_at TEXT, state_check_enabled INTEGER NOT NULL DEFAULT 0, state_check_interval_seconds INTEGER NOT NULL DEFAULT 60 ) STRICT`, @@ -855,4 +858,10 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ ) `); }, + + // Display power state reported by kiosk heartbeat. + (db: DatabaseSync) => { + addColumnIfNotExists(db, "displays", "actual_power_state", "TEXT NOT NULL DEFAULT 'unknown'"); + addColumnIfNotExists(db, "displays", "actual_power_state_at", "TEXT"); + }, ]; diff --git a/server/src/schemas/wire/events.ts b/server/src/schemas/wire/events.ts index 28bad8a..c7a3cbb 100644 --- a/server/src/schemas/wire/events.ts +++ b/server/src/schemas/wire/events.ts @@ -24,6 +24,13 @@ export const kioskHeartbeat = av.object( disk_total_mb: av.optional(av.int().min(0)), disk_free_mb: av.optional(av.int().min(0)), disk_used_percent: av.optional(av.number().min(0).max(100)), + displays: av.optional(av.array(av.object({ + index: av.optional(av.int().min(0)), + name: av.string().minLength(1).maxLength(128), + width_px: av.int().min(0), + height_px: av.int().min(0), + power_state: av.optional(av.enum_(["awake", "standby", "unknown"] as const)), + }))), active_layout_id: av.optional(av.int().min(1)), streams_warm: av.optional(av.int().min(0)), streams_hot: av.optional(av.int().min(0)), diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index b2ef705..afbf3c7 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -28,6 +28,7 @@ export interface Entity { created_at: string; } export type DesiredPowerState = "follow_layout" | "on" | "standby"; +export type ActualPowerState = "awake" | "standby" | "unknown"; export type LabelRole = "consume" | "operate"; export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system"; @@ -97,6 +98,8 @@ export interface Display { cec_device_path: string | null; cec_logical_address: number | null; desired_power_state: DesiredPowerState; + actual_power_state: ActualPowerState; + actual_power_state_at: string | null; state_check_enabled: boolean; state_check_interval_seconds: number; is_enabled: boolean; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 0d7a2ef..43a66a5 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1096,7 +1096,7 @@ export function SimpleListPage(props: SimpleListProps) { {props.items.length === 0 ? ( - None configured yet + None configured yet ) : ( props.items.map((item) => ( @@ -1651,13 +1651,14 @@ export function KioskEditPage(props: KioskEditProps) { {props.displays && props.displays.length > 0 ? (
- + {props.displays.map((d) => ( + ))} @@ -2514,6 +2515,7 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
Index: {String(d.index)}
Resolution: {String(d.width_px)}x{String(d.height_px)} (reported by kiosk)
+
Power: {powerBadge(d.actual_power_state)} {d.actual_power_state_at ? as of {formatTime(d.actual_power_state_at)} : ""}
{d.kiosk_id && ( )} @@ -2543,6 +2545,27 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
) : null} + {d.kiosk_id ? ( +
+ + +
+ ) : null} +
@@ -2628,11 +2651,12 @@ export function DisplaysPage(props: DisplaysPageProps) {
+ {props.displays.length === 0 ? ( - + ) : ( props.displays.map((d) => ( @@ -2643,6 +2667,7 @@ export function DisplaysPage(props: DisplaysPageProps) { )} + )) )} @@ -2713,6 +2738,12 @@ function mbPair(used: number | null, total: number | null): string { return `${String(used)} / ${String(total)} MB`; } +function powerBadge(state: Display["actual_power_state"]) { + if (state === "awake") return awake; + if (state === "standby") return standby; + return unknown; +} + // ---- Node-RED Embed --------------------------------------------------- export function NoderedEmbedPage(props: { user: string }) {
NameResolutionIndex
NameResolutionIndexPower
{d.name} {String(d.width_px)}x{String(d.height_px)} {String(d.index)}{powerBadge(d.actual_power_state)}
Name DetailsPower
None configured yet
None configured yet
{String(d.width_px)}x{String(d.height_px)} — index {String(d.index)}{powerBadge(d.actual_power_state)}