From eb095dcb4723f3c237f9556da2a0e8d95aade793 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Fri, 11 Sep 2026 10:33:41 +0200 Subject: [PATCH] Add Waybar widget for fan controller Includes a Python script, Waybar config snippet, and README with install instructions, usage, and optional CSS styling. --- waybar/README.md | 51 +++++++++++++++++++++ waybar/fan.json | 9 ++++ waybar/fan.py | 116 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 waybar/README.md create mode 100644 waybar/fan.json create mode 100755 waybar/fan.py diff --git a/waybar/README.md b/waybar/README.md new file mode 100644 index 0000000..dccdbe2 --- /dev/null +++ b/waybar/README.md @@ -0,0 +1,51 @@ +# Waybar widget for the fan controller + +A `custom/fan` Waybar module that talks to `fan_controller` over its Unix +socket (`/tmp/fan_controller.sock`) — the same protocol as `FanController.qml`. + +## Install + +1. Copy the script: + + ```sh + mkdir -p ~/.config/waybar/scripts + cp waybar/fan.py ~/.config/waybar/scripts/ + chmod +x ~/.config/waybar/scripts/fan.py + ``` + +2. Add the `custom/fan` entry from [`fan.json`](fan.json) to + `~/.config/waybar/config.json` (merge it into your existing module list, + e.g. under `modules-right`). + +3. Restart Waybar. + +## Usage + +- **Status** — shows `temp / mode`, e.g. `48.3° auto 55°` or `51.0° 75%`. + Hover for a tooltip with temp, duty, RPM, mode and safe-mode state. +- **Left click** — cycles `auto → 25% → 50% → 75% → 100% → auto`. +- **Right click** — returns to auto mode. +- CLI equivalents: + + ```sh + fan.py cycle # same as left click + fan.py auto # same as right click + fan.py duty 75 # set manual duty (0-100) + fan.py setpoint 55 # set auto setpoint (35-85) + ``` + +## Optional styling + +The script sets CSS classes `fan`, plus `offline` when the controller is +unreachable and `safe` in safe mode. Example for `style.css`: + +```css +#waybar .fan.offline { background: #505050; } +#waybar .fan.safe { background: #e05050; } +``` + +## Notes + +- `FAN_CONTROLLER_SOCK` env var overrides the socket path if needed. +- Each poll is a short-lived process (5 s interval), matching how the + Quickshell widget talks to the daemon — no persistent client required. diff --git a/waybar/fan.json b/waybar/fan.json new file mode 100644 index 0000000..1aeba68 --- /dev/null +++ b/waybar/fan.json @@ -0,0 +1,9 @@ +{ + "custom/fan": { + "return-type": "json", + "exec": "~/.config/waybar/scripts/fan.py", + "interval": 5, + "on-click": "python3 ~/.config/waybar/scripts/fan.py cycle", + "on-right-click": "python3 ~/.config/waybar/scripts/fan.py auto" + } +} diff --git a/waybar/fan.py b/waybar/fan.py new file mode 100755 index 0000000..16da6e7 --- /dev/null +++ b/waybar/fan.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Waybar widget for the cooling-pad fan controller. + +Talks to fan_controller over its Unix socket (/tmp/fan_controller.sock). + +Usage: + fan.py print status as Waybar JSON (return-type: "json") + fan.py cycle auto -> 25% -> 50% -> 75% -> 100% -> auto (on-click) + fan.py auto switch back to auto mode (on-right-click) + fan.py duty 75 set manual duty, 0-100 + fan.py setpoint 55 set auto-mode setpoint, 35-85 +""" +import json +import os +import socket +import sys + +SOCK = os.environ.get("FAN_CONTROLLER_SOCK", "/tmp/fan_controller.sock") +STEPS = [25, 50, 75, 100] + + +def cmd(command: str) -> str: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.settimeout(2) + s.connect(SOCK) + s.sendall(command.encode() + b"\n") + chunks = [] + while True: + data = s.recv(1024) + if not data: + break + chunks.append(data) + return b"".join(chunks).decode().strip() + + +def status(): + return json.loads(cmd("STATUS")) + + +def emit(text, tooltip=None, cls=None, alt=None): + out = {"text": text} + if tooltip: + out["tooltip"] = tooltip + if cls: + out["class"] = " ".join(cls) + if alt: + out["alt"] = alt + print(json.dumps(out)) + + +def main(): + arg = sys.argv[1] if len(sys.argv) > 1 else None + try: + if arg is None: + st = status() + mode = st.get("mode", "?") + if mode == "auto": + label = f"auto {st['setpoint']:.0f}°" + else: + label = f"{st['duty']:.0f}%" + text = f"{st['temp']:.1f}° {label}" + tooltip = ( + f"Temp: {st['temp']:.1f}°C\n" + f"Fan: {st['duty']:.0f}% / {st['rpm']} rpm\n" + f"Mode: {mode}" + ) + if mode == "auto": + tooltip += f" (setpoint {st['setpoint']:.0f}°C)" + if st.get("safe"): + tooltip += "\nSafe mode: ON" + cls = ["fan"] + if st.get("safe"): + cls.append("safe") + emit(text, tooltip, cls, alt=f"{st['rpm']} rpm") + + elif arg == "cycle": + st = status() + if st["mode"] == "manual": + d = float(st["duty"]) + nxt = next((x for x in STEPS if x > d + 0.5), None) + if nxt is None: + cmd("SET_MODE auto") + else: + cmd(f"SET_DUTY {nxt}") + else: + cmd("SET_MODE manual") + cmd(f"SET_DUTY {STEPS[0]}") + + elif arg == "auto": + cmd("SET_MODE auto") + + elif arg == "duty": + d = float(sys.argv[2]) + if not 0 <= d <= 100: + sys.exit("duty must be 0-100") + cmd("SET_MODE manual") + cmd(f"SET_DUTY {d:.0f}") + + elif arg == "setpoint": + sp = float(sys.argv[2]) + if not 35 <= sp <= 85: + sys.exit("setpoint must be 35-85") + cmd(f"SET_SETPOINT {sp:.1f}") + + else: + sys.exit(__doc__) + + except (OSError, ValueError) as e: + if arg is None: + emit("fan: offline", "fan_controller not reachable", ["fan", "offline"]) + else: + sys.exit(f"error: {e}") + + +if __name__ == "__main__": + main()