Commit 8229893d by PLN (Algolia)

fix(lcxl3): survive an unplug/replug — the board comes back lit

PLN, setting up: "ill often unplug replug another port a controler when setting
up, it needs to resist that, atm i unplugged replugged lost the led" — and then,
with the surface dark, "movign faders dont move sound for now".

A replug destroys the kernel's sequencer client and rebuilds it, dropping every
subscription on it. rtmidi is never told, so the driver kept its handles, kept
printing "translating", and kept painting into a void: 129:0 had no "Connecting
To" and the input leg no "Connected From", while the board sat there dark and
deaf. reconcile() reported healthy all the way through, because the leg it
watches — the virtual port into SuperCollider — is virtual-to-virtual and
survives the event untouched. The half that breaks was the half nobody checked.

Names cannot detect this and that is the trap: the board came back as client 24,
the SAME number, so find_ports() answered, in_name still matched, and every
string looked right. Only the subscription knows.

So the reconcile tick now checks the board link first and relinks on loss:
reopen the Surface, re-assert DAW mode and the relative rows, repaint, relabel.
Board before SC, because a dark surface is the louder failure.

Two things learned by testing it rather than trusting it:

- ANY-subscription is not a health signal. The first cut asked "does the DAW port
  have a subscriber?" and answered True on the exact state it exists to catch —
  a replug leaves DEBRIS, and aconnect went on printing the board wired to
  clients 132/133 that no longer existed while our own ports sat detached. It now
  matches aconnect's pid= against os.getpid() and requires OUR client on both
  ends: no input is a dead controller, no output is a dark one.
- A relink does not re-seed. Seeding pushes values downstream, and downstream is
  the one path that never broke — SC already holds them.

