# VARASYS PolyMeter - PM_G-1 "Grid" firmware (CircuitPython edition)
# Pimoroni Pico Scroll Pack (PIM545): a plain Raspberry Pi Pico (RP2040) + a 17x7 single-colour
# white LED matrix (IS31FL3731 over I2C @ 0x74) + 4 buttons (A/B/X/Y). No touchscreen, no joystick,
# no speaker. Audio is over USB-MIDI (the editor's "Device audio"); an OPTIONAL piezo on a free GPIO
# can be enabled below.
#
# Sibling to PM_K-1 (../pico-cp/) and PM_X-1 (../pico-explorer/). SAME engine, SAME program-string
# grammar, SAME programs.json, SAME web editor, SAME live-sync protocol. The Grid build is READ-ONLY
# on the device (no on-device beat editing); editing happens in the web editor with Live sync on.
#
# The 7-row x 17-column matrix is the editor's lane x step pad grid in miniature: each lane is a row,
# each step a column, brightness encodes accent / normal / ghost; a moving playhead column tracks the
# beat. Three views (button B cycles): Grid, Pendulum, BPM.
#
# WHY CIRCUITPYTHON: the board mounts as a USB drive (CIRCUITPY) carrying this code + your tracks +
# an offline copy of the editor; edits in the web editor are pushed over USB-MIDI. Pinout in README.md.

import board, busio, digitalio, time, json, gc, os, supervisor
try:
    import pwmio                          # only needed if an optional piezo is wired (P_BUZZER below)
except ImportError:
    pwmio = None
supervisor.runtime.autoreload = False     # we write our own files (log + pushed programs); never self-restart
APP_VERSION = "0.0.1"                     # firmware version (the A/B updater pushes/compares this)
DEVICE_ID   = "G"                         # 'G' = Grid (Scroll Pack); 'K' = 52Pi kit, 'X' = Explorer (see docs/livesync-protocol.md)
try:
    import rtc                            # set from the editor's clock SysEx so the log has real timestamps
except ImportError:
    rtc = None
try:
    import usb_midi                       # sends a MIDI note per click to the computer + carries the editor link
except ImportError:
    usb_midi = None
try:
    from binascii import a2b_base64       # decode the base64-encoded .mpy pushed by the editor's one-click update
except ImportError:
    a2b_base64 = None

# ============================== CONFIG (tweak if needed) ==============================
MIDI_ENABLED  = True         # send a USB-MIDI note per click (play via the web editor's "Device audio")
MIDI_CHANNEL  = 10           # 1..16 - GM channel 10 is the drum channel
MIDI_CLOCK_OUT          = False  # send 24 PPQN MIDI Clock so a DAW can slave its tempo to the metronome
MIDI_CLOCK_OUT_TRANSPORT = True
MIDI_CLOCK_IN           = False  # follow an external 24 PPQN clock
MIDI_CLOCK_IN_TRANSPORT = True
MUTE_SPEAKER     = False     # always silence the optional piezo
SPEAKER_AUTO_MUTE = False    # auto-mute the piezo when a MIDI host is listening (Live sync heartbeats every 5s)
BRIGHTNESS    = 160          # accent brightness 0..255; normal/ghost scale from it (Y/X tune tempo, not this)

# ----- pins (Pimoroni Pico Scroll Pack layout; verified against pimoroni-pico pico_scroll source) -----
P_BTNA, P_BTNB, P_BTNX, P_BTNY = board.GP12, board.GP13, board.GP14, board.GP15   # the 4 switches
P_SDA, P_SCL = board.GP4, board.GP5                                               # IS31FL3731 I2C bus
MATRIX_ADDR  = 0x74          # IS31FL3731 default address on the Scroll Pack
P_BUZZER     = None          # OPTIONAL: set to e.g. board.GP16 if you solder a piezo to a free GPIO; None = silent (MIDI only)

MIN_LOG_SEC = 5              # don't log plays shorter than this

