Commit 50c8f13a by PLN (Algolia)

feat(surface): the LCXL3 bench — a place to look at the new device before trusting it

Not an extension of lcxl-leds.py, deliberately. v2 and v3 are different
protocols, not different constants: v2 is a template/palette SysEx driving
3-colour buttons, v3 has true RGB per control, a 128x64 OLED, endless encoders,
and a CC map that only exists in DAW mode. Folding v3 in would produce a file
that lies about both. When the numbering settles, lcxl_grid.py stays the one
authored table and this becomes its transport.

Every byte sequence in here is quoted from the official programmer's reference
v1.0, not recalled — RGB is 01h 53h <idx> <R> <G> <B>, the bitmap is 09h with
1216 bytes and a 7Fh terminator rather than F7h, and the feature CCs live on
channel 7 of the DAW in port.

Transport is python-rtmidi via mido rather than shelling out. aseqsend on this
system takes -s as a FILENAME: `aseqsend -p 20:0 -s "B0 25 05"` prints
'cannot open B0 25 05' and exits 0. A silent no-op, which is how a first paint
attempt can look like a hardware answer. lcxl-leds.py shells out that way
today, which is worth revisiting.

The subcommand that matters is `keystone`. The guide files 'Colouring the
surface' and 'Controlling the screen' inside the DAW mode chapter, prefaced
'only available once DAW mode is enabled'. If that is how the document is
organised rather than a hardware restriction, paint works in standalone and the
corpus keeps its v2 numbering — a 169-file difference. So it gets tested in
three steps with PLN's eyes on the surface, not assumed either way.

