#!/usr/bin/env python3
r"""lcxl-leds — paint the LaunchControl XL, from the hardware side, with OUR colour coding.

The problem this exists to kill
-------------------------------
The surface went dark. Not "wrong colours" — DARK, with only knob A1 green from a
hand-sent test SysEx. PLN's words, three times over: *"why no button lights, still
see only A1 green? recover that asap i see no feedback anymore not even yellow
lifeline"*, then *"back from 'all yellow' to 'our coding, but touch-reactive and
persistent post touches'"*.

Three failure modes stacked up:

  1. **Nothing painted at boot.** The LED painter lived in the Pulsar HUD
     (`lib/lcxl-leds.js`), so the surface was only ever lit if Pulsar was open, had
     activated the package, and its frame pipeline worked. #57 says its `lastFrame`
     dedupe latches an early empty frame — so even with Pulsar open, one bad first
     frame means a permanently dark board and no error anywhere.
  2. **Factory yellow winning.** When anything DID light, it snapped back to a flat
     amber/yellow — that was a real Midi-Through feedback loop echoing velocity 127
     back as an LED colour byte (see the `reference_lcxl_midi_feedback_loop` memory),
     not the device's idea of a default.
  3. **No persistence.** The LEDs are **write-only — there is no readback** — so
     software must OWN the LED state and re-assert it. Nothing did.

So this is a standalone, stdlib-only CLI/daemon that works with Pulsar closed, is
callable from `gig-up.sh` and `tidal-remote.py`, and keeps an in-process model of
every control so a repaint is always available from truth rather than from whatever
the last MIDI event happened to be.

What it is NOT
--------------
It **never sends a CC**. Not one. It is SysEx-out only. Sending CCs would move live
audio parameters (and CC 77-84 are MIDI-learned to Ardour track gains — CC77 down is
total silence). Input is consumed strictly read-only, by watching `aseqdump` as a
child process, which does not steal MIDI from SuperCollider/Tidal.

Protocol (verified on hardware 2026-07-27, not inferred)
--------------------------------------------------------
    F0 00 20 29 02 11 78 <template> <index> <value> [<index> <value> ...] F7
      template 00 = User 1 (PLN is always User 1)
      index 00-07 knob row A · 08-0F row B · 10-17 row C
            18-1F top button row · 20-27 bottom button row
            28-2B Device/Mute/Solo/RecArm · 2C-2F arrows
    colour = (16 * green) + red + flags, red/green each 0-3; flags 12 normal, 8 flash

Row D (faders) has **no LEDs at all**. Many pairs fit in one message, so a full
40-LED repaint is ONE write.

**Dim vs full reads poorly on this hardware** — established by having PLN read a
ramp back. So FLASH, never brightness, carries anything that must be unmissable.

State language (shared with the HUD painter, deliberately)
----------------------------------------------------------
    dark   = this track does not map this control   <- the biggest cognitive win
    dim    = mapped, currently at/near neutral
    full   = mapped and engaged
    flash  = at the extreme of its range / armed / dangerous
    hue    = ROLE FAMILY: green rhythm · amber bass+lead+pad · red riser/FX
Five roles collapse into three hues because the hardware has exactly two LEDs.

Usage
-----
    tools/lcxl-leds.py --map                 # convention paint (no track) — the BOOT paint
    tools/lcxl-leds.py --map TRACK.tidal     # paint by what that track actually binds
    tools/lcxl-leds.py --test                # walk every index; a dead LED is visible
    tools/lcxl-leds.py --all green
    tools/lcxl-leds.py --off
    tools/lcxl-leds.py --watch [TRACK.tidal] # touch-reactive, persistent daemon
    ... any of the above with --dry-run      # print the hex, send nothing

Port resolution
---------------
Resolved **at every call** by matching the name "Launch Control XL" in `amidi -l`.
Never cached across a failure. The port has already moved (20:0 -> 24:0) on a replug,
and a binding resolved once at boot then silently invalidated by a device event is
this rig's single most expensive recurring bug class.

If sends start failing while input still arrives, that is a USB OUT endpoint stall
(urb -32) and **only a replug fixes it** — this tool says so rather than looping.
"""

