Commit 34b60d4a by PLN (Algolia)

midi-autoconnect: start lcxl3-driver when a raw LCXL3 is on the bus

gig-up picks the painter once at launch; a board that enumerates later never
got one and no fader reached Ardour. The unit stays manual (it crash-loops and
Conflicts= the v2 painter on a v2-only box), so the loop that already knows
which generation is plugged in re-runs the chooser: start, not restart, raw
kernel client only, never the v2 painter, bounded retries with a cooldown.
parent 9aeee916
...@@ -174,8 +174,74 @@ reconcile() { ...@@ -174,8 +174,74 @@ reconcile() {
cut_direct_to_sc # rule 3: one hop, not two cut_direct_to_sc # rule 3: one hop, not two
} }
echo "midi-autoconnect: reconciling every ${INTERVAL}s" # Rule 4: a raw LCXL3 on the bus means lcxl3-driver has to be running.
while :; do #
# gig-up's leds() picks the painter ONCE, at launch. A board that enumerates
# later (a slow hub, a replug) was never given one: on 2026-10-11 the set
# started with the surface on the bus and untranslated, so no fader reached
# Ardour and the LEDs stayed at factory colours. lcxl3-driver is deliberately
# "manual" (rig_units.py): on a v2-only box it would crash-loop and Conflicts=
# the v2 painter, so it cannot simply be enabled at login. This loop only runs
# during a rig bring-up and already knows which generation is present, so it is
# where the chooser is re-run.
#
# * `start`, never `restart`: idempotent, and it cannot bounce a live driver.
# * Only the RAW kernel client counts. 'ParVagues LCXL3' is the driver's own
# virtual port and matching it would make the driver vouch for itself.
# * The v2 painter is never auto-started (it Conflicts= the v3 driver).
# * BOUNDED: a driver that fails with the board present is retried a few times,
# then left alone. An unbounded retry is how priapos reached 1069 restarts;
# the gate (BLOCK) and the log say what is wrong instead.
DRIVER_UNIT="lcxl3-driver.service"
DRIVER_COOLDOWN="${MIDI_AUTOCONNECT_DRIVER_COOLDOWN:-30}"
DRIVER_MAX_TRIES="${MIDI_AUTOCONNECT_DRIVER_TRIES:-4}"
DRIVER_TRIES=0
DRIVER_LAST=0
DRIVER_STATE=""
raw_lcxl3_present() {
aconnect -l 2>/dev/null | grep -Eq "^client [0-9]+: 'LCXL3[^']*' \[type=kernel"
}
ensure_surface_driver() {
local now
if ! raw_lcxl3_present; then
DRIVER_TRIES=0
DRIVER_STATE="absent"
return 0
fi
if systemctl --user is-active --quiet "$DRIVER_UNIT"; then
if [ "$DRIVER_STATE" != "up" ]; then
echo "midi-autoconnect: LCXL3 present, lcxl3-driver is up"
DRIVER_STATE="up"
fi
DRIVER_TRIES=0
return 0
fi
if [ "$DRIVER_TRIES" -ge "$DRIVER_MAX_TRIES" ]; then
if [ "$DRIVER_STATE" != "gaveup" ]; then
echo "midi-autoconnect: LCXL3 present but lcxl3-driver will not start after ${DRIVER_MAX_TRIES} tries; leaving it (journalctl --user -u lcxl3-driver)"
DRIVER_STATE="gaveup"
fi
return 0
fi
printf -v now '%(%s)T' -1
[ $((now - DRIVER_LAST)) -ge "$DRIVER_COOLDOWN" ] || return 0
DRIVER_LAST=$now
DRIVER_TRIES=$((DRIVER_TRIES + 1))
echo "midi-autoconnect: LCXL3 present, lcxl3-driver is down: starting it (try ${DRIVER_TRIES}/${DRIVER_MAX_TRIES})"
DRIVER_STATE="starting"
systemctl --user start "$DRIVER_UNIT" 2>/dev/null \
|| echo "midi-autoconnect: systemctl --user start $DRIVER_UNIT failed"
}
# Source guard: the tests source this file for its functions and must not
# inherit the endless loop below.
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
echo "midi-autoconnect: reconciling every ${INTERVAL}s"
while :; do
reconcile reconcile
ensure_surface_driver
sleep "$INTERVAL" sleep "$INTERVAL"
done done
fi
"""Rule 4 of midi-autoconnect: a raw LCXL3 on the bus gets its driver started.
2026-10-11. The rig launched with the surface not yet on the USB bus, gig-up's
one-shot painter chooser found nothing, and when the board enumerated afterwards
nothing ever started lcxl3-driver: no fader reached Ardour. The unit is "manual"
on purpose (rig_units.py: on a v2-only box it crash-loops and Conflicts= the v2
painter), so the fix lives in the loop that already knows which generation is
plugged in.
These tests run the real shell function against stub `aconnect` and `systemctl`.
"""
from __future__ import annotations
import os
import pathlib
import subprocess
import textwrap
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "midi-autoconnect.sh"
RAW_V3 = "client 24: 'LCXL3 1' [type=kernel,card=2]\n 0 'LCXL3 1 MIDI In '\n"
VIRTUAL_ONLY = "client 133: 'RtMidiOut Client' [type=user,pid=3833359]\n 0 'ParVagues LCXL3 '\n"
RAW_V2 = "client 24: 'Launch Control XL' [type=kernel,card=2]\n 0 'Launch Control XL MIDI 1'\n"
def _run(tmp_path, aconnect_out, driver_active, calls=1, tries=4, cooldown=0):
stubs = tmp_path / "bin"
stubs.mkdir(exist_ok=True)
log = tmp_path / "systemctl.log"
log.write_text("")
(tmp_path / "aconnect.out").write_text(aconnect_out)
(stubs / "aconnect").write_text("#!/bin/sh\ncat '%s'\n" % (tmp_path / "aconnect.out"))
(stubs / "systemctl").write_text(textwrap.dedent(f"""\
#!/bin/sh
echo "$@" >> '{log}'
case "$*" in
*is-active*) [ "{int(driver_active)}" = 1 ] ;;
*) exit 0 ;;
esac
"""))
for s in stubs.iterdir():
s.chmod(0o755)
env = dict(os.environ, PATH=f"{stubs}:{os.environ['PATH']}",
MIDI_AUTOCONNECT_DRIVER_COOLDOWN=str(cooldown),
MIDI_AUTOCONNECT_DRIVER_TRIES=str(tries))
body = "\n".join(["ensure_surface_driver"] * calls)
out = subprocess.run(["bash", "-c", f"source '{SCRIPT}'\n{body}"],
env=env, capture_output=True, text=True, timeout=20)
assert out.returncode == 0, out.stderr
starts = [ln for ln in log.read_text().splitlines() if ln.startswith("--user start")]
return starts, out.stdout
def test_sourcing_the_script_does_not_start_the_loop(tmp_path):
starts, _ = _run(tmp_path, "", driver_active=False)
assert starts == []
def test_no_surface_starts_nothing(tmp_path):
starts, _ = _run(tmp_path, "", driver_active=False)
assert starts == []
def test_raw_v3_with_driver_down_starts_the_driver_once(tmp_path):
starts, out = _run(tmp_path, RAW_V3, driver_active=False)
assert starts == ["--user start lcxl3-driver.service"]
assert "lcxl3-driver is down" in out
def test_the_drivers_own_virtual_port_does_not_count_as_the_board(tmp_path):
"""'ParVagues LCXL3' is type=user. Matching it would let the driver vouch for itself."""
starts, _ = _run(tmp_path, VIRTUAL_ONLY, driver_active=False)
assert starts == []
def test_a_running_driver_is_left_alone(tmp_path):
starts, out = _run(tmp_path, RAW_V3, driver_active=True, calls=3)
assert starts == []
assert out.count("lcxl3-driver is up") == 1 # logged on the transition only
def test_the_v2_board_never_gets_the_v3_driver(tmp_path):
"""lcxl3-driver Conflicts= lcxl-leds-watch: starting it blind kills the v2 painter."""
starts, _ = _run(tmp_path, RAW_V2, driver_active=False, calls=3)
assert starts == []
def test_the_cooldown_spaces_the_attempts(tmp_path):
starts, _ = _run(tmp_path, RAW_V3, driver_active=False, calls=5, cooldown=3600)
assert len(starts) == 1
def test_retries_are_bounded_then_it_says_so(tmp_path):
"""An unbounded retry is how a broken unit reaches four digits of restarts."""
starts, out = _run(tmp_path, RAW_V3, driver_active=False, calls=10, tries=3)
assert len(starts) == 3
assert out.count("will not start after 3 tries") == 1
def test_it_never_touches_the_v2_painter(tmp_path):
stubs_log = []
starts, _ = _run(tmp_path, RAW_V3, driver_active=False, calls=4)
stubs_log = (tmp_path / "systemctl.log").read_text()
assert "lcxl-leds-watch" not in stubs_log
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