Commit f7d38407 by PLN (Algolia)

fix(lcxl): find the mk3 — "LCXL3 1" is not "Launch Control XL"

A new Launch Control XL 3 sat there showing factory-default LEDs. Not autostart
(lcxl-leds-watch was active), not wiring (aconnect showed the surface connected
to Midi Through): the port resolver matches the product name "Launch Control XL",
and the mk3 enumerates as "LCXL3 1". So _find_hw_port()/_find_seq_port() both
returned None and every frame went into the void — while the parse stage kept
logging "-> 27 controls" and looking perfectly healthy. A failure with no error
message, which is the worst kind and the whole reason gig-preflight now exists.

NAME_RE also matches \blcxl\d*\b. The inline HUI exclusion becomes SKIP_PORT_RE,
which additionally drops the mk3's DAW and "To DIN" ports — the mk2 exposed one
non-LED port, the mk3 exposes three, and sending a frame to any of them is a
silent no-op.

Validated: resolver now returns hw:1,0,0 and seq 20:0; it returned None before.

NOT fixed here — the LED dialect. The device answered a Universal Device Inquiry
with:

  F0 7E 00 06 02  00 20 29  48 01  00 00  01 01 0B 39  F7
                  Novation  family        firmware 1.1.11

and per Novation's programmer's reference the mk3 lights controls in RGB, one
message per control:

  F0 00 20 29 02 15 01 53 <control index> <R> <G> <B> F7

versus this file's mk2 frame: device id 0x11, command 0x78, a User-1 template,
batched (index, value) pairs, and a bicolor 2-bit red x 2-bit green = 16-state
colour model. So mk3 support is a second dialect plus a replacement colour model
(RGB doesn't map onto 16 bicolor states), and the per-control message form means
a full 40-control repaint costs 40 SysEx messages where the mk2 cost one — the
paint loop will need rate limiting. Tracked in SRE TODO.d.
parent e42586b8
...@@ -542,7 +542,17 @@ def sysex_frame(frame: list[int]) -> str: ...@@ -542,7 +542,17 @@ def sysex_frame(frame: list[int]) -> str:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# port resolution + send # port resolution + send
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
NAME_RE = re.compile(r"launch\s*control\s*xl", re.I) # The mk2 enumerates as "Launch Control XL"; the mk3 as "LCXL3 1" — Novation
# changed the USB product string outright. A name-matched resolver therefore
# finds NOTHING on a mk3, and because the watcher's parse stage still logs
# "-> N controls" it looks healthy while painting into the void. That is the
# 2026-09-05 "default LEDs, no OLED" bug: not autostart, not wiring, a name.
NAME_RE = re.compile(r"launch\s*control\s*xl|\blcxl\d*\b", re.I)
# Ports on the SAME device that do not speak the LED SysEx dialect. The mk2
# exposes a HUI port (the original exclusion); the mk3 exposes a DAW port plus
# two DIN-thru outs. Sending a frame to any of them is a silent no-op.
SKIP_PORT_RE = re.compile(r"\b(?:HUI|DAW)\b|To\s+DIN", re.I)
# Port lookups are `aconnect -o` / `amidi -l` subprocesses, ~1.7 ms each measured on # Port lookups are `aconnect -o` / `amidi -l` subprocesses, ~1.7 ms each measured on
# this machine. The original code ran BOTH on every single LED write, so one lit knob # this machine. The original code ran BOTH on every single LED write, so one lit knob
...@@ -587,7 +597,7 @@ def _find_hw_port() -> str | None: ...@@ -587,7 +597,7 @@ def _find_hw_port() -> str | None:
except Exception: except Exception:
return None return None
for line in out.splitlines(): for line in out.splitlines():
if not NAME_RE.search(line) or re.search(r"\bHUI\b", line): if not NAME_RE.search(line) or SKIP_PORT_RE.search(line):
continue continue
m = re.search(r"(hw:\d+,\d+(?:,\d+)?)", line) m = re.search(r"(hw:\d+,\d+(?:,\d+)?)", line)
if m: if m:
...@@ -621,7 +631,7 @@ def _find_seq_port(direction: str = "-i") -> str | None: ...@@ -621,7 +631,7 @@ def _find_seq_port(direction: str = "-i") -> str | None:
continue continue
if client: if client:
pm = re.match(r"^\s+(\d+) '(.*?)\s*'", line) pm = re.match(r"^\s+(\d+) '(.*?)\s*'", line)
if pm and not re.search(r"\bHUI\b", pm.group(2)): if pm and not SKIP_PORT_RE.search(pm.group(2)):
return f"{client}:{pm.group(1)}" return f"{client}:{pm.group(1)}"
return None return None
......
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