Verified by cutting both legs with `aconnect -d` (what the replug does to the
subscriptions) and watching it come back within one tick.
parent 796afa6b
...@@ -588,6 +588,83 @@ RAW_CLIENTS = ("LCXL3", "Launch Control XL") ...@@ -588,6 +588,83 @@ RAW_CLIENTS = ("LCXL3", "Launch Control XL")
THRU_CLIENTS = ("Midi Through",) THRU_CLIENTS = ("Midi Through",)
def board_link_alive(kind: str = "DAW") -> bool | None:
"""Is the board's port still SUBSCRIBED to anything?
None = no board on the bus (unplugged). False = present but nothing wired
to it. True = a link stands.
WHY SUBSCRIPTIONS AND NOT NAMES — the replug trap, found 2026-09-06
PLN: "ill often unplug replug another port a controler when setting up,
it needs to resist that, atm i unplugged replugged lost the led".
A replug destroys the kernel's sequencer client and rebuilds it, which
silently drops every subscription on it. rtmidi is not told, so the
driver keeps its file handles, keeps saying "translating", and keeps
painting into a void. Observed: the board came back as client 24 —
the SAME number it had before — so `find_ports()` still answered,
`self.surface.in_name` still matched, and every string looked right
while `129:0` had no "Connecting To" and `130:0` had no "Connected
From". Name-matching cannot see this; only the subscription can.
The virtual port survives the same event (131:0 -> 128:0 was still up),
which is exactly why reconcile() reported success throughout and why
this needed a check of its own rather than a wider reconcile.
IT MUST BE *OUR* PORTS, NOT ANY PORTS — the false positive, caught in test
The first cut of this asked only "does the board's DAW port have any
subscription?" and answered True on the very state it exists to catch.
A replug leaves DEBRIS: `aconnect -l` went on printing the board wired
to clients 132:0 and 133:0 which no longer existed at all (the client
list held 129-131 and nothing else), while the driver's own 129/130 sat
detached. Any-subscription is therefore not a health signal; it can be
satisfied entirely by ghosts. So the question is whether a client
belonging to THIS PROCESS is on both ends, and the answer comes from
matching aconnect's own `pid=` field against os.getpid().
"""
out = _aconnect_pairs()
if not out:
return None
mine: set[str] = set() # ALSA client ids owned by this process
me = f"pid={os.getpid()}"
for line in out.splitlines():
if line.startswith("client ") and me in line:
mine.add(line.split()[1].rstrip(":"))
if not mine:
return None # our own ports are gone; nothing to judge
in_board = False # inside the LCXL3 client block
on_port = False # inside the port we care about
seen = False
to_us = from_us = False
for line in out.splitlines():
if line.startswith("client "):
in_board = "LCXL3" in line
on_port = False
continue
if not in_board or not line.strip():
continue
tok = line.strip().split()
if line[0] in " \t" and tok and tok[0].isdigit():
# A port row: 0 'LCXL3 1 MIDI In ' / 1 'LCXL3 1 DAW In '
on_port = kind in line
seen = seen or on_port
continue
if not on_port:
continue
# Peers are printed as "client:port", sometimes "129:0[real:0]".
peers = {p.split(":")[0] for p in tok[2:] if ":" in p}
if "Connecting To:" in line: # board -> us (we hear the knobs)
to_us = to_us or bool(peers & mine)
elif "Connected From:" in line: # us -> board (we light the LEDs)
from_us = from_us or bool(peers & mine)
if not seen:
return None # no DAW port on the bus: unplugged
# BOTH directions, because either one missing is a broken surface: no input
# is a dead controller, no output is a dark one.
return to_us and from_us
def prune(protect: set[str], verbose: bool = False, def prune(protect: set[str], verbose: bool = False,
cut_thru: bool = True) -> list[str]: cut_thru: bool = True) -> list[str]:
"""Cut every raw path into SuperCollider, protecting `protect` port ids. """Cut every raw path into SuperCollider, protecting `protect` port ids.
...@@ -736,22 +813,41 @@ class Driver: ...@@ -736,22 +813,41 @@ class Driver:
def start(self) -> None: def start(self) -> None:
print(f"enabling DAW mode (the only mode that paints, and the only one") print(f"enabling DAW mode (the only mode that paints, and the only one")
print(f" that reports the fixed map we translate from)") print(f" that reports the fixed map we translate from)")
self.arm_surface(verbose=True)
print(f"virtual port published: {VIRTUAL_PORT_NAME!r}")
reconcile(verbose=True, cut_thru=not self.args.keep_thru)
def arm_surface(self, verbose: bool = False, seed: bool = True) -> None:
"""Everything that must be said to the BOARD itself, and nothing else.
Split out of start() so a relink after a replug can re-run exactly this
and not touch the virtual port or the SC wiring, which survive the event
(see board_link_alive). A replugged board comes back in its default mode
with a dark surface, so re-subscribing alone would leave a live-but-unlit
board — the state that still reads as broken. Mode, relative rows, paint
and OLED all have to be re-asserted. Nothing is lost by repainting: the
values are the driver's own.
"""
self.surface.daw_mode(True) self.surface.daw_mode(True)
time.sleep(0.2) time.sleep(0.2)
if self.args.relative: if self.args.relative:
for row in REL_ROWS: for row in REL_ROWS:
self.surface.relative(row, True) self.surface.relative(row, True)
print(" encoder rows B+C -> RELATIVE (deltas; the driver owns the values)") if verbose:
print(" row A stays absolute: A1-A4 are Ardour's, we hold no truth for them") print(" encoder rows B+C -> RELATIVE (deltas; the driver owns the values)")
print(f"virtual port published: {VIRTUAL_PORT_NAME!r}") print(" row A stays absolute: A1-A4 are Ardour's, we hold no truth for them")
reconcile(verbose=True, cut_thru=not self.args.keep_thru)
if self.lang: if self.lang:
# Touch overlays should get out of the way fast (units of 0.1 s). # Touch overlays should get out of the way fast (units of 0.1 s).
self.surface.feature(L.FEAT_DISPLAY_TIMEOUT, 12) self.surface.feature(L.FEAT_DISPLAY_TIMEOUT, 12)
self.full_paint() self.full_paint(quiet=not verbose)
self.oled_home() self.oled_home()
self.oled_labels() self.oled_labels(quiet=not verbose)
if not self.args.no_seed: # `seed` is off on a relink, and that is a decision, not a shortcut:
# seeding pushes values DOWNSTREAM, and the downstream stream
# (131:0 -> SC) survives a replug untouched. SC already holds these
# values. Re-emitting them mid-set would be traffic in exchange for
# nothing, into the one path that never broke.
if seed and not self.args.no_seed:
# Seed ONLY non-Ardour knob cells: DJF to bypass, fx to 0. Never # Seed ONLY non-Ardour knob cells: DJF to bypass, fx to 0. Never
# buttons (a seeded gate is the mutebomb), never faders/A1-A4 # buttons (a seeded gate is the mutebomb), never faders/A1-A4
# (Ardour-learned: CC77 unsolicited goes-to-silence), never ^93. # (Ardour-learned: CC77 unsolicited goes-to-silence), never ^93.
...@@ -761,7 +857,40 @@ class Driver: ...@@ -761,7 +857,40 @@ class Driver:
and v2 in self.mapped: and v2 in self.mapped:
self.emit(v2, val) self.emit(v2, val)
n += 1 n += 1
print(f" seeded {n} knob cells (DJF at bypass, fx at 0)") if verbose:
print(f" seeded {n} knob cells (DJF at bypass, fx at 0)")
def relink_board(self) -> bool:
"""Re-subscribe to the board after an unplug/replug, then relight it.
Called from the tick when board_link_alive() says the subscription is
gone. Returns False (quietly) while the board is still absent or still
settling, so the caller just tries again on the next tick — a controller
being moved between ports must never be able to take the driver down.
"""
if all(p is None for p in L.find_ports("daw")):
return False # not back on the bus yet
try:
self.surface.close()
except Exception:
pass # the handles are already dead
try:
# L.Surface() calls sys.exit() when a port is missing. A board that
# is half-enumerated is a RACE, not a reason to stop translating,
# so SystemExit is caught here and turned into "retry next tick".
self.surface = L.Surface("daw", need_input=True)
except SystemExit:
return False
except Exception as e:
print(f" relink: reopen failed ({e}) — retrying")
return False
try:
self.arm_surface(verbose=False, seed=False)
except Exception as e:
print(f" relink: reopened but arming failed ({e}) — retrying")
return False
print(f" relink: board back on {self.surface.in_name!r} — mode + paint re-asserted")
return True
# ------------------------------------------------------------- paint ---- # ------------------------------------------------------------- paint ----
...@@ -1372,6 +1501,14 @@ class Driver: ...@@ -1372,6 +1501,14 @@ class Driver:
) )
now = time.time() now = time.time()
if now - last_recon > self.args.reconcile_secs: if now - last_recon > self.args.reconcile_secs:
# The BOARD link first, then the SC link. Deliberately on the
# same tick rather than a timer of its own: this tick already
# shells out to aconnect, so the check is one more spawn every
# 5s instead of a new cadence to reason about. And board before
# SC because a dead surface is the louder failure — reconcile
# reported healthy all through the replug that killed the LEDs.
if board_link_alive() is not True:
self.relink_board()
reconcile(verbose=self.args.verbose, reconcile(verbose=self.args.verbose,
cut_thru=not self.args.keep_thru) cut_thru=not self.args.keep_thru)
last_recon = now last_recon = now
......
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