`sweep` exists for the same reason: the DAW-mode index map is a low-resolution
diagram in the PDF, so we light each index in turn and read the map off the
hardware.
parent 60604b5b
#!/usr/bin/env python3
r"""lcxl3 — the Launch Control XL 3 bench.
A place to *look at* the new surface before we commit the rig to it: enumerate its
two interfaces, decode everything it sends, paint its LEDs, draw on its screen, and
answer the one question that decides the architecture (see `keystone`).
WHY THIS EXISTS SEPARATELY FROM `lcxl-leds.py`
v2 and v3 are different protocols, not different constants. v2 LEDs are a
template/palette SysEx with 3-colour buttons; v3 has true RGB per control, a
128x64 OLED, endless encoders, and a *fixed* CC map that only exists in DAW
mode. Folding v3 into the v2 tool would produce a file that lies about both.
When the migration lands, `lcxl_grid.py` stays the ONE authored table
(feedback_one_parser_per_concept) and this module becomes its transport.
TRANSPORT — and why not aseqsend
`lcxl-leds.py` shells out to `aseqsend`, which on this system takes `-s` as a
FILENAME, not a hex string: `aseqsend -p 20:0 -s "B0 25 05"` prints
"cannot open B0 25 05" and exits 0. Silent no-op. So we use python-rtmidi via
mido, which also gets us real input callbacks instead of parsing aseqdump.
Install: sudo pacman -S python-mido python-rtmidi
Deliberately system packages, not a venv: the rig's systemd --user units run
system python (reference_rig_reconciler_units).
THE TWO INTERFACES (programmer's reference, "MIDI on Launch Control XL 3")
MIDI In/Out — Custom Modes; the surface's own output in standalone.
DAW In/Out — everything in this file that paints or draws.
To DIN Out 1/2 — host -> the unit's DIN jacks. Irrelevant here: this machine
has no DIN *input* at all, so DIN buys the rig nothing today.
ALL BYTE SEQUENCES BELOW ARE QUOTED FROM the official programmer's reference guide
v1.0, not recalled:
https://fael-downloads-prod.focusrite.com/customer/prod/downloads/launch_control_xl_3_programmer_s_reference_guide-pdf_en.pdf
"""
from __future__ import annotations
import argparse
import sys
import time
# ---------------------------------------------------------------- protocol ----
HEADER = (0xF0, 0x00, 0x20, 0x29, 0x02, 0x15)
CMD_COLOUR_RGB = 0x01 # then 0x53 <index> <R> <G> <B>
CMD_DAW_MODE = 0x02 # then 0x7F on / 0x00 off
CMD_CONFIG_DISPLAY = 0x04 # then <target> <config>
CMD_SET_TEXT = 0x06 # then <target> <field> <ascii...>
CMD_BITMAP = 0x09 # then <target> <1216 bytes>, terminated 0x7F
# DAW-mode control indices. These double as the LED addresses ("The Control Change
# indices listed are also used for sending colour to the corresponding LEDs").
FADERS = range(5, 13) # 05h-0Ch
ENCODERS = range(13, 37) # 0Dh-24h, three rows of 8
ENC_ROWS = (range(13, 21), range(21, 29), range(29, 37))
BUTTONS = range(37, 53) # two rows of 8
ALL_CONTROLS = list(FADERS) + list(ENCODERS) + list(BUTTONS)
# Feature controls: DAW *in* port, channel 7 (status B6h); query on channel 8.
FEAT_MODE_SELECT = 0x1E # 30 - reports AND sets surface mode
FEAT_SHIFT = 0x3F # 63
FEAT_REL_ROW = {1: 0x45, 2: 0x48, 3: 0x49} # 69 / 72 / 73
FEAT_FADER_PICKUP = 0x46 # 70
FEAT_TOUCH = 0x47 # 71
FEAT_LED_BRIGHT = 0x6F # 111, non-volatile
FEAT_SCREEN_BRIGHT = 0x70 # 112, non-volatile
FEAT_DISPLAY_TIMEOUT = 0x71 # 113, 1/10 s units
FEAT_ENCODER_CURVE = 0x79 # 121, 0 slow / 1 med / 2 fast
# Display targets ("Configure displays")
TGT_STATIONARY = 0x35 # 53 - permanent display
TGT_TEMPORARY = 0x36 # 54 - overlay/temporary
TGT_BITMAP_STATIONARY = 0x20 # 32 - bitmap only accepts 32 or 33
TGT_BITMAP_TEMPORARY = 0x21 # 33
# Surface modes, for FEAT_MODE_SELECT
MODES = {"mixer": 0x01, "control": 0x02}
for _n in range(1, 5): # Custom 1-4 -> 06h-09h
MODES[f"custom{_n}"] = 0x05 + _n
for _n in range(5, 9): # Custom 5-8 -> 12h-15h
MODES[f"custom{_n}"] = 0x12 + (_n - 5)
for _n in range(9, 17): # Custom 8-16 -> 16h-1Dh (guide's own numbering)
MODES[f"custom{_n}"] = 0x16 + (_n - 9)
# Channels are 1-based in the guide, 0-based in mido.
CH_BUTTONS = 0 # channel 1
CH_FEATURE = 6 # channel 7
CH_FEATURE_QUERY = 7 # channel 8
CH_TOUCH = 14 # channel 15
CH_ANALOGUE = 15 # channel 16 - encoders and faders
def control_name(index: int) -> str:
"""Human label for a DAW-mode control index."""
if index in FADERS:
return f"fader {index - 4}"
for row, rng in enumerate(ENC_ROWS, start=1):
if index in rng:
return f"encoder r{row}c{index - rng.start + 1}"
if index in BUTTONS:
row = "E" if index < 45 else "F"
col = (index - 37) % 8 + 1
return f"button {row}{col}"
return f"cc {index}"
# --------------------------------------------------------------- transport ----
def _mido():
try:
import mido # noqa: PLC0415
except ImportError:
sys.exit(
"lcxl3: python-mido / python-rtmidi are not installed.\n"
" sudo pacman -S python-mido python-rtmidi\n"
"(system packages on purpose — the rig's systemd --user units run "
"system python, not a venv.)"
)
return mido
def find_ports(kind: str = "daw") -> tuple[str | None, str | None]:
"""(input_name, output_name) for the LCXL3's MIDI or DAW interface.
Matched on the *substring* the device actually advertises. The v2 tools match
"Launch Control XL", which does NOT match v3's "LCXL3 1" — that stale string is
still live in tools/bridge/surface.py.
"""
mido = _mido()
want = "DAW" if kind == "daw" else "MIDI"
ins = [p for p in mido.get_input_names() if "LCXL3" in p]
outs = [p for p in mido.get_output_names() if "LCXL3" in p]
def pick(ports: list[str]) -> str | None:
exact = [p for p in ports if want in p]
if exact:
return exact[0]
# The MIDI interface is the one WITHOUT "DAW" in its name.
if want == "MIDI":
plain = [p for p in ports if "DAW" not in p]
if plain:
return plain[0]
return None
return pick(ins), pick(outs)
class Surface:
"""An open connection to one of the LCXL3's interfaces."""
def __init__(self, kind: str = "daw", need_input: bool = False):
mido = _mido()
self.kind = kind
in_name, out_name = find_ports(kind)
if out_name is None:
sys.exit(
f"lcxl3: no LCXL3 {kind.upper()} output port found.\n"
" Run `lcxl3.py ports` to see what the system advertises."
)
self.out = mido.open_output(out_name)
self.out_name = out_name
self.inp = None
self.in_name = in_name
if need_input:
if in_name is None:
sys.exit(f"lcxl3: no LCXL3 {kind.upper()} input port found.")
self.inp = mido.open_input(in_name)
def close(self) -> None:
try:
self.out.close()
if self.inp:
self.inp.close()
except Exception:
pass
# -- raw sends ----------------------------------------------------------
def sysex(self, *payload: int) -> None:
"""Send HEADER + payload (do NOT include F0/F7)."""
mido = _mido()
self.out.send(mido.Message("sysex", data=list(HEADER[1:]) + list(payload)))
def cc(self, control: int, value: int, channel: int = CH_BUTTONS) -> None:
mido = _mido()
self.out.send(
mido.Message("control_change", channel=channel, control=control, value=value)
)
def note(self, note: int, velocity: int, channel: int = 15) -> None:
mido = _mido()
self.out.send(
mido.Message("note_on", channel=channel, note=note, velocity=velocity)
)
# -- documented operations ---------------------------------------------
def daw_mode(self, on: bool) -> None:
"""9Fh 0Ch 7Fh/00h, or the SysEx equivalent. We send the SysEx."""
self.sysex(CMD_DAW_MODE, 0x7F if on else 0x00)
def feature_controls(self, on: bool) -> None:
"""9Fh 0Bh 7Fh/00h — needed to reach feature CCs in standalone."""
self.note(0x0B, 0x7F if on else 0x00, channel=15)
def feature(self, cc: int, value: int) -> None:
self.cc(cc, value, channel=CH_FEATURE)
def rgb(self, index: int, r: int, g: int, b: int) -> None:
"""F0 .. 01h 53h <index> <R> <G> <B> F7. R/G/B are 7-bit (0-127)."""
self.sysex(CMD_COLOUR_RGB, 0x53, index, r & 0x7F, g & 0x7F, b & 0x7F)
def palette(self, index: int, colour: int) -> None:
"""B0h <index> <colour index> — the 128-entry palette path."""
self.cc(index, colour & 0x7F, channel=CH_BUTTONS)
def relative(self, row: int, on: bool) -> None:
self.feature(FEAT_REL_ROW[row], 0x7F if on else 0x00)
def configure_display(self, target: int, config: int) -> None:
self.sysex(CMD_CONFIG_DISPLAY, target, config & 0x7F)
def set_text(self, target: int, field: int, text: str) -> None:
"""ASCII 20h-7Eh only; 1Bh/1Ch/1Dh/1Eh are box/box/flat/heart."""
data = [ord(c) & 0x7F for c in text if 0x20 <= ord(c) <= 0x7E]
self.sysex(CMD_SET_TEXT, target, field, *data)
def bitmap(self, rows: list[int], target: int = TGT_BITMAP_STATIONARY) -> None:
"""1216 bytes: 19 per row x 64 rows, 7 pixels per byte, MSB leftmost.
NOTE the terminator: the guide ends this message with 7Fh, not F7h.
"""
if len(rows) != 1216:
raise ValueError(f"bitmap needs exactly 1216 bytes, got {len(rows)}")
self.sysex(CMD_BITMAP, target, *rows, 0x7F)
# ------------------------------------------------------------ bitmap helper ----
WIDTH, HEIGHT, ROW_BYTES = 128, 64, 19
def pack_bitmap(pixels: list[list[int]]) -> list[int]:
"""[64][128] of 0/1 -> the 1216-byte payload.
7 pixels per byte, highest bit leftmost, 19 bytes covering 128 px (five unused
bits in the last byte). 19*7 = 133 >= 128.
"""
out: list[int] = []
for y in range(HEIGHT):
row = pixels[y]
for bidx in range(ROW_BYTES):
byte = 0
for bit in range(7):
x = bidx * 7 + bit
on = row[x] if x < WIDTH else 0
if on:
byte |= 1 << (6 - bit)
out.append(byte & 0x7F)
return out
def blank() -> list[list[int]]:
return [[0] * WIDTH for _ in range(HEIGHT)]
def demo_pixels(phase: int = 0) -> list[list[int]]:
"""Something unmistakably OURS, so 'did it work' needs no interpretation:
a border, a diagonal, and a moving filled bar."""
px = blank()
for x in range(WIDTH):
px[0][x] = px[HEIGHT - 1][x] = 1
for y in range(HEIGHT):
px[y][0] = px[y][WIDTH - 1] = 1
for i in range(min(WIDTH, HEIGHT)):
px[i][i] = 1
bar_w = 30
start = phase % (WIDTH - bar_w - 4) + 2
for y in range(HEIGHT // 2 - 6, HEIGHT // 2 + 6):
for x in range(start, start + bar_w):
px[y][x] = 1
return px
# ------------------------------------------------------------------ commands ----
def cmd_ports(_args) -> int:
mido = _mido()
print("inputs:")
for p in mido.get_input_names():
print(f" {'*' if 'LCXL3' in p else ' '} {p}")
print("outputs:")
for p in mido.get_output_names():
print(f" {'*' if 'LCXL3' in p else ' '} {p}")
for kind in ("midi", "daw"):
i, o = find_ports(kind)
print(f"resolved {kind.upper():4}: in={i!r} out={o!r}")
return 0
def _describe(msg) -> str:
if msg.type == "control_change":
ch = msg.channel + 1
tag = ""
if ch == 16:
tag = f" <- {control_name(msg.control)}"
elif ch == 7:
names = {
FEAT_MODE_SELECT: "MODE SELECT",
FEAT_SHIFT: "SHIFT",
FEAT_LED_BRIGHT: "led brightness",
FEAT_SCREEN_BRIGHT: "screen brightness",
}
tag = f" <- feature: {names.get(msg.control, f'cc{msg.control}')}"
elif ch == 1:
tag = f" <- {control_name(msg.control)}"
elif ch == 15:
tag = f" <- TOUCH {control_name(msg.control)}"
return f"cc ch{ch:<2} #{msg.control:<3} = {msg.value:<3}{tag}"
if msg.type == "sysex":
d = list(msg.data)
head = " ".join(f"{b:02X}" for b in d[:6])
rest = " ".join(f"{b:02X}" for b in d[6:14])
more = f" ... (+{len(d) - 14} bytes)" if len(d) > 14 else ""
return f"sysex {head} | {rest}{more}"
return str(msg)
def cmd_listen(args) -> int:
s = Surface(args.port, need_input=True)
print(f"listening on {s.in_name!r} for {args.secs}s — touch the surface (ctrl-c to stop)")
end = time.time() + args.secs
seen = 0
try:
while time.time() < end:
for msg in s.inp.iter_pending():
seen += 1
print(f" {_describe(msg)}")
time.sleep(0.002)
except KeyboardInterrupt:
print()
finally:
s.close()
print(f"{seen} message(s)")
return 0
def cmd_daw(args) -> int:
s = Surface("daw")
s.daw_mode(args.state == "on")
print(f"DAW mode -> {args.state} (sent on {s.out_name!r})")
s.close()
return 0
def cmd_mode(args) -> int:
if args.name not in MODES:
sys.exit(f"lcxl3: unknown mode {args.name!r}. one of: {', '.join(MODES)}")
s = Surface("daw")
s.feature(FEAT_MODE_SELECT, MODES[args.name])
print(f"mode -> {args.name} (0x{MODES[args.name]:02X})")
s.close()
return 0
def cmd_paint(args) -> int:
s = Surface(args.port)
if args.rgb:
r, g, b = args.rgb
s.rgb(args.index, r, g, b)
print(f"rgb {control_name(args.index)} (#{args.index}) -> ({r},{g},{b})")
else:
s.palette(args.index, args.colour)
print(f"palette {control_name(args.index)} (#{args.index}) -> {args.colour}")
s.close()
return 0
def cmd_sweep(args) -> int:
"""Light every control in turn. This is how we SEE the index map, rather than
trusting a table read off a low-resolution diagram in the PDF."""
s = Surface(args.port)
print(f"sweeping {len(ALL_CONTROLS)} indices on {s.out_name!r}, {args.delay}s apart")
print("watch the surface and say what lights, in order:")
try:
for i in ALL_CONTROLS:
if args.rgb:
s.rgb(i, 0x7F, 0x00, 0x00)
else:
s.palette(i, 0x05)
print(f" #{i:<3} {control_name(i)}")
time.sleep(args.delay)
if args.clear:
s.rgb(i, 0, 0, 0) if args.rgb else s.palette(i, 0)
except KeyboardInterrupt:
print("\ninterrupted")
finally:
s.close()
return 0
def cmd_bright(args) -> int:
s = Surface("daw")
cc = FEAT_SCREEN_BRIGHT if args.screen else FEAT_LED_BRIGHT
s.feature(cc, args.value)
what = "screen" if args.screen else "LED"
print(f"{what} brightness -> {args.value} (CC {cc}, non-volatile)")
s.close()
return 0
def cmd_relative(args) -> int:
s = Surface("daw")
rows = [args.row] if args.row else [1, 2, 3]
for r in rows:
s.relative(r, args.state == "on")
print(f"encoder row {r} relative -> {args.state} (CC {FEAT_REL_ROW[r]})")
s.close()
return 0
def cmd_text(args) -> int:
s = Surface("daw")
target = TGT_TEMPORARY if args.temporary else TGT_STATIONARY
# config: bit0-4 = arrangement. 2 = "Title, Parameter Name and Text Value".
s.configure_display(target, args.arrangement)
for field, line in enumerate(args.lines):
s.set_text(target, field, line)
s.configure_display(target, 0x7F) # 7Fh = bring up with current contents
print(f"text -> target 0x{target:02X}, arrangement {args.arrangement}: {args.lines}")
s.close()
return 0
def cmd_bitmap(args) -> int:
s = Surface("daw")
target = TGT_BITMAP_TEMPORARY if args.temporary else TGT_BITMAP_STATIONARY
frames = args.frames
print(f"sending {frames} frame(s) to target 0x{target:02X} ({len(pack_bitmap(blank()))} bytes each)")
for n in range(frames):
s.bitmap(pack_bitmap(demo_pixels(n * 3)), target)
time.sleep(args.delay)
print("done — the guide says the firmware holds ONE bitmap at a time,")
print("and ACKs each frame (F0 00 20 29 02 15 09 7F) for animation pacing.")
s.close()
return 0
def cmd_keystone(args) -> int:
"""THE question: is paint really DAW-mode-only, as the guide implies?
The guide puts 'Colouring the surface' and 'Controlling the screen' inside the
DAW mode chapter, prefaced "only available once DAW mode is enabled". If that
is merely how the document is organised and not a hardware restriction, we can
paint in standalone — and then the corpus keeps its v2 CC numbering and NOTHING
has to be renumbered. That is a 169-file difference, so it gets tested.
"""
print(__doc__.strip().splitlines()[0])
print()
print("=" * 68)
print("STEP 1 — STANDALONE. Nothing has enabled DAW mode in this run.")
print("=" * 68)
s = Surface("midi")
print(f" painting via {s.out_name!r} (the MIDI interface)")
for i in BUTTONS:
s.palette(i, 0x05) # red
time.sleep(0.3)
for i in ENCODERS:
s.rgb(i, 0x00, 0x7F, 0x00) # green rings
s.close()
print(" sent: 16 palette CCs to buttons, 24 RGB SysEx to encoder rings")
print()
input(" LOOK AT THE SURFACE. Anything lit? press enter to continue... ")
print("=" * 68)
print("STEP 2 — same paint, but on the DAW interface WITHOUT DAW mode on.")
print("=" * 68)
d = Surface("daw")
for i in BUTTONS:
d.palette(i, 0x2D) # a blue-ish palette entry
time.sleep(0.3)
print(" sent: 16 palette CCs to buttons on the DAW port, DAW mode still OFF")
print()
input(" LOOK AGAIN. press enter to continue... ")
print("=" * 68)
print("STEP 3 — enable DAW mode, then paint.")
print("=" * 68)
d.daw_mode(True)
time.sleep(0.2)
for i in BUTTONS:
d.palette(i, 0x15) # yellow-ish
time.sleep(0.3)
for i in ENCODERS:
d.rgb(i, 0x7F, 0x00, 0x7F) # magenta rings
print(" sent: DAW mode ON, then 16 palette + 24 RGB")
print()
input(" LOOK AGAIN. press enter to finish... ")
if not args.stay:
d.daw_mode(False)
print(" DAW mode -> OFF (the guide says a host should exit DAW mode on quit)")
d.close()
print()
print("Report which STEP lit the surface. That answers:")
print(" step 1 lights -> paint works in standalone. The corpus keeps v2")
print(" numbering, b5ad8b6 merges on its own merits, done.")
print(" only step 3 -> paint needs DAW mode, whose CC map partly overlaps")
print(" v2's with different meanings. Then we want the")
print(" surface-driver adapter, not a second renumber.")
return 0
# ---------------------------------------------------------------------- cli ----
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="lcxl3", description=__doc__.splitlines()[0])
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("ports", help="list MIDI ports and resolve the two interfaces").set_defaults(
func=cmd_ports
)
q = sub.add_parser("listen", help="decode everything the surface sends")
q.add_argument("--port", choices=("midi", "daw"), default="daw")
q.add_argument("--secs", type=float, default=20.0)
q.set_defaults(func=cmd_listen)
q = sub.add_parser("daw", help="enable/disable DAW mode")
q.add_argument("state", choices=("on", "off"))
q.set_defaults(func=cmd_daw)
q = sub.add_parser("mode", help="select a surface mode (mixer|control|custom1..16)")
q.add_argument("name")
q.set_defaults(func=cmd_mode)
q = sub.add_parser("paint", help="colour one control")
q.add_argument("index", type=int)
q.add_argument("--rgb", nargs=3, type=int, metavar=("R", "G", "B"))
q.add_argument("--colour", type=int, default=5, help="palette index (0-127)")
q.add_argument("--port", choices=("midi", "daw"), default="daw")
q.set_defaults(func=cmd_paint)
q = sub.add_parser("sweep", help="light every index in turn, to SEE the map")
q.add_argument("--delay", type=float, default=0.35)
q.add_argument("--rgb", action="store_true", help="use RGB SysEx instead of palette")
q.add_argument("--clear", action="store_true", help="unlight each after showing it")
q.add_argument("--port", choices=("midi", "daw"), default="daw")
q.set_defaults(func=cmd_sweep)
q = sub.add_parser("bright", help="LED (or --screen) brightness, non-volatile")
q.add_argument("value", type=int)
q.add_argument("--screen", action="store_true")
q.set_defaults(func=cmd_bright)
q = sub.add_parser("relative", help="switch encoder rows to relative (endless) output")
q.add_argument("state", choices=("on", "off"))
q.add_argument("--row", type=int, choices=(1, 2, 3))
q.set_defaults(func=cmd_relative)
q = sub.add_parser("text", help="write text lines to the OLED")
q.add_argument("lines", nargs="+")
q.add_argument("--arrangement", type=int, default=2)
q.add_argument("--temporary", action="store_true")
q.set_defaults(func=cmd_text)
q = sub.add_parser("bitmap", help="draw a demo bitmap (optionally animated)")
q.add_argument("--frames", type=int, default=1)
q.add_argument("--delay", type=float, default=0.12)
q.add_argument("--temporary", action="store_true")
q.set_defaults(func=cmd_bitmap)
q = sub.add_parser("keystone", help="does paint need DAW mode? the 169-file question")
q.add_argument("--stay", action="store_true", help="leave DAW mode on at the end")
q.set_defaults(func=cmd_keystone)
args = p.parse_args(argv)
return args.func(args)
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