from __future__ import annotations

import argparse
import re
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent

TEMPLATE = 0x00  # User 1

# --- colour palette (same numbers as ~lcxlCol in start_and_midi.scd) ---
OFF = 12
COLOURS = {
    "off": 12,
    "redlo": 13, "red": 15,
    "amberlo": 29, "amber": 63,
    "yellow": 62,
    "greenlo": 28, "green": 60,
    "redflash": 11, "amberflash": 59, "yellowflash": 58, "greenflash": 56,
}
HUE = {
    "green": {"dim": 28, "full": 60, "flash": 56},
    "amber": {"dim": 29, "full": 63, "flash": 59},
    "red":   {"dim": 13, "full": 15, "flash": 11},
}
ROLE_HUE = {"rhythm": "green", "bass": "amber", "lead": "amber",
            "pad": "amber", "riser": "red", "fx": "red"}

# --- CC layout (source of truth: reference_controller_map) ---
ROW_SEGMENTS = [
    ("A", 13, [1, 2, 3, 4, 5, 6, 7, 8]),
    ("B", 29, [1, 2, 3, 4, 5, 6, 7, 8]),
    ("C", 49, [1, 2, 3, 4, 5, 6, 7, 8]),
    ("D", 77, [1, 2, 3, 4, 5, 6, 7, 8]),   # faders — no LEDs
    ("E", 41, [1, 2, 3, 4]), ("E", 57, [5, 6, 7, 8]),
    ("F", 73, [1, 2, 3, 4]), ("F", 89, [5, 6, 7, 8]),
]
ROW_BASE = {"A": 0x00, "B": 0x08, "C": 0x10, "E": 0x18, "F": 0x20}
LIT_INDICES = 40  # 24 knobs + 16 buttons. Side/arrow LEDs left to the device.

DJ_FILTERS = [49, 50, 51]          # gF1/gF2/gF3, centre 64 = bypass
PANIC_CHORD = [73, 74, 91, 92]     # the four-button panic chord
PANIC_CC = 93

# ParVagues channel convention: lane -> role. Used for the CONVENTION paint (no
# track given) so a boot always produces a meaningful board, not a guess-free dark one.
LANE_ROLE = {1: "rhythm", 2: "rhythm", 3: "rhythm", 4: "bass",
             5: "lead", 6: "lead", 7: "rhythm", 8: "pad"}

# Orbit -> role fallback, mirroring the HUD's role-classifier.
ORBIT_ROLE = {1: "rhythm", 2: "rhythm", 3: "rhythm", 4: "bass", 5: "lead",
              6: "rhythm", 7: "rhythm", 8: "rhythm", 9: "pad", 10: "riser",
              11: "lead", 12: "lead"}

# Sound-name role patterns, ported from the HUD's role-classifier.js. Order
# matters: riser before rhythm so "risers:N" isn't eaten by the drum wordlist.
SOUND_PATTERNS = [
    ("riser",  re.compile(r"(risers?|sweep|whoosh|uplifter|fx_uplift)", re.I)),
    ("rhythm", re.compile(r"(kick|bd\b|:bd|808bd|808bb|snare|\bsn\b|\bhh\b|\boh\b|"
                          r"\bch\b|hat|breaks?|drums?|808|clap|cymbal|tom|perc|rim|"
                          r"cowbell|electro|jungle|gretsch|\bcp\b)", re.I)),
    ("bass",   re.compile(r"(bass|sub|cbow|cpluck|moog|acid|\bcb\b)", re.I)),
    ("pad",    re.compile(r"(pad|voice|vox|choir|noise|wind|rain|ambient|drone|"
                          r"ghost|foul|suns|birds|crow|atmosph|air)", re.I)),
    ("lead",   re.compile(r"(lead|synth|gameboy|arpy|piano|keys|guitar|fpiano|"
                          r"qstab|pluck|melody)", re.I)),
]


# --------------------------------------------------------------------------- #
# CC <-> LED index
# --------------------------------------------------------------------------- #
def decode_cc(cc: int) -> tuple[str, int | None]:
    for row, start, lanes in ROW_SEGMENTS:
        i = cc - start
        if 0 <= i < len(lanes):
            return row, lanes[i]
    return "?", None


def cc_to_index(cc: int) -> int | None:
    """LED index for a CC, or None for faders and unknown CCs (no LED exists)."""
    row, lane = decode_cc(cc)
    base = ROW_BASE.get(row)
    if base is None or lane is None:
        return None
    idx = base + (lane - 1)
    return idx if idx < LIT_INDICES else None


ALL_LIT_CCS = [cc for cc in range(128) if cc_to_index(cc) is not None]


# --------------------------------------------------------------------------- #
# value -> colour
# --------------------------------------------------------------------------- #
def filter_colour(value: int) -> int:
    """DJ-filter knob ramp. Brightness = distance from neutral, hue = direction.

    The three DJF knobs are the one place where a control's VALUE matters more than
    whether it has been touched: mid-set you need "am I filtering, and which way?".
    A fully-bright amber row therefore means "all three filters are out of the way".
    """
    v = max(0, min(127, int(value)))
    if v <= 25:  return 13   # dark red — LPF hard down, toward subbass
    if v <= 51:  return 14   # red — LPF closing
    if v <= 60:  return 29   # dark amber — approaching centre
    if v <= 67:  return 63   # BRIGHT amber — centre detent == true bypass
    if v <= 76:  return 29   # dark amber — just past centre
    if v <= 102: return 44   # green — HPF climbing
    return 28                # dark green — HPF hard up


def control_colour(cc: int, role: str, value: int | None, touched: bool) -> int:
    """The one colour rule for the whole surface. Persistent by construction:
    it is a pure function of the MODEL (role + value + touched), never of an event.
    """
    if cc in DJ_FILTERS:
        # Never dark: a filter you cannot see is a filter you forget is closed.
        return filter_colour(64 if value is None else value)

    hue = HUE[ROLE_HUE.get(role, "red")]
    row, _ = decode_cc(cc)

    if row in ("E", "F"):  # buttons: on/off state
        on = bool(value)
        if cc in PANIC_CHORD or cc == PANIC_CC:
            return HUE["red"]["flash"] if on else HUE["red"]["dim"]
        return hue["full"] if on else hue["dim"]

    # knobs (rows A/B/C)
    if not touched or value is None:
        return hue["dim"]          # mapped, never moved
    if value < 8:
        return hue["dim"]          # moved, but back at its neutral end
    if value >= 120:
        return hue["flash"]        # pinned at the extreme — unmissable
    return hue["full"]


# --------------------------------------------------------------------------- #
# .tidal parsing — which controls does THIS track bind, and to what role
# --------------------------------------------------------------------------- #
BLOCK_ORBIT = re.compile(r"^\s*(?:d|p\s*)(\d+)\b")
CC_REF = re.compile(r"\^(\d+)")
SOUND_REF = re.compile(r"\b(?:sound|s)\s+\"([^\"]+)\"")


def classify_sound(sound: str | None, orbit: int | None) -> str:
    if sound:
        for role, rx in SOUND_PATTERNS:
            if rx.search(sound):
                return role
    if orbit and orbit in ORBIT_ROLE:
        return ORBIT_ROLE[orbit]
    return "fx"


def parse_track(path: Path) -> dict[int, str]:
    """cc -> role for every `^NN` the file references.

    Segmentation, and why it is NOT just "split on blank lines": a blank line is
    Tidal's block separator, but a `dN` pattern often runs many lines with full-line
    `-- comments` inside it, and stripping those comments manufactures blank lines
    that would cut a block in half. So segment boundaries are **original** blank
    lines *plus* every line that starts a new `dN`, and comments are stripped only
    for the CC scan. Verified against a real track: the naive version attributed 9/9
    bindings to the "fx" fallback because no segment head ever matched `dN`.

    A segment with no `dN` head (a helper definition like `gF = ...`) is
    cross-cutting FX.
    """
    raw = path.read_text(errors="replace").splitlines()
    bindings: dict[int, str] = {}

    segments: list[list[str]] = []
    cur: list[str] = []
    for ln in raw:
        if ln.strip() == "":
            if cur:
                segments.append(cur)
            cur = []
            continue
        if BLOCK_ORBIT.match(ln) and cur:
            segments.append(cur)
            cur = []
        cur.append(ln)
    if cur:
        segments.append(cur)

    for seg in segments:
        # Comments stripped here only — a commented-out binding must not light a knob.
        body = "\n".join(re.sub(r"--.*$", "", ln) for ln in seg)
        ccs = [int(m) for m in CC_REF.findall(body)]
        if not ccs:
            continue
        om = BLOCK_ORBIT.match(seg[0])
        orbit = int(om.group(1)) if om else None
        # `sound "x"` / `s "x"` when present; otherwise ParVagues writes the sample
        # name as a bare mini-notation string (`d2 $ gF1 $ "~ c . <...>"`), so fall
        # back to the first quoted string whose CONTENT matches a role pattern —
        # a test, not a guess, which keeps mini-notation like "t f!7" from scoring.
        sm = SOUND_REF.search(body)
        sound = sm.group(1) if sm else None
        if sound is None:
            for cand in re.findall(r"\"([^\"]+)\"", body):
                if any(rx.search(cand) for _, rx in SOUND_PATTERNS):
                    sound = cand
                    break
        role = classify_sound(sound, orbit)
        for cc in ccs:
            # An orbit/sound-derived role beats the bare "fx" fallback, so a CC
            # first seen in a helper block can still be upgraded by its real orbit.
            if cc not in bindings or (bindings[cc] == "fx" and role != "fx"):
                bindings[cc] = role

    # The three DJ filters are GLOBAL and must never be dark, whether or not this
    # particular file names them: a closed filter you cannot see is the single
    # easiest way to lose a track on stage. Same for the four-button panic chord —
    # it works on every track, so it is always part of the map.
    for cc in DJ_FILTERS + PANIC_CHORD:
        bindings.setdefault(cc, "fx")
    return bindings


def convention_bindings() -> dict[int, str]:
    """The no-track paint: every seeded control lit by its LANE's conventional role.

    This is what boot uses. It is honest about being a convention rather than a
    reading of a file, and it guarantees the surface is never dark after a boot —
    the "yellow lifeline", except in our colours.
    """
    out: dict[int, str] = {}
    for cc in ALL_LIT_CCS:
        _, lane = decode_cc(cc)
        out[cc] = LANE_ROLE.get(lane or 0, "fx")
    return out


# --------------------------------------------------------------------------- #
# frame building + SysEx
# --------------------------------------------------------------------------- #
def build_frame(bindings: dict[int, str], values: dict[int, int],
                touched: set[int]) -> list[int]:
    """index -> colour for the whole lit surface. Unbound controls go DARK, which is
    the point: the board becomes a map of the file in front of you."""
    frame = [OFF] * LIT_INDICES
    for cc, role in bindings.items():
        idx = cc_to_index(cc)
        if idx is None:
            continue
        frame[idx] = control_colour(cc, role, values.get(cc), cc in touched)
    return frame


def sysex_pairs(pairs: list[tuple[int, int]]) -> str:
    body = "".join(f" {i:02X} {v:02X}" for i, v in pairs)
    return f"F0 00 20 29 02 11 78 {TEMPLATE:02X}{body} F7"


def sysex_frame(frame: list[int]) -> str:
    return sysex_pairs(list(enumerate(frame)))


# --------------------------------------------------------------------------- #
# port resolution + send
# --------------------------------------------------------------------------- #
NAME_RE = re.compile(r"launch\s*control\s*xl", re.I)


def find_hw_port() -> str | None:
    """The `amidi` raw port (hw:C,D,S), resolved BY NAME, every call. Never cached.

    Excludes the HUI port: the LCXL exposes two, and only the first speaks the
    Launchpad SysEx dialect.
    """
    if not shutil.which("amidi"):
        return None
    try:
        out = subprocess.run(["amidi", "-l"], capture_output=True, text=True,
                             timeout=5).stdout
    except Exception:
        return None
    for line in out.splitlines():
        if not NAME_RE.search(line) or re.search(r"\bHUI\b", line):
            continue
        m = re.search(r"(hw:\d+,\d+(?:,\d+)?)", line)
        if m:
            return m.group(1)
    return None


def find_seq_port(direction: str = "-i") -> str | None:
    """The ALSA-sequencer client:port for the LCXL.

    `-i` = readable (source) side, for `aseqdump` — a READ-ONLY subscription that
    does not steal MIDI from SuperCollider. `-o` = writable (destination) side, for
    `aseqsend` — which is how we push SysEx.
    """
    if not shutil.which("aconnect"):
        return None
    try:
        out = subprocess.run(["aconnect", direction], capture_output=True, text=True,
                             timeout=5).stdout
    except Exception:
        return None
    client = None
    for line in out.splitlines():
        m = re.match(r"^client (\d+): '([^']*)'", line)
        if m:
            client = m.group(1) if NAME_RE.search(m.group(2)) else None
            continue
        if client:
            pm = re.match(r"^\s+(\d+) '(.*?)\s*'", line)
            if pm and not re.search(r"\bHUI\b", pm.group(2)):
                return f"{client}:{pm.group(1)}"
    return None


class Sender:
    """SysEx-only writer with stall detection.

    TRANSPORT — and this cost the first live attempt. `amidi -p hw:2,0,0 -S ...` is
    what lit knob A1 by hand, but it only worked because nothing else held the raw
    device. Once SuperCollider is up, the ALSA **sequencer** layer owns the rawmidi
    substream and `amidi` dies with *"cannot open port hw:2,0,0: Device or resource
    busy"* — i.e. the tool that works on a cold rig fails on a LIVE one, which is
    the only rig that matters. So the primary transport is `aseqsend` to the LCXL's
    writable **sequencer** port, which coexists with SuperCollider; `amidi` stays as
    the fallback for when no seq client exists.

    Failures are counted, not ignored. If writes keep failing we say USB-OUT-STALL
    and tell PLN to replug, because that is the only thing that fixes urb -32 — and
    a tool that retries forever on a stalled endpoint just hides a dark board.
    """

    STALL_AFTER = 5

    def __init__(self, dry_run: bool = False, verbose: bool = True):
        self.dry_run = dry_run
        self.verbose = verbose
        self.fails = 0
        self.sends = 0
        self.stalled = False

    def send(self, hexmsg: str) -> bool:
        if self.dry_run:
            print(hexmsg)
            self.sends += 1
            return True

        # Both transports re-resolve the port on EVERY write. A replug already moved
        # this device once (20:0 -> 24:0); a cached binding would go silently dead.
        attempts: list[tuple[str, list[str]]] = []
        seq = find_seq_port("-o")
        if seq and shutil.which("aseqsend"):
            attempts.append(("aseqsend", ["aseqsend", "-p", seq, hexmsg]))
        hw = find_hw_port()
        if hw and shutil.which("amidi"):
            attempts.append(("amidi", ["amidi", "-p", hw, "-S", hexmsg]))

        if not attempts:
            self._fail("no 'Launch Control XL' port found (aconnect -o / amidi -l) "
                       "— unplugged?")
            return False

        errs = []
        for name, cmd in attempts:
            try:
                r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
            except Exception as e:
                errs.append(f"{name} raised: {e}")
                continue
            if r.returncode == 0:
                self.fails = 0
                self.sends += 1
                return True
            errs.append(f"{name} exit {r.returncode}: "
                        f"{(r.stderr or r.stdout).strip()}")
        self._fail("; ".join(errs))
        return False

    def _fail(self, why: str) -> None:
        self.fails += 1
        if self.verbose:
            print(f"lcxl-leds: send FAILED — {why}", file=sys.stderr)
        if self.fails >= self.STALL_AFTER and not self.stalled:
            self.stalled = True
            print("lcxl-leds: USB OUT ENDPOINT STALL suspected "
                  f"({self.fails} consecutive failures).\n"
                  "  If input still works but LEDs are dark, only a REPLUG fixes this\n"
                  "  (urb -32). Nothing in software will recover it — see the\n"
                  "  reference_lcxl_led_stall memory.", file=sys.stderr)


# --------------------------------------------------------------------------- #
# commands
# --------------------------------------------------------------------------- #
def cmd_off(s: Sender) -> int:
    return 0 if s.send(sysex_frame([OFF] * LIT_INDICES)) else 1


def cmd_all(s: Sender, name: str) -> int:
    col = COLOURS.get(name.lower())
    if col is None:
        print(f"lcxl-leds: unknown colour '{name}'. Known: "
              f"{', '.join(sorted(COLOURS))}", file=sys.stderr)
        return 2
    return 0 if s.send(sysex_frame([col] * LIT_INDICES)) else 1


def cmd_test(s: Sender, delay: float = 0.06) -> int:
    """Walk every index in each hue, then paint a row-identifying pattern.

    Purpose is diagnostic: a DEAD LED shows up as the one that never lights during
    the walk, which you cannot see from a static frame.
    """
    ok = True
    for col in (HUE["red"]["full"], HUE["amber"]["full"], HUE["green"]["full"]):
        for i in range(LIT_INDICES):
            ok &= s.send(sysex_pairs([(i, col)]))
            if not s.dry_run:
                time.sleep(delay)
        if not s.dry_run:
            time.sleep(0.15)
    # Land on a row-legible pattern so the walk ends in something readable.
    frame = [OFF] * LIT_INDICES
    for row, base in ROW_BASE.items():
        hue = {"A": "green", "B": "amber", "C": "red", "E": "green", "F": "amber"}[row]
        for lane in range(8):
            if base + lane < LIT_INDICES:
                frame[base + lane] = HUE[hue]["full" if lane % 2 == 0 else "dim"]
    ok &= s.send(sysex_frame(frame))
    return 0 if ok else 1


def resolve_track(raw: str) -> Path:
    p = Path(raw)
    if p.exists():
        return p
    cand = REPO / raw
    if cand.exists():
        return cand
    if not raw.endswith(".tidal"):
        raw += ".tidal"
    hits = sorted((REPO / "live").rglob(raw)) + sorted((REPO / "copycat").rglob(raw))
    if len(hits) == 1:
        return hits[0]
    print(f"lcxl-leds: cannot resolve track '{raw}' ({len(hits)} matches)",
          file=sys.stderr)
    raise SystemExit(2)


def load_bindings(track: str | None, quiet: bool = False) -> dict[int, str]:
    if not track:
        b = convention_bindings()
        if not quiet:
            print(f"lcxl-leds: no track given → CONVENTION paint "
                  f"({len(b)} controls lit by lane role)")
        return b
    path = resolve_track(track)
    b = parse_track(path)
    if not quiet:
        roles = {}
        for r in b.values():
            roles[r] = roles.get(r, 0) + 1
        lit = sum(1 for cc in b if cc_to_index(cc) is not None)
        print(f"lcxl-leds: {path.name} binds {len(b)} controls "
              f"({lit} with LEDs); roles: "
              + ", ".join(f"{k}={v}" for k, v in sorted(roles.items())))
        nolights = sorted(cc for cc in b if cc_to_index(cc) is None)
        if nolights:
            print(f"  (bound but no LED exists: {nolights} — row D faders have none)")
    return b


def cmd_map(s: Sender, track: str | None, values: dict[int, int] | None = None,
            quiet: bool = False) -> int:
    bindings = load_bindings(track, quiet=quiet)
    values = values or {}
    frame = build_frame(bindings, values, set(values))
    lit = sum(1 for c in frame if c != OFF)
    if not quiet:
        print(f"lcxl-leds: painting {lit}/{LIT_INDICES} LEDs "
              f"({LIT_INDICES - lit} dark = unbound on this track)")
    return 0 if s.send(sysex_frame(frame)) else 1


