Commit 406a4610 by PLN (Algolia)

feat(rig): one authored unit inventory + parvagues-rig.target — the list lived in three places

PLN: "we gotta ensure these are unified proper gig single service or package
easy to maintain/add things to."

tools/rig_units.py is now THE inventory. Three consumers, no re-typing:
the Bridge panel imports SERVICES, gig-up.sh reads --ensure, and
parvagues-rig.target is GENERATED by --target. Same shape as lcxl_grid.py (#97),
for the same reason: the list was previously a hand-typed table in bridge/rig.py
(5 rows, missing parvagues-bridge), a hardcoded loop in gig-up.sh (4 names), and
whatever [Install] each unit carried. Adding a unit to the rig is one row.

Two real bugs closed:

* gig-up.sh only ever ran `systemctl start`, never `enable`. A start fixes
  tonight and changes nothing about the next login — exactly how
  tidal-ardour-autoroute sat `disabled` while every pre-gig check passed.
  --ensure does both verbs, per-unit boot policy: the reconcilers are "login"
  (they idle, hold no ports, make no sound), parvagues-sc stays "on-demand"
  because a login must not start SuperDirt.

* `is-enabled` returning "linked" means NOT enabled — it reads like a healthy
  word. --status says so in words instead of printing systemd term.

Also learned the hard way while doing this: a unit file symlinked from
~/.config/systemd/user into a FEATURE branch vanishes on `git checkout`, and
`systemctl disable` on a linked unit deletes the symlink itself. Live infra
files belong on master; both units are there now.

systemctl --user start parvagues-rig.target  # the single handle
parent 4d9f1059
...@@ -23,6 +23,7 @@ from __future__ import annotations ...@@ -23,6 +23,7 @@ from __future__ import annotations
import json import json
import os import os
import subprocess import subprocess
import sys
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
...@@ -34,21 +35,25 @@ TIDAL = L.TIDAL ...@@ -34,21 +35,25 @@ TIDAL = L.TIDAL
GIG_UP = TIDAL / "tools" / "gig-up.sh" GIG_UP = TIDAL / "tools" / "gig-up.sh"
THERMAL_CONF = Path("/etc/thermal-mode.conf") THERMAL_CONF = Path("/etc/thermal-mode.conf")
# unit -> (label, process that proves it is actually working, blurb) # THE INVENTORY IS AUTHORED ONCE, in tools/rig_units.py.
# A None process means the unit IS the whole story (nothing to cross-check). #
SERVICES = [ # This used to be a hand-typed table, and it silently omitted lcxl-leds-watch:
("parvagues-sc", "SuperDirt", "scsynth", "the sound"), # the panel showed a fully green rig while the LCXL sat dark for a whole session
("parvagues-sc-watchdog", "sc-watchdog", None, "restarts SuperDirt if it dies"), # (the note that used to live here). On 2026-08-23 the same class of bug bit
("tidal-ardour-autoroute","autoroute", None, "orbits → Ardour tracks"), # again one layer over — tidal-ardour-autoroute was `disabled`, so it never
("midi-autoconnect", "MIDI wiring", None, "LCXL ↔ Tidal, re-applied"), # started at login, and orbits 5-9 reached no Ardour track. Three copies of the
# 2026-08-21: this unit was MISSING from the table, so the panel showed a # list existed: this one, gig-up.sh's loop, and each unit's [Install].
# fully green rig while the LCXL sat dark for a whole session — the exact #
# failure this module's own docstring warns about, one row away. It had been # One authored copy now, three consumers: this panel, `gig-up.sh --ensure`, and
# stopped (not crashed) with the board unplugged, and `Restart=always` does # the GENERATED parvagues-rig.target. Same shape as lcxl_grid.py (#97). Adding a
# NOT bring back a unit somebody stopped. Nothing else on the box checks it: # unit to the rig is one row in rig_units.py and nothing here.
# not gig-up.sh, not converge. Now it is on the panel and `enable`d. sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
("lcxl-leds-watch", "LCXL LEDs", None, "the surface paint / MIDI feedback"),
] from rig_units import SERVICES as _INVENTORY # noqa: E402
# The panel does not care about boot policy (that is `--ensure`'s business), so
# it keeps its own 4-tuple shape rather than spreading a 5th field everywhere.
SERVICES = [(u, label, proc, blurb) for u, label, proc, _boot, blurb in _INVENTORY]
# Apps converge refuses to launch, which is exactly what the button adds. # Apps converge refuses to launch, which is exactly what the button adds.
APPS = ["pulsar", "ardour"] APPS = ["pulsar", "ardour"]
......
...@@ -222,11 +222,22 @@ if (( CONVERGE )); then ...@@ -222,11 +222,22 @@ if (( CONVERGE )); then
# `disabled`, so neither started at login, and the LED watcher had been STOPPED # `disabled`, so neither started at login, and the LED watcher had been STOPPED
# with the board unplugged — `Restart=always` does not undo a stop. The surface # with the board unplugged — `Restart=always` does not undo a stop. The surface
# was dark for a whole session and nothing here or on the Bridge panel noticed. # was dark for a whole session and nothing here or on the Bridge panel noticed.
for u in parvagues-sc-watchdog tidal-ardour-autoroute lcxl-leds-watch midi-autoconnect; do # 2026-08-23: this loop used to hardcode four names and only ever `start`ed
if ! systemctl --user is-active --quiet "$u.service"; then # them. Both halves were bugs. The name list was a THIRD copy (the Bridge panel
systemctl --user start "$u.service" >>"$LOG" 2>&1 && { echo " ${G}+${Z} started $u"; did=1; } # and each unit's [Install] were the others), and it silently omitted
fi # parvagues-bridge; and a `start` fixes tonight while changing nothing about
done # the next login — which is exactly how tidal-ardour-autoroute sat `disabled`
# until a launch found orbits 5-9 reaching no Ardour track. `--ensure --apply`
# reads the ONE authored inventory (tools/rig_units.py) and does both verbs,
# respecting each unit's boot policy (SuperDirt stays on-demand).
if out=$(python3 tools/rig_units.py --ensure --apply 2>&1); then
printf '%s\n' "$out" >>"$LOG"
printf '%s\n' "$out" | grep -qv 'nothing to do' && {
printf '%s\n' "$out" | sed "s/^/ ${G}+${Z} /"; did=1; }
else
printf '%s\n' "$out" >>"$LOG"
echo " ${R}!${Z} rig units: see $LOG"
fi
# THE GEAR, via the gearbox rather than powerprofilesctl directly. # THE GEAR, via the gearbox rather than powerprofilesctl directly.
# #
......
[Unit]
# GENERATED by tools/rig_units.py --target. Do not hand-edit: add a row to
# SERVICES in that module and regenerate, or the list drifts out of the one
# place that is supposed to own it.
#
# The single handle for "bring the whole rig up":
# systemctl --user start parvagues-rig.target
#
# This target is deliberately NOT enabled at boot. It Wants parvagues-sc, and a
# login must not start SuperDirt (see the boot policy note in rig_units.py).
# The reconcilers are enabled individually, by `rig_units.py --ensure`.
Description=The ParVagues rig (SuperDirt + every reconciler)
After=pipewire.service
Wants=pipewire.service
Wants=parvagues-sc.service
Wants=parvagues-sc-watchdog.service
Wants=tidal-ardour-autoroute.service
Wants=midi-autoconnect.service
Wants=lcxl-leds-watch.service
Wants=parvagues-bridge.service
#!/usr/bin/env python3
"""rig_units — THE inventory of systemd --user units that ARE the rig.
WHY THIS FILE EXISTS
--------------------
2026-08-23. PLN launched the rig and found orbits 5-9 reaching no Ardour track:
`tidal-ardour-autoroute.service` was `disabled` + `inactive`. Orbits 1-4 and
10-12 were still wired as leftovers from an earlier session, so it read as
"half works" rather than "the service is off".
Two structural causes, both fixed here:
1. THE LIST LIVED IN THREE PLACES. `tools/bridge/rig.py` had a SERVICES table
(five rows), `gig-up.sh` had a hardcoded loop (four names), and boot state
lived in whatever `[Install]` section each unit happened to carry. When
`lcxl-leds-watch` was missing from the Bridge table the panel showed a fully
green rig while the board sat dark for a session — the #97 lesson (one grid,
five copies, the HUD's went stale) repeating on a different noun.
So: this module is the ONE authored copy. `gig-up.sh` reads `--list`, the
Bridge imports `SERVICES`, and `parvagues-rig.target` is GENERATED by
`--target`. Adding a unit to the rig = adding one row here.
2. `gig-up.sh` only ever ran `systemctl start`, never `enable`. A `start` fixes
tonight and changes nothing about the next login, which is precisely how a
unit stays disabled for weeks while every pre-gig check passes. `--ensure`
does both, and reports which of the two it had to do.
BOOT POLICY, and why it is a per-unit field rather than a global
---------------------------------------------------------------
"login" enable at default.target. The reconcilers: they idle at ~0 CPU,
hold no audio ports, make no sound, and their whole job is to
re-assert a binding that something else keeps breaking. There is
no such thing as "too early" for them.
"on-demand" NOT enabled. `parvagues-sc` is the sound: it claims MIDI, opens
the audio graph, and its own unit deliberately refuses
Restart=always because audio gear must never flap. PLN launches
the rig on purpose; a login should not start SuperDirt.
`is-enabled` returning **`linked`** means the unit file is a symlink and the
unit is NOT enabled — it reads like a healthy word and is not one. That is the
state `parvagues-sc` is in, correctly, and the state the watchdog was in by
accident. `--status` spells it out rather than printing systemd's word.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
# unit, label, process that proves it really works (None = the unit is the whole
# story), boot policy, blurb
SERVICES = [
("parvagues-sc", "SuperDirt", "scsynth", "on-demand", "the sound"),
("parvagues-sc-watchdog", "sc-watchdog", None, "login", "restarts SuperDirt if scsynth dies"),
("tidal-ardour-autoroute", "autoroute", None, "login", "orbits → Ardour Tidal NN tracks"),
("midi-autoconnect", "MIDI wiring", None, "login", "LCXL → Midi Through, return leg cut"),
("lcxl-leds-watch", "LCXL LEDs", None, "login", "the surface paint"),
("parvagues-bridge", "Bridge", None, "login", "dashboard + perf toolbar"),
]
TARGET = "parvagues-rig.target"
UNITS = [s[0] for s in SERVICES]
BOOT = {s[0]: s[3] for s in SERVICES}
BLURB = {s[0]: s[4] for s in SERVICES}
LABEL = {s[0]: s[1] for s in SERVICES}
PROOF = {s[0]: s[2] for s in SERVICES}
def _sc(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(
["systemctl", "--user", *args], capture_output=True, text=True
)
def states() -> dict[str, dict[str, str]]:
"""{unit: {"active":…, "enabled":…}} in TWO forks, not two per unit.
Parsed by KEY (`Id=`), never by position: `systemctl show` emits properties
in systemd's own order, not the order you asked for — pairing them
positionally is what silently swapped LoadState and ActiveState in the
Bridge's copy of this.
"""
out: dict[str, dict[str, str]] = {u: {"active": "?", "enabled": "?"} for u in UNITS}
r = _sc("show", "--property=Id", "--property=ActiveState", *[f"{u}.service" for u in UNITS])
unit = None
for line in r.stdout.splitlines():
k, _, v = line.partition("=")
if k == "Id":
unit = v.removesuffix(".service")
elif k == "ActiveState" and unit in out:
out[unit]["active"] = v
# is-enabled has no --property form; one fork for all of them.
r = _sc("is-enabled", *[f"{u}.service" for u in UNITS])
for u, line in zip(UNITS, r.stdout.splitlines()):
out[u]["enabled"] = line.strip()
return out
def gen_target() -> str:
wants = "\n".join(f"Wants={u}.service" for u in UNITS)
return f"""[Unit]
# GENERATED by tools/rig_units.py --target. Do not hand-edit: add a row to
# SERVICES in that module and regenerate, or the list drifts out of the one
# place that is supposed to own it.
#
# The single handle for "bring the whole rig up":
# systemctl --user start {TARGET}
#
# This target is deliberately NOT enabled at boot. It Wants parvagues-sc, and a
# login must not start SuperDirt (see the boot policy note in rig_units.py).
# The reconcilers are enabled individually, by `rig_units.py --ensure`.
Description=The ParVagues rig (SuperDirt + every reconciler)
After=pipewire.service
Wants=pipewire.service
{wants}
"""
def ensure(apply: bool) -> int:
"""Enable per policy AND start. The half gig-up.sh never did."""
st = states()
todo = []
for u in UNITS:
s = st[u]
# "linked" is NOT enabled, and neither is "disabled".
if BOOT[u] == "login" and s["enabled"] not in ("enabled", "enabled-runtime"):
todo.append(("enable", u, s["enabled"]))
if s["active"] != "active":
todo.append(("start", u, s["active"]))
if not todo:
print(f"rig: all {len(UNITS)} units enabled-per-policy and active — nothing to do")
return 0
for verb, u, was in todo:
if apply:
r = _sc(verb, f"{u}.service")
ok = "ok" if r.returncode == 0 else f"FAILED: {r.stderr.strip()}"
print(f"rig: {verb:<6} {u:<24} (was {was}) — {ok}")
else:
print(f"rig: would {verb:<6} {u:<24} (is {was})")
if not apply:
print("\nrig: dry run — pass --apply to act")
return 0
def status() -> int:
st = states()
bad = 0
print(f"{'unit':<24} {'boot':<10} {'enabled':<10} {'active':<10} note")
for u in UNITS:
s = st[u]
want_enabled = BOOT[u] == "login"
note = []
if want_enabled and s["enabled"] not in ("enabled", "enabled-runtime"):
note.append("NOT enabled at boot")
bad += 1
if s["active"] != "active":
note.append("NOT running")
bad += 1
if s["enabled"] == "linked" and not want_enabled:
note.append("on-demand, as designed")
print(f"{u:<24} {BOOT[u]:<10} {s['enabled']:<10} {s['active']:<10} {'; '.join(note) or BLURB[u]}")
print(f"\nrig: {len(UNITS)} unit(s), {bad} problem(s)")
return 1 if bad else 0
def main() -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--list", action="store_true", help="unit names, one per line (for shell consumers)")
g.add_argument("--status", action="store_true", help="enabled/active table; exit 1 if anything is off")
g.add_argument("--ensure", action="store_true", help="enable per boot policy AND start")
g.add_argument("--target", action="store_true", help=f"print the generated {TARGET}")
p.add_argument("--apply", action="store_true", help="with --ensure: actually act")
a = p.parse_args()
if a.list:
print("\n".join(UNITS))
return 0
if a.target:
print(gen_target(), end="")
return 0
if a.ensure:
return ensure(a.apply)
return status()
if __name__ == "__main__":
sys.exit(main())
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