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. /// Wake the display — fire both CEC and DPMS.
pub fn wake() { pub fn wake() {
info!("power: 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 { fn cec_standby() -> bool {
run_cec(&["--standby", "--to", "0"]) run_cec(&["--standby", "--to", "0"])
} }
@ -105,6 +119,27 @@ fn wlr_output_on() -> bool {
ok 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> { fn list_outputs() -> Vec<String> {
let out = match Command::new("wlr-randr").output() { let out = match Command::new("wlr-randr").output() {
Ok(out) => out, Ok(out) => out,

View file

@ -13,8 +13,8 @@ pub use ui::WorkerMsg;
pub enum ServerMsg { pub enum ServerMsg {
ReloadBundle, ReloadBundle,
Standby, Standby(Option<u32>),
Wake, Wake(Option<u32>),
/// Some(0..=255) = manual PWM. None = restore auto. /// Some(0..=255) = manual PWM. None = restore auto.
Fan(Option<u32>), Fan(Option<u32>),
/// Switch to a specific layout by ID, optionally scoped to one display. /// 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; 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 { fn kiosk_app_version() -> &'static str {
option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
} }
@ -258,17 +266,19 @@ pub fn report_layout_change(
pub fn heartbeat( pub fn heartbeat(
server: &str, server: &str,
key: &str, key: &str,
displays: &[(String, u32, u32)], displays: &[DisplayReport],
hw: &crate::hwmon::HwInfo, hw: &crate::hwmon::HwInfo,
) -> bool { ) -> bool {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let display_info: Vec<_> = displays let display_info: Vec<_> = displays.iter().map(|d| {
.iter() serde_json::json!({
.enumerate() "index": d.index,
.map(|(index, (name, w, h))| { "name": &d.name,
serde_json::json!({ "index": index, "name": name, "width_px": w, "height_px": h }) "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 // Surface the LAN-side local key + port to admin so the UI can show a
// copy-paste URL for bookmark-style layout switches. // copy-paste URL for bookmark-style layout switches.
let local_key = load_or_create_local_key(); 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"), None => warn!("reload-bundle: fetch failed, keeping current render"),
} }
} }
ServerMsg::Standby => cec::standby(), ServerMsg::Standby(display_id) => {
ServerMsg::Wake => { let _ = tx_for_reload.send(WorkerMsg::Standby(display_id));
let _ = tx_for_reload.send(WorkerMsg::Wake); }
ServerMsg::Wake(display_id) => {
let _ = tx_for_reload.send(WorkerMsg::Wake(display_id));
} }
ServerMsg::Fan(pwm) => { ServerMsg::Fan(pwm) => {
if !hwmon::set_fan(pwm) { if !hwmon::set_fan(pwm) {
@ -303,15 +305,8 @@ fn activate(app: &Application) {
switch_layout_anywhere(layout_id); switch_layout_anywhere(layout_id);
} }
} }
WorkerMsg::Wake => { WorkerMsg::Standby(display_id) => standby_display(display_id),
cec::wake(); WorkerMsg::Wake(display_id) => wake_display(display_id),
DISPLAYS.with(|ds| {
for st in ds.borrow_mut().values_mut() {
st.is_asleep = false;
st.last_activity = Instant::now();
}
});
}
} }
} }
gtk::glib::ControlFlow::Continue gtk::glib::ControlFlow::Continue
@ -325,7 +320,68 @@ pub enum WorkerMsg {
display_id: Option<u32>, display_id: Option<u32>,
layout_id: 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. /// 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(); st.last_activity = Instant::now();
if st.is_asleep { if st.is_asleep {
info!("activity while asleep → waking display {display_id}"); 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(); cec::wake();
}
st.is_asleep = false; 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 { 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(); let hw = hwmon::read();
server::heartbeat(server_url, kiosk_key, &displays, &hw) 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") { if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") {
return; 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 { let Some(info) = firmware::check(server_url, kiosk_key, current) else {
return; return;
}; };
@ -454,10 +541,19 @@ fn install_idle_watchdog() {
} }
if a.sleep { if a.sleep {
info!( info!(
"sleep timeout reached on display {} → CEC standby", "sleep timeout reached on display {}",
a.display_id 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(); cec::standby();
}
DISPLAYS.with(|ds| { DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&a.display_id) { if let Some(st) = ds.borrow_mut().get_mut(&a.display_id) {
st.is_asleep = true; st.is_asleep = true;
@ -591,8 +687,8 @@ fn render_bundle(
let mut new_state: HashMap<u32, DisplayState> = HashMap::new(); let mut new_state: HashMap<u32, DisplayState> = HashMap::new();
for (i, bd) in displays.iter().enumerate() { for (i, bd) in displays.iter().enumerate() {
let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id)); let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id));
let window = match existing { let (window, was_asleep) = match existing {
Some(st) => st.window, Some(st) => (st.window, st.is_asleep),
None => { None => {
let w = ApplicationWindow::builder() let w = ApplicationWindow::builder()
.application(app) .application(app)
@ -611,7 +707,7 @@ fn render_bundle(
if let Some(monitor) = gdk_monitors.get(i) { if let Some(monitor) = gdk_monitors.get(i) {
w.fullscreen_on_monitor(monitor); w.fullscreen_on_monitor(monitor);
} }
w (w, false)
} }
}; };
new_state.insert( new_state.insert(
@ -620,7 +716,7 @@ fn render_bundle(
window, window,
current_layout_id: None, current_layout_id: None,
last_activity: Instant::now(), 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); let _ = tx.send(ServerMsg::ReloadBundle);
} else if text.contains("\"type\":\"standby\"") { } else if text.contains("\"type\":\"standby\"") {
info!("ws: standby received"); 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\"") { } else if text.contains("\"type\":\"wake\"") {
info!("ws: wake received"); 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\"") { } else if text.contains("\"type\":\"layout-switch\"") {
info!("ws: layout-switch received: {text}"); info!("ws: layout-switch received: {text}");
let msg = serde_json::from_str::<serde_json::Value>(&text).ok(); 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.post("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch);
app.get("/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 // Node-RED embedded page
app.get("/admin/nodered", (event) => { app.get("/admin/nodered", (event) => {
const user = event.context.user!; 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 emitDisplayPower = (kioskId: number, state: "on" | "standby") => {
const displays = deps.repo.listDisplaysForKiosk(kioskId); const displays = deps.repo.listDisplaysForKiosk(kioskId);
const displayId = displays[0]?.id ?? null; 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", { deps.nodered.forward("display.power.changed", {
display_id: displayId, display_id: displayId,
kiosk_id: kioskId, kiosk_id: kioskId,

View file

@ -302,7 +302,13 @@ function registerKioskRoutes(
bundle_version?: string; bundle_version?: string;
kiosk_app_version?: string; kiosk_app_version?: string;
os_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_temp_c?: number | null;
cpu_load_percent?: number | null; cpu_load_percent?: number | null;
fan_rpm?: number | null; fan_rpm?: number | null;
@ -389,17 +395,27 @@ function registerKioskRoutes(
?? existing.find((d) => d.index === reportedIndex); ?? existing.find((d) => d.index === reportedIndex);
if (match) { if (match) {
seenDisplayIds.add(match.id); seenDisplayIds.add(match.id);
const powerState = reported.power_state === "awake" || reported.power_state === "standby"
? reported.power_state
: reported.power_state === "unknown"
? "unknown"
: null;
if ( if (
match.name !== reported.name match.name !== reported.name
|| match.index !== reportedIndex || match.index !== reportedIndex
|| match.width_px !== reported.width_px || match.width_px !== reported.width_px
|| match.height_px !== reported.height_px || match.height_px !== reported.height_px
|| (powerState != null && match.actual_power_state !== powerState)
) { ) {
repo.updateDisplay(match.id, { repo.updateDisplay(match.id, {
name: reported.name, name: reported.name,
index: reportedIndex, index: reportedIndex,
width_px: reported.width_px, width_px: reported.width_px,
height_px: reported.height_px, height_px: reported.height_px,
...(powerState != null ? {
actual_power_state: powerState,
actual_power_state_at: new Date().toISOString(),
} : {}),
} as any); } as any);
} }
} else { } else {
@ -410,6 +426,17 @@ function registerKioskRoutes(
width_px: reported.width_px, width_px: reported.width_px,
height_px: reported.height_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); seenDisplayIds.add(created.id);
} }
} }

View file

@ -16,6 +16,7 @@ import type {
CameraType, CameraType,
CellContentType, CellContentType,
DesiredPowerState, DesiredPowerState,
ActualPowerState,
Display, Display,
Entity, Entity,
EntityType, EntityType,
@ -135,6 +136,8 @@ export function rowToDisplay(r: Row): Display {
cec_device_path: sn(r["cec_device_path"]), cec_device_path: sn(r["cec_device_path"]),
cec_logical_address: nn(r["cec_logical_address"]), cec_logical_address: nn(r["cec_logical_address"]),
desired_power_state: s(r["desired_power_state"]) as DesiredPowerState, 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_enabled: b(r["state_check_enabled"]),
state_check_interval_seconds: n(r["state_check_interval_seconds"]), state_check_interval_seconds: n(r["state_check_interval_seconds"]),
is_enabled: b(r["is_enabled"]), is_enabled: b(r["is_enabled"]),

View file

@ -111,6 +111,9 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
cec_logical_address INTEGER, cec_logical_address INTEGER,
desired_power_state TEXT NOT NULL DEFAULT 'follow_layout' desired_power_state TEXT NOT NULL DEFAULT 'follow_layout'
CHECK(desired_power_state IN ('follow_layout', 'on', 'standby')), 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_enabled INTEGER NOT NULL DEFAULT 0,
state_check_interval_seconds INTEGER NOT NULL DEFAULT 60 state_check_interval_seconds INTEGER NOT NULL DEFAULT 60
) STRICT`, ) 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_total_mb: av.optional(av.int().min(0)),
disk_free_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)), 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)), active_layout_id: av.optional(av.int().min(1)),
streams_warm: av.optional(av.int().min(0)), streams_warm: av.optional(av.int().min(0)),
streams_hot: 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; created_at: string;
} }
export type DesiredPowerState = "follow_layout" | "on" | "standby"; export type DesiredPowerState = "follow_layout" | "on" | "standby";
export type ActualPowerState = "awake" | "standby" | "unknown";
export type LabelRole = "consume" | "operate"; export type LabelRole = "consume" | "operate";
export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system"; export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system";
@ -97,6 +98,8 @@ export interface Display {
cec_device_path: string | null; cec_device_path: string | null;
cec_logical_address: number | null; cec_logical_address: number | null;
desired_power_state: DesiredPowerState; desired_power_state: DesiredPowerState;
actual_power_state: ActualPowerState;
actual_power_state_at: string | null;
state_check_enabled: boolean; state_check_enabled: boolean;
state_check_interval_seconds: number; state_check_interval_seconds: number;
is_enabled: boolean; is_enabled: boolean;

View file

@ -1096,7 +1096,7 @@ export function SimpleListPage(props: SimpleListProps) {
</thead> </thead>
<tbody> <tbody>
{props.items.length === 0 ? ( {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) => ( props.items.map((item) => (
<tr> <tr>
@ -1651,13 +1651,14 @@ export function KioskEditPage(props: KioskEditProps) {
{props.displays && props.displays.length > 0 ? ( {props.displays && props.displays.length > 0 ? (
<div class="table-wrap"> <div class="table-wrap">
<table> <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> <tbody>
{props.displays.map((d) => ( {props.displays.map((d) => (
<tr> <tr>
<td><a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a></td> <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.width_px)}x{String(d.height_px)}</td>
<td>{String(d.index)}</td> <td>{String(d.index)}</td>
<td>{powerBadge(d.actual_power_state)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -2514,6 +2515,7 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
<div style="color:#666; font-size:0.85rem; margin-bottom:1rem"> <div style="color:#666; font-size:0.85rem; margin-bottom:1rem">
<div>Index: {String(d.index)}</div> <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>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 && ( {d.kiosk_id && (
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div> <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> </div>
) : null} ) : 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}`}> <form method="post" action={`/admin/displays/${d.id}`}>
<div class="form-group"> <div class="form-group">
<label for="name">Name</label> <label for="name">Name</label>
@ -2628,11 +2651,12 @@ export function DisplaysPage(props: DisplaysPageProps) {
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Details</th> <th>Details</th>
<th>Power</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{props.displays.length === 0 ? ( {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) => ( props.displays.map((d) => (
<tr> <tr>
@ -2643,6 +2667,7 @@ export function DisplaysPage(props: DisplaysPageProps) {
)} )}
</td> </td>
<td style="color:#666">{String(d.width_px)}x{String(d.height_px)} index {String(d.index)}</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> </tr>
)) ))
)} )}
@ -2713,6 +2738,12 @@ function mbPair(used: number | null, total: number | null): string {
return `${String(used)} / ${String(total)} MB`; 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 --------------------------------------------------- // ---- Node-RED Embed ---------------------------------------------------
export function NoderedEmbedPage(props: { user: string }) { export function NoderedEmbedPage(props: { user: string }) {