# ----- BUILT-IN playlists: same defaults as the Kit / Explorer so all firmwares feel identical -----
BUILTIN_SETLISTS = [
    ("Styles", [
        ("Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"),
        ("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"),
        ("Purdie half-time shuffle", "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
        ("Samba (2/4)", "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
        ("Nanigo (6/8 bembe)", "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"),
        ("6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"),
        ("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"),
        ("5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"),
    ]),
    ("Practice", [
        ("5 over 4 polyrhythm", "t100;kick:4;claves:5~"),
        ("3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"),
        ("2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"),
        ("Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"),
        ("Tempo builder 80 up", "t80;woodblock:4;rmp80/4/4"),
        ("Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"),
    ]),
    ("Song (continuous)", [
        ("Intro - hats & kick", "t88;b4;kick:4=X.x.;hatClosed:4/2=gggggggg"),
        ("Groove in - backbeat", "t88;b4;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"),
        ("Half-time shuffle", "t92;b4;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
        ("Build - ramp 92-120", "t92;b4;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"),
        ("Four-on-the-floor (909)", "t124;b4;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"),
        ("Samba break (2/4)", "t116;b4;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
        ("Peak - 16ths", "t132;b4;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"),
        ("Outro - ramp down", "t132;b4;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"),
    ]),
]

SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare909":38,
    "clap":39,"clap808":39,"clap909":39, "rim":37, "hatClosed":42,"hat808":42,"hat909":42,
    "hatOpen":46,"openHat808":46, "ride":51,"ride909":51, "crash":49,"crash909":49,
    "tomLow":41,"tom808":45,"tomMid":45,"tomHigh":48, "tambourine":54,
    "cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":37}
GM_DEFAULT = 37
MIDI_VEL = {2: 120, 1: 90, 3: 45}                    # accent / normal / ghost

# ============================== POLYMETER ENGINE (identical to ../pico-explorer/app.py) ==============================
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0,
       'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2}   # ornament hits: UPPER = accented, lower = normal
ORN = {'f': 1, 'F': 1, 'd': 2, 'D': 2, 'z': 3, 'Z': 3}   # ornament type: 0 none / 1 flam / 2 drag / 3 roll
PRIO = {2: 3, 1: 2, 3: 1}
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
          43: "tomLow", 44: "hatClosed", 45: "tomMid", 46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash",
          50: "tomHigh", 51: "ride", 53: "ride", 54: "tambourine", 56: "cowbell", 75: "claves", 76: "woodblock", 77: "woodblock"}

def _euclid(k, n, rot):                                # even distribution: k hits over n steps, rotated (matches web euclid())
    n = max(1, n); k = max(0, min(n, k)); rot = ((rot % n) + n) % n
    return [1 if ((((i + rot) % n) * k) % n) < k else 0 for i in range(n)]

def parse_program(s):
    bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None; rep = None; end = None
    for tok in s.strip().split(';'):
        tok = tok.strip()
        if not tok: continue
        if tok[0] == 't' and tok[1:].isdigit():
            bpm = int(tok[1:]); continue
        if tok[0] == 'b' and tok[1:].isdigit():
            bars = int(tok[1:]); continue
        if tok.startswith('rmp'):
            p = tok[3:].split('/')
            if len(p) == 3:
                try: ramp = {'start': int(p[0]), 'amt': int(p[1]), 'every': max(1, int(p[2]))}
                except ValueError: pass
            continue
        if tok.startswith('tr') and '/' in tok and ':' not in tok:
            p = tok[2:].split('/')
            if len(p) == 2:
                try: trainer = {'play': max(0, int(p[0])), 'mute': max(0, int(p[1]))}
                except ValueError: pass
            continue
        if tok.startswith('rep='):                      # rep=<n> cycles before the end-action fires (playback flow)
            try: rep = max(1, int(tok[4:]))
            except ValueError: pass
            continue
        if tok.startswith('end='):                      # end=stop | end=next(+1) | end=<+/-N> relative goto; absent = loop forever
            v = tok[4:]
            if v == 'stop': end = 'stop'
            elif v == 'next': end = 1
            else:
                try: end = int(v)
                except ValueError: pass
            continue
        if ':' not in tok: continue
        lane = _parse_lane(tok)
        if lane: lanes.append(lane)
    if not lanes: lanes = [_parse_lane("beep:4")]
    return max(5, min(300, bpm)), lanes, bars, ramp, trainer, rep, end

def _parse_lane(tok):
    poly = '~' in tok; mute = '!' in tok
    tok = tok.replace('~', '').replace('!', '')
    gain = ''
    if '@' in tok: tok, _, g = tok.partition('@'); gain = '@' + g
    sound, _, rest = tok.partition(':')
    if sound.isdigit(): sound = GM_NUM.get(int(sound), sound)         # GM note-number alias (e.g. 36 -> kick)
    euc = None                                                       # euclidean (k,n,rot) shorthand - pulled before the =/ splits
    lp = rest.find('(')
    if lp >= 0:
        rp = rest.find(')', lp)
        if rp > lp:
            nums = [int(x) for x in rest[lp + 1:rp].split(',') if x.strip().isdigit()]
            rest = rest[:lp] + rest[rp + 1:]
            if nums: euc = nums
    pattern = None
    if '=' in rest: rest, _, pattern = rest.partition('=')
    sub = 1; swing = False
    if '/' in rest:
        rest, _, sd = rest.partition('/')
        swing = sd.endswith('s'); sd = sd.rstrip('s')
        sub = int(sd) if sd.isdigit() else 1
    groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4]
    beats = sum(groups); starts = set(); acc = 0
    for gp in groups: starts.add(acc); acc += gp
    if euc:                                              # euclidean: k hits over n steps, first hit accented
        k = euc[0]; n = euc[1] if len(euc) > 1 else beats * sub; rot = euc[2] if len(euc) > 2 else 0
        if len(euc) > 1:
            if n % beats == 0: sub = n // beats
            else: groups = [n]; sub = 1
        steps = n; levels = []; first = True
        for h in _euclid(k, n, rot):
            if h: levels.append(2 if first else 1); first = False
            else: levels.append(0)
        orns = [0] * len(levels)                         # euclid hits carry no ornament
    elif pattern:
        steps = beats * sub
        levels = [PAT.get(ch, 0) for ch in pattern]
        orns = [ORN.get(ch, 0) for ch in pattern]        # per-step flam/drag/roll, parallel to levels
        if len(levels) < steps:
            levels += [0] * (steps - len(levels)); orns += [0] * (steps - len(orns))
        steps = len(levels)
    else:
        steps = beats * sub
        levels = []
        for i in range(steps):
            if i % sub == 0: levels.append(2 if (i // sub) in starts else 1)   # beat: accent on group starts
            else: levels.append(1)                                             # off-beat subdivisions sound at normal
        orns = [0] * steps
    if sound not in SOUND_GM: sound = "beep"            # unknown sound -> beep (match web)
    return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'orns': orns,
            'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}

PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'}
ORN_CH = {1: ('f', 'F'), 2: ('d', 'D'), 3: ('z', 'Z')}  # ornament -> (normal, accented) pattern char
def _cell_ch(v, o):                                     # (level, ornament) -> one pattern char
    if o in ORN_CH: return ORN_CH[o][1 if v >= 2 else 0]
    return PAT_CH.get(v, '.')
def lane_to_str(L):
    s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
    if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
    orns = L.get('orns') or [0] * len(L['levels'])
    s += '=' + ''.join(_cell_ch(v, orns[i] if i < len(orns) else 0) for i, v in enumerate(L['levels']))
    s += L.get('gain', '')
    if L['poly']: s += '~'
    if L['mute']: s += '!'
    return s

_ALNUM = "abcdefghijklmnopqrstuvwxyz0123456789"
def _slkey(t):
    return "".join(c for c in t.lower() if c in _ALNUM)
def load_user_setlists():
    try:
        with open("/programs.json") as f: d = json.load(f)
    except Exception as e:
        print("programs.json:", e); return []
    def items_of(pl): return [(p.get("name", "?"), p.get("prog", "")) for p in pl if p.get("prog")]
    out = []
    try:
        if isinstance(d.get("setlists"), list):
            for sl in d["setlists"]:
                it = items_of(sl.get("programs", []))
                if it: out.append((sl.get("title", "My set list"), it))
        elif isinstance(d.get("programs"), list):
            it = items_of(d["programs"])
            if it: out.append((d.get("title", "My set list"), it))
    except Exception as e:
        print("setlists:", e)
    return out

# ============================== IS31FL3731 DRIVER (vendored: bulk-framebuffer, one I2C block write per frame) ==============================
# The Scroll Pack wires its 17x7 matrix to the IS31FL3731 with the Scroll pHAT HD pixel map
# (verified from adafruit_is31fl3731.scroll_phat_hd). We keep a 144-byte PWM framebuffer and push
# the whole thing in a single I2C transaction at the colour-register offset (0x24) - per-pixel I2C
# writes are far too slow to animate a metronome.
def _pixel_addr(x, y):
    if x <= 8:
        x = 8 - x; y = 6 - y
    else:
        x = x - 8; y = y - 8
    return x * 16 + y

class Matrix:
    WIDTH = 17; HEIGHT = 7
    def __init__(self, i2c, addr=MATRIX_ADDR):
        self.i2c = i2c; self.addr = addr
        self._zero = bytes(144)
        self.fb = bytearray(145); self.fb[0] = 0x24            # fb[0] = COLOR_OFFSET register; fb[1:] = 144 PWM bytes
        self._w(bytes([0xFD, 0x0B]))                           # select the Function (config) bank
        self._w(bytes([0x0A, 0x00]))                           # Shutdown register -> software shutdown (sleep) while we configure
        self._w(bytes([0x00]) + bytes(13))                     # clear config regs 0x00..0x0C: Picture Mode, frame 0, audiosync off
        self._w(bytes([0xFD, 0x00]))                           # select frame 0
        self._w(bytes([0x00]) + b"\xff" * 18)                  # LED-control regs 0x00..0x11 -> enable every LED
        self._w(bytes([0xFD, 0x0B])); self._w(bytes([0x0A, 0x01]))   # back to config bank; Shutdown -> normal operation
        self._w(bytes([0xFD, 0x00]))                           # frame 0 selected for all subsequent PWM writes
        self.show()                                            # blank it
    def _w(self, data):
        self.i2c.writeto(self.addr, data)
    def clear(self):
        self.fb[1:] = self._zero
    def get(self, x, y):
        if 0 <= x < 17 and 0 <= y < 7: return self.fb[1 + _pixel_addr(x, y)]
        return 0
    def set(self, x, y, v):
        if 0 <= x < 17 and 0 <= y < 7:
            self.fb[1 + _pixel_addr(x, y)] = v & 0xFF
    def show(self):
        try: self.i2c.writeto(self.addr, self.fb)
        except Exception: pass

# 3x5 digit glyphs for the BPM view (each value is 5 rows; bit2 = leftmost column)
DIGITS = {
    '0': (7, 5, 5, 5, 7), '1': (2, 6, 2, 2, 7), '2': (7, 1, 7, 4, 7), '3': (7, 1, 7, 1, 7),
    '4': (5, 5, 7, 1, 1), '5': (7, 4, 7, 1, 7), '6': (7, 4, 7, 5, 7), '7': (7, 1, 2, 2, 2),
    '8': (7, 5, 7, 5, 7), '9': (7, 5, 7, 1, 7),
}
# 3x5 uppercase letters + marks for the boot splash (scrolls the model name "PM-G1 GRID")
LETTERS = {
    'P': (7, 5, 7, 4, 4), 'M': (5, 7, 7, 5, 5), 'G': (7, 4, 5, 5, 7), 'R': (7, 5, 7, 6, 5),
    'I': (7, 2, 2, 2, 7), 'D': (6, 5, 5, 5, 6), '-': (0, 0, 7, 0, 0), ' ': (0, 0, 0, 0, 0),
}

# ============================== APP ==============================
def _make_i2c():
    # The Pico Scroll Pack has NO external I2C pull-up resistors - it relies on the RP2040's
    # *internal* pull-ups (Pimoroni's C++ calls gpio_pull_up()). CircuitPython's busio.I2C
    # refuses with "No pull up found on SDA or SCL" unless those internal pulls are on, so:
    #   1) pre-enable the internal pull-ups on the pads, then try the fast hardware busio;
    #   2) if that still won't init, fall back to bitbangio, which drives the lines using the
    #      internal pulls inherently (slower, but always works on a pull-up-less board).
    try:
        for p in (P_SDA, P_SCL):
            d = digitalio.DigitalInOut(p); d.switch_to_input(pull=digitalio.Pull.UP); d.deinit()
    except Exception:
        pass
    try:
        return busio.I2C(P_SCL, P_SDA, frequency=400_000)
    except Exception as e:
        print("busio I2C unavailable (%s) - using bitbangio" % e)
        import bitbangio
        return bitbangio.I2C(P_SCL, P_SDA, frequency=400_000)

class App:
    def __init__(self):
        self.i2c = _make_i2c()
        while not self.i2c.try_lock(): pass            # the firmware owns the matrix bus for its lifetime
        self.mtx = Matrix(self.i2c)
        self.midi = usb_midi.ports[1] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 1) else None
        self.midi_in = usb_midi.ports[0] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 0) else None
        self._mbuf = bytearray(64); self.midi_host = False; self.last_midi_in = 0.0
        self._sx = bytearray(); self._sxon = False                 # USB-MIDI SysEx assembler
        self._fw = None; self._fw_n = 0; self._fw_pushing = False  # chunked firmware transfer state + bus-quiet flag
        # optional piezo (the Scroll Pack has no onboard speaker)
        if P_BUZZER is not None and pwmio is not None:
            self.spk = pwmio.PWMOut(P_BUZZER, frequency=1600, variable_frequency=True, duty_cycle=0)
        else:
            self.spk = None
        self._buzz_off = 0
        # buttons - active-low with internal pull-ups
        self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
        self.btnX = self._btn(P_BTNX); self.btnY = self._btn(P_BTNY)
        self._prev = {'A': True, 'B': True, 'X': True, 'Y': True}
        self._press = {'A': 0, 'B': 0}                 # press-start time for A/B tap-vs-hold
        self._held_t = {'X': 0, 'Y': 0}; self._next_rep = {'X': 0, 'Y': 0}   # tempo auto-repeat on held X/Y
        self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.bars = 0
        self.ramp = None; self.trainer = None; self._lastbar = -1; self._muted = False; self._ramp_base = 120
        self.rep = None; self.end = None               # per-track playback flow: rep=cycles, end=stop|next|+/-N goto
        self.continue_on = False; self._advance = False
        self._next_pending = None; self._seam_t = 0
        self.view = 0                                  # 0 = Grid, 1 = Pendulum, 2 = BPM (button A-hold cycles)
        self._beatflash = 0; self._beatflash_off = 0
        self._bpm_flash = 0                            # while set, render() briefly shows the BPM view (so X/Y nudges are visible in any view)
        self._beat_ns = 60_000_000_000 // self.bpm
        self._note_buf = bytearray([0x90, 0, 0])
        self._clock_byte = bytes([0xF8]); self._start_byte = bytes([0xFA]); self._stop_byte = bytes([0xFC])
        try:
            o = os.urandom(4); self._sync_origin = "d" + "".join("%02x" % b for b in o)
        except Exception:
            self._sync_origin = "d%08x" % (time.monotonic_ns() & 0xFFFFFFFF)
        self._sync_armed = False; self._sync_seq = 0; self._sync_applying = False
        self._sync_heartbeat_next = 0.0
        self._clock_next = 0; self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False
        self.lane_pads = []; self.lane_lit = []        # unused on the LED build; kept so live-sync guards stay valid
        self.usb_conn = False; self._m_steps = 0; self._seg_start = 0.0
        # practice log + settings
        self.can_write = self._probe_write(); self._load_settings()
        self.log = self._load_log(); self.play_start = None; self.play_bpm = 0; self.play_name = ""
        self.sl = 0; self.rebuild_setlists()
        self.dirty = True
        self.load(0)

    def _btn(self, pin):
        d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP
        return d

    # ---------- program / set lists ----------
    def rebuild_setlists(self):
        self.setlists = [{'title': t, 'items': it, 'builtin': True} for t, it in BUILTIN_SETLISTS]
        seen = set(_slkey(t) for t, _ in BUILTIN_SETLISTS)
        for t, it in load_user_setlists():
            if _slkey(t) in seen: continue
            seen.add(_slkey(t)); self.setlists.append({'title': t, 'items': it, 'builtin': False})
        if self.sl >= len(self.setlists): self.sl = 0
    def switch_setlist(self, delta=1):
        if len(self.setlists) < 2: return
        if self._sync_applying: return
        was = self.running
        if was: self.running = False; self._log_play()
        self.sl = (self.sl + delta) % len(self.setlists)
        self.load(0)
        if was: self.running = True; self._reset_clock(); self._start_play()
        self.dirty = True
        self._sync_broadcast("sel=%d/%d" % (self.sl, self.idx))
    def load(self, i):
        items = self.setlists[self.sl]['items']
        self.idx = i % len(items)
        self.name, prog = items[self.idx]
        self.bpm, self.lanes, self.bars, self.ramp, self.trainer, self.rep, self.end = parse_program(prog)
        self._beat_ns = 60_000_000_000 // max(1, self.bpm); self._rebuild_dur_all()
        self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
        self._next_pending = None
        self._reset_clock(); self.dirty = True
    def _prog_str(self):
        parts = ['t' + str(self.bpm)]
        if self.bars: parts.append('b' + str(self.bars))
        if self.ramp: parts.append('rmp%d/%d/%d' % (self.ramp.get('start', self.bpm), self.ramp['amt'], self.ramp['every']))
        if self.trainer: parts.append('tr%d/%d' % (self.trainer['play'], self.trainer['mute']))
        for L in self.lanes: parts.append(lane_to_str(L))
        if self.end is not None:
            if self.rep and self.rep > 1: parts.append('rep=' + str(self.rep))
            parts.append('end=' + ('stop' if self.end == 'stop' else 'next' if self.end == 1 else ('+%d' % self.end if self.end > 0 else str(self.end))))
        return ';'.join(parts)

    # ---------- per-lane step durations (cached tuple; no method call in tick) ----------
    def _rebuild_dur(self, L):
        beat = self._beat_ns
        sub = max(1, L['sub']); steps = max(1, L['steps'])
        if L.get('poly') and self.lanes:
            m = self.lanes[0]; master_bar = beat * (m['steps'] // max(1, m['sub']))
            d = master_bar // steps; L['durs'] = tuple(d for _ in range(steps))
        elif L.get('swing') and sub % 2 == 0:
            pair = beat // max(1, sub // 2); lng = (pair * 2) // 3; sht = pair // 3
            L['durs'] = tuple(lng if (s % sub) % 2 == 0 else sht for s in range(steps))
        else:
            d = beat // sub; L['durs'] = tuple(d for _ in range(steps))
    def _rebuild_dur_all(self):
        for L in self.lanes: self._rebuild_dur(L)
    def _reset_clock(self):
        now = time.monotonic_ns()
        for L in self.lanes:
            L['next'] = now; L['step'] = -1
        self._m_steps = 0; self._seg_start = time.monotonic()
    def _regen_levels(self, L):                       # remote lane= deltas recompute default accents
        sub = L['sub']; groups = L['groups']; starts = set(); acc = 0
        for gp in groups: starts.add(acc); acc += gp
        L['steps'] = sum(groups) * sub
        L['levels'] = [(2 if (i // sub) in starts else 1) if i % sub == 0 else 0 for i in range(L['steps'])]
    def _padbase(self, L, s):
        return 0 if L['mute'] else L['levels'][s]

    # ---------- audio (optional piezo only) ----------
    def click(self, level):
        if self.spk is None: return
        self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
        self.spk.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000)
        self._buzz_off = time.monotonic_ns() + 22_000_000

    # ---------- live sync (HELLO/FULL/DELTA/BYE on SysEx 0x40-0x43; see src/livesync.js) ----------
    def _sync_send(self, op, text):
        if self.midi is None: return
        b = bytearray((0xF0, 0x7D, op))
        for c in text:
            v = ord(c); b.append(v if v < 0x80 else 0x3F)
        b.append(0xF7)
        try: self.midi.write(b)
        except Exception: pass
    def _sync_broadcast(self, evt):
        if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
        text = "%s;%d;%s" % (self._sync_origin, self._sync_seq, evt); self._sync_seq += 1
        self._sync_send(0x42, text)
    def _sync_broadcast_full(self):
        if not self._sync_armed or self.midi is None or self._fw_pushing: return
        try: patch = self._prog_str()
        except Exception: return
        text = "%s;%d;%d;%d;%d;%s" % (self._sync_origin, self._sync_seq,
                                      1 if self.running else 0, self.sl, self.idx, patch)
        self._sync_seq += 1
        self._sync_send(0x41, text)
        self._sync_heartbeat_next = time.monotonic() + 5.0
    def _sync_apply_full(self, running, patch):
        self._sync_applying = True
        try:
            try:
                gc.collect()
                try: cur = self._prog_str()
                except Exception: cur = None
                if patch and patch != cur:
                    bpm, lanes, bars, ramp, trainer, rep, end = parse_program(patch)
                    self.bpm = bpm; self.lanes = lanes; self.bars = bars; self.ramp = ramp
                    self.trainer = trainer; self.rep = rep; self.end = end
                    self._beat_ns = 60_000_000_000 // max(1, bpm); self._rebuild_dur_all()
                    self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
                    self._reset_clock(); self.dirty = True
                if running and not self.running: self.toggle()
                elif (not running) and self.running: self.toggle()
            except Exception as e:
                try: print("sync FULL apply:", e)
                except Exception: pass
        finally:
            self._sync_applying = False
    def _sync_apply_delta(self, evt):
        self._sync_applying = True
        try:
            eq = evt.find('=')
            key = evt if eq < 0 else evt[:eq]
            val = '' if eq < 0 else evt[eq+1:]
            if key == 'play':
                if not self.running: self.toggle()
            elif key == 'stop':
                if self.running: self.toggle()
            elif key == 'bpm':
                try: self.set_bpm(int(val))
                except Exception: pass
            elif key == 'sel':
                p = val.split('/')
                if len(p) == 2:
                    try:
                        sl = int(p[0]); item = int(p[1])
                        if sl >= 0 and item >= 0:
                            if sl < len(self.setlists) and sl != self.sl: self.sl = sl
                            items = self.setlists[self.sl]['items']
                            if 0 <= item < len(items) and item != self.idx: self.goto(item)
                    except Exception: pass
            elif key == 'beat':                     # PM_G-1 doesn't EMIT beat= (no on-device editing) but DOES apply
                p = val.split('/')
                if len(p) == 3:
                    try:
                        li = int(p[0]); s = int(p[1]); lvl = int(p[2])
                        if 0 <= li < len(self.lanes):
                            L = self.lanes[li]
                            if 0 <= s < len(L['levels']):
                                L['levels'][s] = lvl & 3; self.dirty = True
                    except Exception: pass
            elif key == 'lane':                     # apply but don't emit
                p = val.split('/')
                if len(p) >= 3:
                    try:
                        li = int(p[0]); field = p[1]; v = '/'.join(p[2:])
                        if 0 <= li < len(self.lanes):
                            L = self.lanes[li]; structural = False
                            if   field == 'sound':   L['sound'] = v
                            elif field == 'groups':
                                try: L['groups'] = [int(x) for x in v.split('+')]; structural = True
                                except Exception: pass
                            elif field == 'sub':
                                try: L['sub'] = int(v); structural = True
                                except Exception: pass
                            elif field == 'swing':   L['swing'] = (v == '1'); structural = True
                            elif field == 'enabled': L['mute'] = not (v == '1')
                            elif field == 'gain':
                                try: L['gain'] = int(v)
                                except Exception: pass
                            elif field == 'poly':    L['poly'] = (v == '1'); structural = True
                            if structural: self._regen_levels(L)
                            if li == 0 and structural: self._rebuild_dur_all()
                            else: self._rebuild_dur(L)
                            self.dirty = True
                    except Exception: pass
        finally:
            self._sync_applying = False

    def midi_send(self, note, vel):
        if self.midi is None or self._fw_pushing: return            # keep the bus quiet during a firmware push
        b = self._note_buf
        b[0] = 0x90 | ((MIDI_CHANNEL - 1) & 0x0F)
        b[1] = note & 0x7F; b[2] = vel & 0x7F
        try: self.midi.write(b)
        except Exception: pass

    # ---------- transport ----------
    def toggle(self):
        self.running = not self.running
        if self.running:
            self._reset_clock(); self._start_play(); self._clock_next = time.monotonic_ns()
            if MIDI_CLOCK_OUT and MIDI_CLOCK_OUT_TRANSPORT and self.midi is not None:
                try: self.midi.write(self._start_byte)
                except Exception: pass
        else:
            if self.spk: self.spk.duty_cycle = 0
            self._log_play()
            if MIDI_CLOCK_OUT and MIDI_CLOCK_OUT_TRANSPORT and self.midi is not None:
                try: self.midi.write(self._stop_byte)
                except Exception: pass
        self.dirty = True
        self._sync_broadcast("play" if self.running else "stop")
    def set_bpm(self, v):
        v = max(5, min(300, v))
        if v != self.bpm:
            self.bpm = v; self._beat_ns = 60_000_000_000 // v
            self._rebuild_dur_all(); self.dirty = True
            self._bpm_flash = time.monotonic() + 0.7      # flash the tempo so the nudge is visible even in Grid/Pendulum
            self._sync_broadcast("bpm=%d" % v)
    def goto(self, i):
        was = self.running
        if was: self.running = False; self._log_play()
        self.load(i)
        if was: self.running = True; self._reset_clock(); self._start_play()
        self.dirty = True
        self._sync_broadcast("sel=%d/%d" % (self.sl, self.idx))
    def tap(self):
        now = time.monotonic()
        if not hasattr(self, '_taps'): self._taps = []
        self._taps = [t for t in self._taps if now - t < 2.4]
        self._taps.append(now)
        if len(self._taps) >= 2:
            span = (self._taps[-1] - self._taps[0]) / (len(self._taps) - 1)
            if span > 0: self.set_bpm(round(60 / span))
    def cycle_view(self):
        self.view = (self.view + 1) % 3; self.dirty = True

    # ---------- scheduler (timing identical to the Explorer; display calls replaced by self.dirty) ----------
    def tick(self):
        now = time.monotonic_ns()
        if self._buzz_off and now >= self._buzz_off:
            if self.spk: self.spk.duty_cycle = 0
            self._buzz_off = 0
        if self._slaved and (now - self._clock_in_last_t) > 1_000_000_000: self._slaved = False
        if self.running:
            fired_best = 0; fired_prio = -1
            for li, L in enumerate(self.lanes):
                if self._advance: break
                adv = False
                while now >= L['next']:
                    L['step'] = (L['step'] + 1) % L['steps']
                    if li == 0:
                        self._m_steps += 1
                        nb = (self._m_steps - 1) // L['steps']
                        if nb != self._lastbar: self._lastbar = nb; self._on_new_bar(nb)
                        if self._advance: break
                        if self.ramp and L['steps'] > 0 and not self._slaved:
                            mlen = L['steps']; bar_pos = self._m_steps / mlen
                            seg_bar = (bar_pos % self.bars) if self.bars else bar_pos
                            new_bpm = max(5, min(300, int(self._ramp_base + seg_bar / self.ramp['every'] * self.ramp['amt'])))
                            if new_bpm != self.bpm:
                                self.bpm = new_bpm; self._beat_ns = 60_000_000_000 // new_bpm; self._rebuild_dur_all()
                    lvl = 0 if L['mute'] else L['levels'][L['step']]
                    if lvl > 0:
                        p = PRIO.get(lvl, 0)
                        if p > fired_prio: fired_prio = p; fired_best = lvl
                        if not self._muted:
                            self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90))
                    L['next'] += L['durs'][L['step']]; adv = True
                if adv: self.dirty = True
            if fired_best and not self._muted:
                self._beatflash = fired_best; self._beatflash_off = now + 70_000_000
                if not MUTE_SPEAKER and not (SPEAKER_AUTO_MUTE and self.midi_host):
                    self.click(fired_best)
                self.dirty = True
            if self._advance:
                self._advance = False; self._do_advance()
        if self.running and MIDI_CLOCK_OUT and self.midi is not None and not self._slaved and not self._fw_pushing:
            clk = self._clock_byte; tick_ns = self._beat_ns // 24
            while now >= self._clock_next:
                try: self.midi.write(clk)
                except Exception: pass
                self._clock_next += tick_ns
    def _end_plan(self):
        end = self.end
        if end is None:
            if self.continue_on and self.bars: end = 1
            else: return None
        cyc = self.bars if self.bars else 1
        reps = self.rep if self.rep else 1
        return (cyc * reps, end)
    def _goto_target(self, offset):
        items = self.setlists[self.sl]['items']; n = len(items)
        t = self.idx + offset
        return 0 if t < 0 else (t % n if t >= n else t)
    def _end_stop(self):
        self.running = False
        if self.spk: self.spk.duty_cycle = 0
        self._log_play(); self.dirty = True; self._sync_broadcast("stop")
    def _on_new_bar(self, bar):
        plan = self._end_plan()
        if plan is not None and plan[1] != 'stop' and self._next_pending is None and bar == plan[0] - 1:
            self._prepare_next(self._goto_target(plan[1]))
        if self.bars and bar > 0 and bar % self.bars == 0:
            self._seg_start = time.monotonic()
        if plan is not None and bar > 0 and bar == plan[0]:
            action = plan[1]
            if not (self.bars and bar % self.bars == 0): self._seg_start = time.monotonic()
            if action == 'stop':
                self._end_stop()
            else:
                if self._next_pending is None: self._prepare_next(self._goto_target(action))
                if self._next_pending is not None:
                    self._seam_t = self.lanes[0]['next']
                    self._advance = True
        t = self.trainer
        self._muted = bool(t and (t['play'] + t['mute']) and (bar % (t['play'] + t['mute'])) >= t['play'])
    def _prepare_next(self, target=None):
        items = self.setlists[self.sl]['items']
        nxt = (self.idx + 1) % len(items) if target is None else target
        if nxt == self.idx: return
        name, prog = items[nxt]
        gc.collect()
        try:
            bpm, lanes, bars, ramp, trainer, rep, end = parse_program(prog)
        except MemoryError:
            gc.collect(); return
        beat = 60_000_000_000 // max(1, bpm)
        for L in lanes:
            sub = max(1, L['sub']); steps = max(1, L['steps'])
            if L.get('poly'):
                m = lanes[0]; mbar = beat * (m['steps'] // max(1, m['sub']))
                d = mbar // steps; L['durs'] = tuple(d for _ in range(steps))
            elif L.get('swing') and sub % 2 == 0:
                pair = beat // max(1, sub // 2); lng = (pair * 2) // 3; sht = pair // 3
                L['durs'] = tuple(lng if (s % sub) % 2 == 0 else sht for s in range(steps))
            else:
                d = beat // sub; L['durs'] = tuple(d for _ in range(steps))
        self._next_pending = {'lanes': lanes, 'bpm': bpm, 'bars': bars, 'ramp': ramp,
                              'trainer': trainer, 'name': name, 'idx': nxt, 'rep': rep, 'end': end}
    def _do_advance(self):
        n = self._next_pending
        if n is None: return
        self._next_pending = None
        self.lanes = n['lanes']; self.bpm = n['bpm']; self.bars = n['bars']
        self.ramp = n['ramp']; self.trainer = n['trainer']; self.name = n['name']; self.idx = n['idx']
        self.rep = n['rep']; self.end = n['end']
        self._beat_ns = 60_000_000_000 // max(1, self.bpm); self._rebuild_dur_all()
        self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False; self._m_steps = 0
        seam = self._seam_t
        for L in self.lanes: L['next'] = seam; L['step'] = -1
        self._seg_start = time.monotonic(); self.dirty = True

    # ---------- inputs (4 buttons, active-low) ----------
    # A: tap = play/stop, hold (>=600ms) = cycle view.   B: tap = next track, hold = next set list.
    # X / Y: tempo down / up (tap = +-1, auto-repeat while held, +-5 after 1.5s).
    def poll(self):
        now = time.monotonic_ns()
        a = self.btnA.value; b = self.btnB.value; x = self.btnX.value; y = self.btnY.value
        if (not a) and self._prev['A']: self._press['A'] = now
        if a and (not self._prev['A']):
            if now - self._press['A'] >= 600_000_000: self.cycle_view()
            else: self.toggle()
        if (not b) and self._prev['B']: self._press['B'] = now
        if b and (not self._prev['B']):
            if now - self._press['B'] >= 600_000_000: self.switch_setlist(1)
            else: self.goto(self.idx + 1)
        if (not x) and self._prev['X']:
            self._held_t['X'] = now; self._next_rep['X'] = now + 350_000_000; self.set_bpm(self.bpm - 1)
        elif (not x) and (not self._prev['X']) and now >= self._next_rep['X']:
            self._next_rep['X'] = now + 120_000_000
            self.set_bpm(self.bpm + (-5 if (now - self._held_t['X']) > 1_500_000_000 else -1))
        if (not y) and self._prev['Y']:
            self._held_t['Y'] = now; self._next_rep['Y'] = now + 350_000_000; self.set_bpm(self.bpm + 1)
        elif (not y) and (not self._prev['Y']) and now >= self._next_rep['Y']:
            self._next_rep['Y'] = now + 120_000_000
            self.set_bpm(self.bpm + (5 if (now - self._held_t['Y']) > 1_500_000_000 else 1))
        self._prev['A'] = a; self._prev['B'] = b; self._prev['X'] = x; self._prev['Y'] = y
        # USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx
        if self.midi_in is not None:
            try: n = self.midi_in.readinto(self._mbuf)
            except Exception: n = 0
            if n:
                self.last_midi_in = time.monotonic(); self._feed_midi(self._mbuf, n)
            host = bool(self.last_midi_in) and (time.monotonic() - self.last_midi_in) < 1.0
            if host != self.midi_host:
                self.midi_host = host
                if host and SPEAKER_AUTO_MUTE and self.spk: self.spk.duty_cycle = 0

    # ---------- LED rendering ----------
    def _lvl_bright(self, lvl):
        if lvl == 2: return BRIGHTNESS                      # accent
        if lvl == 1: return max(8, BRIGHTNESS // 4)         # normal
        if lvl == 3: return max(3, BRIGHTNESS // 16)        # ghost
        return 0
    def _splash(self, text):
        # Scroll text across the matrix once at boot, right-to-left, as a 3x5 font on rows 1..5.
        # Doubles as a liveness + pixel-map check: if "PM-G1 GRID" reads correctly, the firmware
        # booted and the LED mapping is right.
        cols = []
        for ch in text:
            g = DIGITS.get(ch) or LETTERS.get(ch.upper()) or LETTERS.get(' ')
            for cx in range(3):
                c = 0
                for ry in range(5):
                    if g[ry] & (1 << (2 - cx)): c |= (1 << ry)
                cols.append(c)
            cols.append(0)                              # 1px gap between glyphs
        n = len(cols); m = self.mtx; off = -16
        while off < n:
            m.clear()
            for x in range(17):
                ci = x + off
                if 0 <= ci < n:
                    c = cols[ci]
                    for ry in range(5):
                        if c & (1 << ry): m.set(x, ry + 1, BRIGHTNESS)
            m.show(); time.sleep(0.05); off += 1
    def render(self):
        self.mtx.clear()
        v = self.view
        if v != 2 and self._bpm_flash and time.monotonic() < self._bpm_flash: v = 2   # transient tempo readout
        if v == 2: self._render_bpm()
        elif v == 1: self._render_pendulum()
        else: self._render_grid()
        self.mtx.show()
    def _render_grid(self):
        m = self.mtx; lanes = self.lanes
        n = min(len(lanes), 7)
        if n == 0: return
        y0 = (7 - n) // 2                                   # centre the lanes vertically
        for li in range(n):
            L = lanes[li]; steps = max(1, L['steps']); y = y0 + li
            lit = L['step'] if self.running else -1
            off = (17 - steps) // 2 if steps <= 17 else 0
            for s in range(steps):
                col = (s + off) if steps <= 17 else (s * 17) // steps
                lvl = 0 if L['mute'] else L['levels'][s]
                if s == lit:
                    val = 255 if lvl else 70                # playhead: bright on a hit, a dim travelling dot on a rest
                else:
                    val = self._lvl_bright(lvl)
                if val and val > m.get(col, y): m.set(col, y, val)
    def _render_pendulum(self):
        m = self.mtx
        if not self.lanes: return
        L = self.lanes[0]; steps = max(1, L['steps'])
        sub = max(1, L['sub']); beats = max(1, steps // sub)
        frac = (((self._m_steps - 1) % steps) / steps) if self.running else 0.0
        tri = frac * 2 if frac < 0.5 else 2 * (1 - frac)    # bounce 0..1..0 across the bar (metronome arm)
        col = int(tri * 16 + 0.5)
        flash = self._beatflash if (self._beatflash and time.monotonic_ns() < self._beatflash_off) else 0
        val = 255 if flash == 2 else (150 if flash else 90)
        for y in range(7): m.set(col, y, val)
        for bi in range(beats):                              # faint beat ticks along the bottom edge
            bc = (bi * 17) // beats
            if m.get(bc, 6) < 24: m.set(bc, 6, 24)
    def _render_bpm(self):
        m = self.mtx; s = str(self.bpm)[-3:]
        w = len(s) * 4 - 1; x0 = (17 - w) // 2; y0 = 1
        val = BRIGHTNESS if self.running else max(20, BRIGHTNESS // 2)
        for i, ch in enumerate(s):
            g = DIGITS.get(ch)
            if not g: continue
            bx = x0 + i * 4
            for ry in range(5):
                row = g[ry]
                for rx in range(3):
                    if row & (1 << (2 - rx)): m.set(bx + rx, y0 + ry, val)

    # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs + live-sync) ----------
    def _feed_midi(self, buf, n):
        now_ns = time.monotonic_ns() if MIDI_CLOCK_IN else 0
        for i in range(n):
            b = buf[i]
            if b == 0xF0: self._sx = bytearray(); self._sxon = True
            elif b == 0xF7:
                if self._sxon: self._handle_sysex(self._sx)
                self._sxon = False
            elif b == 0xF8 and MIDI_CLOCK_IN: self._slave_tick(now_ns)
            elif b == 0xFA and MIDI_CLOCK_IN_TRANSPORT and MIDI_CLOCK_IN: self._slave_start()
            elif b == 0xFB and MIDI_CLOCK_IN_TRANSPORT and MIDI_CLOCK_IN: self._slave_start()
            elif b == 0xFC and MIDI_CLOCK_IN_TRANSPORT and MIDI_CLOCK_IN: self._slave_stop()
            elif b >= 0xF8: pass
            elif self._sxon:
                if len(self._sx) < 60000: self._sx.append(b)
                else: self._sxon = False
    def _slave_tick(self, now_ns):
        if self._clock_in_last_t == 0:
            self._clock_in_last_t = now_ns; self._slaved = True; return
        interval = now_ns - self._clock_in_last_t
        self._clock_in_last_t = now_ns
        if interval < 8_300_000 or interval > 500_000_000: return
        if self._clock_in_avg == 0: self._clock_in_avg = interval
        else: self._clock_in_avg = (self._clock_in_avg * 7 + interval) // 8
        new_bpm = max(5, min(300, int(60_000_000_000 // (self._clock_in_avg * 24))))
        if new_bpm != self.bpm:
            self.bpm = new_bpm; self._beat_ns = 60_000_000_000 // new_bpm; self._rebuild_dur_all()
        self._slaved = True
    def _slave_start(self):
        if not self.running:
            self.running = True; self._reset_clock(); self._start_play(); self.dirty = True
        self._clock_in_last_t = 0; self._clock_in_avg = 0
    def _slave_stop(self):
        if self.running:
            self.running = False
            if self.spk: self.spk.duty_cycle = 0
            self._log_play(); self.dirty = True
        self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False
    def _handle_sysex(self, sx):
        if len(sx) < 2 or sx[0] != 0x7D: return
        cmd = sx[1]
        if cmd == 0x01 and len(sx) >= 8 and rtc is not None:
            try: rtc.RTC().datetime = time.struct_time((2000 + sx[2], sx[3], sx[4], sx[5], sx[6], sx[7], 0, -1, -1))
            except Exception: pass
        elif cmd == 0x02:                              # version query -> reply 0x03 + "G;<APP_VERSION>"
            if self.midi:
                payload = DEVICE_ID + ";" + APP_VERSION
                self.midi.write(bytes([0xF0, 0x7D, 0x03]) + payload.encode() + bytes([0xF7]))
        elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43:
            try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
            except Exception: return
            origin = text.split(";", 1)[0] if text else ""
            if origin == self._sync_origin: return
            self._sync_armed = True
            if cmd == 0x40:
                self._sync_broadcast_full()
            elif cmd == 0x43:
                self._sync_armed = False
            elif cmd == 0x41:
                parts = text.split(";", 5)
                if len(parts) >= 6:
                    try:
                        running = parts[2] == "1"; patch = parts[5]
                        self._sync_apply_full(running, patch)
                    except Exception: pass
            elif cmd == 0x42:
                parts = text.split(";", 2)
                if len(parts) >= 3: self._sync_apply_delta(parts[2])
        elif cmd == 0x10:
            try:
                with open("/programs.json", "wb") as f: f.write(bytes(sx[2:]))
                self.rebuild_setlists(); self.load(0)
                self._ack(True)
            except Exception:
                self._ack(False)
        elif cmd == 0x21:
            try:
                try: self._fw.close()
                except Exception: pass
                self._fw = open("/app.new", "wb"); self._fw_n = 0
                self._fw_pushing = True
                self._ack(True)
            except Exception:
                self._fw = None; self._fw_pushing = False; self._ack(False)
        elif cmd == 0x22:
            try:
                if self._fw is None or a2b_base64 is None: raise OSError()
                self._fw.write(a2b_base64(bytes(sx[2:])))
                self._fw.flush(); self._fw_n += 1
                gc.collect()
                self._ack(True)
            except Exception:
                try: self._fw.close()
                except Exception: pass
                self._fw = None; self._fw_pushing = False; self._ack(False)
        elif cmd == 0x23:
            try:
                try: self._fw.close()
                except Exception: pass
                self._fw = None; gc.collect()
                with open("/app.new", "rb") as f: head = f.read(2)
                if os.stat("/app.new")[6] < 4000 or len(head) < 2 or head[0] != 0x43 or head[1] != 0x06:
                    try: os.remove("/app.new")
                    except OSError: pass
                    self._fw_pushing = False; self._ack(False); return
                try: os.remove("/app.bak")
                except OSError: pass
                os.rename("/app.mpy", "/app.bak")
                os.rename("/app.new", "/app.mpy")
                open("/trial", "w").close()
                self._fw_pushing = False
                self._ack(True); time.sleep(0.4); supervisor.reload()
            except Exception:
                self._fw_pushing = False; self._ack(False)
    def _ack(self, ok):
        if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F if ok else 0x7E, 0xF7]))

    # ---------- practice log (saved to /history.json) ----------
    def _probe_write(self):
        try:
            with open("/.wtest", "w") as f: f.write("1")
            try: os.remove("/.wtest")
            except Exception: pass
            return True
        except OSError:
            return False
    def _load_settings(self):
        global MUTE_SPEAKER, SPEAKER_AUTO_MUTE, MIDI_ENABLED, MIDI_CHANNEL, MIDI_CLOCK_OUT, MIDI_CLOCK_IN, BRIGHTNESS
        try:
            with open("/settings.json") as f: d = json.load(f)
        except Exception: return
        try:
            sm = d.get("speaker", "auto")
            MUTE_SPEAKER = (sm == "off"); SPEAKER_AUTO_MUTE = (sm == "auto")
            MIDI_ENABLED   = bool(d.get("midi_out", MIDI_ENABLED))
            MIDI_CHANNEL   = max(1, min(16, int(d.get("midi_channel", MIDI_CHANNEL))))
            MIDI_CLOCK_OUT = bool(d.get("clock_out", MIDI_CLOCK_OUT))
            MIDI_CLOCK_IN  = bool(d.get("clock_in", MIDI_CLOCK_IN))
            BRIGHTNESS     = max(16, min(255, int(d.get("brightness", BRIGHTNESS))))
        except Exception as e: print("settings:", e)
    def _load_log(self):
        try:
            with open("/history.json") as f: return json.load(f).get("log", [])
        except Exception:
            return []
    def _save_log(self):
        if not self.can_write: return
        try:
            with open("/history.json", "w") as f: json.dump({"log": self.log[:200]}, f)
        except OSError:
            self.can_write = False
    def _start_play(self):
        self.play_start = time.monotonic(); self.play_bpm = self.bpm; self.play_name = self.name
    def _log_play(self):
        if self.play_start is None: return
        dur = int(time.monotonic() - self.play_start); self.play_start = None
        if dur < MIN_LOG_SEC: return
        mlen = self.lanes[0]['steps'] if self.lanes else 1
        t = time.localtime()
        self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
                            "dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name})
        del self.log[200:]
        self._save_log()

    def run(self):
        boot = time.monotonic()
        try: os.stat("/trial"); committed = False
        except OSError: committed = True
        try: self._splash("PM-G1 GRID")               # boot banner (scrolls once); wrapped so a splash bug never blocks the app
        except Exception: pass
        next_frame = 0.0
        while True:
            try:
                self.tick(); self.poll()
                tnow = time.monotonic()
                if not committed and tnow - boot > 5:
                    try: os.remove("/trial")
                    except Exception: pass
                    committed = True
                if self._sync_armed and tnow >= self._sync_heartbeat_next:
                    self._sync_broadcast_full()
                if self.running and tnow >= next_frame:      # keep pendulum/playhead moving even with no input
                    self.dirty = True; next_frame = tnow + 0.04
                if self._bpm_flash:                          # keep rendering through the tempo flash, then one frame to revert
                    if tnow >= self._bpm_flash: self._bpm_flash = 0
                    self.dirty = True
                if self.dirty:
                    self.dirty = False; self.render()
                time.sleep(0.0005)
            except MemoryError:
                gc.collect(); time.sleep(0.05)
            except Exception as e:
                try: print("tick error:", e)
                except Exception: pass
                time.sleep(0.05)

App().run()
