mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56: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.
|
/// 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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
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"),
|
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}");
|
||||||
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;
|
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
|
||||||
);
|
);
|
||||||
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| {
|
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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"]),
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue