Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a47277c86 | |||
| eb095dcb47 |
@@ -0,0 +1,80 @@
|
||||
# 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 files:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/waybar/scripts
|
||||
cp waybar/fan.py ~/.config/waybar/scripts/
|
||||
chmod +x ~/.config/waybar/scripts/fan.py
|
||||
```
|
||||
|
||||
2. The daemon runs on the Pi, so bridge its Unix socket over SSH once:
|
||||
|
||||
```sh
|
||||
cp waybar/fan-tunnel.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now fan-tunnel
|
||||
```
|
||||
|
||||
This forwards `127.0.0.1:10250` → `root@10.55.0.1:/tmp/fan_controller.sock`.
|
||||
|
||||
3. Add the `custom/cooling_pad` entry (below) to
|
||||
`~/.config/waybar/config.jsonc` (merge it into your existing module list,
|
||||
e.g. under `modules-right`).
|
||||
|
||||
4. Restart Waybar.
|
||||
|
||||
### waybar config
|
||||
|
||||
```jsonc
|
||||
"custom/cooling_pad": {
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
### Connection details
|
||||
|
||||
`fan.py` uses the local `/tmp/fan_controller.sock` if it exists, otherwise the
|
||||
tunnel at `127.0.0.1:10250`. Override with `FAN_CONTROLLER_ENDPOINT`
|
||||
(`unix:/path` or `host:port`). If the widget shows `fan: offline`, check
|
||||
`systemctl --user status fan-tunnel` and `ssh root@10.55.0.1 'ls -la /tmp/fan_controller.sock'`.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=SSH tunnel for Pi fan_controller socket (waybar widget)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/ssh -N \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-o ServerAliveInterval=15 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-L 10250:/tmp/fan_controller.sock root@10.55.0.1
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"custom/cooling_pad": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/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
|
||||
|
||||
Connection: the daemon's command socket lives on the Pi. This script uses the
|
||||
local Unix socket if present, otherwise a TCP tunnel to it (default
|
||||
127.0.0.1:10250, provided by the fan-tunnel systemd service:
|
||||
ssh -N -L 10250:/tmp/fan_controller.sock root@10.55.0.1).
|
||||
Override with FAN_CONTROLLER_ENDPOINT: "unix:/path" or "host:port".
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
UNIX_PATH = "/tmp/fan_controller.sock"
|
||||
TUNNEL_HOST, TUNNEL_PORT = "127.0.0.1", 10250
|
||||
STEPS = [25, 50, 75, 100]
|
||||
|
||||
|
||||
def make_socket():
|
||||
endpoint = os.environ.get("FAN_CONTROLLER_ENDPOINT")
|
||||
if endpoint is None:
|
||||
if os.path.exists(UNIX_PATH):
|
||||
endpoint = f"unix:{UNIX_PATH}"
|
||||
else:
|
||||
endpoint = f"{TUNNEL_HOST}:{TUNNEL_PORT}"
|
||||
if endpoint.startswith("unix:"):
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.connect(endpoint[len("unix:"):])
|
||||
else:
|
||||
host, port = endpoint.rsplit(":", 1)
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.connect((host, int(port)))
|
||||
return s
|
||||
|
||||
|
||||
def cmd(command: str) -> str:
|
||||
with make_socket() as s:
|
||||
s.settimeout(2)
|
||||
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()
|
||||
Reference in New Issue
Block a user