diff --git a/kiosk/src/hwmon.rs b/kiosk/src/hwmon.rs new file mode 100644 index 0000000..3f57fb8 --- /dev/null +++ b/kiosk/src/hwmon.rs @@ -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, + pub fan_rpm: Option, + pub fan_pwm: Option, +} + +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) -> 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 { + 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 { + let dir = find_fan_hwmon()?; + let raw = fs::read_to_string(dir.join(file)).ok()?; + raw.trim().parse().ok() +} + +fn find_fan_hwmon() -> Option { + 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 +} diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index 0ed7a61..8d45ee5 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -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), } use gtk4::prelude::{ApplicationExt, ApplicationExtManual}; diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 9e24776..ffee442 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -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(); diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 3c0e585..42be68c 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -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::("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) { diff --git a/kiosk/src/ws_client.rs b/kiosk/src/ws_client.rs index 308cce1..ed2a958 100644 --- a/kiosk/src/ws_client.rs +++ b/kiosk/src/ws_client.rs @@ -46,6 +46,19 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { } 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 = 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::().ok()); + if v.is_none() { continue; } + v + }; + let _ = tx.send(ServerMsg::Fan(pwm)); } else { info!("ws: msg: {text}"); } diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 2ca72b1..23d4217 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -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>(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}` } }); + }); } diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index b3160a8..320c4ba 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -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 diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index 2a41ef1..cd83db4 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -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"]), }; } diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 3d9ebe1..c767fe7 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -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"); + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index ee99fda..57886c8 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -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, ); } diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index 02a5370..cd1edde 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -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; } diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index d582748..2b28e7e 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1212,6 +1212,33 @@ export function KioskEditPage(props: KioskEditProps) { + +
+
Hardware
+
+
CPU: {k.cpu_temp_c != null ? `${k.cpu_temp_c.toFixed(1)}°C` : "—"}
+
Fan: {k.fan_rpm != null ? `${k.fan_rpm} RPM` : "—"}
+
PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
{/* Associated displays */}