Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions plasmoid/contents/ui/main.qml
Original file line number Diff line number Diff line change
@@ -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 }
}
}
14 changes: 14 additions & 0 deletions plasmoid/metadata.json
Original file line number Diff line number Diff line change
@@ -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"
}
80 changes: 79 additions & 1 deletion src/audio.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<PowerDevice> {
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<PowerDevice> {
run("upower", &["--dump"]).map(|t| parse_upower(&t)).unwrap_or_default()
}

// -------------------------------------------------------------------- mic

#[derive(Clone, Copy, Debug, PartialEq)]
Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading