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
import json
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
......@@ -34,21 +35,25 @@ 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"),
# 2026-08-21: this unit was MISSING from the table, so the panel showed a
# 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
# stopped (not crashed) with the board unplugged, and `Restart=always` does
# NOT bring back a unit somebody stopped. Nothing else on the box checks it:
# not gig-up.sh, not converge. Now it is on the panel and `enable`d.
("lcxl-leds-watch", "LCXL LEDs", None, "the surface paint / MIDI feedback"),
]
# THE INVENTORY IS AUTHORED ONCE, in tools/rig_units.py.
#
# This used to be a hand-typed table, and it silently omitted lcxl-leds-watch:
# the panel showed a fully green rig while the LCXL sat dark for a whole session
# (the note that used to live here). On 2026-08-23 the same class of bug bit
# again one layer over — tidal-ardour-autoroute was `disabled`, so it never
# started at login, and orbits 5-9 reached no Ardour track. Three copies of the
# list existed: this one, gig-up.sh's loop, and each unit's [Install].
#
# One authored copy now, three consumers: this panel, `gig-up.sh --ensure`, and
# the GENERATED parvagues-rig.target. Same shape as lcxl_grid.py (#97). Adding a
# unit to the rig is one row in rig_units.py and nothing here.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
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 = ["pulsar", "ardour"]
......
......@@ -222,11 +222,22 @@ if (( CONVERGE )); then
# `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
# 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
if ! systemctl --user is-active --quiet "$u.service"; then
systemctl --user start "$u.service" >>"$LOG" 2>&1 && { echo " ${G}+${Z} started $u"; did=1; }
fi
done
# 2026-08-23: this loop used to hardcode four names and only ever `start`ed
# them. Both halves were bugs. The name list was a THIRD copy (the Bridge panel
# and each unit's [Install] were the others), and it silently omitted
# parvagues-bridge; and a `start` fixes tonight while changing nothing about
# 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.
#
......
[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