mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
feat(display): report and control power state
This commit is contained in:
parent
6cfb37aa64
commit
49e420dea5
12 changed files with 295 additions and 37 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
140
kiosk/src/ui.rs
140
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<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}");
|
||||
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<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
|
||||
);
|
||||
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<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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue