#!/usr/bin/env bash
# Plug & Pass - macOS configurator (single file).
# Updates the password + settings on a Waveshare RP2040-Zero running CircuitPython
# by generating a complete code.py and overwriting it on the CIRCUITPY drive.
# No recovery here -- settings/password only.

KEY="PlugAndPass_xor_2026"   # must match the exe / web / firmware

# ── keyboard layouts (index-aligned: name | module | class) ──────────────────
LAY_NAME=(  "US English" "UK English" "German (DE)" "French (FR)" "French (Mac)" \
            "Spanish (ES)" "Italian (IT)" "Portuguese (PT)" "Brazilian (BR)" \
            "Swedish (SV)" "Danish (DA)" "Hungarian (HU)" "Czech (CZ)" \
            "Czech Alt (CZ1)" "Turkish (TR)" "US Dvorak" )
LAY_MODULE=( keyboard_layout_us keyboard_layout_win_uk keyboard_layout_win_de \
            keyboard_layout_win_fr keyboard_layout_mac_fr keyboard_layout_win_es \
            keyboard_layout_win_it keyboard_layout_win_po keyboard_layout_win_br \
            keyboard_layout_win_sw keyboard_layout_win_da keyboard_layout_win_hu \
            keyboard_layout_win_cz keyboard_layout_win_cz1 keyboard_layout_win_tr \
            keyboard_layout_us_dvo )
LAY_CLASS=( KeyboardLayoutUS KeyboardLayout KeyboardLayout KeyboardLayout KeyboardLayout \
            KeyboardLayout KeyboardLayout KeyboardLayout KeyboardLayout KeyboardLayout \
            KeyboardLayout KeyboardLayout KeyboardLayout KeyboardLayout KeyboardLayout \
            KeyboardLayout )
NLAY=${#LAY_NAME[@]}

# ── find the CIRCUITPY drive (macOS mounts under /Volumes) ────────────────────
find_device() {
    local v
    for v in /Volumes/*; do
        [ -d "$v" ] && [ -f "$v/boot_out.txt" ] && { printf '%s' "$v"; return 0; }
    done
    return 1
}

# ── light XOR+hex obfuscation (same scheme as the other tools) ───────────────
xor_to_hex() {                 # text on stdin -> hex
    local klen=${#KEY} i=0 b kc h out="" bytes
    bytes=$(od -An -v -tu1)
    for b in $bytes; do
        printf -v kc '%d' "'${KEY:$((i%klen)):1}"
        printf -v h '%02x' $(( b ^ kc ))
        out="$out$h"; i=$((i+1))
    done
    printf '%s' "$out"
}
hex_to_text() {                # $1 hex -> text bytes on stdout
    local hex="$1" klen=${#KEY} i=0 j byte kc
    for (( j=0; j+1 < ${#hex}; j+=2 )); do
        byte=$(( 16#${hex:$j:2} ))
        printf -v kc '%d' "'${KEY:$((i%klen)):1}"
        printf "\\$(printf '%03o' $(( byte ^ kc )))"
        i=$((i+1))
    done
}

# ── read existing code.py into CUR_* defaults ────────────────────────────────
load_existing() {
    CUR_PW=""; CUR_ENC=0; CUR_LAYOUT=0; CUR_STARTUP=1.6; CUR_TYPING=0.6; CUR_HAS=0; CUR_AUTO=1; CUR_LEDS=1
    CUR_MACRO_LINE=""; CUR_MBTN_LINE=""; CUR_MAUTO_LINE=""
    local f="$1" c raw s ty mod i
    [ -f "$f" ] || return
    CUR_HAS=1
    c=$(cat "$f")
    printf '%s\n' "$c" | grep '#ENC' | grep -q 'True' && CUR_ENC=1
    raw=$(printf '%s\n' "$c" | grep '#PW' | head -1 | sed -n 's/.*PASSWORD = "\(.*\)"  *#PW.*/\1/p')
    if [ "$CUR_ENC" = "1" ]; then
        CUR_PW=$(hex_to_text "$raw")
    else
        CUR_PW=$(printf '%s' "$raw" | sed 's/\\"/"/g; s/\\\\/\\/g')
    fi
    s=$(printf '%s\n' "$c" | grep '#TIMESLEEP' | sed -n 's/.*time\.sleep(\([0-9.][0-9.]*\)).*/\1/p')
    [ -n "$s" ] && CUR_STARTUP=$s
    ty=$(printf '%s\n' "$c" | awk '/kbd = Keyboard/{getline; print; exit}' | sed -n 's/.*time\.sleep(\([0-9.][0-9.]*\)).*/\1/p')
    [ -n "$ty" ] && CUR_TYPING=$ty
    mod=$(printf '%s\n' "$c" | grep '#KEYBOARD1' | sed -n 's/.*from adafruit_hid\.\([a-z0-9_][a-z0-9_]*\) import.*/\1/p')
    if [ -n "$mod" ]; then for (( i=0; i<NLAY; i++ )); do [ "${LAY_MODULE[$i]}" = "$mod" ] && CUR_LAYOUT=$i && break; done; fi
    case "$(printf '%s\n' "$c" | grep '#TRIGGER')" in *'"button"'*) CUR_AUTO=0;; *) CUR_AUTO=1;; esac
    printf '%s\n' "$c" | grep '#LEDS' | grep -q 'False' && CUR_LEDS=0 || CUR_LEDS=1
    # preserve any macro embedded by the exe/web editor (this CLI doesn't edit macros)
    CUR_MACRO_LINE=$(printf '%s\n' "$c" | grep '#MACRO$' | head -1)
    CUR_MBTN_LINE=$(printf '%s\n' "$c" | grep '#MACROBTN' | head -1)
    CUR_MAUTO_LINE=$(printf '%s\n' "$c" | grep '#MACROAUTO' | head -1)
}

