diff --git a/README.md b/README.md index cece4cb..97db7ba 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ irm https://raw.githubusercontent.com/SusieeTheLinuxUser/rig-hub/main/install.ps | ❄ **Cooler** | Liquid, CPU, pump and fan readouts. Put any image or GIF on the LCD, rotate it to any angle (handy for upside-down mounts), set brightness. | | 🎧 **Headset** | Battery level, sidetone, auto power-off. Low-battery warning. | | 🎀 **Mic** | Mute (in sync with the hardware button), gain, monitoring, headphone volume. | -| πŸ“Œ **Tray** | Headset battery, liquid temp and current DPI in the system tray. Closing the window keeps it there. | +| πŸ“Œ **Tray** | Batteries (headset, plus anything UPower sees: UPS, wireless mice, controllers), temps, pump/fan speeds and current DPI in the tray menu. Closing the window keeps it there. | +| 🧩 **KDE widget** | The same at a glance in your Plasma panel: add "Rig Hub" from Add Widgets (Plasma 6). Rig Hub installs it on KDE and keeps it updated along with the app. Needs Rig Hub running. | | 🎨 **Themes** | Midnight, Daylight, Catppuccin Mocha, Nord, Dracula, Gruvbox. | ## Supported hardware diff --git a/plasmoid/contents/ui/main.qml b/plasmoid/contents/ui/main.qml new file mode 100644 index 0000000..1c8aa59 --- /dev/null +++ b/plasmoid/contents/ui/main.qml @@ -0,0 +1,104 @@ +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.plasmoid +import org.kde.plasma.components as PlasmaComponents +import org.kde.plasma.plasma5support as P5Support +import org.kde.kirigami as Kirigami + +PlasmoidItem { + id: root + // { panel: "85%", rows: [[label, value], …] } written by Rig Hub; null while it isn't running + property var status: null + // pgrep first: the file outlives Rig Hub, and stale numbers would look live + readonly property string poll: `pgrep -x rig-hub >/dev/null && cat "\${XDG_RUNTIME_DIR:-\${XDG_CACHE_HOME:-$HOME/.cache}}/rig-hub-status.json"` + // a second launch just brings the running one to the front + readonly property string open: `PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" setsid -f rig-hub` + + toolTipMainText: "Rig Hub" + toolTipSubText: status ? status.rows.map(r => `${r[0]} ${r[1]}`).join("\n") : "Rig Hub isn't running" + + P5Support.DataSource { + id: exec + engine: "executable" + onNewData: (source, data) => { + disconnectSource(source) + if (source !== root.poll) + return + try { + root.status = data.stdout ? JSON.parse(data.stdout) : null + } catch (e) { + root.status = null + } + } + } + + Timer { + interval: 5000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: exec.connectSource(root.poll) + } + + compactRepresentation: MouseArea { + Layout.minimumWidth: compact.implicitWidth + onClicked: root.expanded = !root.expanded + RowLayout { + id: compact + anchors.centerIn: parent + spacing: Kirigami.Units.smallSpacing + Kirigami.Icon { + source: "audio-headset" + Layout.preferredWidth: Kirigami.Units.iconSizes.small + Layout.preferredHeight: Kirigami.Units.iconSizes.small + opacity: root.status ? 1 : 0.5 + } + PlasmaComponents.Label { + visible: text !== "" + text: root.status ? root.status.panel : "" + font.bold: true + } + } + } + + fullRepresentation: ColumnLayout { + Layout.preferredWidth: Kirigami.Units.gridUnit * 16 + spacing: Kirigami.Units.smallSpacing + + Kirigami.Heading { + level: 4 + text: "Rig Hub" + } + Repeater { + model: root.status ? root.status.rows : [] + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + PlasmaComponents.Label { + text: modelData[0] + Layout.fillWidth: true + } + PlasmaComponents.Label { + text: modelData[1] + font.bold: true + } + } + } + PlasmaComponents.Label { + visible: !root.status + text: "Rig Hub isn't running." + opacity: 0.6 + } + PlasmaComponents.Button { + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.fillWidth: true + icon.name: "window-new" + text: root.status ? "Open Rig Hub" : "Start Rig Hub" + onClicked: { + exec.connectSource(root.open) + root.expanded = false + } + } + Item { Layout.fillHeight: true } + } +} diff --git a/plasmoid/metadata.json b/plasmoid/metadata.json new file mode 100644 index 0000000..e58fbcd --- /dev/null +++ b/plasmoid/metadata.json @@ -0,0 +1,14 @@ +{ + "KPlugin": { + "Id": "com.susiee.righub", + "Name": "Rig Hub", + "Description": "Headset and other batteries, cooler temps and mouse DPI from Rig Hub", + "Icon": "audio-headset", + "Authors": [{ "Name": "Susiee" }], + "License": "GPL-3.0-or-later", + "Website": "https://github.com/SusieeTheLinuxUser/rig-hub", + "Version": "0.1" + }, + "KPackageStructure": "Plasma/Applet", + "X-Plasma-API-Minimum-Version": "6.0" +} diff --git a/src/audio.rs b/src/audio.rs index 1e93c69..ba9dbc0 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -1,4 +1,4 @@ -//! Headset via the `headsetcontrol` CLI, USB mic via `amixer`. +//! Headset via the `headsetcontrol` CLI, USB mic via `amixer`, other batteries via `upower`. use anyhow::{Context, Result, bail}; use std::process::Command; @@ -72,6 +72,46 @@ pub fn headset_set(flag: &str, value: u32) -> Result<()> { Ok(()) } +// -------------------------------------------------------------- batteries + +#[derive(Clone, Debug, PartialEq)] +pub struct PowerDevice { + pub name: String, + /// "57%", or a coarse level like "low" for devices that only report one + pub level: String, + pub charging: bool, +} + +/// Battery-powered devices UPower knows about (UPS, wireless mice, controllers…) from `upower --dump`. +pub fn parse_upower(text: &str) -> Vec { + text.split("\n\n") + .filter_map(|block| { + let field = |k: &str| block.lines().find_map(|l| l.trim().strip_prefix(k)).map(str::trim); + let path = field("Device:")?; + if path.ends_with("DisplayDevice") { + return None; // the summary of all batteries, not a device + } + // "57%" or, in some locales, "57,5%" + let percent: u8 = field("percentage:")?.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()?; + // with a coarse battery-level, UPower's percentage is only a guess + let level = match field("battery-level:") { + Some(l) if !matches!(l, "none" | "unknown") => l.to_string(), + _ => format!("{percent}%"), + }; + Some(PowerDevice { + // no model: the object name (e.g. "mouse_hidpp_battery_0") at least tells two apart + name: field("model:").filter(|m| !m.is_empty()).unwrap_or_else(|| path.rsplit('/').next().unwrap_or(path)).to_string(), + level, + charging: field("state:") == Some("charging"), + }) + }) + .collect() +} + +pub fn batteries() -> Vec { + run("upower", &["--dump"]).map(|t| parse_upower(&t)).unwrap_or_default() +} + // -------------------------------------------------------------------- mic #[derive(Clone, Copy, Debug, PartialEq)] @@ -180,6 +220,44 @@ mod tests { assert_eq!(parse_headset(r#"{"devices":[]}"#).unwrap(), None); } + #[test] + fn upower() { + let dump = "Device: /org/freedesktop/UPower/devices/ups_hiddev1 + model: Back-UPS ES 650G2 + ups + state: fully-charged + percentage: 100% + +Device: /org/freedesktop/UPower/devices/mouse_hidpp_battery_0 + model: G305 + mouse + state: charging + percentage: 57,5% + +Device: /org/freedesktop/UPower/devices/gaming_input_0 + gaming-input + state: discharging + percentage: 55% + battery-level: low + +Device: /org/freedesktop/UPower/devices/line_power_AC + line-power + online: yes + +Device: /org/freedesktop/UPower/devices/DisplayDevice + ups + percentage: 100% +"; + assert_eq!( + parse_upower(dump), + [ + PowerDevice { name: "Back-UPS ES 650G2".into(), level: "100%".into(), charging: false }, + PowerDevice { name: "G305".into(), level: "57%".into(), charging: true }, + PowerDevice { name: "gaming_input_0".into(), level: "low".into(), charging: false }, + ] + ); + } + #[cfg(unix)] #[test] fn failed_command_is_an_error() { diff --git a/src/main.rs b/src/main.rs index 3a2786e..62fd997 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,9 +138,9 @@ enum TrayMsg { struct Tray { _icon: tray_icon::TrayIcon, - headset: MenuItem, - liquid: MenuItem, - dpi: MenuItem, + menu: Menu, + /// greyed-out status rows at the top of the menu, one per `status_rows` entry + rows: Vec, show: MenuItem, bug: MenuItem, quit: MenuItem, @@ -149,20 +149,18 @@ struct Tray { impl Tray { fn new(ctx: &workers::Context, tx: Sender) -> Option { - let info = |t: &str| MenuItem::new(t, false, None); - let (headset, liquid, dpi) = (info("Headset: …"), info("Liquid: …"), info("Mouse: …")); let show = MenuItem::new("Open Rig Hub", true, None); let bug = MenuItem::new("Report a bug…", true, None); let quit = MenuItem::new("Quit", true, None); let menu = Menu::new(); - menu.append_items(&[&headset, &liquid, &dpi, &PredefinedMenuItem::separator(), &show, &bug, &quit]).ok()?; + menu.append_items(&[&PredefinedMenuItem::separator(), &show, &bug, &quit]).ok()?; let icon = tray_icon::Icon::from_rgba(icon_rgba(64), 64, 64).ok()?; let tray = tray_icon::TrayIconBuilder::new() .with_id("rig-hub") .with_title("Rig Hub") .with_tooltip("Rig Hub") .with_icon(icon) - .with_menu(Box::new(menu)) + .with_menu(Box::new(menu.clone())) .with_menu_on_left_click(false) .build() .ok()?; @@ -178,34 +176,85 @@ impl Tray { c.request_repaint(); } })); - Some(Self { _icon: tray, headset, liquid, dpi, show, bug, quit, last: String::new() }) + Some(Self { _icon: tray, menu, rows: Vec::new(), show, bug, quit, last: String::new() }) } fn refresh(&mut self, st: &State) { - let headset = match st.headset.ok().map(|h| &h.battery) { - Some(audio::Battery::Level(p)) => format!("🎧 Headset {p}%"), - Some(audio::Battery::Charging(Some(p))) => format!("🎧 Headset {p}% ⚑"), - Some(audio::Battery::Charging(None)) => "🎧 Headset charging ⚑".into(), - _ => "🎧 Headset off".into(), - }; - let liquid = match st.cooler.ok().and_then(|c| c.status.as_ref()) { - Some(s) => format!("πŸ’§ Liquid {:.1} Β°C", s.liquid_c), - None => "πŸ’§ Liquid –".into(), - }; - let dpi = match current_dpi(st) { - Some(d) => format!("πŸ–± Mouse {d} DPI"), - None => "πŸ–± Mouse –".into(), - }; - let all = format!("{headset}\n{liquid}\n{dpi}"); - if all == self.last { + let rows = status_rows(st); + let all = rows.iter().map(|(l, v)| format!("{l} {v}")).collect::>(); + let joined = all.join("\n"); + if joined == self.last { return; } - self.headset.set_text(&headset); - self.liquid.set_text(&liquid); - self.dpi.set_text(&dpi); - let _ = self._icon.set_tooltip(Some(&all)); - self.last = all; + if self.rows.len() != rows.len() { + // a battery came or went + for item in self.rows.drain(..) { + let _ = self.menu.remove(&item); + } + for i in 0..rows.len() { + let item = MenuItem::new("", false, None); + let _ = self.menu.insert(&item, i); + self.rows.push(item); + } + } + for (item, text) in self.rows.iter().zip(&all) { + item.set_text(text); + } + let _ = self._icon.set_tooltip(Some(&joined)); + // on a failed write keep `last` stale, so the next refresh tries again + if write_status(st, &rows) { + self.last = joined; + } + } +} + +/// (label, value) for everything worth a glance: the tray menu and the KDE widget both show these. +fn status_rows(st: &State) -> Vec<(String, String)> { + let mut rows = vec![( + "🎧 Headset".to_string(), + match st.headset.ok().map(|h| &h.battery) { + Some(audio::Battery::Level(p)) => format!("{p}%"), + Some(audio::Battery::Charging(Some(p))) => format!("{p}% ⚑"), + Some(audio::Battery::Charging(None)) => "charging ⚑".into(), + _ => "off".into(), + }, + )]; + for b in &st.batteries { + rows.push((format!("πŸ”‹ {}", b.name), format!("{}{}", b.level, if b.charging { " ⚑" } else { "" }))); } + let status = st.cooler.ok().and_then(|c| c.status.as_ref()); + let or_dash = |v: Option| v.unwrap_or_else(|| "–".into()); + rows.push(("πŸ’§ Liquid".into(), or_dash(status.map(|s| format!("{:.1} Β°C", s.liquid_c))))); + rows.push(("🌑 CPU".into(), or_dash(st.cpu_c.map(|c| format!("{c:.0} Β°C"))))); + rows.push(("πŸŒ€ Pump".into(), or_dash(status.map(|s| format!("{} rpm", s.pump_rpm))))); + rows.push(("πŸŒ€ Fans".into(), or_dash(status.map(|s| format!("{} rpm", s.fan_rpm))))); + rows.push(("πŸ–± Mouse".into(), or_dash(current_dpi(st).map(|d| format!("{d} DPI"))))); + rows +} + +/// Somewhere only this user can write: never a shared /tmp, where another user could plant files. +fn private_dir() -> PathBuf { + dirs::runtime_dir().or_else(dirs::cache_dir).unwrap_or_else(std::env::temp_dir) +} + +/// For the KDE widget (`plasmoid/`), which polls this file while Rig Hub runs. The widget looks in +/// the same place: `${XDG_RUNTIME_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}}`. +fn status_path() -> PathBuf { + private_dir().join("rig-hub-status.json") +} + +fn write_status(st: &State, rows: &[(String, String)]) -> bool { + let panel = match st.headset.ok().map(|h| &h.battery) { + Some(audio::Battery::Level(p) | audio::Battery::Charging(Some(p))) => format!("{p}%"), + _ => String::new(), + }; + write_atomic(&status_path(), &serde_json::json!({ "panel": panel, "rows": rows }).to_string()) +} + +/// Write then rename, so readers (the widget, Plasma) never see half a file. +fn write_atomic(path: &Path, body: &str) -> bool { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, body).is_ok() && std::fs::rename(&tmp, path).is_ok() } #[cfg(target_os = "linux")] @@ -383,7 +432,8 @@ impl App { }); } Self { - tray: Tray::new(&repaint, tx), + // test runs stay out of the tray and the widget's status file + tray: screenshot.is_none().then(|| Tray::new(&repaint, tx)).flatten(), st, w, repaint, @@ -411,6 +461,7 @@ impl App { self.diagram = None; self.preview = None; self.repaint.set(ctx); + kwin_raise(); } fn closes_to_tray(&self) -> bool { @@ -449,6 +500,81 @@ impl App { } } +/// Wayland ignores an app focusing its own window (winit's `focus_window` is a no-op there), so ask +/// KWin to do it with a one-off script. Off KDE the D-Bus calls fail and nothing happens. +#[cfg(target_os = "linux")] +fn kwin_raise() { + use std::sync::atomic::{AtomicBool, Ordering}; + // both raisers would register the same script name, and KWin refuses the second + static RAISING: AtomicBool = AtomicBool::new(false); + if RAISING.swap(true, Ordering::SeqCst) { + return; + } + std::thread::spawn(|| { + raise(); + RAISING.store(false, Ordering::SeqCst); + }); + + fn raise() { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let script = private_dir().join("rig-hub-raise.js"); + let js = r#"for (const w of workspace.windowList()) if (w.resourceClass === "rig-hub") { w.minimized = false; workspace.activeWindow = w; }"#; + // KWin runs whatever is in this file, so nobody else may be able to write it + let _ = std::fs::remove_file(&script); + let written = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&script) + .and_then(|mut f| f.write_all(js.as_bytes())); + if written.is_err() { + return; + } + let kwin = |path: &str, method: &str, args: &[&str]| { + std::process::Command::new("dbus-send") + .args(["--session", "--print-reply", "--dest=org.kde.KWin", path, method]) + .args(args) + .output() + .ok() + }; + // a freshly opened window may not be mapped yet, so try again a bit later + for wait in [250, 750] { + std::thread::sleep(Duration::from_millis(wait)); + kwin("/Scripting", "org.kde.kwin.Scripting.unloadScript", &["string:rig-hub-raise"]); + let Some(out) = kwin("/Scripting", "org.kde.kwin.Scripting.loadScript", &[&format!("string:{}", script.display()), "string:rig-hub-raise"]) else { + return; // no dbus-send + }; + // the reply ends with "int32