Commit b893a06d by PLN (Algolia)

tray: the gearbox gets the one face PLN actually looks at

PLN, 2026-08-21: "i dont use the webui most of the time, tray indicator is the
only ui i use for parvagues gear bro". The gearbox shipped with an engine, a CLI
and a web panel — and its one everyday surface still said Silent/Cool/Standard/
Extreme/Normal, still wrote governor/EPP/platform_profile itself through
`sudo perf-audio`, and was DISABLED at login. So the surface he uses was the one
surface that had not moved.

What this does

* tools/bridge/gearbox.py — the client both faces speak. Names the three gears
  and the three trims once, reads the persisted state unprivileged, and builds
  the argv for a switch. rig.py's gear() now delegates to it, so the tray and
  the web panel cannot drift on what a gear is called.
* perf-tray.py — the mode list is gone. In its place: gear (off/standard/
  aggressive) and trim (quiet/auto/cool) as two INLINE radio blocks under their
  own headers, because the separation is the design. A reader who sees one flat
  list keeps believing there is a single "how fast" dial and that quiet and cool
  are two words for it; two headers teach the model every time the menu opens.
* It no longer writes any thermal knob itself. Switching goes `sudo -n` to the
  arbiter, which is the point — a tray reaching around it would rebuild the
  four-writer mess GEARBOX.md removed, in one click.
* The desired-mode bookkeeping is gone with it. The gearbox persists the gear and
  re-asserts it from a udev hook on power-source changes and a system-sleep hook
  on resume; a second bookkeeper in the tray could only ever disagree.
* The checked gear is read from the FILE, never inferred from sysfs. Inference
  (perf.detect_mode) cannot tell a deliberate gear from power-profiles-daemon
  having moved the same knobs, which it does by design since the gearbox
  cooperates with ppd rather than masking it.
* "Gear ▸" (the apps and services) is now "Rig ▸": gear is the thermal lever
  now, and the web panel already calls that collection the rig.
* perf-tray.service was never tracked — it lived only in ~/.config. Now in the
  repo, so the unit and the script it runs can no longer drift apart.

The icon carries the lever, so it reads with the menu shut

Width = gear, colour = trim, drawn as the badge's border. One element for two
axes because the panel renders this at ~22 px and any design with two separate
marks turns to mush there. Width is the cue that has to survive a dark room and
a peripheral glance ("am I in the gig gear?" is the mid-set question), so gear
gets shape and trim, which is set-and-forget, gets colour. Off draws no ring: a
bare badge is the honest picture of nothing steering the machine.

First attempt used 2 px / 5 px, which looked right in a mockup and VANISHED on
the panel — 64→22 px scaling turns a 2 px stroke into 0.7 px of grey. Rendering
every combination at 20/24/32 px before believing it gave 6 px / 11 px, and then
forced the temperature numeral and the sparkline to shrink with the ring, since
an 11 px border over a 30 px numeral clips the digits at exactly the size where
aggressive is the gear whose temperature you most want to read.

Verified on hardware, through the real menu over its D-Bus interface (Plasma's
own protocol), not by calling the Python directly:

  * live tooltip reads "gear: Standard · Cool · cap 15/35W"
  * the menu exports two radio groups, correctly checked, headers live
  * clicking Aggressive -> GEAR=aggressive, RAPL 45 W / 115 W
  * clicking Standard   -> GEAR=standard, back to 15/35 W
  * clicking Cool       -> TRIM=cool, fans 0 -> ~2900 rpm
  * a CLI-side gear change is picked up by the tray within one refresh
  * enabled and running under graphical-session.target, so it survives logout

Harness caveat worth writing down: the very FIRST dbusmenu Event sent to a
never-opened menu landed on the wrong item (asked for Aggressive, got trim Auto).
Every subsequent one mapped exactly, from both dbus-send and gdbus. Reading it as
provisional ids on a menu Qt has not yet had to show — a human cannot click an
item without opening the menu first, which is what the later probes did.

