Commit 26a1e7b8 by PLN (Algolia)

bridge: one RIG UP button, and converge stops fighting the gearbox

TWO OWNERS BECOME ONE. gig-up --converge called `powerprofilesctl set
performance` while the gearbox wrote the same governor/EPP/platform_profile
through thermal-mode, with no arbiter — whichever ran last won, and ppd
re-asserts itself on AC transitions, so a converge done on battery was undone by
plugging in for the set. converge now drives the gearbox. It deliberately does
NOT touch trim: quiet-vs-cool is a room-by-room call (a mic'd quiet room wants
Quiet even at a gig) and converge cannot know which room it is in.

The resolver rejects a PRE-gearbox thermal-mode rather than calling a verb it
does not have, and falls back to ppd when there is no gearbox at all — a rig
check must never fail because a sibling repo is missing.

THE PANEL, the GUI half of the button whose CLI half converge already was.
converge owns services and refuses to open windows ("a script that opens windows
under someone is a script they stop trusting"), leaving Pulsar and Ardour
reported-but-not-launched. The Bridge is a thing PLN clicked, so it is allowed
to open his apps. Converge runs off the request thread: a cold SuperDirt boot
warms 51 banks and gig-up waits up to 100s for it, which held open as an HTTP
request would read as a hung dashboard.

The list under the button carries gig-up's two hard-won rules:

  * A GREEN UNIT IS NOT SOUND. 2026-08-01 had parvagues-sc.service active while
    scsynth was dead. `systemctl start` is a no-op on an active unit, so that
    state reads `broken` and says "needs restart, not start" — reporting it as
    down would send the reader to a button that cannot fix their problem.
  * ABSENT IS NOT DOWN. A thing you cannot start is a different problem from a
    thing you can, and collapsing them wastes the reader's time under pressure.

Verified live: POST /api/rig converged the rig for real — SuperDirt booted,
watchdog and autoroute up, faders restored, setlist and preload regenerated —
and the panel tracked it through the poll. The audio ladder then reported 5/5
with scsynth and sclang joining the PipeWire trio.

One bug found on the way, kept as a test: `systemctl show --value` emits
properties in ITS order, not the caller's, so pairing them positionally swapped
LoadState and ActiveState and made every inactive unit report its state as
"loaded". Parsed by key now, with Id making each block self-identifying.

56 bridge tests green (13 new).
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 2ef09af6
...@@ -15,6 +15,15 @@ python3 bridge.py perf cool # switch perf mode (needs deploy, below) ...@@ -15,6 +15,15 @@ python3 bridge.py perf cool # switch perf mode (needs deploy, below)
## What it shows ## What it shows
- **The rig** — one **RIG UP** button over an honest status list. The button runs
`tools/gig-up.sh --converge` (services: setlist, preload, faders, SuperDirt,
watchdog, autoroute) and then launches Pulsar and Ardour, which converge
deliberately refuses to open. The list is how you see what it actually did.
A service whose unit is `active` while its process is **dead** reads `broken`,
not `up``systemctl start` is a no-op on an active unit, and that exact case
(2026-08-01) was a green unit over silence. Absent ≠ down: a thing you cannot
start gets no button.
- **Toolbar** — live package-temp badge (colour tracks heat: green<55 · - **Toolbar** — live package-temp badge (colour tracks heat: green<55 ·
amber<70 · orange<85 · red≥85, same thresholds as the tray), current perf mode amber<70 · orange<85 · red≥85, same thresholds as the tray), current perf mode
as a segmented switch (Cool / Standard / Extreme / Normal), and freq / fan / as a segmented switch (Cool / Standard / Extreme / Normal), and freq / fan /
...@@ -75,9 +84,11 @@ cd tools/bridge && python3 -m pytest tests/ -q # pure perf logic (no sudo) ...@@ -75,9 +84,11 @@ cd tools/bridge && python3 -m pytest tests/ -q # pure perf logic (no sudo)
``` ```
tools/bridge/ tools/bridge/
perf.py # rootless thermals + mode detect + sudo switch (from perf-tray) perf.py # rootless thermals + mode detect + sudo switch (from perf-tray)
rig.py # RIG UP: rig status (unit AND process) + converge job
server.py # stdlib http.server dashboard (GET/POST /api/perf, /api/hub) server.py # stdlib http.server dashboard (GET/POST /api/perf, /api/hub)
bridge.py # CLI (serve / status / perf) bridge.py # CLI (serve / status / perf)
ui/index.html # the dashboard (Ship's Bridge tokens) ui/index.html # the dashboard (Ship's Bridge tokens)
parvagues-bridge.service # systemd --user unit (autostart) parvagues-bridge.service # systemd --user unit (autostart)
tests/test_perf.py tests/test_perf.py
tests/test_rig.py
``` ```
"""rig — the rig's state, and the one button that brings it up.
The GUI half of `tools/gig-up.sh --converge`, which is the CLI half. converge
owns SERVICES and deliberately refuses to open windows ("a script that opens
windows under someone is a script they stop trusting"), leaving Pulsar and
Ardour reported-but-not-launched. That refusal is why this module exists: the
Bridge is a thing PLN clicked, so it is allowed to open his apps.
Two rules carried over from gig-up, because they are what make the panel worth
looking at:
* A GREEN UNIT IS NOT SOUND. On 2026-08-01 parvagues-sc.service was `active`
while scsynth was dead, and `systemctl start` is a no-op on an active unit —
so the unit state alone would have shown a healthy rig over silence. Every
service that has a process to point at is judged on BOTH.
* REPORT ABSENT AS ABSENT. A piece that is not installed is not "down"; a
down thing you can start and an absent thing you cannot are different
problems, and collapsing them wastes the reader's time under pressure.
"""
from __future__ import annotations
import json
import os
import subprocess
import threading
import time
from pathlib import Path
import launchers as L
TIDAL = L.TIDAL
GIG_UP = TIDAL / "tools" / "gig-up.sh"
THERMAL_CONF = Path("/etc/thermal-mode.conf")
# unit -> (label, process that proves it is actually working, blurb)
# A None process means the unit IS the whole story (nothing to cross-check).
SERVICES = [
("parvagues-sc", "SuperDirt", "scsynth", "the sound"),
("parvagues-sc-watchdog", "sc-watchdog", None, "restarts SuperDirt if it dies"),
("tidal-ardour-autoroute","autoroute", None, "orbits → Ardour tracks"),
("midi-autoconnect", "MIDI wiring", None, "LCXL ↔ Tidal, re-applied"),
]
# Apps converge refuses to launch, which is exactly what the button adds.
APPS = ["pulsar", "ardour"]
def _unit_states(units):
"""{unit: {active, load}} for every unit in ONE fork.
Per-unit `is-active` would be four forks per poll, and the Bridge polls every
two seconds with an audio graph running — the same reasoning that made
launchers._cmdlines() scan /proc once instead of pgrep-ing per item.
Parsed by KEY, never by position: `systemctl show --value` emits properties
in systemd's own order, not the order you asked for, so pairing them
positionally silently swapped LoadState and ActiveState here. `Id` makes each
block self-identifying, which is the only version of this that cannot drift.
"""
try:
r = subprocess.run(
["systemctl", "--user", "show", "-p", "Id", "-p", "ActiveState",
"-p", "LoadState", *[f"{u}.service" for u in units]],
capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
return {}
out, cur = {}, {}
for line in r.stdout.splitlines():
if "=" not in line:
continue
k, _, v = line.partition("=")
if k == "Id" and cur:
out[cur.pop("_id", "")] = cur
cur = {}
if k == "Id":
cur["_id"] = v[:-8] if v.endswith(".service") else v
elif k == "ActiveState":
cur["active"] = v
elif k == "LoadState":
cur["load"] = v
if cur:
out[cur.pop("_id", "")] = cur
return out
def gear():
"""The gear and trim the gearbox persisted. Unprivileged read, no sudo.
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.
"""
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
def _lcxl():
"""Launch Control XL presence, read from procfs — no aconnect fork."""
try:
cards = Path("/proc/asound/cards").read_text()
except OSError:
return False
return "Launch Control XL" in cards or "LCXL" in cards
def status():
"""Every piece of the rig, with the state the reader actually needs."""
lines = L._cmdlines() # one /proc scan for the whole snapshot
names = {a for a, _ in lines}
states = _unit_states([u for u, _, _, _ in SERVICES])
items = []
for unit, label, proc, blurb in SERVICES:
st = states.get(unit, {})
load, active = st.get("load", "unknown"), st.get("active", "unknown")
if load in ("not-found", "masked"):
state = "absent"
detail = f"unit {load}"
elif proc is not None:
alive = proc in names
if active == "active" and alive:
state, detail = "up", f"{proc} running"
elif active == "active" and not alive:
# THE 2026-08-01 case. Naming it in the UI matters: `start` will
# not fix it, only `restart` will, and the panel should not let
# someone click a button that is a no-op on their real problem.
state, detail = "broken", f"unit active but {proc} is DEAD — needs restart, not start"
else:
state, detail = "down", active
else:
state = "up" if active == "active" else "down"
detail = active
items.append({"key": unit, "name": label, "blurb": blurb,
"state": state, "detail": detail, "kind": "service"})
for key in APPS:
spec = L.BY_KEY.get(key)
if not spec:
continue
if not L.available(spec):
state, detail = "absent", "not installed"
elif L.is_running(spec, lines):
state, detail = "up", "running"
else:
state, detail = "down", "not running"
items.append({"key": key, "name": spec["name"], "blurb": spec["blurb"],
"state": state, "detail": detail, "kind": "app"})
items.append({"key": "lcxl", "name": "LCXL", "kind": "surface",
"blurb": "the control surface",
"state": "up" if _lcxl() else "down",
"detail": "connected" if _lcxl() else "not detected"})
return {"items": items, "gear": gear(), "converge": _job.state()}
# ── converge, run off the request thread ─────────────────────────────────────
# A cold SuperDirt boot recompiles the class library and warms 51 banks, which
# gig-up waits up to 100 s for. Holding an HTTP request open that long would
# stall the UI poll and read as a hung dashboard, so converge runs in a worker
# and the panel watches it through the ordinary status poll.
class _Converge:
def __init__(self):
self._lock = threading.Lock()
self._running = False
self._started = 0.0
self._finished = 0.0
self._rc = None
self._out = ""
def state(self):
with self._lock:
return {"running": self._running, "rc": self._rc,
"started": self._started, "finished": self._finished,
"out": self._out[-4000:]}
def start(self, launch_apps=True):
with self._lock:
if self._running:
return {"ok": True, "status": "already-running",
"msg": "converge is already in flight"}
self._running = True
self._started = time.time()
self._rc = None
self._out = ""
threading.Thread(target=self._run, args=(launch_apps,), daemon=True).start()
return {"ok": True, "status": "started", "msg": "bringing the rig up…"}
def _run(self, launch_apps):
out, rc = "", 1
try:
# --converge implies --live. NOT --audio: that makes sound, and RIG UP
# must stay safe to press mid-set, backstage, or at 3am — the same
# promise gig-up's default already makes.
r = subprocess.run(["bash", str(GIG_UP), "--converge", "--quiet"],
capture_output=True, text=True, timeout=300,
cwd=str(TIDAL))
out, rc = (r.stdout or "") + (r.stderr or ""), r.returncode
except subprocess.TimeoutExpired:
out, rc = "converge timed out after 300s", 124
except (OSError, subprocess.SubprocessError) as e:
out, rc = f"could not run {GIG_UP}: {e}", 127
if launch_apps:
# The half converge refuses to do. Best-effort and reported: a DAW
# that will not open must not make the whole button look failed.
for key in APPS:
try:
res = L.launch(key)
out += f"\n {key}: {res.get('status')} — {res.get('msg')}"
except Exception as e: # never let a launcher kill the job
out += f"\n {key}: error — {e}"
with self._lock:
self._running = False
self._finished = time.time()
self._rc = rc
self._out = out
_job = _Converge()
def rig_up(launch_apps=True):
return _job.start(launch_apps)
...@@ -16,6 +16,8 @@ API: ...@@ -16,6 +16,8 @@ API:
GET /api/perf → perf.snapshot() (mode, temps, freq, fans, throttle) GET /api/perf → perf.snapshot() (mode, temps, freq, fans, throttle)
POST /api/perf {mode} → switch perf mode → {ok, msg, snapshot} POST /api/perf {mode} → switch perf mode → {ok, msg, snapshot}
GET /api/launchers → {web:[…], apps:[…]} (live running/available state) GET /api/launchers → {web:[…], apps:[…]} (live running/available state)
GET /api/rig → rig.status() (every piece + gear/trim + converge job)
POST /api/rig → RIG UP: converge the services, then launch the apps
POST /api/launch {key} → start-or-open a tool → {ok, status, msg, url?} POST /api/launch {key} → start-or-open a tool → {ok, status, msg, url?}
GET /api/health → {ok:true} GET /api/health → {ok:true}
""" """
...@@ -36,6 +38,7 @@ HERE = Path(__file__).resolve().parent ...@@ -36,6 +38,7 @@ HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE)) sys.path.insert(0, str(HERE))
import perf as P # noqa: E402 import perf as P # noqa: E402
import launchers as L # noqa: E402 import launchers as L # noqa: E402
import rig as R # noqa: E402
import midistream as MS # noqa: E402 import midistream as MS # noqa: E402
UI = HERE / "ui" UI = HERE / "ui"
...@@ -59,6 +62,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -59,6 +62,8 @@ class Handler(BaseHTTPRequestHandler):
return self._json(P.snapshot(_THERM)) return self._json(P.snapshot(_THERM))
if p == "/api/launchers": if p == "/api/launchers":
return self._json(L.snapshot()) return self._json(L.snapshot())
if p == "/api/rig":
return self._json(R.status())
if p == "/api/midi/ports": if p == "/api/midi/ports":
return self._json(_MIDI.list_ports()) return self._json(_MIDI.list_ports())
if p == "/api/midi/stream": if p == "/api/midi/stream":
...@@ -77,6 +82,14 @@ class Handler(BaseHTTPRequestHandler): ...@@ -77,6 +82,14 @@ class Handler(BaseHTTPRequestHandler):
ok, msg = P.set_mode(mode) ok, msg = P.set_mode(mode)
return self._json({"ok": ok, "msg": msg, "snapshot": P.snapshot(_THERM)}, return self._json({"ok": ok, "msg": msg, "snapshot": P.snapshot(_THERM)},
200 if ok else 400) 200 if ok else 400)
if p == "/api/rig":
# Same trust model as /api/perf: the 127.0.0.1 bind IS the guard.
# This one converges services and opens windows, so it must not be
# LAN-triggerable unless the operator passed --host explicitly.
body = self._body()
launch = body.get("launch_apps", True)
r = R.rig_up(bool(launch))
return self._json(r, 200 if r["ok"] else 400)
if p == "/api/launch": if p == "/api/launch":
key = (self._body().get("key") or "").strip() key = (self._body().get("key") or "").strip()
r = L.launch(key) r = L.launch(key)
......
"""Tests for rig — the RIG UP panel's state.
Every one of these is a bug that was actually made while writing the module, or
a promise the panel makes to someone reading it under pressure. Nothing here
runs systemctl, gig-up, or a launcher: the point is the classification, and a
test that boots SuperDirt is a test nobody will run.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import rig as R
# ── systemctl parsing ────────────────────────────────────────────────────────
def test_unit_states_parses_by_key_not_by_position(monkeypatch):
"""`systemctl show --value` emits properties in ITS order, not the caller's.
The first version paired them positionally and silently swapped LoadState
and ActiveState, so every loaded-but-inactive unit reported its state as
'loaded'. Parsing by key is the only version that cannot drift.
"""
out = ("Id=parvagues-sc.service\nLoadState=loaded\nActiveState=inactive\n"
"Id=midi-autoconnect.service\nActiveState=active\nLoadState=loaded\n")
class R_:
stdout = out
monkeypatch.setattr(R.subprocess, "run", lambda *a, **k: R_())
st = R._unit_states(["parvagues-sc", "midi-autoconnect"])
assert st["parvagues-sc"] == {"load": "loaded", "active": "inactive"}
# note the reversed property order in the second block — must still be right
assert st["midi-autoconnect"] == {"load": "loaded", "active": "active"}
def test_unit_states_survives_systemctl_being_absent(monkeypatch):
def boom(*a, **k):
raise OSError("no systemctl")
monkeypatch.setattr(R.subprocess, "run", boom)
assert R._unit_states(["parvagues-sc"]) == {}
# ── the classification the panel exists for ──────────────────────────────────
def _status_with(monkeypatch, states, running=()):
monkeypatch.setattr(R, "_unit_states", lambda units: states)
monkeypatch.setattr(R.L, "_cmdlines", lambda: [(n, n) for n in running])
monkeypatch.setattr(R, "_lcxl", lambda: False)
return {i["key"]: i for i in R.status()["items"]}
def test_active_unit_with_dead_process_is_broken_not_up(monkeypatch):
"""The 2026-08-01 failure: unit active, scsynth dead, silence on stage.
`systemctl start` is a NO-OP on an active unit, so reporting this as merely
'down' would send the reader to a button that cannot fix their problem. It
has to say restart.
"""
items = _status_with(monkeypatch,
{"parvagues-sc": {"load": "loaded", "active": "active"}},
running=())
sc = items["parvagues-sc"]
assert sc["state"] == "broken"
assert "DEAD" in sc["detail"] and "restart" in sc["detail"]
def test_active_unit_with_live_process_is_up(monkeypatch):
items = _status_with(monkeypatch,
{"parvagues-sc": {"load": "loaded", "active": "active"}},
running=("scsynth",))
assert items["parvagues-sc"]["state"] == "up"
def test_missing_unit_is_absent_not_down(monkeypatch):
"""A thing you cannot start and a thing you can are different problems."""
items = _status_with(monkeypatch,
{"parvagues-sc": {"load": "not-found", "active": "inactive"}})
assert items["parvagues-sc"]["state"] == "absent"
def test_masked_unit_is_absent(monkeypatch):
items = _status_with(monkeypatch,
{"parvagues-sc": {"load": "masked", "active": "inactive"}})
assert items["parvagues-sc"]["state"] == "absent"
def test_unit_systemctl_could_not_answer_for_is_not_reported_up(monkeypatch):
"""An empty answer must never read as healthy — silence is not success."""
items = _status_with(monkeypatch, {})
assert items["parvagues-sc"]["state"] != "up"
# ── gear ─────────────────────────────────────────────────────────────────────
def test_gear_reads_conf(monkeypatch, tmp_path):
c = tmp_path / "conf"
c.write_text("MODE=cool\nGEAR=aggressive\nTRIM=quiet\nSTOCK_x=1\n")
monkeypatch.setattr(R, "THERMAL_CONF", c)
assert R.gear() == {"gear": "aggressive", "trim": "quiet"}
def test_gear_absent_conf_is_none_not_a_guess(monkeypatch, tmp_path):
"""No gearbox installed is a real state; the panel must not imply a gear."""
monkeypatch.setattr(R, "THERMAL_CONF", tmp_path / "nope")
assert R.gear() == {"gear": None, "trim": None}
def test_gear_off_reads_as_no_gear(monkeypatch, tmp_path):
"""`off` persists an empty MODE and GEAR=off — not a missing conf."""
c = tmp_path / "conf"
c.write_text("MODE=\nGEAR=off\nSTOCK_x=1\n")
monkeypatch.setattr(R, "THERMAL_CONF", c)
assert R.gear()["gear"] == "off"
# ── the converge job ─────────────────────────────────────────────────────────
def test_converge_refuses_to_double_start():
"""Two clicks must not run two converges at each other's SuperDirt."""
job = R._Converge()
job._running = True
r = job.start()
assert r["status"] == "already-running"
def test_converge_reports_a_missing_gig_up_instead_of_raising(monkeypatch):
job = R._Converge()
monkeypatch.setattr(R, "GIG_UP", Path("/definitely/not/here.sh"))
monkeypatch.setattr(R.subprocess, "run",
lambda *a, **k: (_ for _ in ()).throw(OSError("nope")))
job._run(launch_apps=False)
s = job.state()
assert s["running"] is False and s["rc"] == 127 and "not/here" in s["out"]
def test_converge_never_makes_sound(monkeypatch):
"""RIG UP must stay safe backstage: --converge, never --audio.
gig-up's own contract is that the default gate is cold. The button inherits
that promise, so the flag list is asserted rather than trusted.
"""
seen = {}
class R_:
stdout, stderr, returncode = "", "", 0
monkeypatch.setattr(R.subprocess, "run",
lambda argv, **k: (seen.update(argv=argv), R_())[1])
R._Converge()._run(launch_apps=False)
assert "--audio" not in seen["argv"]
assert "--converge" in seen["argv"]
...@@ -72,6 +72,36 @@ ...@@ -72,6 +72,36 @@
.dot.on{background:var(--cool);box-shadow:0 0 6px var(--cool)} .dot.on{background:var(--cool);box-shadow:0 0 6px var(--cool)}
.toast{font-family:var(--mono);font-size:12px;color:var(--mute);padding:0 20px 8px;min-height:16px} .toast{font-family:var(--mono);font-size:12px;color:var(--mute);padding:0 20px 8px;min-height:16px}
.toast.err{color:var(--critical)} .toast.err{color:var(--critical)}
/* ── the rig ─────────────────────────────────────────────── */
.rig-head{display:flex;align-items:center;gap:16px;margin-bottom:16px;flex-wrap:wrap}
.rigup{background:var(--brand-deep);border:1px solid var(--brand);color:#fff;border-radius:12px;
padding:14px 30px;font-family:var(--sans);font-weight:700;font-size:15px;letter-spacing:.06em;
cursor:pointer;transition:.15s}
.rigup:hover:not(:disabled){background:var(--brand);transform:translateY(-1px)}
.rigup:disabled{opacity:.55;cursor:progress;transform:none}
.gearchip{font-family:var(--mono);font-size:12px;color:var(--mute);
border:1px solid var(--hairline);border-radius:9px;padding:7px 12px}
.gearchip b{color:var(--ink);font-weight:600}
.rig{border:1px solid var(--hairline);border-radius:12px;background:var(--raised);
overflow:hidden;margin-bottom:10px}
.rig-row{display:flex;align-items:center;gap:12px;padding:11px 16px;
border-bottom:1px solid var(--hairline);font-size:13px}
.rig-row:last-child{border-bottom:0}
.rig-row .nm{min-width:110px;font-weight:600}
.rig-row .dt{color:var(--mute);font-family:var(--mono);font-size:11px;flex:1}
/* absent is NOT down: a thing you cannot start is a different problem from a
thing you can, so it reads dimmer and offers no button. */
.rig-row.absent{opacity:.45}
.rig-row.broken{background:#c6282814}
.rig-row.broken .dt{color:var(--hot)}
.st{width:9px;height:9px;border-radius:99px;background:var(--faint);flex:none}
.st.up{background:var(--cool);box-shadow:0 0 6px var(--cool)}
.st.broken{background:var(--critical);box-shadow:0 0 6px var(--critical)}
.rig-row button{background:transparent;border:1px solid var(--hairline);color:var(--mute);
border-radius:8px;padding:5px 12px;font-family:var(--mono);font-size:11px;cursor:pointer}
.rig-row button:hover{border-color:var(--brand);color:var(--ink)}
.rig-log{font-family:var(--mono);font-size:11px;color:var(--faint);white-space:pre-wrap;
margin-bottom:28px;min-height:15px}
/* ── live MIDI ───────────────────────────────────────────── */ /* ── live MIDI ───────────────────────────────────────────── */
.midi-ctl{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap} .midi-ctl{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.midi-ctl select{background:var(--raised);border:1px solid var(--hairline);color:var(--ink); .midi-ctl select{background:var(--raised);border:1px solid var(--hairline);color:var(--ink);
...@@ -117,7 +147,14 @@ ...@@ -117,7 +147,14 @@
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<main> <main>
<h2>Fleet</h2> <h2>Rig</h2>
<div class="rig-head">
<button class="rigup" id="rigup">▓ RIG UP ▓</button>
<span class="gearchip" id="gearchip">gear <b></b></span>
</div>
<div class="rig" id="rig"></div>
<div class="rig-log" id="riglog"></div>
<h2>Fleet</h2>
<div class="hub" id="web"></div> <div class="hub" id="web"></div>
<h2>Launch</h2> <h2>Launch</h2>
<div class="hub" id="apps"></div> <div class="hub" id="apps"></div>
...@@ -266,6 +303,56 @@ async function doLaunch(btn){ ...@@ -266,6 +303,56 @@ async function doLaunch(btn){
setTimeout(loadLaunchers,900); setTimeout(loadLaunchers,900);
}catch(e){ toast("✗ "+e,true); if(win)win.close(); } }catch(e){ toast("✗ "+e,true); if(win)win.close(); }
} }
// ── the rig ──────────────────────────────────────────────────
// One button converges the SERVICES (via gig-up --converge) and launches the
// APPS converge deliberately refuses to open. The list underneath is the honest
// half: it is how you see what the button actually did, and fix one thing by
// hand when it did not.
function rigRow(it){
const act = it.state==="down" && it.kind==="app"
? `<button data-launch="${it.key}">start</button>` : "";
return `<div class="rig-row ${it.state}">
<span class="st ${it.state==="up"?"up":it.state==="broken"?"broken":""}"></span>
<span class="nm">${it.name}</span>
<span class="dt">${it.detail}</span>${act}</div>`;
}
function paintRig(r){
$("#rig").innerHTML = r.items.map(rigRow).join("");
document.querySelectorAll("[data-launch]").forEach(b=>b.onclick=async e=>{
e.stopPropagation();
const key=b.dataset.launch; b.disabled=true; toast(`launching ${key}…`);
try{ const x=await api("/api/launch",{method:"POST",
headers:{"Content-Type":"application/json"},body:JSON.stringify({key})});
toast((x.ok?"✓ ":"✗ ")+x.msg,!x.ok); }
catch(err){ toast("✗ "+err,true); }
setTimeout(loadRig,900);
});
const g=r.gear||{};
// No conf at all means the gearbox is not installed here — say that rather
// than implying some gear is engaged.
$("#gearchip").innerHTML = g.gear
? `gear <b>${g.gear}</b> · trim <b>${g.trim||"auto"}</b>`
: `gear <b>not installed</b>`;
const c=r.converge||{};
const btn=$("#rigup");
btn.disabled = !!c.running;
btn.textContent = c.running ? "▓ bringing up… ▓" : "▓ RIG UP ▓";
if(c.running) $("#riglog").textContent = "converging — SuperDirt's first boot warms 51 banks, this can take ~100s";
else if(c.finished) $("#riglog").textContent =
(c.rc===0?"✓ ":"✗ rc="+c.rc+" ")+new Date(c.finished*1000).toLocaleTimeString()+"\n"+(c.out||"").trim();
}
async function loadRig(){ try{ paintRig(await api("/api/rig")); }catch(e){} }
$("#rigup").onclick=async()=>{
$("#rigup").disabled=true; toast("bringing the rig up…");
try{ const r=await api("/api/rig",{method:"POST",
headers:{"Content-Type":"application/json"},body:JSON.stringify({launch_apps:true})});
toast((r.ok?"✓ ":"✗ ")+r.msg,!r.ok); }
catch(e){ toast("✗ "+e,true); $("#rigup").disabled=false; }
loadRig();
};
// ── live MIDI ──────────────────────────────────────────────── // ── live MIDI ────────────────────────────────────────────────
let es=null; let es=null;
async function loadMidiPorts(){ async function loadMidiPorts(){
...@@ -313,6 +400,9 @@ $("#midiToggle").onclick=()=>{ es?midiDisconnect():midiConnect(); }; ...@@ -313,6 +400,9 @@ $("#midiToggle").onclick=()=>{ es?midiDisconnect():midiConnect(); };
$("#midiRefresh").onclick=loadMidiPorts; $("#midiRefresh").onclick=loadMidiPorts;
loadLaunchers(); setInterval(loadLaunchers,5000); tick(); setInterval(tick,2000); loadMidiPorts(); loadLaunchers(); setInterval(loadLaunchers,5000); tick(); setInterval(tick,2000); loadMidiPorts();
// The rig polls faster than the fleet: while a converge is in flight this is the
// only thing telling PLN it is still working, and a 5 s gap reads as a hang.
loadRig(); setInterval(loadRig,2500);
</script> </script>
</body> </body>
</html> </html>
...@@ -54,6 +54,25 @@ ...@@ -54,6 +54,25 @@
set -u set -u
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 2 cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 2
# The gearbox lives in the SRE repo, not this one, and is not on PATH by default.
# Resolve it once: PATH first (if PLN has linked it), then the canonical checkout.
# Empty means "no gearbox here" and every gear step below degrades to a no-op —
# a rig check must never fail because a sibling repo is missing.
THERMAL_MODE=""
for _tm in thermal-mode "$HOME/Work/SRE/thermal/thermal-mode"; do
if command -v "$_tm" >/dev/null 2>&1; then THERMAL_MODE=$(command -v "$_tm")
elif [ -x "$_tm" ]; then THERMAL_MODE="$_tm"
else continue; fi
# Found something called thermal-mode — but a PRE-gearbox copy only speaks the
# old five modes and would answer `aggressive` with "unknown command". Treat a
# tool that does not know the gear as no tool at all, so this degrades to the
# ppd fallback instead of printing an error at PLN eight days before a gig.
if "$THERMAL_MODE" --help 2>&1 | grep -q aggressive; then break; fi
THERMAL_MODE=""
done
# Unprivileged read of the gear the gearbox persisted. No root, no sudo prompt.
current_gear() { sed -n 's/^GEAR=//p' /etc/thermal-mode.conf 2>/dev/null | head -n1; }
AUDIO=0 AUDIO=0
QUIET=0 QUIET=0
LIVE=0 LIVE=0
...@@ -205,8 +224,31 @@ if (( CONVERGE )); then ...@@ -205,8 +224,31 @@ if (( CONVERGE )); then
fi fi
done done
if command -v powerprofilesctl >/dev/null 2>&1 && ! powerprofilesctl get 2>/dev/null | grep -q performance; then # THE GEAR, via the gearbox rather than powerprofilesctl directly.
powerprofilesctl set performance >>"$LOG" 2>&1 && { echo " ${G}+${Z} perf mode on"; did=1; } #
# Both used to write the same knobs — governor, EPP, platform_profile — with no
# arbiter, so converge and `thermal-mode status` could disagree about what gear
# the machine was in, and whichever ran last won. Worse, ppd re-asserts itself
# on AC transitions, so a converge done on battery was undone by plugging in
# for the set. One owner now: the gearbox drives ppd, and the reassert hooks
# keep it there. See SRE/thermal/GEARBOX.md.
#
# `aggressive` is the gig gear: full watts the source can deliver, turbo, and
# the audio chain at RT. Trim is deliberately NOT touched — quiet-vs-cool is
# PLN's room-by-room call (a mic'd quiet room wants Quiet even at a gig), and
# converge has no way to know which room it is in.
if [ -n "$THERMAL_MODE" ]; then
if [ "$(current_gear)" != aggressive ]; then
if "$THERMAL_MODE" aggressive >>"$LOG" 2>&1; then
echo " ${G}+${Z} gear: aggressive"; did=1
else
echo " ${Y}!${Z} could not engage the gear — see: $THERMAL_MODE aggressive"
fi
fi
elif command -v powerprofilesctl >/dev/null 2>&1 && ! powerprofilesctl get 2>/dev/null | grep -q performance; then
# No gearbox on this machine: fall back to the old blunt instrument rather
# than leaving the rig on a battery-capped clock.
powerprofilesctl set performance >>"$LOG" 2>&1 && { echo " ${G}+${Z} perf mode on (no gearbox; ppd directly)"; did=1; }
fi fi
# Apps: report, never launch. # Apps: report, never launch.
...@@ -432,9 +474,15 @@ if (( LIVE )); then ...@@ -432,9 +474,15 @@ if (( LIVE )); then
"no PRELOAD line since this boot — systemctl --user restart parvagues-sc to warm the set's banks" \ "no PRELOAD line since this boot — systemctl --user restart parvagues-sc to warm the set's banks" \
bash -c 't=$(systemctl --user show parvagues-sc.service -p ActiveEnterTimestamp --value); [ -n "$t" ] || exit 1; journalctl -t parvagues-sc --since "$t" --no-pager 2>/dev/null | grep -q "banks OK"' bash -c 't=$(systemctl --user show parvagues-sc.service -p ActiveEnterTimestamp --value); [ -n "$t" ] || exit 1; journalctl -t parvagues-sc --since "$t" --no-pager 2>/dev/null | grep -q "banks OK"'
soft "perf mode" \ if [ -n "$THERMAL_MODE" ]; then
"powerprofilesctl set performance # and plug in AC: battery caps turbo" \ soft "gear" \
bash -c 'powerprofilesctl get 2>/dev/null | grep -q performance' "$THERMAL_MODE aggressive # and plug in AC: battery caps the watts" \
bash -c '[ "$(sed -n "s/^GEAR=//p" /etc/thermal-mode.conf 2>/dev/null | head -n1)" = aggressive ]'
else
soft "perf mode" \
"powerprofilesctl set performance # and plug in AC: battery caps turbo" \
bash -c 'powerprofilesctl get 2>/dev/null | grep -q performance'
fi
fi fi
# --- verdict ----------------------------------------------------------------- # --- verdict -----------------------------------------------------------------
......
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