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:
Mitchell R 2026-05-11 11:47:07 +02:00
parent 5a67c80caa
commit 51c58e7abf
12 changed files with 200 additions and 15 deletions

80
kiosk/src/hwmon.rs Normal file
View 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
}

View file

@ -1,6 +1,7 @@
mod server; mod server;
mod bundle; mod bundle;
mod cec; mod cec;
mod hwmon;
mod pipeline; mod pipeline;
mod ui; mod ui;
mod ws_client; mod ws_client;
@ -9,6 +10,8 @@ pub enum ServerMsg {
ReloadBundle, ReloadBundle,
Standby, Standby,
Wake, Wake,
/// Some(0..=255) = manual PWM. None = restore auto.
Fan(Option<u32>),
} }
use gtk4::prelude::{ApplicationExt, ApplicationExtManual}; use gtk4::prelude::{ApplicationExt, ApplicationExtManual};

View file

@ -148,8 +148,13 @@ pub fn fetch_bundle(server: &str, key: &str) -> KioskBundle {
resp.json().expect("bad bundle JSON") resp.json().expect("bad bundle JSON")
} }
/// Send heartbeat with display geometry. /// Send heartbeat with display geometry + hwmon.
pub fn heartbeat(server: &str, key: &str, displays: &[(String, u32, u32)]) { pub fn heartbeat(
server: &str,
key: &str,
displays: &[(String, u32, u32)],
hw: &crate::hwmon::HwInfo,
) {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let display_info: Vec<_> = displays.iter().map(|(name, w, h)| { let display_info: Vec<_> = displays.iter().map(|(name, w, h)| {
serde_json::json!({ "name": name, "width_px": w, "height_px": 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!({ .json(&serde_json::json!({
"kiosk_app_version": env!("CARGO_PKG_VERSION"), "kiosk_app_version": env!("CARGO_PKG_VERSION"),
"displays": display_info, "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)) .timeout(Duration::from_secs(5))
.send(); .send();

View file

@ -16,6 +16,7 @@ use tracing::{info, warn};
use crate::bundle::KioskBundle; use crate::bundle::KioskBundle;
use crate::cec; use crate::cec;
use crate::hwmon;
use crate::pipeline; use crate::pipeline;
use crate::server; use crate::server;
use crate::ws_client; use crate::ws_client;
@ -101,15 +102,17 @@ fn activate(app: &Application) {
} }
ServerMsg::Standby => cec::standby(), ServerMsg::Standby => cec::standby(),
ServerMsg::Wake => cec::wake(), ServerMsg::Wake => cec::wake(),
ServerMsg::Fan(pwm) => { hwmon::set_fan(pwm); }
} }
} }
}); });
// Heartbeat loop — also reports display geometry // Heartbeat loop — reports display geometry + hwmon
loop { loop {
std::thread::sleep(std::time::Duration::from_secs(60)); std::thread::sleep(std::time::Duration::from_secs(60));
let displays = query_displays(); 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; 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) { if let Some((paintable, badge)) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) {
let picture = Picture::for_paintable(&paintable); 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_vexpand(true);
picture.set_hexpand(true); picture.set_hexpand(true);
// Wrap in Overlay so we can stack a stream-role badge on top // 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. /// 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( fn ensure_warm(
cam_id: u32, cam_id: u32,
cam: &crate::bundle::BundleCamera, cam: &crate::bundle::BundleCamera,
selector: Option<&str>, selector: Option<&str>,
area_fraction: f32, area_fraction: f32,
) -> Option<(gtk::gdk::Paintable, char)> { ) -> Option<(gtk::gdk::Paintable, char)> {
let existing = WARM_CAMERAS.with(|w| { let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
w.borrow().get(&cam_id).map(|(_, p, b)| (p.clone(), *b))
// 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 { if let Some((pipe, paintable, badge)) = cached {
return Some(pair); if badge == desired_badge {
return Some((paintable, badge));
} }
let (uri, badge) = cam.pick_stream(selector, area_fraction)?; info!("camera {cam_id}: stream change {badge} → {desired_badge}, swapping");
pipeline::stop(&pipe);
WARM_CAMERAS.with(|w| { w.borrow_mut().remove(&cam_id); });
}
let (pipe, sink) = pipeline::create_camera_pipeline(&cam.name, &uri)?; let (pipe, sink) = pipeline::create_camera_pipeline(&cam.name, &uri)?;
let paintable = sink.property::<gtk::gdk::Paintable>("paintable"); let paintable = sink.property::<gtk::gdk::Paintable>("paintable");
pipeline::play(&pipe); pipeline::play(&pipe);
WARM_CAMERAS.with(|w| { 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})"); info!("warmed pipeline for camera {cam_id} (stream: {desired_badge})");
Some((paintable, badge)) Some((paintable, desired_badge))
} }
fn show_logo(window: &ApplicationWindow) { fn show_logo(window: &ApplicationWindow) {

View file

@ -46,6 +46,19 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
} 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 _ = 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 { } else {
info!("ws: msg: {text}"); info!("ws: msg: {text}");
} }

View file

@ -1110,4 +1110,18 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
getCoordinator().sendToKiosk(id, { type: "wake" }); getCoordinator().sendToKiosk(id, { type: "wake" });
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); 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}` } });
});
} }

View file

@ -259,12 +259,18 @@ function registerKioskRoutes(
kiosk_app_version?: string; kiosk_app_version?: string;
os_version?: string; os_version?: string;
displays?: Array<{ name: string; width_px: number; height_px: number }>; displays?: Array<{ name: string; width_px: number; height_px: number }>;
cpu_temp_c?: number | null;
fan_rpm?: number | null;
fan_pwm?: number | null;
}>(event); }>(event);
repo.touchKiosk(kiosk.id, { repo.touchKiosk(kiosk.id, {
bundle_version: body?.bundle_version ?? null, bundle_version: body?.bundle_version ?? null,
kiosk_app_version: body?.kiosk_app_version ?? null, kiosk_app_version: body?.kiosk_app_version ?? null,
os_version: body?.os_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 // Sync displays reported by the kiosk

View file

@ -240,6 +240,9 @@ export function rowToKiosk(r: Row): Kiosk {
last_seen_at: sn(r["last_seen_at"]), last_seen_at: sn(r["last_seen_at"]),
last_bundle_version: sn(r["last_bundle_version"]), last_bundle_version: sn(r["last_bundle_version"]),
display_id: nn(r["display_id"]), 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"]), created_at: s(r["created_at"]),
}; };
} }

View file

@ -589,4 +589,11 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
`); `);
db.exec("PRAGMA foreign_keys = ON"); 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");
},
]; ];

View file

@ -971,6 +971,9 @@ export class Repository {
bundle_version?: string | null; bundle_version?: string | null;
kiosk_app_version?: string | null; kiosk_app_version?: string | null;
os_version?: string | null; os_version?: string | null;
cpu_temp_c?: number | null;
fan_rpm?: number | null;
fan_pwm?: number | null;
}, },
): void { ): void {
this.prep( this.prep(
@ -978,13 +981,19 @@ export class Repository {
last_seen_at = ?, last_seen_at = ?,
last_bundle_version = COALESCE(?, last_bundle_version), last_bundle_version = COALESCE(?, last_bundle_version),
kiosk_app_version = COALESCE(?, kiosk_app_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 = ?`, WHERE id = ?`,
).run( ).run(
isoNow(), isoNow(),
patch.bundle_version ?? null, patch.bundle_version ?? null,
patch.kiosk_app_version ?? null, patch.kiosk_app_version ?? null,
patch.os_version ?? null, patch.os_version ?? null,
patch.cpu_temp_c ?? null,
patch.fan_rpm ?? null,
patch.fan_pwm ?? null,
id, id,
); );
} }

View file

@ -204,6 +204,9 @@ export interface Kiosk {
last_seen_at: string | null; last_seen_at: string | null;
last_bundle_version: string | null; last_bundle_version: string | null;
display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id 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; created_at: string;
} }

View file

@ -1212,6 +1212,33 @@ export function KioskEditPage(props: KioskEditProps) {
<button type="submit" class="btn btn-sm btn-ghost">Standby</button> <button type="submit" class="btn btn-sm btn-ghost">Standby</button>
</form> </form>
</div> </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> </div>
{/* Associated displays */} {/* Associated displays */}