18 tests for gearbox.py (79 in the bridge suite, all green). They defend two
things, neither arithmetic: the sudoers contract (the argv IS a security
boundary — `--trim cool` must stay two words, and `performance`/`--boot`, both
real helper verbs, must never be reachable from a face that only knows gears),
and the honest-unset rule (never-engaged is not the same state as off, which is
why off had to become an explicit gear).
parent 26a1e7b8
#!/usr/bin/env python3
"""
perf-tray — system-tray companion for perf.sh (TidalCycles performance modes).
perf-tray — system-tray face of the thermal GEARBOX (SRE/thermal/GEARBOX.md).
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.
Shows live thermals in the tray (a heat-coloured badge with a real temp sparkline)
and lets you move the gear lever without leaving your livecoding session.
This is THE gear UI. PLN, 2026-08-21: "i dont use the webui most of the time,
tray indicator is the only ui i use for parvagues gear bro" — hands on keyboard
and LCXL, eyes on Pulsar and the room, so a browser tab is a context switch that
does not happen mid-set. Everything the gearbox can do has to be reachable here.
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.
* TWO AXES, never one list of modes: GEAR (off/standard/aggressive) is how hard
the machine may work, TRIM (quiet/auto/cool) is how the heat gets paid for, and
trim applies in every gear. Collapsing them back into five flat "modes" is the
exact mistake GEARBOX.md exists to undo — the old menu here did that, offering
Silent/Cool/Standard/Extreme/Normal as if quiet and cool were the same wish.
* The gearbox ENGINE is shell in the SRE repo; `tools/bridge/gearbox.py` is the
shared client both this tray and the web Bridge speak, so the two faces cannot
drift on what a gear is called. Reading state is rootless (a plain file); only
switching needs root, via `sudo -n` against the root-owned, exact-args
sudoers-whitelisted helper.
* It does NOT write governor/EPP/platform_profile itself. That is the point: four
independent writers of the same knobs (perf.sh, perf.py, thermal-mode, gig-up,
plus power-profiles-daemon making five) is what the gearbox replaced, and a
tray that reached around the arbiter would rebuild the mess in one click.
* 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
import time
......@@ -29,13 +40,14 @@ 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
from PyQt5.QtCore import QTimer, Qt, QProcess, QRect, QRectF
# 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, write_desired, # noqa: E402
dgpu_state, thermald_active, power_caps_w, gpu_manager_mode)
from perf import (Thermals, dgpu_state, thermald_active, # noqa: E402
power_caps_w, gpu_manager_mode)
import gearbox as GB # noqa: E402
import launchers as LA # noqa: E402
SERVICE = "perf-tray" # systemd --user unit controlling autostart
......@@ -115,9 +127,30 @@ def temp_color(c):
return QColor("#c62828") # red
def make_icon(temp_c, hist=None):
# The gear, drawn as the badge's BORDER: width = gear, colour = trim.
#
# Both axes in one element, because a tray icon is ~22 px on this panel and any
# design with two separate marks turns to mush at that size. Width is the axis
# that has to survive a dark room and a peripheral glance ("am I in the gig gear?"
# is the question asked mid-set), so gear gets the SHAPE cue and trim, which is
# set-and-forget, gets the colour. Off draws no ring at all: a bare badge is the
# honest picture of nothing steering the machine.
# Widths are DELIBERATELY heavy. The first version used 2 px and 5 px on the 64 px
# pixmap, which looked right in a mockup and vanished on the panel: Qt renders the
# icon at ~22 px, so a 2 px stroke becomes 0.7 px of grey mush. Verified by
# rendering every combination at 20/24/32 px before believing it — a tray cue that
# only works at 64 px is a cue that does not work.
GEAR_RING_PX = {"off": 0, "standard": 6, "aggressive": 11}
TRIM_INK = {"quiet": "#9fa8da", # indigo — the fans are being held back
"auto": "#eceff1", # bone — platform's choice
"cool": "#4dd0e1"} # cyan — the fans are allowed to work
def make_icon(temp_c, hist=None, gear=None, trim=None):
"""Heat-coloured badge + the temp number over a REAL sparkline of the recent
package-temp history (the wave is live data now, not a decorative sine)."""
package-temp history (the wave is live data now, not a decorative sine),
ringed by the gear + trim so the lever is visible with the menu shut."""
ring_px = GEAR_RING_PX.get(gear or "", 0)
px = QPixmap(64, 64)
px.fill(Qt.transparent)
p = QPainter(px)
......@@ -136,11 +169,11 @@ def make_icon(temp_c, hist=None):
if hi - lo < 6:
mid = (hi + lo) / 2
lo, hi = mid - 3, mid + 3
y_bot, y_top = 59, 40
y_bot, y_top = 59 - ring_px // 2, 40 + ring_px // 3
path = QPainterPath()
n = len(pts)
for i, v in enumerate(pts):
x = 5 + (i / (n - 1)) * 54
x = 5 + ring_px // 2 + (i / (n - 1)) * (54 - ring_px)
y = y_bot - (v - lo) / (hi - lo) * (y_bot - y_top)
(path.moveTo if i == 0 else path.lineTo)(x, y)
pen = QPen(QColor(255, 255, 255, 180))
......@@ -150,13 +183,30 @@ def make_icon(temp_c, hist=None):
p.setPen(pen)
p.drawPath(path)
# temp number in the upper area, clear of the sparkline
# temp number in the upper area, clear of the sparkline — and clear of the
# gear ring, which is why the size depends on the gear: an 11 px ring drawn
# over a 30 px numeral clips the digits at panel size, and aggressive is
# exactly the gear in which the temperature matters most.
p.setPen(QColor("white"))
f = QFont()
f.setBold(True)
f.setPixelSize(30 if (temp_c is not None and temp_c < 100) else 24)
f.setPixelSize((30 if (temp_c is not None and temp_c < 100) else 24) - ring_px // 2)
p.setFont(f)
p.drawText(QRect(0, 0, 64, 38), Qt.AlignCenter, "--" if temp_c is None else str(temp_c))
p.drawText(QRect(ring_px, ring_px, 64 - 2 * ring_px, 38 - ring_px),
Qt.AlignCenter, "--" if temp_c is None else str(temp_c))
# gear ring, drawn last so nothing else can paint over it
rw = ring_px
if rw:
pen = QPen(QColor(TRIM_INK.get(trim or "auto", TRIM_INK["auto"])))
pen.setWidth(rw)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
# Inset by half the pen width: Qt strokes centred on the path, so a raw
# 2,2,60,60 rect loses the outer half of a thick ring off the pixmap edge.
i = rw / 2.0
p.drawRoundedRect(QRectF(2 + i, 2 + i, 60 - rw, 60 - rw), 14 - i, 14 - i)
p.end()
return QIcon(px)
......@@ -166,7 +216,9 @@ class PerfTray:
self.app = app
self.therm = Thermals()
self.hist = [] # rolling package-temp history for the icon sparkline
self.tray = QSystemTrayIcon(make_icon(self.therm.package_c(), self.hist))
gb0 = GB.state()
self.tray = QSystemTrayIcon(
make_icon(self.therm.package_c(), self.hist, gb0["gear"], gb0["trim"]))
self.tray.setToolTip("perf-tray")
self.menu = QMenu()
......@@ -180,19 +232,48 @@ class PerfTray:
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)
# ── the gearbox: two axes, laid out as two axes ──────────────────────
#
# Both are INLINE rather than tucked in a submenu, and they are separated
# by their own dim header, because the separation IS the design: a reader
# who sees one flat list will keep believing there is a single "how fast"
# dial and that "quiet" and "cool" are two words for it. Six rows is a
# cheap price for a menu that teaches the model every time it opens.
self.gear_header = QAction("⚙ gear", self.menu)
self.gear_header.setEnabled(False)
self.menu.addAction(self.gear_header)
self.gear_group = QActionGroup(self.menu)
self.gear_group.setExclusive(True)
self.gear_actions = {}
for key, (label, blurb) in GB.GEARS.items():
act = QAction(f"{label} — {blurb}", self.menu, checkable=True)
act.triggered.connect(lambda _c, k=key: self.set_gear(k))
self.gear_group.addAction(act)
self.menu.addAction(act)
self.gear_actions[key] = act
self.trim_header = QAction("🌡 trim — applies in every gear", self.menu)
self.trim_header.setEnabled(False)
self.menu.addAction(self.trim_header)
self.trim_group = QActionGroup(self.menu)
self.trim_group.setExclusive(True)
self.trim_actions = {}
for key, (label, blurb) in GB.TRIMS.items():
act = QAction(f"{label} — {blurb}", self.menu, checkable=True)
act.triggered.connect(lambda _c, k=key: self.set_trim(k))
self.trim_group.addAction(act)
self.menu.addAction(act)
self.mode_actions[key] = act
self.trim_actions[key] = act
if not GB.installed():
# A sibling repo that was never installed here is a legitimate state,
# not an error — but a live-looking radio button that silently cannot
# move is worse than a greyed one, so say it and grey them.
self.gear_header.setText("⚙ gear — gearbox NOT INSTALLED")
for act in (*self.gear_actions.values(), *self.trim_actions.values()):
act.setEnabled(False)
self.menu.addSeparator()
bridge_act = QAction("⚓ Open Bridge", self.menu)
......@@ -246,14 +327,16 @@ class PerfTray:
# _build_menu ran once at startup and read only `available`, so a running
# Ardour looked identical to a stopped one — and the safe action ("do
# nothing, it's up") looked identical to the dangerous one.
self.gear_menu = self.menu.addMenu("Gear ▸")
self.gear_actions = {}
# Named "Rig ▸", not "Gear ▸": "gear" is now the thermal lever above, and
# the web panel already calls this collection of processes the rig (RIG UP).
self.rig_menu = self.menu.addMenu("Rig ▸")
self.rig_actions = {}
snap = LA.snapshot()
for item in (*snap["web"], *snap["apps"]):
act = QAction(item["name"], self.gear_menu)
act = QAction(item["name"], self.rig_menu)
act.triggered.connect(lambda _c, k=item["key"]: self._launch(k))
self.gear_menu.addAction(act)
self.gear_actions[item["key"]] = act
self.rig_menu.addAction(act)
self.rig_actions[item["key"]] = act
self._apply_gear(snap)
# Gear state is refreshed on menu-open ONLY, never on the 2s timer: reading
# it walks /proc, and doing that 30x a minute for a label nobody is looking
......@@ -280,33 +363,59 @@ class PerfTray:
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):
# ------------------------------------------------------------ the gearbox
def set_gear(self, key):
self._shift("gear", key, GB.gear_argv(key))
def set_trim(self, key):
self._shift("trim", key, GB.trim_argv(key))
def _shift(self, kind, key, argv):
"""Move one axis, through the arbiter, without blocking the tray.
QProcess and not subprocess.run: the helper walks both RAPL zones, pushes
a firmware thermal profile and (in the loud gears) re-priorities the audio
chain, which takes about a second — long enough that a synchronous call
would visibly freeze the menu on every click.
No desired-state file is written here. The gearbox persists the gear
itself and re-asserts it from a udev hook on power-source changes and from
a system-sleep hook on resume, so a second bookkeeper in the tray could
only ever disagree with the first. (perf-tray used to keep one, for a
watcher that no longer exists.)
"""
if not GB.installed():
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}",
"perf-tray: no gearbox here",
f"{GB.HELPER} missing. Install it:\n"
f"cd ~/Work/SRE/thermal && sudo ./install.sh --reassert",
QSystemTrayIcon.Warning, 8000,
)
return
proc = QProcess(self.app)
proc.setProgram("sudo")
proc.setArguments(["-n", SCRIPT, flag])
proc.setProgram(argv[0])
proc.setArguments(argv[1:])
def done(_code, _status):
err = bytes(proc.readAllStandardError()).decode(errors="replace")
out = bytes(proc.readAllStandardOutput()).decode(errors="replace").strip()
err = bytes(proc.readAllStandardError()).decode(errors="replace").strip()
if proc.exitCode() != 0:
self.tray.showMessage(
"perf-tray: mode change failed",
err.strip() or "sudo -n refused (check perf-audio.sudoers)",
f"perf-tray: {kind} change failed",
err or out or "sudo -n refused (check /etc/sudoers.d/thermal-mode)",
QSystemTrayIcon.Critical, 8000,
)
else:
# Record intent so The Bridge's watcher reasserts THIS mode across
# AC/battery flips & resume (and never fights a deliberate switch).
write_desired(key)
# Show what the machine ACTUALLY got, not what was asked for:
# `aggressive` sizes its watts from the live power source, so the
# same click means 45/95 W on the barrel brick and 25/45 W on
# battery. That number is the whole reason to read the toast.
self.tray.showMessage(
f"perf-tray: {GB.label(kind, key)}",
out or err or f"{kind} = {key}",
QSystemTrayIcon.Information, 5000,
)
QTimer.singleShot(400, self.refresh)
proc.finished.connect(done)
......@@ -321,7 +430,7 @@ class PerfTray:
up = 0
total = 0
for item in (*snap["web"], *snap["apps"]):
act = self.gear_actions.get(item["key"])
act = self.rig_actions.get(item["key"])
if act is None:
continue
total += 1
......@@ -341,7 +450,7 @@ class PerfTray:
# running app RAISES it — which is the thing you actually want mid-set
# — so the row stays live. Only a missing binary is unclickable.
act.setEnabled(item["available"])
self.gear_menu.setTitle(f"Gear ▸ {up}/{total} up")
self.rig_menu.setTitle(f"Rig ▸ {up}/{total} up")
if getattr(self, "gig_act", None) is not None:
pulsar = next((i for i in snap["apps"] if i["key"] == "pulsar"), None)
# Say which of the two things the click will do. "Focus" vs "Open" is
......@@ -455,11 +564,30 @@ class PerfTray:
self.hist.append(pkg)
if len(self.hist) > 24: # ~48s of history at the 2s refresh
self.hist.pop(0)
self.tray.setIcon(make_icon(pkg, self.hist))
mode = detect_mode()
if mode in self.mode_actions:
self.mode_actions[mode].setChecked(True)
gb = GB.state()
self.tray.setIcon(make_icon(pkg, self.hist, gb["gear"], gb["trim"]))
# Check the gear the FILE says, never one inferred from sysfs. The old
# menu inferred it (perf.detect_mode reads governor/EPP/max_perf_pct), and
# inference cannot tell a deliberate gear from power-profiles-daemon
# having quietly moved the same knobs — which it does, by design, since
# the gearbox cooperates with ppd instead of masking it.
for key, act in self.gear_actions.items():
act.blockSignals(True)
act.setChecked(key == gb["gear"])
act.blockSignals(False)
for key, act in self.trim_actions.items():
act.blockSignals(True)
act.setChecked(key == (gb["trim"] or GB.DEFAULT_TRIM))
act.blockSignals(False)
if GB.installed():
self.gear_header.setText(
"⚙ gear — " + (GB.label("gear", gb["gear"]) if gb["gear"]
else "none engaged (ppd owns the knobs)"))
self.trim_header.setText(
"🌡 trim — " + GB.label("trim", gb["trim"] or GB.DEFAULT_TRIM)
+ ("" if gb["trim"] else " (default)"))
# Reflect autostart state without re-triggering the toggle handler.
self.autostart_action.blockSignals(True)
......@@ -484,10 +612,10 @@ class PerfTray:
if gm:
gpu = f"{gpu}·{gm}"
td = "on" if thermald_active() else "off"
label = MODES.get(mode, ("?",))[0]
label = GB.summary()
tip = (
f"perf-tray — mode: {label} · cap {cap}\n"
f"perf-tray — gear: {label} · cap {cap}\n"
f"package {pkg}°C (hottest core {hottest}°C)\n"
f"freq {favg}/{fmax} MHz avg/max · fans {' / '.join(str(f) for f in fans) or 'n/a'} RPM\n"
f"gpu {gpu} · thermald {td} · throttle Δ {t.throttle_delta()}"
......@@ -495,7 +623,7 @@ class PerfTray:
self.tray.setToolTip(tip)
rows = [
f"mode: {label} · cap {cap}",
f"gear: {label} · cap {cap}",
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"gpu {gpu} · thermald {td} · throttle Δ {t.throttle_delta()}",
......
[Unit]
Description=perf-tray (ParVagues gearbox tray — gear x trim + live thermals)
After=graphical-session.target
PartOf=graphical-session.target
[Service]
# Inherits the Wayland/Plasma session env from the user manager.
ExecStart=/usr/bin/python3 %h/Work/Sound/Tidal/perf-tray.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=graphical-session.target
"""
gearbox — Python client for the thermal gearbox (SRE/thermal, GEARBOX.md).
ONE source of the gearbox vocabulary for every face: the Qt tray (perf-tray.py,
the face PLN actually uses mid-set), the web Bridge, and anything else that grows
later. The engine itself is shell and lives in the SRE repo — this module never
duplicates its POLICY, it only names the gears and shells out.
The model, in one paragraph (the long version is SRE/thermal/GEARBOX.md):
Two ORTHOGONAL axes, not one list of modes.
GEAR — how hard the machine may work: off / standard / aggressive
TRIM — how the heat gets paid for: quiet / auto / cool
Trim applies in EVERY gear. Quiet and cool are in tension: you cannot be fast
AND quiet AND cool, and the whole point of splitting the axes is that the UI
stops pretending you can.
Reading is unprivileged (a plain file); only SWITCHING needs root, via
`sudo -n` against the root-owned, sudoers-whitelisted helper — the same contract
perf.py uses for perf-audio. `-n` matters: the tray has no tty, so an
interactive sudo would hang a menu click forever instead of failing loudly.
"""
import os
import subprocess
from pathlib import Path
# Root-owned helper installed by SRE/thermal/install.sh. Every one of the argv
# forms built below is an exact-args NOPASSWD rule in /etc/sudoers.d/thermal-mode
# — that exactness is the security model, so DO NOT interpolate anything into
# these commands beyond a key validated against the tables below.
HELPER = os.environ.get("THERMAL_MODE_HELPER", "/usr/local/sbin/thermal-mode-apply")
CONF = Path(os.environ.get("THERMAL_MODE_CONF", "/etc/thermal-mode.conf"))
# gear key -> (label, blurb). Order = the lever's travel, idle → hard.
GEARS = {
"off": ("Off", "stop steering — ppd gets the knobs back"),
"standard": ("Standard", "the everyday livecoding regime"),
"aggressive": ("Aggressive", "full watts the source can give, turbo, RT audio"),
}
# trim key -> (label, blurb). Order = quiet → cool, the axis it actually is.
TRIMS = {
"quiet": ("Quiet", "minimise noise; heat and sustain pay for it"),
"auto": ("Auto", "let the platform balance it"),
"cool": ("Cool", "minimise temperature; fans are allowed to work"),
}
# What the helper does when nothing has ever set a trim.
DEFAULT_TRIM = "auto"
def installed():
"""True when the root helper is present and executable.
Absent is a legitimate state (the gearbox lives in a sibling repo and may
simply not be installed on this box), so every caller must be able to render
"not installed" instead of implying a gear.
"""
return os.access(HELPER, os.X_OK)
def read_conf(conf=None):
"""The helper's persisted state as a dict. Never raises."""
out = {}
try:
for line in Path(conf or CONF).read_text().splitlines():
if "=" in line and not line.lstrip().startswith("#"):
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
except OSError:
pass
return out
def state(conf=None):
"""Where the lever is, read unprivileged.
`gear`/`trim` are None when never set — that is NOT the same as "off" (a
deliberate off) and the distinction is the whole reason `off` had to become
an explicit gear rather than the absence of one.
"""
c = read_conf(conf)
gear = c.get("GEAR") or None
trim = c.get("TRIM") or None
return {
"installed": installed(),
"gear": gear if gear in GEARS else None,
"trim": trim if trim in TRIMS else None,
# The finer five-mode name the gear resolved to, and the Dell/ACPI
# profile last pushed. Diagnostics: they explain a gear, they are not it.
"mode": c.get("MODE") or None,
"bios": c.get("BIOS_PROFILE") or None,
}
def gear_argv(key):
"""argv to engage a gear. Raises ValueError on anything not in the table —
the sudoers rules are exact-args, so an unvalidated key can only produce a
confusing sudo refusal instead of an honest error."""
if key not in GEARS:
raise ValueError(f"unknown gear: {key!r}")
return ["sudo", "-n", HELPER, key]
def trim_argv(key):
"""argv to set the trim. `--trim <key>` is two words on purpose: that is the
exact pair the sudoers rule whitelists."""
if key not in TRIMS:
raise ValueError(f"unknown trim: {key!r}")
return ["sudo", "-n", HELPER, "--trim", key]
def apply(kind, key, timeout=25):
"""Blocking switch, for the web face and tests. Returns (ok, message).
The Qt tray does NOT use this — it drives the same argv through QProcess so a
menu click cannot freeze the tray while RAPL zones and fan profiles settle
(measured: an aggressive→standard switch takes about a second, and the RT
ladder walk can add more).
"""
argv = gear_argv(key) if kind == "gear" else trim_argv(key)
if not installed():
return False, f"{HELPER} not installed (see SRE/thermal/install.sh)"
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError) as e:
return False, str(e)
out = (r.stdout or "").strip() or (r.stderr or "").strip()
if r.returncode != 0:
return False, out or "sudo -n refused (check /etc/sudoers.d/thermal-mode)"
return True, out
def label(kind, key):
"""Human label for a key, or the key itself when it is unknown/None. Faces
show what the file SAYS even if this module has never heard of it — a
gearbox newer than its UI must not render a blank."""
table = GEARS if kind == "gear" else TRIMS
if key in table:
return table[key][0]
return key or "—"
def summary(conf=None):
"""One line for a tooltip or a status row: "standard · cool"."""
s = state(conf)
if not s["installed"] and not s["gear"]:
return "not installed"
g = label("gear", s["gear"]) if s["gear"] else "unset"
t = label("trim", s["trim"] or DEFAULT_TRIM)
return f"{g} · {t}"
......@@ -27,6 +27,7 @@ import threading
import time
from pathlib import Path
import gearbox as GB
import launchers as L
TIDAL = L.TIDAL
......@@ -89,17 +90,13 @@ def gear():
Absent conf = the gearbox was never installed here, which is a legitimate
state and not an error: the panel says so instead of implying a gear.
Delegates to gearbox.py so the panel and the Qt tray cannot drift apart on
what a gear is called; THERMAL_CONF stays the parameter so the module remains
monkeypatchable in tests.
"""
g = {"gear": None, "trim": None}
try:
for line in THERMAL_CONF.read_text().splitlines():
if line.startswith("GEAR="):
g["gear"] = line[5:].strip() or None
elif line.startswith("TRIM="):
g["trim"] = line[5:].strip() or None
except OSError:
pass
return g
s = GB.state(THERMAL_CONF)
return {"gear": s["gear"], "trim": s["trim"]}
def _lcxl():
......
"""Tests for gearbox — the client both gear faces speak.
Two things are being defended here, and neither is arithmetic:
1. THE SUDOERS CONTRACT. Switching goes through exact-args NOPASSWD rules, so
the argv this module builds is a security boundary, not a convenience. A
stray word, a joined "--trim cool", or an unvalidated key turns a working
click into an opaque sudo refusal — or, worse, widens what the rule matches.
2. THE HONEST-UNSET RULE. "no gear has ever been engaged" and "the gear is off"
are different states. Collapsing them is what made `off` have to become an
explicit gear in the first place, and a UI that guesses here lies about who
owns the CPU.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import gearbox as GB
def conf(tmp_path, text):
p = tmp_path / "thermal-mode.conf"
p.write_text(text)
return p
# ── reading state ────────────────────────────────────────────────────────────
def test_reads_gear_and_trim(tmp_path):
c = conf(tmp_path, "MODE=cool\nBIOS_PROFILE=cool\nGEAR=standard\nTRIM=cool\n")
s = GB.state(c)
assert (s["gear"], s["trim"], s["mode"], s["bios"]) == ("standard", "cool", "cool", "cool")
def test_stock_rapl_keys_do_not_confuse_the_parser(tmp_path):
"""The conf also carries the STOCK_* values `off` restores from. They share
the file with GEAR/TRIM and must be inert to every reader of it."""
c = conf(tmp_path, "GEAR=aggressive\nTRIM=quiet\n"
"STOCK_intel_rapl_0_PL1=12000000\n"
"STOCK_intel_rapl_mmio_0_PL2=135000000\n")
s = GB.state(c)
assert (s["gear"], s["trim"]) == ("aggressive", "quiet")
def test_absent_conf_is_unset_not_off(tmp_path):
"""No gearbox installed here is a real state. Reporting it as `off` would
claim a deliberate decision nobody made."""
s = GB.state(tmp_path / "nope.conf")
assert s["gear"] is None and s["trim"] is None
def test_off_is_a_gear_not_an_absence(tmp_path):
s = GB.state(conf(tmp_path, "GEAR=off\n"))
assert s["gear"] == "off"
def test_unknown_gear_in_conf_reads_as_none(tmp_path):
"""A conf written by a NEWER gearbox must not check a radio button that this
UI cannot honour — better no selection than a wrong one."""
s = GB.state(conf(tmp_path, "GEAR=ludicrous\nTRIM=cryogenic\n"))
assert s["gear"] is None and s["trim"] is None
def test_empty_value_is_none(tmp_path):
"""`off` clears MODE= to an empty value rather than deleting the line."""
s = GB.state(conf(tmp_path, "MODE=\nGEAR=off\n"))
assert s["mode"] is None and s["gear"] == "off"
def test_comments_are_ignored(tmp_path):
s = GB.state(conf(tmp_path, "# written by thermal-mode-apply\nGEAR=standard\n"))
assert s["gear"] == "standard"
# ── the argv that sudoers has to match ───────────────────────────────────────
def test_gear_argv_is_non_interactive(monkeypatch):
"""`-n` is not optional: the tray has no tty, so an interactive sudo would
hang a menu click forever instead of failing where someone can see it."""
monkeypatch.setattr(GB, "HELPER", "/usr/local/sbin/thermal-mode-apply")
assert GB.gear_argv("aggressive") == [
"sudo", "-n", "/usr/local/sbin/thermal-mode-apply", "aggressive"]
def test_trim_argv_keeps_flag_and_value_as_two_words(monkeypatch):
"""The sudoers rule whitelists the PAIR `--trim cool`. Joining them into one
argument matches nothing and fails as a permission error, which is the most
misleading way this could break."""
monkeypatch.setattr(GB, "HELPER", "/usr/local/sbin/thermal-mode-apply")
assert GB.trim_argv("cool")[-2:] == ["--trim", "cool"]
@pytest.mark.parametrize("key", ["", "off; rm -rf /", "performance", None, "--boot"])
def test_unknown_gear_never_reaches_sudo(key):
"""Validate against the table, not against sudo. `performance` is a real
helper verb and `--boot` is a real flag — neither is a gear, and neither
should be reachable from a face that only knows about gears."""
with pytest.raises(ValueError):
GB.gear_argv(key)
@pytest.mark.parametrize("key", ["", "cold", None, "--boot"])
def test_unknown_trim_never_reaches_sudo(key):
with pytest.raises(ValueError):
GB.trim_argv(key)
def test_apply_refuses_when_helper_absent(monkeypatch):
"""No helper = say so, do not shell out. Otherwise the failure surfaces as a
sudo error about a missing file, which reads like a permissions problem."""
monkeypatch.setattr(GB, "HELPER", "/nonexistent/thermal-mode-apply")
ok, msg = GB.apply("gear", "standard")
assert not ok and "not installed" in msg
# ── labels the faces render ──────────────────────────────────────────────────
def test_label_falls_back_to_the_raw_key():
"""A gearbox newer than its UI must not render a blank row."""
assert GB.label("gear", "ludicrous") == "ludicrous"
assert GB.label("gear", None) == "—"
def test_summary_defaults_trim_but_never_defaults_gear(tmp_path, monkeypatch):
"""Trim has a real default in the engine (auto), so filling it in is honest.
A gear does not: an unset gear means nobody is steering, and printing
"standard" there would invent a decision."""
monkeypatch.setattr(GB, "installed", lambda: True)
assert GB.summary(conf(tmp_path, "GEAR=standard\n")) == "Standard · Auto"
assert GB.summary(conf(tmp_path, "TRIM=cool\n")) == "unset · Cool"
def test_summary_says_not_installed(tmp_path, monkeypatch):
monkeypatch.setattr(GB, "installed", lambda: False)
assert GB.summary(tmp_path / "nope.conf") == "not installed"
# ── the vocabulary itself ────────────────────────────────────────────────────
def test_three_gears_and_three_trims_in_lever_order():
"""The count IS the design decision (GEARBOX.md): five flat modes asked the
reader to believe quiet and cool were the same wish. If a fourth gear ever
lands here, that decision is being revisited — deliberately, not by drift."""
assert list(GB.GEARS) == ["off", "standard", "aggressive"]
assert list(GB.TRIMS) == ["quiet", "auto", "cool"]
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