# ── build the full code.py to stdout ─────────────────────────────────────────
# ── the macro-capable firmware template ──────────────────────────────────────
# The heredoc body is AUTO-GENERATED from firmware_macro/code.py by
# tools/gen_web_assets.py. It is a *quoted* heredoc (<<'PPEOF') so the firmware's
# own $ and backticks are emitted literally; generate_code substitutes the tagged
# lines afterwards. Do not hand-edit between the GEN-FW markers.
fw_template() {
# >>>GEN-FW>>>
cat <<'PPEOF'
import time
import usb_hid
import digitalio
import microcontroller
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode

# ---- settings (the configurator writes these) -------------------------------
PASSWORD = ""  #PW
ENCRYPTED = False  #ENC
TRIGGER_MODE = "both"  #TRIGGER
LEDS = True  #LEDS
# Macro: a Plug & Pass macro script (one command per line, \n-separated).
MACRO = ""  #MACRO
MACRO_BUTTON = True  #MACROBTN   # run the macro when the BOOT button is pressed
MACRO_AUTO = False  #MACROAUTO   # also run the macro on plug-in / RESET

# Convenience for testing: if no macro is embedded above, use a macro.txt on the
# drive if one is present (e.g. straight from the importer). The configurator
# normally embeds the macro into MACRO above instead.
if not MACRO.strip():
    try:
        with open("macro.txt") as _mf:
            MACRO = _mf.read()
    except Exception:
        pass

if ENCRYPTED and PASSWORD:
    _raw = bytes.fromhex(PASSWORD)
    _key = b"PlugAndPass_xor_2026"
    _dec = bytearray(len(_raw))
    for _i in range(len(_raw)):
        _dec[_i] = _raw[_i] ^ _key[_i % len(_key)]
    PASSWORD = _dec.decode("utf-8")

# wait for USB to be ready
time.sleep(1.6) #TIMESLEEP
kbd = Keyboard(usb_hid.devices)
time.sleep(0.6)

from adafruit_hid.keyboard_layout_us import KeyboardLayoutUS #KEYBOARD1
layout = KeyboardLayoutUS(kbd) #KEYBOARD2

_led = None
# True once a macro has explicitly driven the LED (via an LED command). While set,
# the idle "breathing" animation is suppressed so the macro's final LED state
# (e.g. LED OFF) persists instead of being overwritten.
_led_touched = [False]
if LEDS:
    try:
        import neopixel
        import board
        _led = neopixel.NeoPixel(board.GP16, 1, brightness=0.3)
    except Exception:
        _led = None

# Optional HID devices used by the macro mouse / media commands. Each is guarded
# so a unit that somehow lacks the library still types the password normally —
# the unsupported command simply does nothing.
_mouse = None
try:
    from adafruit_hid.mouse import Mouse
    _mouse = Mouse(usb_hid.devices)
except Exception:
    _mouse = None

_cc = None
_MEDIA = {}
try:
    from adafruit_hid.consumer_control import ConsumerControl
    from adafruit_hid.consumer_control_code import ConsumerControlCode as _CCC
    _cc = ConsumerControl(usb_hid.devices)
    _MEDIA = {"VOLUP": _CCC.VOLUME_INCREMENT, "VOLUMEUP": _CCC.VOLUME_INCREMENT,
              "VOLDOWN": _CCC.VOLUME_DECREMENT, "VOLUMEDOWN": _CCC.VOLUME_DECREMENT,
              "MUTE": _CCC.MUTE, "PLAYPAUSE": _CCC.PLAY_PAUSE,
              "NEXT": _CCC.SCAN_NEXT_TRACK, "PREV": _CCC.SCAN_PREVIOUS_TRACK,
              "PREVIOUS": _CCC.SCAN_PREVIOUS_TRACK, "STOP": _CCC.STOP,
              "BRIGHTUP": _CCC.BRIGHTNESS_INCREMENT, "BRIGHTDOWN": _CCC.BRIGHTNESS_DECREMENT}
except Exception:
    _cc = None
    _MEDIA = {}

try:
    import random as _random
except Exception:
    _random = None


def type_password():
    if PASSWORD:
        layout.write(PASSWORD)
        kbd.send(Keycode.ENTER)


# >>> MACRO INTERPRETER >>>  (host-testable; see tools/test_macro.py)
_MMOD = {"CTRL": Keycode.CONTROL, "CONTROL": Keycode.CONTROL, "SHIFT": Keycode.SHIFT,
         "ALT": Keycode.ALT, "GUI": Keycode.GUI, "WIN": Keycode.GUI, "WINDOWS": Keycode.GUI,
         "META": Keycode.GUI, "CMD": Keycode.GUI}
_MKEY = {"ENTER": Keycode.ENTER, "RETURN": Keycode.ENTER, "TAB": Keycode.TAB,
         "SPACE": Keycode.SPACEBAR, "ESC": Keycode.ESCAPE, "ESCAPE": Keycode.ESCAPE,
         "BACKSPACE": Keycode.BACKSPACE, "DELETE": Keycode.DELETE, "INSERT": Keycode.INSERT,
         "HOME": Keycode.HOME, "END": Keycode.END, "PAGEUP": Keycode.PAGE_UP,
         "PAGEDOWN": Keycode.PAGE_DOWN, "UP": Keycode.UP_ARROW, "DOWN": Keycode.DOWN_ARROW,
         "LEFT": Keycode.LEFT_ARROW, "RIGHT": Keycode.RIGHT_ARROW, "CAPSLOCK": Keycode.CAPS_LOCK,
         "PRINTSCREEN": Keycode.PRINT_SCREEN, "MENU": Keycode.APPLICATION, "APP": Keycode.APPLICATION}
for _c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
    _MKEY[_c] = getattr(Keycode, _c)
for _n, _w in zip("0123456789", ["ZERO", "ONE", "TWO", "THREE", "FOUR",
                                 "FIVE", "SIX", "SEVEN", "EIGHT", "NINE"]):
    _MKEY[_n] = getattr(Keycode, _w)
for _i in range(1, 13):
    _MKEY["F" + str(_i)] = getattr(Keycode, "F" + str(_i))


# functions/constants available inside expressions ($x + 5, chr($n), 2 ** 8, ...)
_FUNCS = {"chr": chr, "ord": ord, "int": int, "str": str, "float": float,
          "len": len, "hex": hex, "oct": oct, "bin": bin, "abs": abs,
          "min": min, "max": max, "round": round, "pow": pow,
          "True": True, "False": False, "true": True, "false": False}
if _random is not None:
    _FUNCS["rand"] = _random.randint
_LIMIT = 200000          # max keystroke actions, to stop a runaway loop
_count = [0]
_default_delay = [0]     # ms auto-inserted after every line (DEFAULTDELAY)


def _strip_dollar(e):
    # turn $name into name so it resolves as a variable inside an expression
    out = ""
    i = 0
    while i < len(e):
        if e[i] == "$" and i + 1 < len(e) and (e[i + 1].isalpha() or e[i + 1] == "_"):
            i += 1
            continue
        out += e[i]
        i += 1
    return out


def _eval(expr, V):
    try:
        return eval(_strip_dollar(expr.strip()), _FUNCS, V)
    except Exception:
        return 0


def _subst(s, V):
    # replace $name and $(expr) inside typed text
    out = ""
    i = 0
    while i < len(s):
        if s[i] == "$" and i + 1 < len(s):
            if s[i + 1] == "(":
                depth = 0
                j = i + 1
                while j < len(s):
                    if s[j] == "(":
                        depth += 1
                    elif s[j] == ")":
                        depth -= 1
                        if depth == 0:
                            break
                    j += 1
                out += str(_eval(s[i + 2:j], V))
                i = j + 1
                continue
            if s[i + 1].isalpha() or s[i + 1] == "_":
                j = i + 1
                while j < len(s) and (s[j].isalnum() or s[j] == "_"):
                    j += 1
                out += str(V.get(s[i + 1:j], ""))
                i = j
                continue
        out += s[i]
        i += 1
    return out


def _codes_for(tokens):
    codes = []
    for t in tokens:
        if t in _MMOD:
            codes.append(_MMOD[t])
        elif t in _MKEY:
            codes.append(_MKEY[t])
    return codes


def _macro_combo(tokens):
    codes = _codes_for(tokens)
    if codes:
        kbd.send(*codes)


def _mouse_button(name):
    if _mouse is None:
        return None
    n = (name or "").upper()
    if n in ("RIGHT", "R"):
        return _mouse.RIGHT_BUTTON
    if n in ("MIDDLE", "MID", "M"):
        return _mouse.MIDDLE_BUTTON
    return _mouse.LEFT_BUTTON


def _run_dialog(text):
    # Windows: open the Run dialog, type the command, press Enter.
    kbd.send(Keycode.GUI, Keycode.R)
    time.sleep(0.5)
    layout.write(text)
    time.sleep(0.15)
    kbd.send(Keycode.ENTER)


def _linux_scan_cmd(rel, extra):
    # POSIX one-liner: find CIRCUITPY under the usual mount roots, copy to /tmp
    # (removable media is often mounted noexec), make executable, run it.
    roots = "/media/*/CIRCUITPY /run/media/*/CIRCUITPY /media/CIRCUITPY /mnt/*/CIRCUITPY"
    return ('for d in ' + roots + '; do [ -e "$d/' + rel + '" ] && { cp "$d/' + rel
            + '" /tmp/.pp_run 2>/dev/null && chmod +x /tmp/.pp_run && /tmp/.pp_run'
            + extra + ' & break; }; done')


# --- action-command handlers: arg = the text after the command word ----------
def _act_mouse(arg, V):
    if _mouse is None:
        return
    p = arg.split()
    dx = int(_eval(p[0], V)) if len(p) > 0 else 0
    dy = int(_eval(p[1], V)) if len(p) > 1 else 0
    _mouse.move(x=dx, y=dy)


def _act_scroll(arg, V):
    if _mouse is None:
        return
    _mouse.move(wheel=int(_eval(arg, V)) if arg.strip() else 0)


def _act_click(arg, V):
    b = _mouse_button(arg.split()[0] if arg.strip() else "")
    if b is not None:
        _mouse.click(b)


def _act_rclick(arg, V):
    if _mouse is not None:
        _mouse.click(_mouse.RIGHT_BUTTON)


def _act_mclick(arg, V):
    if _mouse is not None:
        _mouse.click(_mouse.MIDDLE_BUTTON)


def _act_mousedown(arg, V):
    b = _mouse_button(arg.split()[0] if arg.strip() else "")
    if b is not None:
        _mouse.press(b)


def _act_mouseup(arg, V):
    b = _mouse_button(arg.split()[0] if arg.strip() else "")
    if b is not None:
        _mouse.release(b)


def _act_hold(arg, V):
    codes = _codes_for([t.upper() for t in arg.replace("-", " ").split()])
    if codes:
        kbd.press(*codes)


def _act_release(arg, V):
    if arg.strip():
        codes = _codes_for([t.upper() for t in arg.replace("-", " ").split()])
        if codes:
            kbd.release(*codes)
    else:
        kbd.release_all()


def _act_media(arg, V):
    if _cc is not None and arg.strip():
        name = arg.split()[0].upper()
        if name in _MEDIA:
            _cc.send(_MEDIA[name])


def _act_led(arg, V):
    if _led is None:
        return
    _led_touched[0] = True
    a = arg.strip().upper()
    if a == "" or a == "OFF":
        _led[0] = (0, 0, 0)
    else:
        p = arg.split()
        r = int(_eval(p[0], V)) if len(p) > 0 else 0
        g = int(_eval(p[1], V)) if len(p) > 1 else 0
        b = int(_eval(p[2], V)) if len(p) > 2 else 0
        _led[0] = (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
    try:
        _led.show()
    except Exception:
        pass


def _act_random_delay(arg, V):
    p = arg.split()
    lo = int(_eval(p[0], V)) if len(p) > 0 else 0
    hi = int(_eval(p[1], V)) if len(p) > 1 else lo
    if hi < lo:
        lo, hi = hi, lo
    ms = _random.randint(lo, hi) if _random is not None else lo
    try:
        time.sleep(ms / 1000)
    except Exception:
        pass


def _act_default_delay(arg, V):
    try:
        _default_delay[0] = int(_eval(arg, V))
    except Exception:
        _default_delay[0] = 0


def _act_open(arg, V):
    t = _subst(arg, V).strip()
    if t:
        _run_dialog(t)


def _split_rel(arg, V):
    a = _subst(arg, V).strip()
    if not a:
        return None, ""
    sp = a.split(None, 1)
    return sp[0], ((" " + sp[1]) if len(sp) > 1 else "")


def _act_rundrive(arg, V):
    rel, extra = _split_rel(arg, V)
    if rel is None:
        return
    drives = "D E F G H I J K L M N O P Q R S T U V W X Y Z"
    _run_dialog('cmd /c for %d in (' + drives + ') do @if exist "%d:\\' + rel
                + '" start "" "%d:\\' + rel + '"' + extra)


def _act_lrun(arg, V):
    # Linux, a shell prompt focused (headless TTY or an open terminal).
    rel, extra = _split_rel(arg, V)
    if rel is None:
        return
    layout.write(_linux_scan_cmd(rel, extra))
    time.sleep(0.15)
    kbd.send(Keycode.ENTER)


def _act_lgrun(arg, V):
    # Linux GUI: open the desktop run dialog (GNOME/KDE/XFCE Alt+F2), then bash -c.
    rel, extra = _split_rel(arg, V)
    if rel is None:
        return
    kbd.send(Keycode.ALT, Keycode.F2)
    time.sleep(0.5)
    layout.write("bash -c '" + _linux_scan_cmd(rel, extra) + "'")
    time.sleep(0.15)
    kbd.send(Keycode.ENTER)


def _plain(s):
    # strip quotes so message text can't break the host command it is placed in
    return s.replace('"', "").replace("'", "")


def _mac_terminal(cmd):
    # macOS: open Spotlight, launch Terminal, then run the command there.
    kbd.send(Keycode.GUI, Keycode.SPACEBAR)
    time.sleep(0.6)
    layout.write("Terminal")
    time.sleep(0.3)
    kbd.send(Keycode.ENTER)
    time.sleep(1.6)
    layout.write(cmd)
    time.sleep(0.15)
    kbd.send(Keycode.ENTER)


def _act_messagebox(arg, V):
    # Windows: modal OK box via mshta (show-only, no return value).
    _run_dialog("mshta \"javascript:alert('" + _plain(_subst(arg, V)) + "');close();\"")


def _act_lmessagebox(arg, V):
    # Linux GUI: try zenity, then kdialog, then xmessage.
    t = _plain(_subst(arg, V))
    kbd.send(Keycode.ALT, Keycode.F2)
    time.sleep(0.5)
    layout.write('bash -c \'zenity --info --text="' + t + '" || kdialog --msgbox "'
                 + t + '" || xmessage "' + t + '"\'')
    time.sleep(0.15)
    kbd.send(Keycode.ENTER)


def _act_mmessagebox(arg, V):
    # macOS: osascript dialog, run from a Terminal window.
    _mac_terminal('osascript -e \'display dialog "' + _plain(_subst(arg, V))
                  + '" buttons {"OK"} default button "OK"\'')


def _act_mrun(arg, V):
    # macOS: CIRCUITPY always mounts at /Volumes/CIRCUITPY.
    rel, extra = _split_rel(arg, V)
    if rel is None:
        return
    _mac_terminal('cp "/Volumes/CIRCUITPY/' + rel + '" /tmp/.pp_run 2>/dev/null'
                  + ' && chmod +x /tmp/.pp_run && /tmp/.pp_run' + extra)


_ACTIONS = {"MOUSE": _act_mouse, "SCROLL": _act_scroll,
            "CLICK": _act_click, "RCLICK": _act_rclick, "MCLICK": _act_mclick,
            "MOUSEDOWN": _act_mousedown, "MOUSEUP": _act_mouseup,
            "HOLD": _act_hold, "RELEASE": _act_release, "RELEASEALL": _act_release,
            "MEDIA": _act_media, "LED": _act_led,
            "RANDOM_DELAY": _act_random_delay, "RANDOMDELAY": _act_random_delay,
            "DEFAULTDELAY": _act_default_delay, "DEFAULT_DELAY": _act_default_delay,
            "OPEN": _act_open, "RUN": _act_open, "RUNDRIVE": _act_rundrive,
            "LRUN": _act_lrun, "LGRUN": _act_lgrun, "MRUN": _act_mrun,
            "MESSAGEBOX": _act_messagebox, "MSGBOX": _act_messagebox,
            "LMESSAGEBOX": _act_lmessagebox, "LMSGBOX": _act_lmessagebox,
            "MMESSAGEBOX": _act_mmessagebox, "MMSGBOX": _act_mmessagebox}


def _macro_exec(line, V):
    u = line.upper()
    if u[:6] == "DELAY ":
        try:
            time.sleep(_eval(line[6:], V) / 1000)
        except Exception:
            pass
    elif u[:9] == "STRINGLN ":
        layout.write(_subst(line[9:], V))
        kbd.send(Keycode.ENTER)
    elif u == "STRINGLN":
        kbd.send(Keycode.ENTER)
    elif u[:7] == "STRING ":
        layout.write(_subst(line[7:], V))
    elif u[:4] == "KEY ":
        _macro_combo([t.upper() for t in line[4:].split()])
    else:
        sp = line.split(None, 1)
        cmd = sp[0].upper()
        arg = sp[1] if len(sp) > 1 else ""
        if cmd in _ACTIONS:
            _ACTIONS[cmd](arg, V)
        elif cmd in _MEDIA:
            if _cc is not None:
                _cc.send(_MEDIA[cmd])
        else:
            _macro_combo([t.upper() for t in line.replace("-", " ").split()])


def _is_comment(line):
    f = line.split(None, 1)[0].upper()
    return line[0] in "#;" or line[:2] == "//" or f == "REM"


def _parse(lines, i, stops):
    # build a tree of nodes; blocks (LOOP/WHILE/FOR/IF) end at END (IF may have ELSE)
    nodes = []
    while i < len(lines):
        line = lines[i].strip()
        if not line or _is_comment(line):
            i += 1
            continue
        first = line.split(None, 1)[0].upper()
        if first in stops:
            return nodes, i
        rest = line[len(first):].strip()
        if first == "SET" and "=" in rest:
            name, expr = rest.split("=", 1)
            nodes.append(("set", name.strip(), expr.strip()))
            i += 1
        elif first in ("LOOP", "WHILE", "FOR"):
            body, j = _parse(lines, i + 1, ("END",))
            nodes.append((first.lower(), rest, body))
            i = j + 1
        elif first == "IF":
            then, j = _parse(lines, i + 1, ("ELSE", "END"))
            els = None
            if j < len(lines) and lines[j].strip().split(None, 1)[0].upper() == "ELSE":
                els, j = _parse(lines, j + 1, ("END",))
            nodes.append(("if", rest, then, els))
            i = j + 1
        else:
            nodes.append(("line", line))
            i += 1
    return nodes, i


def _post_delay(line):
    d = _default_delay[0]
    if d:
        c = line.split(None, 1)[0].upper()
        if c != "DEFAULTDELAY" and c != "DEFAULT_DELAY":
            try:
                time.sleep(d / 1000)
            except Exception:
                pass


def _execute(nodes, V):
    prev = None
    for node in nodes:
        if _count[0] > _LIMIT:
            return
        t = node[0]
        if t == "line":
            line = node[1]
            if line[:7].upper() == "REPEAT ":
                if prev:
                    for _ in range(int(_eval(line[7:], V))):
                        _count[0] += 1
                        _macro_exec(prev, V)
                        _post_delay(prev)
            else:
                _count[0] += 1
                _macro_exec(line, V)
                _post_delay(line)
                prev = line
        elif t == "set":
            V[node[1]] = _eval(node[2], V)
        elif t == "loop":
            for _ in range(int(_eval(node[1], V))):
                if _count[0] > _LIMIT:
                    break
                _execute(node[2], V)
        elif t == "while":
            while _eval(node[1], V):
                if _count[0] > _LIMIT:
                    break
                _count[0] += 1
                _execute(node[2], V)
        elif t == "for":
            var, rng = node[1].split("=", 1)
            var = var.strip()
            ti = rng.upper().find(" TO ")
            se = rng[:ti]
            r2 = rng[ti + 4:]
            si = r2.upper().find(" STEP ")
            if si >= 0:
                ee, ste = r2[:si], r2[si + 6:]
            else:
                ee, ste = r2, "1"
            x = int(_eval(se, V))
            end = int(_eval(ee, V))
            step = int(_eval(ste, V)) or 1
            while (x <= end) if step > 0 else (x >= end):
                if _count[0] > _LIMIT:
                    break
                V[var] = x
                _execute(node[2], V)
                x += step
        elif t == "if":
            if _eval(node[1], V):
                _execute(node[2], V)
            elif node[3] is not None:
                _execute(node[3], V)


def run_macro(text):
    _count[0] = 0
    _default_delay[0] = 0
    nodes, _ = _parse(text.split("\n"), 0, ())
    _execute(nodes, {})
    # never leave keys or mouse buttons stuck down
    try:
        kbd.release_all()
    except Exception:
        pass
    if _mouse is not None:
        try:
            _mouse.release_all()
        except Exception:
            pass
# <<< MACRO INTERPRETER <<<


# BOOT button (custom firmware exposes it as GPIO33); polarity auto-calibrated
_boot = digitalio.DigitalInOut(microcontroller.pin.GPIO33)
_boot.switch_to_input(pull=digitalio.Pull.UP)
_released = _boot.value


def _pressed():
    if _boot.value == _released:
        return False
    for _ in range(3):
        time.sleep(0.015)
        if _boot.value == _released:
            return False
    return True


# auto: on plug-in / RESET
if TRIGGER_MODE in ("auto", "both"):
    if _led:
        _led[0] = (0, 80, 70)
        _led.show()
    type_password()
    if _led:
        _led[0] = (0, 150, 0)
        _led.show()
        time.sleep(0.4)
        _led[0] = (0, 0, 0)
        _led.show()
if MACRO.strip() and MACRO_AUTO:
    run_macro(MACRO)

# button: type on each BOOT-button press (macro if set, else password)
if TRIGGER_MODE in ("button", "both") or (MACRO.strip() and MACRO_BUTTON):
    while True:
        up = True
        bright = 0.0
        while not _pressed():
            # Breathe only while the macro hasn't taken over the LED, so an
            # LED command in the macro (e.g. LED OFF) keeps its final state.
            if _led and not _led_touched[0]:
                bright += 0.02 if up else -0.02
                if bright >= 0.3:
                    up = False
                elif bright <= 0.0:
                    up = True
                _led[0] = (0, int(bright * 255), int(bright * 200))
                _led.show()
            time.sleep(0.03)
        if _led and not _led_touched[0]:
            _led[0] = (0, 0, 0)
            _led.show()
        while _pressed():
            time.sleep(0.02)
        time.sleep(0.2)
        if MACRO.strip() and MACRO_BUTTON:
            run_macro(MACRO)
        else:
            type_password()
PPEOF
# <<<GEN-FW<<<
}

generate_code() {
    local pwfield encpy su ty mod cls trig ledspy
    if [ "$ENC" = "1" ]; then pwfield=$(printf '%s' "$PW" | xor_to_hex); encpy="True"
    else pwfield=$(printf '%s' "$PW" | sed 's/\\/\\\\/g; s/"/\\"/g'); encpy="False"; fi
    printf -v su '%.1f' "$STARTUP"; printf -v ty '%.1f' "$TYPING"
    mod="${LAY_MODULE[$LAYOUT]}"; cls="${LAY_CLASS[$LAYOUT]}"
    [ "$AUTO" = "0" ] && trig="button" || trig="both"   # BOOT button always types
    [ "$LEDS" = "0" ] && ledspy="False" || ledspy="True"
    # Replacement lines passed to awk via the environment (literal -- no escape/delimiter
    # issues). The macro lines are PRESERVED verbatim from the device (this CLI has no
    # macro editor); if the device had none, the template defaults (empty macro) are used.
    export PP_PW="PASSWORD = \"$pwfield\"  #PW"
    export PP_ENC="ENCRYPTED = $encpy  #ENC"
    export PP_TRIG="TRIGGER_MODE = \"$trig\"  #TRIGGER"
    export PP_LEDS="LEDS = $ledspy  #LEDS"
    export PP_KB1="from adafruit_hid.$mod import $cls #KEYBOARD1"
    export PP_KB2="layout = $cls(kbd) #KEYBOARD2"
    export PP_TS="time.sleep($su) #TIMESLEEP"
    export PP_TY="time.sleep($ty)"
    export PP_MACRO="${CUR_MACRO_LINE:-MACRO = \"\"  #MACRO}"
    export PP_MBTN="${CUR_MBTN_LINE:-MACRO_BUTTON = True  #MACROBTN   # run the macro when the BOOT button is pressed}"
    export PP_MAUTO="${CUR_MAUTO_LINE:-MACRO_AUTO = False  #MACROAUTO   # also run the macro on plug-in / RESET}"
    fw_template | awk '
        /#PW$/        { print ENVIRON["PP_PW"];    next }
        /#ENC$/       { print ENVIRON["PP_ENC"];   next }
        /#TRIGGER$/   { print ENVIRON["PP_TRIG"];  next }
        /#LEDS$/      { print ENVIRON["PP_LEDS"];  next }
        /#MACRO$/     { print ENVIRON["PP_MACRO"]; next }
        /#MACROBTN/   { print ENVIRON["PP_MBTN"];  next }
        /#MACROAUTO/  { print ENVIRON["PP_MAUTO"]; next }
        /#KEYBOARD1$/ { print ENVIRON["PP_KB1"];   next }
        /#KEYBOARD2$/ { print ENVIRON["PP_KB2"];   next }
        /#TIMESLEEP$/ { print ENVIRON["PP_TS"];    next }
        /^time\.sleep\(0\.6\)$/ { if(!tydone){ print ENVIRON["PP_TY"]; tydone=1; next } }
        { print }
    '
}

is_number() { case "$1" in ''|*[!0-9.]*) return 1;; *) return 0;; esac; }