# --------------------------------------------------------------------------- #
# --watch : the touch-reactive, persistent daemon
# --------------------------------------------------------------------------- #
# aseqdump's ACTUAL output format, taken from the binary's own format strings
# rather than guessed:
#   Control change         %2d, controller %d, value %d
#   Note on                %2d, note %d, velocity %s
# The first version of these regexes assumed bare space-separated numbers and would
# have matched NOTHING — a daemon that runs clean, logs nothing, and paints nothing.
# Green code proves nothing about whether the data reaches it; verify the seam.
ASEQ_CC = re.compile(r"Control change\s+(\d+),\s*controller\s+(\d+),\s*value\s+(\d+)")
ASEQ_NOTE = re.compile(r"Note (on|off)\s+(\d+),\s*note\s+(\d+),\s*velocity\s+(\d+)")


def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0,
              verbose: bool = True, ack_unbound: bool = False) -> int:
    """Own the LED state; repaint the touched control from the model on every change.

    Persistence is not a feature bolted on — it is the data model. `values` is the
    truth, `control_colour` is a pure function of it, so any repaint (a touch, the
    periodic re-assert, a reconnect) reproduces exactly the same board. The device's
    own defaults never get a say because we re-assert on a timer and the device has
    no readback to argue with.

    READ-ONLY input: `aseqdump` subscribes to the LCXL port without stealing it, so
    SuperCollider keeps receiving every CC. We never write a CC anywhere.
    """
    if not shutil.which("aseqdump"):
        print("lcxl-leds: FAIL — aseqdump not found (alsa-utils)", file=sys.stderr)
        return 2

    bindings = load_bindings(track, quiet=not verbose)
    values: dict[int, int] = {}
    touched: set[int] = set()

    # Paint immediately, so the board is never dark while we wait for a first touch.
    s.send(sysex_frame(build_frame(bindings, values, touched)))
    if verbose:
        print("lcxl-leds --watch: initial frame painted; listening (Ctrl-C to stop)")

    backoff = 1.0
    events = 0

    # The re-assert runs on its OWN thread, deliberately. The obvious placement —
    # inside the aseqdump read loop — only fires when an event arrives, so during the
    # long silences that make up most of a set (exactly when a device hiccup would go
    # unnoticed) it would never run at all. A timer thread makes persistence hold
    # while nobody is touching anything, which is the whole point of "persistent post
    # touches". The lock keeps a full repaint from interleaving with a single-LED one.
    send_lock = threading.Lock()

    def locked_send(msg: str) -> None:
        with send_lock:
            s.send(msg)

    def reassert_loop() -> None:
        while reassert:
            time.sleep(reassert)
            locked_send(sysex_frame(build_frame(bindings, dict(values), set(touched))))

    if reassert:
        threading.Thread(target=reassert_loop, daemon=True).start()

    while True:
        port = find_seq_port()      # re-resolved on every (re)spawn, never cached
        if port is None:
            print("lcxl-leds --watch: no LCXL sequencer port; retrying in "
                  f"{backoff:.0f}s", file=sys.stderr)
            time.sleep(backoff)
            backoff = min(backoff * 2, 30.0)
            continue

        if verbose:
            print(f"lcxl-leds --watch: aseqdump -p {port}")
        try:
            proc = subprocess.Popen(["aseqdump", "-p", port],
                                    stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                                    text=True, bufsize=1)
        except Exception as e:
            print(f"lcxl-leds --watch: aseqdump spawn failed: {e}", file=sys.stderr)
            time.sleep(backoff); backoff = min(backoff * 2, 30.0)
            continue

        started = time.time()
        try:
            for line in proc.stdout:                      # type: ignore[union-attr]
                cc = val = None
                m = ASEQ_CC.search(line)
                if m:
                    cc, val = int(m.group(2)), int(m.group(3))
                else:
                    n = ASEQ_NOTE.search(line)
                    if n:
                        # Row F sends Notes, not CCs — and the note NUMBER equals
                        # the CC number in our map (73-76 / 89-92), so both event
                        # kinds fold into one model keyed by control number.
                        cc = int(n.group(3))
                        val = 0 if n.group(1) == "off" else int(n.group(4))
                if cc is not None and val is not None:
                    events += 1
                    idx = cc_to_index(cc)
                    changed = values.get(cc) != val
                    values[cc] = val
                    touched.add(cc)
                    if idx is not None and changed:
                        role = bindings.get(cc)
                        if role is None:
                            # Unbound on this track. Default is to STAY DARK: "dark
                            # means this control does nothing here" is the strongest
                            # signal on the board, and lighting it on touch would
                            # undermine the one thing PLN reads at a glance. The
                            # touch itself is still recorded in the model, so
                            # --ack-unbound can surface it when debugging.
                            colour = (HUE["red"]["dim"] if (ack_unbound and val)
                                      else OFF)
                        else:
                            colour = control_colour(cc, role, val, True)
                        locked_send(sysex_pairs([(idx, colour)]))
                        if verbose:
                            row, lane = decode_cc(cc)
                            print(f"  CC{cc:<3d} {row}{lane} = {val:<3d} "
                                  f"-> idx {idx:02X} colour {colour}"
                                  f"{'' if role else '  [unbound]'}")
        except KeyboardInterrupt:
            proc.terminate()
            print(f"\nlcxl-leds --watch: stopped after {events} events, "
                  f"{s.sends} sends, {len(touched)} controls touched")
            return 0
        except Exception as e:
            print(f"lcxl-leds --watch: read loop error: {e}", file=sys.stderr)
        finally:
            try:
                proc.terminate()
            except Exception:
                pass

        # Respawn with backoff — but reset it if we had a healthy long run, so a
        # one-off device blip doesn't leave us in a 30s-poll rut all gig.
        if time.time() - started > 30:
            backoff = 1.0
        print(f"lcxl-leds --watch: aseqdump exited; respawning in {backoff:.0f}s",
              file=sys.stderr)
        time.sleep(backoff)
        backoff = min(backoff * 2, 30.0)


