feat(display): report and control power state

This commit is contained in:
Mitchell R 2026-05-21 09:10:30 +02:00
parent 6cfb37aa64
commit 49e420dea5
No known key found for this signature in database
12 changed files with 295 additions and 37 deletions

View file

@ -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<String> {
let out = match Command::new("wlr-randr").output() {
Ok(out) => out,

View file

@ -13,8 +13,8 @@ pub use ui::WorkerMsg;
pub enum ServerMsg {
ReloadBundle,
Standby,
Wake,
Standby(Option<u32>),
Wake(Option<u32>),
/// Some(0..=255) = manual PWM. None = restore auto.
Fan(Option<u32>),
/// Switch to a specific layout by ID, optionally scoped to one display.

View file

@ -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();

View file

@ -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<u32>,
layout_id: u32,
},
Wake,
Standby(Option<u32>),
Wake(Option<u32>),
}
fn output_name_for_display(display_id: u32) -> Option<String> {
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<u32>) {
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<u32>) {
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}");
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<server::DisplayReport> = 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
);
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<u32, DisplayState> = 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,
},
);
}

View file

@ -67,10 +67,16 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&text).ok();

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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"]),

View file

@ -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");
},
];

View file

@ -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)),

View file

@ -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;

View file

@ -1096,7 +1096,7 @@ export function SimpleListPage(props: SimpleListProps) {
</thead>
<tbody>
{props.items.length === 0 ? (
<tr><td colspan="2" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
) : (
props.items.map((item) => (
<tr>
@ -1651,13 +1651,14 @@ export function KioskEditPage(props: KioskEditProps) {
{props.displays && props.displays.length > 0 ? (
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Resolution</th><th>Index</th></tr></thead>
<thead><tr><th>Name</th><th>Resolution</th><th>Index</th><th>Power</th></tr></thead>
<tbody>
{props.displays.map((d) => (
<tr>
<td><a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a></td>
<td>{String(d.width_px)}x{String(d.height_px)}</td>
<td>{String(d.index)}</td>
<td>{powerBadge(d.actual_power_state)}</td>
</tr>
))}
</tbody>
@ -2514,6 +2515,7 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
<div style="color:#666; font-size:0.85rem; margin-bottom:1rem">
<div>Index: {String(d.index)}</div>
<div>Resolution: {String(d.width_px)}x{String(d.height_px)} <span style="color:#999">(reported by kiosk)</span></div>
<div>Power: {powerBadge(d.actual_power_state)} {d.actual_power_state_at ? <span style="color:#999">as of {formatTime(d.actual_power_state_at)}</span> : ""}</div>
{d.kiosk_id && (
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div>
)}
@ -2543,6 +2545,27 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
</div>
) : null}
{d.kiosk_id ? (
<div style="margin-bottom:1rem; display:flex; gap:0.5rem; flex-wrap:wrap">
<button
type="button"
class="btn btn-sm"
{...{
"hx-post": `/admin/displays/${d.id}/power/wake`,
"hx-swap": "none",
}}
>Wake Display</button>
<button
type="button"
class="btn btn-sm btn-ghost"
{...{
"hx-post": `/admin/displays/${d.id}/power/standby`,
"hx-swap": "none",
}}
>Standby Display</button>
</div>
) : null}
<form method="post" action={`/admin/displays/${d.id}`}>
<div class="form-group">
<label for="name">Name</label>
@ -2628,11 +2651,12 @@ export function DisplaysPage(props: DisplaysPageProps) {
<tr>
<th>Name</th>
<th>Details</th>
<th>Power</th>
</tr>
</thead>
<tbody>
{props.displays.length === 0 ? (
<tr><td colspan="2" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
) : (
props.displays.map((d) => (
<tr>
@ -2643,6 +2667,7 @@ export function DisplaysPage(props: DisplaysPageProps) {
)}
</td>
<td style="color:#666">{String(d.width_px)}x{String(d.height_px)} index {String(d.index)}</td>
<td>{powerBadge(d.actual_power_state)}</td>
</tr>
))
)}
@ -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 <span class="badge badge-green">awake</span>;
if (state === "standby") return <span class="badge badge-blue">standby</span>;
return <span class="badge badge-gray">unknown</span>;
}
// ---- Node-RED Embed ---------------------------------------------------
export function NoderedEmbedPage(props: { user: string }) {