# ── main ─────────────────────────────────────────────────────────────────────
printf '\n=== Plug & Pass  (macOS) ===\n\n'

DEV=$(find_device) || DEV=""
while [ -z "$DEV" ]; do
    printf 'No Plug & Pass (CIRCUITPY) device found.\n'
    printf 'Plug it in, then press [Enter] to retry -- or type q to quit: '
    read ans
    [ "$ans" = q ] || [ "$ans" = Q ] && { printf 'Cancelled.\n'; exit 0; }
    DEV=$(find_device) || DEV=""
done
printf 'Device found at: %s\n' "$DEV"

load_existing "$DEV/code.py"
if [ "$CUR_HAS" = "1" ]; then
    printf 'Loaded current settings from the device (password is hidden).\n\n'
else
    printf 'No existing code.py found; starting from defaults.\n\n'
fi

# password (Enter keeps the current one)
printf 'Password  (press Enter to keep the current one):\n'
printf '  New password: '
read -s newpw; printf '\n'
if [ -z "$newpw" ]; then
    PW="$CUR_PW"
    printf '  (keeping current password)\n'
else
    printf '  Confirm new password: '
    read -s pw2; printf '\n'
    [ "$newpw" = "$pw2" ] || { printf 'Passwords do not match. Aborting.\n'; exit 1; }
    PW="$newpw"