# --------------------------------------------------------------------------- #
def main() -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    g = ap.add_mutually_exclusive_group(required=True)
    g.add_argument("--map", nargs="?", const="", metavar="TRACK.tidal",
                   help="paint by role; no argument = the channel-convention paint")
    g.add_argument("--watch", nargs="?", const="", metavar="TRACK.tidal",
                   help="touch-reactive persistent daemon")
    g.add_argument("--test", action="store_true", help="walk every LED index")
    g.add_argument("--all", metavar="COLOUR", help="paint everything one colour")
    g.add_argument("--off", action="store_true", help="all LEDs off")
    g.add_argument("--ports", action="store_true", help="show resolved ports and exit")
    ap.add_argument("--dry-run", action="store_true", help="print hex, send nothing")
    ap.add_argument("--ack-unbound", action="store_true",
                    help="--watch: faintly light controls this track does NOT bind "
                         "when touched (off by default — dark is the signal)")
    ap.add_argument("--reassert", type=float, default=30.0,
                    help="--watch: full re-assert interval in seconds (0 = off)")
    ap.add_argument("-q", "--quiet", action="store_true")
    args = ap.parse_args()

    if args.ports:
        print(f"aseqsend (LED out, primary): {find_seq_port('-o') or 'NOT FOUND'}")
        print(f"amidi    (LED out, fallback): {find_hw_port() or 'NOT FOUND'}")
        print(f"aseqdump (CC in, read-only) : {find_seq_port('-i') or 'NOT FOUND'}")
        return 0

    s = Sender(dry_run=args.dry_run, verbose=not args.quiet)

    if args.off:
        return cmd_off(s)
    if args.all:
        return cmd_all(s, args.all)
    if args.test:
        return cmd_test(s)
    if args.map is not None:
        return cmd_map(s, args.map or None, quiet=args.quiet)
    if args.watch is not None:
        return cmd_watch(s, args.watch or None, reassert=args.reassert,
                         verbose=not args.quiet, ack_unbound=args.ack_unbound)
    return 2


if __name__ == "__main__":
    sys.exit(main())
