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