fi

# encrypt
encdef=N; [ "$CUR_ENC" = "1" ] && encdef=Y
printf '\nEncrypt the password stored on the device? [y/N] (current: %s): ' "$encdef"
read e
case "$e" in
    y|Y) ENC=1 ;;
    n|N) ENC=0 ;;
    *)   ENC=$CUR_ENC ;;
esac

# auto (type on plug-in/RESET) -- the BOOT button always types regardless
autodef=Y; [ "$CUR_AUTO" = "0" ] && autodef=N
printf '\nType automatically on plug-in / RESET? [Y/n] (current: %s): ' "$autodef"
read a
case "$a" in n|N) AUTO=0 ;; y|Y) AUTO=1 ;; *) AUTO=$CUR_AUTO ;; esac

# status LED / light
leddef=Y; [ "$CUR_LEDS" = "0" ] && leddef=N
printf 'Use the status LED / light? [Y/n] (current: %s): ' "$leddef"
read ld
case "$ld" in n|N) LEDS=0 ;; y|Y) LEDS=1 ;; *) LEDS=$CUR_LEDS ;; esac

# keyboard layout
printf '\nKeyboard layout:\n'
for (( i=0; i<NLAY; i++ )); do printf '  %2d) %s\n' "$i" "${LAY_NAME[$i]}"; done
printf 'Choose [default %d - %s]: ' "$CUR_LAYOUT" "${LAY_NAME[$CUR_LAYOUT]}"
read l
[ -z "$l" ] && l=$CUR_LAYOUT
{ is_number "$l" && [ "$l" -ge 0 ] && [ "$l" -lt "$NLAY" ]; } || l=$CUR_LAYOUT
LAYOUT=$l

# delays
printf '\nStartup delay in seconds  [default %s]: ' "$CUR_STARTUP"
read s; [ -z "$s" ] && s=$CUR_STARTUP; is_number "$s" || s=$CUR_STARTUP; STARTUP=$s
printf 'Typing delay in seconds   [default %s]: ' "$CUR_TYPING"
read t; [ -z "$t" ] && t=$CUR_TYPING; is_number "$t" || t=$CUR_TYPING; TYPING=$t

# summary + confirm
encyn=No; [ "$ENC" = "1" ] && encyn=Yes
printf '\n--- About to write ---\n'
printf '  Device:        %s\n' "$DEV"
printf '  Keyboard:      %s\n' "${LAY_NAME[$LAYOUT]}"
printf '  Startup delay: %s s\n' "$STARTUP"
printf '  Typing delay:  %s s\n' "$TYPING"
printf '  Encrypt:       %s\n' "$encyn"
autoyn=No; [ "$AUTO" = "1" ] && autoyn=Yes
ledyn=No;  [ "$LEDS" = "1" ] && ledyn=Yes
printf '  Auto on plug-in: %s\n' "$autoyn"
printf '  Status LED:      %s\n' "$ledyn"
printf '  Password:      (hidden)\n\n'
printf 'Are you sure you want to write code.py to the device? [y/N]: '
read yn
case "$yn" in y|Y) ;; *) printf 'Cancelled. Nothing was written.\n'; exit 0 ;; esac

# write: whole file via a temp on the device, then atomic replace
TMP="$DEV/.pp_$$.tmp"
if generate_code > "$TMP" 2>/dev/null && mv -f "$TMP" "$DEV/code.py" 2>/dev/null; then
    sync
    printf '\nDone. code.py written to the device.\n'
    printf 'Safely eject the CIRCUITPY drive, then unplug and replug it to use.\n'
else
    rm -f "$TMP" 2>/dev/null
    printf '\nERROR: could not write to the device.\n'
    printf 'If CircuitPython has it read-only, unplug, hold BOOT, replug, and try again.\n'
    exit 1
fi
