Commit ce3da569 by PLN (Algolia)

refactor(perf-tray): fold onto shared tools/bridge/perf.py (DRY) + wave icon

- perf-tray.py drops its duplicated Thermals/detect_mode/_read*/MODES/SCRIPT
  and imports them from tools/bridge/perf.py — the tray is now just the Qt face
  of the same logic the web Bridge serves (one source of truth)
- tray icon restyled with the ParVagues signature wave (drawn in Qt, no asset)
  under the heat-coloured temp badge
- parvagues-bridge.service → WantedBy=default.target so it boots headless with
  linger (always-on dashboard, no login needed); tray stays graphical-session
- deployed: bridge enabled+active on :8773, tray restarted, linger enabled
parent 3afc4309
#!/usr/bin/env python3
"""
perf-tray — system-tray companion for perf.sh (TidalCycles performance modes).
Shows live thermals in the tray (a ParVagues-wave badge whose colour tracks heat)
and lets you switch perf.sh modes from a menu without leaving your livecoding
session.
Design notes
------------
* The perf/thermal LOGIC now lives in The Bridge (`tools/bridge/perf.py`) — the
tray is just its Qt face (DRY; the web dashboard is the other face). Reading
thermals is rootless; only *changing mode* needs root, via `sudo -n <SCRIPT>`
against a ROOT-OWNED, sudoers-whitelisted copy of perf.sh (see
perf-audio.sudoers). We never sudo the user-editable repo copy.
* hwmon device numbers are not stable across boots → chips resolved by name
(in perf.py).
* PyQt5 because it ships in the Arch repos (python-pyqt5) — no extra deps.
"""
import math
import os
import subprocess
import sys
from pathlib import Path
from PyQt5.QtWidgets import (
QApplication, QSystemTrayIcon, QMenu, QAction, QActionGroup,
)
from PyQt5.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QPen, QPainterPath
from PyQt5.QtCore import QTimer, Qt, QProcess, QRect
# Shared perf logic (DRY with the web Bridge). The tray lives at the repo root;
# perf.py lives under tools/bridge.
sys.path.insert(0, str(Path(__file__).resolve().parent / "tools" / "bridge"))
from perf import Thermals, detect_mode, MODES, SCRIPT # noqa: E402
SERVICE = "perf-tray" # systemd --user unit controlling autostart
REFRESH_MS = 2000
def autostart_enabled():
"""True if the perf-tray systemd --user service is enabled (starts at login)."""
try:
r = subprocess.run(
["systemctl", "--user", "is-enabled", SERVICE],
capture_output=True, text=True, timeout=3,
)
return r.stdout.strip() == "enabled"
except (OSError, subprocess.SubprocessError):
return False
def set_autostart(on):
"""Enable/disable login autostart. Does not stop a running instance."""
try:
subprocess.run(
["systemctl", "--user", "enable" if on else "disable", SERVICE],
capture_output=True, text=True, timeout=5,
)
except (OSError, subprocess.SubprocessError):
pass
def temp_color(c):
if c is None:
return QColor("#607d8b")
if c < 55:
return QColor("#2e7d32") # green
if c < 70:
return QColor("#f9a825") # amber
if c < 85:
return QColor("#ef6c00") # orange
return QColor("#c62828") # red
def make_icon(temp_c):
"""Heat-coloured badge with the ParVagues wave signature + the temp number."""
px = QPixmap(64, 64)
px.fill(Qt.transparent)
p = QPainter(px)
p.setRenderHint(QPainter.Antialiasing)
# heat-coloured rounded badge
p.setBrush(temp_color(temp_c))
p.setPen(Qt.NoPen)
p.drawRoundedRect(2, 2, 60, 60, 14, 14)
# ParVagues signature wave across the lower third (two cycles, translucent)
path = QPainterPath()
mid, amp = 47, 6
path.moveTo(2, mid)
for x in range(2, 63):
path.lineTo(x, mid - amp * math.sin((x - 2) / 60 * 2 * math.pi * 2))
pen = QPen(QColor(255, 255, 255, 165))
pen.setWidth(3)
pen.setCapStyle(Qt.RoundCap)
p.setPen(pen)
p.drawPath(path)
# temp number in the upper area, clear of the wave
p.setPen(QColor("white"))
f = QFont()
f.setBold(True)
f.setPixelSize(30 if (temp_c is not None and temp_c < 100) else 24)
p.setFont(f)
p.drawText(QRect(0, 0, 64, 44), Qt.AlignCenter, "--" if temp_c is None else str(temp_c))
p.end()
return QIcon(px)
class PerfTray:
def __init__(self, app):
self.app = app
self.therm = Thermals()
self.tray = QSystemTrayIcon(make_icon(self.therm.package_c()))
self.tray.setToolTip("perf-tray")
self.menu = QMenu()
self._build_menu()
self.tray.setContextMenu(self.menu)
self.tray.show()
self.timer = QTimer()
self.timer.timeout.connect(self.refresh)
self.timer.start(REFRESH_MS)
self.refresh()
def _build_menu(self):
header = QAction("⚡ perf.sh modes", self.menu)
header.setEnabled(False)
self.menu.addAction(header)
self.mode_group = QActionGroup(self.menu)
self.mode_group.setExclusive(True)
self.mode_actions = {}
for key, (label, _flag) in MODES.items():
act = QAction(label, self.menu, checkable=True)
act.triggered.connect(lambda _c, k=key: self.set_mode(k))
self.mode_group.addAction(act)
self.menu.addAction(act)
self.mode_actions[key] = act
self.menu.addSeparator()
self.autostart_action = QAction("Start at login", self.menu, checkable=True)
self.autostart_action.toggled.connect(self.on_autostart_toggled)
self.menu.addAction(self.autostart_action)
self.menu.addSeparator()
# Live stats block — refreshed whenever the menu is about to show.
self.stat_actions = []
for _ in range(4):
a = QAction("", self.menu)
a.setEnabled(False)
self.menu.addAction(a)
self.stat_actions.append(a)
self.menu.aboutToShow.connect(self.refresh)
self.menu.addSeparator()
quit_act = QAction("Quit perf-tray", self.menu)
quit_act.triggered.connect(self.app.quit)
self.menu.addAction(quit_act)
def set_mode(self, key):
flag = MODES[key][1]
if not os.path.exists(SCRIPT):
self.tray.showMessage(
"perf-tray: not deployed",
f"{SCRIPT} missing. Deploy it:\n"
f"sudo install -m755 -o root -g root "
f"~/Work/Sound/Tidal/perf.sh {SCRIPT}",
QSystemTrayIcon.Warning, 8000,
)
return
proc = QProcess(self.app)
proc.setProgram("sudo")
proc.setArguments(["-n", SCRIPT, flag])
def done(_code, _status):
err = bytes(proc.readAllStandardError()).decode(errors="replace")
if proc.exitCode() != 0:
self.tray.showMessage(
"perf-tray: mode change failed",
err.strip() or "sudo -n refused (check perf-audio.sudoers)",
QSystemTrayIcon.Critical, 8000,
)
QTimer.singleShot(400, self.refresh)
proc.finished.connect(done)
proc.start()
def on_autostart_toggled(self, on):
set_autostart(on)
QTimer.singleShot(200, self.refresh)
def refresh(self):
t = self.therm
pkg = t.package_c()
self.tray.setIcon(make_icon(pkg))
mode = detect_mode()
if mode in self.mode_actions:
self.mode_actions[mode].setChecked(True)
# Reflect autostart state without re-triggering the toggle handler.
self.autostart_action.blockSignals(True)
self.autostart_action.setChecked(autostart_enabled())
self.autostart_action.blockSignals(False)
fmax, favg = t.freq_mhz()
fans = t.fans_rpm()
cores = t.cores_c()
hottest = max(cores) if cores else pkg
tip = (
f"perf-tray — mode: {MODES.get(mode, ('?',))[0]}\n"
f"package {pkg}°C (hottest core {hottest}°C)\n"
f"freq {favg}/{fmax} MHz avg/max\n"
f"fans {' / '.join(str(f) for f in fans) or 'n/a'} RPM\n"
f"throttle Δ {t.throttle_delta()} since launch"
)
self.tray.setToolTip(tip)
rows = [
f"mode: {MODES.get(mode, ('?',))[0]}",
f"package {pkg}°C · hottest core {hottest}°C",
f"freq {favg}/{fmax} MHz · fans {'/'.join(str(f) for f in fans) or 'n/a'} RPM",
f"throttle Δ {t.throttle_delta()} since launch",
]
for a, txt in zip(self.stat_actions, rows):
a.setText(txt)
def main():
app = QApplication([])
app.setQuitOnLastWindowClosed(False)
if not QSystemTrayIcon.isSystemTrayAvailable():
print("No system tray available in this session.")
return 1
PerfTray(app)
return app.exec_()
if __name__ == "__main__":
raise SystemExit(main())
[Unit]
Description=The Bridge (ParVagues web dashboard + perf toolbar)
After=graphical-session.target
PartOf=graphical-session.target
After=network.target
[Service]
# Always-on dashboard. Binds 127.0.0.1 by default (mode-switch is sudo-backed —
# don't expose to LAN unless you mean to; pass --host 0.0.0.0 to opt in).
# Always-on, headless dashboard — starts at BOOT (with linger), no login needed.
# Binds 127.0.0.1 by default (mode-switch is sudo-backed — don't expose to LAN
# unless you mean to; pass --host 0.0.0.0 to opt in).
ExecStart=/usr/bin/python3 %h/Work/Sound/Tidal/tools/bridge/bridge.py serve
Restart=on-failure
RestartSec=3
[Install]
WantedBy=graphical-session.target
WantedBy=default.target
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment