mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
feat: Pi fan control + temp monitoring + stream swap on layout change
Kiosk:
- hwmon.rs reads /sys/class/thermal + /sys/class/hwmon for CPU temp,
fan RPM, fan PWM
- Heartbeat reports cpu_temp_c, fan_rpm, fan_pwm
- WS message "fan" with {pwm: N} or {mode: "auto"} sets pwm1_enable+pwm1
- Picture content_fit Cover → Contain (no more cropping/overlay cuts)
- ensure_warm tears down + rebuilds pipeline when desired stream
changes (M↔S swap on layout change)
Server:
- Migration v0.8: add cpu_temp_c, fan_rpm, fan_pwm to kiosks
- Heartbeat persists hwmon fields
- KioskEditPage shows CPU/fan/PWM + Auto/Off/50%/Full buttons
- POST /admin/kiosks/:id/fan dispatches via coordinator WS
This commit is contained in:
parent
5a67c80caa
commit
51c58e7abf
12 changed files with 200 additions and 15 deletions
80
kiosk/src/hwmon.rs
Normal file
80
kiosk/src/hwmon.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! Pi5 hwmon — read CPU temp + fan RPM, override fan PWM.
|
||||
//!
|
||||
//! Read paths:
|
||||
//! - /sys/class/thermal/thermal_zone0/temp (millideg C)
|
||||
//! - /sys/class/hwmon/hwmon*/fan1_input (RPM)
|
||||
//! - /sys/class/hwmon/hwmon*/pwm1 (0-255 current)
|
||||
//!
|
||||
//! Override:
|
||||
//! - echo 1 > pwm1_enable (manual)
|
||||
//! - echo N > pwm1 (0-255)
|
||||
//! - echo 2 > pwm1_enable (auto / cooling_device controlled)
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HwInfo {
|
||||
pub cpu_temp_c: Option<f32>,
|
||||
pub fan_rpm: Option<u32>,
|
||||
pub fan_pwm: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn read() -> HwInfo {
|
||||
HwInfo {
|
||||
cpu_temp_c: read_temp(),
|
||||
fan_rpm: read_u32_in_hwmon("fan1_input"),
|
||||
fan_pwm: read_u32_in_hwmon("pwm1"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set fan PWM (0-255). If pwm is None → restore auto mode.
|
||||
pub fn set_fan(pwm: Option<u32>) -> bool {
|
||||
let Some(dir) = find_fan_hwmon() else {
|
||||
warn!("hwmon: no fan device found");
|
||||
return false;
|
||||
};
|
||||
let pwm_enable = dir.join("pwm1_enable");
|
||||
let pwm_path = dir.join("pwm1");
|
||||
|
||||
match pwm {
|
||||
Some(value) => {
|
||||
let v = value.min(255);
|
||||
if fs::write(&pwm_enable, "1").is_err() {
|
||||
warn!("hwmon: cannot write pwm1_enable");
|
||||
return false;
|
||||
}
|
||||
if fs::write(&pwm_path, v.to_string()).is_err() {
|
||||
warn!("hwmon: cannot write pwm1");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
None => fs::write(&pwm_enable, "2").is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_temp() -> Option<f32> {
|
||||
let raw = fs::read_to_string("/sys/class/thermal/thermal_zone0/temp").ok()?;
|
||||
let m: i64 = raw.trim().parse().ok()?;
|
||||
Some(m as f32 / 1000.0)
|
||||
}
|
||||
|
||||
fn read_u32_in_hwmon(file: &str) -> Option<u32> {
|
||||
let dir = find_fan_hwmon()?;
|
||||
let raw = fs::read_to_string(dir.join(file)).ok()?;
|
||||
raw.trim().parse().ok()
|
||||
}
|
||||
|
||||
fn find_fan_hwmon() -> Option<PathBuf> {
|
||||
let entries = fs::read_dir("/sys/class/hwmon").ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Look for hwmon dirs that have pwm1 (the fan controller)
|
||||
if path.join("pwm1").exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod server;
|
||||
mod bundle;
|
||||
mod cec;
|
||||
mod hwmon;
|
||||
mod pipeline;
|
||||
mod ui;
|
||||
mod ws_client;
|
||||
|
|
@ -9,6 +10,8 @@ pub enum ServerMsg {
|
|||
ReloadBundle,
|
||||
Standby,
|
||||
Wake,
|
||||
/// Some(0..=255) = manual PWM. None = restore auto.
|
||||
Fan(Option<u32>),
|
||||
}
|
||||
|
||||
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
|
||||
|
|
|
|||
|
|
@ -148,8 +148,13 @@ pub fn fetch_bundle(server: &str, key: &str) -> KioskBundle {
|
|||
resp.json().expect("bad bundle JSON")
|
||||
}
|
||||
|
||||
/// Send heartbeat with display geometry.
|
||||
pub fn heartbeat(server: &str, key: &str, displays: &[(String, u32, u32)]) {
|
||||
/// Send heartbeat with display geometry + hwmon.
|
||||
pub fn heartbeat(
|
||||
server: &str,
|
||||
key: &str,
|
||||
displays: &[(String, u32, u32)],
|
||||
hw: &crate::hwmon::HwInfo,
|
||||
) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let display_info: Vec<_> = displays.iter().map(|(name, w, h)| {
|
||||
serde_json::json!({ "name": name, "width_px": w, "height_px": h })
|
||||
|
|
@ -160,6 +165,9 @@ pub fn heartbeat(server: &str, key: &str, displays: &[(String, u32, u32)]) {
|
|||
.json(&serde_json::json!({
|
||||
"kiosk_app_version": env!("CARGO_PKG_VERSION"),
|
||||
"displays": display_info,
|
||||
"cpu_temp_c": hw.cpu_temp_c,
|
||||
"fan_rpm": hw.fan_rpm,
|
||||
"fan_pwm": hw.fan_pwm,
|
||||
}))
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::bundle::KioskBundle;
|
||||
use crate::cec;
|
||||
use crate::hwmon;
|
||||
use crate::pipeline;
|
||||
use crate::server;
|
||||
use crate::ws_client;
|
||||
|
|
@ -101,15 +102,17 @@ fn activate(app: &Application) {
|
|||
}
|
||||
ServerMsg::Standby => cec::standby(),
|
||||
ServerMsg::Wake => cec::wake(),
|
||||
ServerMsg::Fan(pwm) => { hwmon::set_fan(pwm); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Heartbeat loop — also reports display geometry
|
||||
// Heartbeat loop — reports display geometry + hwmon
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
let displays = query_displays();
|
||||
server::heartbeat(&server, &key, &displays);
|
||||
let hw = hwmon::read();
|
||||
server::heartbeat(&server, &key, &displays, &hw);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -253,7 +256,7 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s
|
|||
let area = (cell.col_span * cell.row_span) as f32 / total_area;
|
||||
if let Some((paintable, badge)) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) {
|
||||
let picture = Picture::for_paintable(&paintable);
|
||||
picture.set_content_fit(gtk::ContentFit::Cover);
|
||||
picture.set_content_fit(gtk::ContentFit::Contain);
|
||||
picture.set_vexpand(true);
|
||||
picture.set_hexpand(true);
|
||||
// Wrap in Overlay so we can stack a stream-role badge on top
|
||||
|
|
@ -356,28 +359,37 @@ fn should_attach_kiosk_auth(url: &str, server_url: &str) -> bool {
|
|||
}
|
||||
|
||||
/// Returns (paintable, badge_char) for a camera, creating a warm pipeline if missing.
|
||||
/// badge is 'M' / 'S' (when multi-stream) or ' ' (single stream).
|
||||
/// If cached pipeline's stream differs from what the cell needs (M↔S swap due
|
||||
/// to layout change), tear down old and spin up new.
|
||||
fn ensure_warm(
|
||||
cam_id: u32,
|
||||
cam: &crate::bundle::BundleCamera,
|
||||
selector: Option<&str>,
|
||||
area_fraction: f32,
|
||||
) -> Option<(gtk::gdk::Paintable, char)> {
|
||||
let existing = WARM_CAMERAS.with(|w| {
|
||||
w.borrow().get(&cam_id).map(|(_, p, b)| (p.clone(), *b))
|
||||
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
|
||||
|
||||
// Check cached: if badge matches desired, reuse. Else swap.
|
||||
let cached = WARM_CAMERAS.with(|w| {
|
||||
w.borrow().get(&cam_id).map(|(p, paint, b)| (p.clone(), paint.clone(), *b))
|
||||
});
|
||||
if let Some(pair) = existing {
|
||||
return Some(pair);
|
||||
if let Some((pipe, paintable, badge)) = cached {
|
||||
if badge == desired_badge {
|
||||
return Some((paintable, badge));
|
||||
}
|
||||
info!("camera {cam_id}: stream change {badge} → {desired_badge}, swapping");
|
||||
pipeline::stop(&pipe);
|
||||
WARM_CAMERAS.with(|w| { w.borrow_mut().remove(&cam_id); });
|
||||
}
|
||||
let (uri, badge) = cam.pick_stream(selector, area_fraction)?;
|
||||
|
||||
let (pipe, sink) = pipeline::create_camera_pipeline(&cam.name, &uri)?;
|
||||
let paintable = sink.property::<gtk::gdk::Paintable>("paintable");
|
||||
pipeline::play(&pipe);
|
||||
WARM_CAMERAS.with(|w| {
|
||||
w.borrow_mut().insert(cam_id, (pipe, paintable.clone(), badge));
|
||||
w.borrow_mut().insert(cam_id, (pipe, paintable.clone(), desired_badge));
|
||||
});
|
||||
info!("warmed pipeline for camera {cam_id} (stream: {badge})");
|
||||
Some((paintable, badge))
|
||||
info!("warmed pipeline for camera {cam_id} (stream: {desired_badge})");
|
||||
Some((paintable, desired_badge))
|
||||
}
|
||||
|
||||
fn show_logo(window: &ApplicationWindow) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,19 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
|
|||
} else if text.contains("\"type\":\"wake\"") {
|
||||
info!("ws: wake received");
|
||||
let _ = tx.send(ServerMsg::Wake);
|
||||
} else if text.contains("\"type\":\"fan\"") {
|
||||
info!("ws: fan received: {text}");
|
||||
let pwm: Option<u32> = if text.contains("\"mode\":\"auto\"") {
|
||||
None
|
||||
} else {
|
||||
// Parse "pwm":N
|
||||
let v = text.split("\"pwm\":").nth(1)
|
||||
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
if v.is_none() { continue; }
|
||||
v
|
||||
};
|
||||
let _ = tx.send(ServerMsg::Fan(pwm));
|
||||
} else {
|
||||
info!("ws: msg: {text}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1110,4 +1110,18 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
getCoordinator().sendToKiosk(id, { type: "wake" });
|
||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||
});
|
||||
|
||||
// ---- Fan control ------------------------------------------------------
|
||||
app.post("/admin/kiosks/:id/fan", async (event) => {
|
||||
const id = Number(getRouterParam(event, "id"));
|
||||
const body = await readBody<Record<string, string>>(event);
|
||||
const mode = body?.["mode"];
|
||||
if (mode === "auto") {
|
||||
getCoordinator().sendToKiosk(id, { type: "fan", mode: "auto" });
|
||||
} else {
|
||||
const pwm = Math.max(0, Math.min(255, Number(body?.["pwm"]) || 0));
|
||||
getCoordinator().sendToKiosk(id, { type: "fan", pwm });
|
||||
}
|
||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -259,12 +259,18 @@ function registerKioskRoutes(
|
|||
kiosk_app_version?: string;
|
||||
os_version?: string;
|
||||
displays?: Array<{ name: string; width_px: number; height_px: number }>;
|
||||
cpu_temp_c?: number | null;
|
||||
fan_rpm?: number | null;
|
||||
fan_pwm?: number | null;
|
||||
}>(event);
|
||||
|
||||
repo.touchKiosk(kiosk.id, {
|
||||
bundle_version: body?.bundle_version ?? null,
|
||||
kiosk_app_version: body?.kiosk_app_version ?? null,
|
||||
os_version: body?.os_version ?? null,
|
||||
cpu_temp_c: body?.cpu_temp_c ?? null,
|
||||
fan_rpm: body?.fan_rpm ?? null,
|
||||
fan_pwm: body?.fan_pwm ?? null,
|
||||
});
|
||||
|
||||
// Sync displays reported by the kiosk
|
||||
|
|
|
|||
|
|
@ -240,6 +240,9 @@ export function rowToKiosk(r: Row): Kiosk {
|
|||
last_seen_at: sn(r["last_seen_at"]),
|
||||
last_bundle_version: sn(r["last_bundle_version"]),
|
||||
display_id: nn(r["display_id"]),
|
||||
cpu_temp_c: nn(r["cpu_temp_c"]),
|
||||
fan_rpm: nn(r["fan_rpm"]),
|
||||
fan_pwm: nn(r["fan_pwm"]),
|
||||
created_at: s(r["created_at"]),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -589,4 +589,11 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
|||
`);
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
},
|
||||
|
||||
// ---- hwmon columns on kiosks: cpu_temp_c, fan_rpm, fan_pwm ------
|
||||
(db: DatabaseSync) => {
|
||||
addColumnIfNotExists(db, "kiosks", "cpu_temp_c", "REAL");
|
||||
addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER");
|
||||
addColumnIfNotExists(db, "kiosks", "fan_pwm", "INTEGER");
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -971,6 +971,9 @@ export class Repository {
|
|||
bundle_version?: string | null;
|
||||
kiosk_app_version?: string | null;
|
||||
os_version?: string | null;
|
||||
cpu_temp_c?: number | null;
|
||||
fan_rpm?: number | null;
|
||||
fan_pwm?: number | null;
|
||||
},
|
||||
): void {
|
||||
this.prep(
|
||||
|
|
@ -978,13 +981,19 @@ export class Repository {
|
|||
last_seen_at = ?,
|
||||
last_bundle_version = COALESCE(?, last_bundle_version),
|
||||
kiosk_app_version = COALESCE(?, kiosk_app_version),
|
||||
os_version = COALESCE(?, os_version)
|
||||
os_version = COALESCE(?, os_version),
|
||||
cpu_temp_c = ?,
|
||||
fan_rpm = ?,
|
||||
fan_pwm = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
isoNow(),
|
||||
patch.bundle_version ?? null,
|
||||
patch.kiosk_app_version ?? null,
|
||||
patch.os_version ?? null,
|
||||
patch.cpu_temp_c ?? null,
|
||||
patch.fan_rpm ?? null,
|
||||
patch.fan_pwm ?? null,
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,6 +204,9 @@ export interface Kiosk {
|
|||
last_seen_at: string | null;
|
||||
last_bundle_version: string | null;
|
||||
display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id
|
||||
cpu_temp_c: number | null;
|
||||
fan_rpm: number | null;
|
||||
fan_pwm: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1212,6 +1212,33 @@ export function KioskEditPage(props: KioskEditProps) {
|
|||
<button type="submit" class="btn btn-sm btn-ghost">Standby</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
||||
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Hardware</div>
|
||||
<div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666; margin-bottom:0.75rem">
|
||||
<div>CPU: {k.cpu_temp_c != null ? `${k.cpu_temp_c.toFixed(1)}°C` : "—"}</div>
|
||||
<div>Fan: {k.fan_rpm != null ? `${k.fan_rpm} RPM` : "—"}</div>
|
||||
<div>PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
|
||||
<form method="post" action={`/admin/kiosks/${k.id}/fan`} style="display:inline">
|
||||
<input type="hidden" name="mode" value="auto" />
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Auto</button>
|
||||
</form>
|
||||
<form method="post" action={`/admin/kiosks/${k.id}/fan`} style="display:inline">
|
||||
<input type="hidden" name="pwm" value="0" />
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Off</button>
|
||||
</form>
|
||||
<form method="post" action={`/admin/kiosks/${k.id}/fan`} style="display:inline">
|
||||
<input type="hidden" name="pwm" value="128" />
|
||||
<button type="submit" class="btn btn-sm btn-ghost">50%</button>
|
||||
</form>
|
||||
<form method="post" action={`/admin/kiosks/${k.id}/fan`} style="display:inline">
|
||||
<input type="hidden" name="pwm" value="255" />
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Full</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Associated displays */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue