commit 2d71ce77a1dfb166dd728b05b0d34c9e0a018794 Author: Grant Lanier Date: Fri Jul 17 15:29:53 2026 -0400 Initial public release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1939ca8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.eggs/ +build/ +dist/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# IDE / editors +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Agent / editor tooling +.claude/ + +# Project artifacts +*.log +CompileAndUpload/build/ +CompileAndUpload/upload.log +CompileAndUpload/build.log +firmware/MacroPad/build/ + +# Local user data +*.bak +*.tmp + +# Local machine data inside config/ — secrets, per-machine BLE variables, +# backups, and captured logs must NEVER be committed to the public repo. +config/backups/ +config/profiles_meta.json +config/project.json +config/.blekeyfile +config/.blekeyfile.tmp +config/.ble_keys.json +config/.ble_device_log.json +config/.ble_replay.json +config/.serial_capture.log +config/bt_kbd_macros.json +config/bt_kbd_profiles.json + +# Profiles: ignore everything under config/profiles/, then re-allow ONLY the +# shipped generic examples. Per-machine ".vars.json" sidecars hold +# BLE variables / credentials and are deliberately NOT allow-listed, so they +# stay local. Customer profiles are never committed to the public repo. +config/profiles/* +!config/profiles/Example.json +!config/profiles/Sub-Routines.json + +# Images: routine icons are local user data; none ship publicly. +config/images/* + +# OS +desktop.ini diff --git a/CompileAndUpload/README.md b/CompileAndUpload/README.md new file mode 100644 index 0000000..0609dc4 --- /dev/null +++ b/CompileAndUpload/README.md @@ -0,0 +1,138 @@ +# ATOMS3 MacroPad + +Node-based macro keyboard firmware and programmer for the M5Stack AtomS3. + +--- + +## Scripts + +### `python/GUI.bat` — Macro Editor +Launches the graphical macro editor. Use this to create, edit, and upload macro +programs to the device. The GUI handles connecting to the device automatically. + +--- + +### `compile.bat` — Compile Firmware +Compiles the Arduino firmware without uploading. Useful for verifying that the +firmware builds cleanly after making code changes. Output is saved to `build.log`. + +``` +compile.bat # standard build +compile.bat --clean # force a full rebuild from scratch +``` + +--- + +### `upload.bat` — Upload Firmware +Compiles and uploads firmware to the device. The device must already be in +download mode (ROM bootloader) before running this. Requires a COM port argument. + +``` +upload.bat COM4 +``` + +To enter download mode: hold the side button on the AtomS3 while plugging in USB, +or send `{"cmd":"bootloader"}` to the device over serial if firmware is already running. + +--- + +### `wipe_device.bat` — Wipe Device +Sends the bootloader command to the running firmware, then erases both the NVS +(settings) and LittleFS (macros) flash partitions, leaving the device in a +completely fresh state. Run this before uploading firmware to ensure a clean slate. + +``` +wipe_device.bat # auto-detect COM port +wipe_device.bat COM3 # use a specific COM port +``` + +--- + +### `test_serial.bat` — Serial Communication Test +Runs a quick ping/reconnect test against the device to verify serial communication +is working correctly. Useful for diagnosing connection issues. Output is saved to +`serial_test.log`. + +``` +test_serial.bat +``` + +--- + +## Uploading New Firmware + +Follow these steps any time you want to flash updated firmware onto the device. + +### Step 1 — Verify the build + +``` +compile.bat +``` + +Check the output for `BUILD SUCCESSFUL` and no errors. Fix any compile errors before +continuing. The full output is saved to `build.log`. + +### Step 2 — Wipe the device and enter download mode + +``` +wipe_device.bat +``` + +This will: +1. Auto-detect the device on its normal COM port (e.g. COM3) +2. Send a reboot-to-bootloader command +3. Wait for the device to re-enumerate as a USB-Serial/JTAG port (e.g. COM4) +4. Erase the settings (NVS) and macro storage (LittleFS) partitions + +> **Note:** This erases all saved macros. Re-upload them from the GUI afterwards. + +### Step 3 — Flash the firmware + +``` +upload.bat COM4 +``` + +Replace `COM4` with whatever download-mode port appeared in Step 2. If you are +unsure, check Device Manager — the download-mode port is listed under +*Universal Serial Bus devices* as **USB JTAG/serial debug unit**. + +The script compiles, erases the full flash, and uploads the firmware. After it +finishes the device will reboot and re-enumerate on its normal COM port (e.g. COM3). + +### Step 4 — Re-upload macros + +``` +python/GUI.bat +``` + +Open the editor, connect to the device, and click **Upload All** to restore your +macros. + +--- + +## Typical Workflow + +**First-time setup or clean firmware update:** +1. `wipe_device.bat` — wipe the device +2. `upload.bat COM4` — flash fresh firmware (use the download mode COM port) + +**Code change / firmware-only update:** +1. `compile.bat` — verify the build +2. `wipe_device.bat` — wipe and enter download mode +3. `upload.bat COM4` — flash updated firmware + +**Editing macros:** +1. `python/GUI.bat` — open the editor, connect, and upload macros + +--- + +## Device COM Ports + +The AtomS3 enumerates as two different COM ports depending on its state: + +| State | Port | Description | +|---|---|---| +| Normal (firmware running) | e.g. COM3 | TinyUSB CDC — used by the GUI and wipe script | +| Download mode (bootloader) | e.g. COM4 | USB-Serial/JTAG — used by `upload.bat` and esptool | + +The active port number can vary between machines. Check Device Manager if unsure. diff --git a/CompileAndUpload/auto_enter_bootloader.py b/CompileAndUpload/auto_enter_bootloader.py new file mode 100644 index 0000000..82f43ea --- /dev/null +++ b/CompileAndUpload/auto_enter_bootloader.py @@ -0,0 +1,179 @@ +"""Auto-detect a running ATOMS3 MacroPad and trigger ROM download mode. + +Used by upload.bat to skip the manual "hold the side button while plugging +in" dance when a connected device is already running our firmware (which +exposes the `bootloader` serial command). On success, writes the detected +download-mode port to the file given as argv[1] and exits 0. On any +failure (no device, ping miss, bootloader command rejected, device fails +to re-enumerate) exits non-zero so the .bat falls back to manual mode. + +This only works for devices already running our firmware — stock ATOMS3 +firmware does not support the `bootloader` command, which is why +upload.bat keeps the manual flow as a fallback. +""" + +import json +import sys +import time + +import serial +import serial.tools.list_ports + +DEVICE_ID = "ATOMS3-MACROPAD" +ESPRESSIF_VID = 0x303A +PING_TIMEOUT_S = 2.0 +REENUMERATE_TIMEOUT_S = 8.0 + + +def _espressif_ports() -> list: + return [p for p in serial.tools.list_ports.comports() if p.vid == ESPRESSIF_VID] + + +def _ping(port: str) -> dict | None: + """Open the port without resetting, send a ping, return parsed reply or None.""" + try: + ser = serial.Serial() + ser.port = port + ser.baudrate = 115200 + ser.timeout = PING_TIMEOUT_S + ser.dtr = False + ser.rts = False + ser.open() + except (serial.SerialException, OSError): + return None + try: + time.sleep(0.1) + ser.dtr = True + time.sleep(0.3) + ser.reset_input_buffer() + ser.write(b'{"cmd":"ping"}\n') + line = ser.readline().decode("utf-8", errors="ignore").strip() + if not line: + return None + return json.loads(line) + except (serial.SerialException, json.JSONDecodeError, OSError): + return None + finally: + try: + ser.close() + except Exception: + pass + + +def _find_macropad() -> str | None: + for p in _espressif_ports(): + reply = _ping(p.device) + if reply and reply.get("id") == DEVICE_ID: + print(f" Found {DEVICE_ID} v{reply.get('ver', '?')} on {p.device}", + file=sys.stderr) + return p.device + return None + + +def _send_bootloader(port: str) -> bool: + """Tell the firmware to call usb_persist_restart(RESTART_BOOTLOADER). + + The device acks with `{"rsp":"ok"}` then disappears off this port and + re-enumerates as USB-Serial-JTAG (the native ESP32-S3 bootloader USB + interface). We don't wait for the ack here beyond the read timeout — + even a successful command vanishes the port within ~100 ms. + """ + try: + ser = serial.Serial() + ser.port = port + ser.baudrate = 115200 + ser.timeout = 1.0 + ser.dtr = False + ser.rts = False + ser.open() + except (serial.SerialException, OSError) as exc: + print(f" ERROR: could not open {port}: {exc}", file=sys.stderr) + return False + try: + time.sleep(0.1) + ser.dtr = True + time.sleep(0.3) + ser.reset_input_buffer() + ser.write(b'{"cmd":"bootloader"}\n') + ser.flush() + # Best-effort read of the ack; absence of one is fine — the chip + # is already on its way to the ROM bootloader. + try: + line = ser.readline().decode("utf-8", errors="ignore").strip() + print(f" bootloader cmd reply: {line or '(none)'}", file=sys.stderr) + except Exception: + pass + return True + except (serial.SerialException, OSError) as exc: + print(f" ERROR sending bootloader command: {exc}", file=sys.stderr) + return False + finally: + try: + ser.close() + except Exception: + pass + + +def _wait_for_download_port(prev_macropad_port: str) -> str | None: + """Wait for an Espressif device to appear in download mode. + + Strategy: poll the COM enumeration. Accept the first Espressif port we + can see that either (a) is a *different* port than the one the firmware + was on, or (b) is the same port but `ping` no longer answers (firmware + is gone, USB-Serial-JTAG is up). The same-port case is common on + Windows because the OS often reuses the COM number across the PHY + switch. + """ + deadline = time.monotonic() + REENUMERATE_TIMEOUT_S + # Give the device a moment to vanish before we start polling. + time.sleep(0.5) + while time.monotonic() < deadline: + ports = [p.device for p in _espressif_ports()] + for port in ports: + if port != prev_macropad_port: + return port + # Same port re-appeared (or never disappeared): confirm firmware is + # no longer responding. If ping fails, this is the bootloader. + if prev_macropad_port in ports: + if _ping(prev_macropad_port) is None: + return prev_macropad_port + time.sleep(0.4) + return None + + +def main(argv: list) -> int: + if len(argv) < 2: + print("usage: auto_enter_bootloader.py ", file=sys.stderr) + return 2 + out_path = argv[1] + + print("Scanning for a running ATOMS3 MacroPad...", file=sys.stderr) + fw_port = _find_macropad() + if not fw_port: + print(" No running MacroPad detected on any Espressif COM port.", + file=sys.stderr) + return 1 + + print(f"Asking {fw_port} to enter download mode...", file=sys.stderr) + if not _send_bootloader(fw_port): + return 1 + + print("Waiting for the device to re-enumerate as USB-Serial-JTAG...", + file=sys.stderr) + dl_port = _wait_for_download_port(fw_port) + if not dl_port: + print(" Timed out waiting for download-mode port.", file=sys.stderr) + return 1 + + print(f" Download-mode port: {dl_port}", file=sys.stderr) + try: + with open(out_path, "w", encoding="ascii") as f: + f.write(dl_port) + except OSError as exc: + print(f" ERROR writing {out_path}: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/CompileAndUpload/compile.bat b/CompileAndUpload/compile.bat new file mode 100644 index 0000000..63ff9ca --- /dev/null +++ b/CompileAndUpload/compile.bat @@ -0,0 +1,35 @@ +@echo off +setlocal + +set FQBN=m5stack:esp32:m5stack_atoms3:USBMode=default,CDCOnBoot=cdc,UploadMode=default,PartitionScheme=default_8MB +set SKETCH=%~dp0..\firmware\MacroPad +set LOGFILE=%~dp0build.log + +echo ============================================ +echo Compiling MacroPad firmware... +echo FQBN: %FQBN% +echo Sketch: %SKETCH% +echo ============================================ + +:: Check for --clean flag +set CLEAN_FLAG= +if "%1"=="--clean" set CLEAN_FLAG=--clean + +arduino-cli compile --fqbn "%FQBN%" "%SKETCH%" %CLEAN_FLAG% > "%LOGFILE%" 2>&1 +set RESULT=%ERRORLEVEL% + +:: Show last 20 lines of output +echo. +echo --- Build Output (last 20 lines) --- +powershell -Command "Get-Content '%LOGFILE%' | Select-Object -Last 20" +echo. + +if %RESULT%==0 ( + echo BUILD SUCCESSFUL +) else ( + echo BUILD FAILED (exit code %RESULT%) + echo Full log: %LOGFILE% +) + +echo. +exit /b %RESULT% diff --git a/CompileAndUpload/sync_key.py b/CompileAndUpload/sync_key.py new file mode 100644 index 0000000..68da890 --- /dev/null +++ b/CompileAndUpload/sync_key.py @@ -0,0 +1,196 @@ +"""Initial BLE key sync, run at the end of a firmware flash. + +A flash does a full chip erase (see upload.bat), which wipes the device's +LittleFS — so the ATOMS3 generates a BRAND-NEW per-device AES-256 key on +its first boot. Whatever key the host had stored is now stale, and live +BLE recording / variable sync would fail GCM auth until the next profile +upload re-synced it. This script closes that gap automatically right after +flashing: it waits for the device to boot, pulls the fresh key over USB, +and stores it in the host keystore (both the per-MAC entry and the legacy +single-key file — exactly what serial_manager.upload_all does). + +Robustness: + * The COM port commonly changes across the post-flash reboot, so we + don't trust any hint — we scan every Espressif port each poll. + * We wait up to BOOT_WAIT_S for the device to come up. If it hasn't + appeared within REBOOT_HINT_S we print a one-time nudge to unplug/ + replug (covers boards that don't auto-reboot out of download mode), + then keep polling. + * get_ble_key is retried a few times per session — the firmware needs + LittleFS mounted and the keystore initialised, which finishes a beat + into setup(). + * Ports are opened with DTR/RTS low (the same non-resetting probe + auto_enter_bootloader.py uses) and the firmware disables DTR-reboot, + so connecting never knocks the device back into the bootloader. + * Serial output is line-noisy (the firmware mirrors [BLE.dbg]/[BOOT] + prints onto the same CDC pipe), so we skip non-JSON lines instead of + failing on the first one. + * Exit 0 on success, 1 otherwise — a failure is reported clearly but + never marks the flash itself as failed (the .bat ignores our code). +""" + +import json +import os +import sys +import time + +# Make the repo root importable so `ble_keystore` (and its +# `utils.constants` dependency) resolve when run from CompileAndUpload/. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +try: + import serial + import serial.tools.list_ports +except ImportError: + print(" ERROR: pyserial not installed — cannot sync the BLE key.", + file=sys.stderr) + sys.exit(1) + +import ble_keystore + +DEVICE_ID = "ATOMS3-MACROPAD" +ESPRESSIF_VID = 0x303A + +# Total time to wait for the device to boot + answer. +BOOT_WAIT_S = 40.0 +# Initial settle before the first probe (USB re-enumeration after reset). +INITIAL_SETTLE_S = 2.0 +# If we still haven't seen the device by this point, nudge the user. +REBOOT_HINT_S = 9.0 +# Per-command read window. Generous because boot prints can precede the +# JSON response on the shared CDC pipe. +CMD_TIMEOUT_S = 3.0 +# How many times to ask for the key once the device is confirmed. +KEY_RETRIES = 5 + + +def _espressif_ports() -> list: + return [p for p in serial.tools.list_ports.comports() + if p.vid == ESPRESSIF_VID] + + +def _open(port: str): + """Open the port without triggering a reset (DTR/RTS low, then raise + DTR once the line is up — matches auto_enter_bootloader.py).""" + ser = serial.Serial() + ser.port = port + ser.baudrate = 115200 + ser.timeout = CMD_TIMEOUT_S + ser.dtr = False + ser.rts = False + ser.open() + time.sleep(0.1) + ser.dtr = True + time.sleep(0.3) + ser.reset_input_buffer() + return ser + + +def _cmd(ser, obj: dict, expect_rsp: str) -> dict | None: + """Send one JSON command and return the first reply whose "rsp" + matches expect_rsp. Skips boot/debug noise lines.""" + try: + ser.reset_input_buffer() + ser.write((json.dumps(obj) + "\n").encode("ascii")) + ser.flush() + except (serial.SerialException, OSError): + return None + deadline = time.monotonic() + CMD_TIMEOUT_S + while time.monotonic() < deadline: + try: + line = ser.readline().decode("utf-8", errors="ignore").strip() + except (serial.SerialException, OSError): + return None + if not line: + continue + try: + d = json.loads(line) + except json.JSONDecodeError: + continue # firmware [BLE.dbg]/[BOOT] line — ignore + if isinstance(d, dict) and d.get("rsp") == expect_rsp: + return d + return None + + +def _try_port(port: str): + """Confirm a MacroPad on `port` and pull its key. + Returns (key_bytes, tag_or_None) on success, else None.""" + try: + ser = _open(port) + except (serial.SerialException, OSError): + return None + try: + ping = _cmd(ser, {"cmd": "ping"}, "pong") + if not ping or ping.get("id") != DEVICE_ID: + return None + print(f" Found {DEVICE_ID} v{ping.get('ver', '?')} on {port}.") + for attempt in range(KEY_RETRIES): + rsp = _cmd(ser, {"cmd": "get_ble_key"}, "ble_key") + if rsp: + try: + key = bytes.fromhex(rsp["key"]) + except (KeyError, ValueError): + key = None + if key and len(key) == ble_keystore.KEY_LEN: + tag = rsp.get("tag") + return key, (tag if isinstance(tag, str) else None) + time.sleep(1.0) + print(" Device responded but did not return a valid BLE key.") + return None + finally: + try: + ser.close() + except Exception: + pass + + +def _persist(key: bytes, tag: str | None) -> None: + # Always update the legacy single-key file (back-compat), and the + # per-MAC store when the device reported its tag. Both calls reset + # replay-protection counters on a key change, which is exactly what we + # want for a freshly-reflashed device (new key + new boot session). + ble_keystore.save_key(key) + if tag: + ble_keystore.save_key_for_mac(tag, key) + + +def main(argv: list) -> int: + print("Syncing BLE key — waiting for the ATOMS3 to boot after flashing...") + time.sleep(INITIAL_SETTLE_S) + + deadline = time.monotonic() + BOOT_WAIT_S + nudged = False + while time.monotonic() < deadline: + for port in _espressif_ports(): + res = _try_port(port.device) + if res: + key, tag = res + try: + _persist(key, tag) + except OSError as exc: + print(f" ERROR: could not write keystore: {exc}", + file=sys.stderr) + return 1 + where = tag if tag else "(legacy key file — no device tag)" + print(f" BLE key synced for {where}.") + print(" Live BLE recording and variable sync are ready.") + return 0 + if not nudged and time.monotonic() > (deadline - BOOT_WAIT_S + + REBOOT_HINT_S): + print(" Still waiting... if the device did not reboot into the " + "app on its own,") + print(" unplug and replug it now (it will sync as soon as it " + "boots).") + nudged = True + time.sleep(1.0) + + print(" Could not sync the BLE key (device never answered).") + print(" No harm done — uploading a profile from the app syncs the key " + "too. You can also re-run this step.") + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/CompileAndUpload/upload.bat b/CompileAndUpload/upload.bat new file mode 100644 index 0000000..b6a9869 --- /dev/null +++ b/CompileAndUpload/upload.bat @@ -0,0 +1,292 @@ +@echo off +setlocal EnableDelayedExpansion + +set FQBN=m5stack:esp32:m5stack_atoms3:USBMode=default,CDCOnBoot=cdc,UploadMode=default,PartitionScheme=default_8MB +set SKETCH=%~dp0..\firmware\MacroPad +set BEFORE=%TEMP%\macropad_ports_before.txt +set AFTER=%TEMP%\macropad_ports_after.txt +set DIFF=%TEMP%\macropad_ports_diff.txt +set ESPTOOL= +for /f "delims=" %%E in ('powershell -NoProfile -Command "Get-ChildItem -Path \"$env:LOCALAPPDATA\Arduino15\packages\" -Recurse -Filter esptool.exe -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName"') do set ESPTOOL=%%E + +if not defined ESPTOOL ( + echo ERROR: esptool.exe not found under %%LOCALAPPDATA%%\Arduino15\packages + echo The Arduino M5Stack/ESP32 board package may not be installed. + pause + exit /b 1 +) + +set EXIT_CODE=0 +set AUTO_PORT_FILE=%TEMP%\macropad_auto_port.txt + +:: --lite only changes the on-screen instructions (the AtomS3 Lite has no +:: display to watch). The firmware itself is universal - same FQBN, same +:: binary - and detects the board at boot. +set LITE= +set EXPLICIT_PORT= +if /i "%~1"=="--lite" ( + set LITE=1 + set EXPLICIT_PORT=%2 +) else ( + set EXPLICIT_PORT=%1 + if /i "%~2"=="--lite" set LITE=1 +) + +:: Power-user shortcut: explicit port skips the interactive flow. +if not "%EXPLICIT_PORT%"=="" ( + set PORT=%EXPLICIT_PORT% + goto :compile +) + +echo. +if defined LITE ( + echo =============================================== + echo ATOM S3 Lite MacroPad - Interactive Flasher + echo =============================================== + echo. + echo NOTE: the Lite has no screen. Status comes from + echo its RGB LED - after flashing, a short white pulse + echo means the firmware booted. +) else ( + echo =============================================== + echo ATOM S3 MacroPad - Interactive Flasher + echo =============================================== +) +echo. + +:: ----------------------------------------------- +:: Auto path: if our firmware is already running on +:: a connected ATOMS3, ask it to drop into the ROM +:: bootloader and skip the hold-the-button dance. +:: Falls through to the manual flow on any failure. +:: ----------------------------------------------- +echo Checking for a running MacroPad on USB... +echo. +if exist "%AUTO_PORT_FILE%" del "%AUTO_PORT_FILE%" >nul 2>&1 +python "%~dp0auto_enter_bootloader.py" "%AUTO_PORT_FILE%" +set AUTO_RESULT=%ERRORLEVEL% +if %AUTO_RESULT%==0 if exist "%AUTO_PORT_FILE%" ( + set /p PORT=<"%AUTO_PORT_FILE%" + if not "!PORT!"=="" ( + echo. + echo Auto-detected ATOMS3 in download mode on !PORT!. + echo Skipping manual reconnect steps. + echo. + goto :compile + ) +) +echo. +echo Auto-detection did not find a running MacroPad. +echo Falling back to manual reconnect flow. +echo. +echo This script will detect the device automatically by +echo watching which COM port appears when you plug the ATOMS3 in. +echo. + +:: ---- Step 1: snapshot ports while device is unplugged ---- +echo ----------------------------------------------- +echo STEP 1 of 4: Disconnect the ATOM S3 +echo ----------------------------------------------- +echo. +echo Unplug the ATOM S3 from your computer if it's +echo currently plugged in. If it's not plugged in, +echo no action is needed. +echo. +pause + +powershell -NoProfile -Command "$ports = @([System.IO.Ports.SerialPort]::GetPortNames() | Sort-Object -Unique); [System.IO.File]::WriteAllLines('%BEFORE%', $ports)" +if not exist "%BEFORE%" ( + echo Failed to enumerate COM ports. PowerShell may be restricted on this system. + set EXIT_CODE=1 + goto :end +) + +echo. +echo Currently visible COM ports: +set _ANY= +for /f "usebackq delims=" %%L in ("%BEFORE%") do ( + echo %%L + set _ANY=1 +) +if not defined _ANY echo (none) +echo. + +:: ---- Step 2: ask user to plug in + enter download mode ---- +:reconnect_step +echo ----------------------------------------------- +echo STEP 2 of 4: Plug in and enter download mode +echo ----------------------------------------------- +echo. +if defined LITE ( + echo Plug the ATOM S3 Lite in, then press AND HOLD + echo the small side button until the green light + echo flashes. That puts it into download mode. + echo. + echo ^(The Lite's main RGB LED stays dark in download + echo mode - the green flash is the small internal + echo light next to the USB port.^) +) else ( + echo Plug the ATOM S3 in, then press AND HOLD the + echo small side button until the internal green + echo light flashes. That puts it into download mode. +) +echo. +echo (If you release too early it'll boot normally. +echo Just unplug, replug, and try again.) +echo. +pause + +powershell -NoProfile -Command "$ports = @([System.IO.Ports.SerialPort]::GetPortNames() | Sort-Object -Unique); [System.IO.File]::WriteAllLines('%AFTER%', $ports)" +if not exist "%AFTER%" ( + echo Failed to enumerate COM ports. + set EXIT_CODE=1 + goto :end +) + +:: ---- Step 3: diff (ports in AFTER not in BEFORE) -> %DIFF% ---- +powershell -NoProfile -Command "$b = @(Get-Content '%BEFORE%' -ErrorAction SilentlyContinue); $a = @(Get-Content '%AFTER%' -ErrorAction SilentlyContinue); $new = @($a | Where-Object { $b -notcontains $_ }); [System.IO.File]::WriteAllLines('%DIFF%', $new)" + +set NEW_COUNT=0 +set PORT= +for /f "usebackq delims=" %%P in ("%DIFF%") do ( + set /a NEW_COUNT+=1 + if not defined PORT set PORT=%%P +) + +if !NEW_COUNT! GTR 1 ( + echo. + echo More than one new COM port appeared: + for /f "usebackq delims=" %%L in ("%DIFF%") do echo %%L + echo. + echo Cannot determine which is the ATOM S3. Unplug + echo any other USB-serial devices and try again, or + echo run "upload.bat COMx" with the port specified. + set EXIT_CODE=1 + goto :end +) + +if !NEW_COUNT! EQU 0 ( + echo. + echo No new COM port detected. + echo Most likely you didn't hold the side button long + echo enough, or didn't reconnect the device. + echo. + set RETRY= + set /p RETRY= Try step 2 again? [Y/N]: + if /i "!RETRY!"=="Y" goto :reconnect_step + echo Aborted. + set EXIT_CODE=1 + goto :end +) + +:: ---- Step 4: confirm + countdown ---- +echo. +echo ----------------------------------------------- +echo STEP 3 of 4: Confirm +echo ----------------------------------------------- +echo. +echo Detected ATOM S3 in download mode on !PORT!. +echo. +set CONFIRM= +set /p CONFIRM= Flash this device? [Y/N]: +if /i not "!CONFIRM!"=="Y" ( + echo Aborted. + set EXIT_CODE=1 + goto :end +) + +echo. +echo ----------------------------------------------- +echo STEP 4 of 4: Flashing in 5 seconds... +echo ----------------------------------------------- +echo Press Ctrl+C now to abort. +echo. +for /l %%i in (5,-1,1) do ( + echo %%i... + ping -n 2 127.0.0.1 >nul +) +echo. + +:compile +echo =============================================== +echo Flashing MacroPad firmware +echo Port: %PORT% +echo FQBN: %FQBN% +echo =============================================== +echo. + +echo ----------------------------------------------- +echo Step 1 of 3: Compiling firmware +echo ----------------------------------------------- +arduino-cli compile --fqbn "%FQBN%" "%SKETCH%" +set RESULT=%ERRORLEVEL% +if not %RESULT%==0 ( + echo. + echo COMPILE FAILED - cannot upload. + set EXIT_CODE=%RESULT% + goto :end +) +echo. +echo Compile OK +echo. + +echo ----------------------------------------------- +echo Step 2 of 3: Wiping device flash +echo ----------------------------------------------- +echo Full chip erase - clears firmware, NVS, and +echo LittleFS so the device boots up clean. +echo. +"%ESPTOOL%" --chip esp32s3 --port %PORT% erase_flash +set RESULT=%ERRORLEVEL% +if not %RESULT%==0 ( + echo. + echo ERASE FAILED. + echo. + echo If the device dropped out of download mode, hold the + echo side button while plugging it in, then re-run. + set EXIT_CODE=%RESULT% + goto :end +) +echo. +echo Wipe OK - device flash is now empty +echo. + +echo ----------------------------------------------- +echo Step 3 of 3: Uploading new firmware +echo ----------------------------------------------- +arduino-cli upload -v --fqbn "%FQBN%" --port %PORT% "%SKETCH%" +set RESULT=%ERRORLEVEL% + +echo. +if %RESULT%==0 ( + echo =============================================== + echo UPLOAD SUCCESSFUL + echo =============================================== + echo The ATOM S3 will reboot into the new firmware. + echo The COM port number may change after the reboot. +) else ( + echo =============================================== + echo UPLOAD FAILED (exit code %RESULT%) + echo =============================================== +) +set EXIT_CODE=%RESULT% + +:: ---- Post-flash: sync the freshly-generated BLE key ---- +:: A full chip erase wipes LittleFS, so the device makes a NEW per-device +:: encryption key on first boot. Pull it over USB now so BLE recording / +:: variable sync work immediately without a manual profile upload. This +:: never changes the flasher's exit code — a sync miss isn't a flash +:: failure (the script prints its own guidance and the app re-syncs on the +:: next profile upload anyway). +if %RESULT%==0 ( + echo. + echo ----------------------------------------------- + echo Syncing BLE encryption key + echo ----------------------------------------------- + python "%~dp0sync_key.py" +) + +:end +echo. +pause +exit /b %EXIT_CODE% diff --git a/CompileAndUpload/wipe_device.bat b/CompileAndUpload/wipe_device.bat new file mode 100644 index 0000000..f667bd2 --- /dev/null +++ b/CompileAndUpload/wipe_device.bat @@ -0,0 +1,7 @@ +@echo off +setlocal + +cd /d "%~dp0.." +python wipe_device.py %1 +echo. +pause diff --git a/GUI.bat b/GUI.bat new file mode 100644 index 0000000..90d0107 --- /dev/null +++ b/GUI.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +start "" pythonw main.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fe0d17 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +Developed by Grant Lanier + +# M5Stack Keyboard Emulator + +A node-based macro editor for the M5Stack AtomS3. Build keyboard/mouse/serial automation as visual flow graphs, push them to the device over USB, optionally sync runtime variables over BLE. + +## What it is + +The AtomS3 is an ESP32-S3 board with an LCD that doubles as a button. With this firmware on it, the host PC sees it as a USB HID keyboard + mouse + CDC serial device. Whatever it sends via that USB connection is determined by routines authored in the desktop app and uploaded to flash. + +A routine is a directed graph rather than a flat key sequence. Nodes can branch (user picks an option on the device's screen), loop a user-supplied number of times, send RS-232 over the device's UART pins, probe the host with a Num Lock toggle, or pull a variable dict from the desktop app over BLE and interpolate it into typed text. Once uploaded, the device runs all of this on its own, the desktop app is only needed for authoring and for serving BLE variables. + +## How to use it + +**Flashing a new ATOMS3.** Run `CompileAndUpload/upload.bat` and follow the prompts, it walks you through putting the AtomS3 into download mode and flashes the firmware (see [Flashing a fresh device](#flashing-a-fresh-device) for details). Then run `GUI.bat`. Click **Connect** in the toolbar if the device doesn't auto-connect; the AtomS3 is found by USB VID + a JSON ping handshake. + +**Profiles.** A profile is a named set of routines. The toolbar has a profile dropdown with `+` (new) and `×` (delete). Whatever profile is currently selected is the "active" upload target. The profile named `Sub-Routines` is reserved: routines there don't show in the device menu, they exist only to be called from other routines via the Sub-Routine node. + +**Routines.** The left pane lists routines in the current profile. Use `+` to add, `×` to delete, the arrows to reorder (order = order on the ATOM S3's menu). Click the camera icon to the right of the routine name in the left sidebar to change its image, this image will show on-device. You can rename a routine by opening it and left-clicking on the "Start" block. + +**Modifying Routines.** Once you have selected a routine to edit, right-click the canvas to add a node, drag between ports to connect them (outputs are red and inputs are green). Every routine must have a single **Start** node - its output is the entry point. The right pane shows properties for whatever block is currently selected. Multi-select with shift (or by dragging with right-click), pan with left-click, zoom with the wheel. + +**Settings.** Moving up to the toolbar, the Settings dialog covers global timings (key hold, type delay, combo pre/post, probe timeout, etc.) and screen orientation. These ship with the project and are applied device-side on upload. + +**Upload.** Clicking "Upload Profile" in the toolbar serializes the currently selected profile / settings / sub-routines to JSON and writes it to the device's LittleFS partition. + +**Running routine.** Universally a short press of the screen is "next" / "scroll" and a long press is "select." If at any point you want to cancel a running routine, you can unplug the device or click the physical button on the left side of the ATOM S3. + +**Variables (optional).** Open the Variables window, edit the dict (e.g. `{"ticket": "PROJ-1234"}`). When a running macro hits a Variables node in BLE pull/push/request mode, the device powers on its radio just long enough to fetch the current dict from the desktop app. Any Type Text node with `(VAR{ticket})` in it will then interpolate the value (case-insensitive — `(VAR{TICKET})` matches the same entry). Requires the desktop app to be running and the Bluetooth adapter installed in the desktop to support BLE, you will know that the Python app is ready for BLE connections based off of the "BLE: Ready" status in the toolbar. The legacy `(BLE{name})` syntax is still recognized for backward compatibility. + +**Backups & recovery.** The Backups button in the toolbar opens a snapshot history of the project. Profiles are auto-saved to `config/profiles/` and recovered on next launch. + +# Technical details + +## Flashing a fresh device + +A new AtomS3 has no firmware on it. From the project root, run: + +``` +CompileAndUpload\upload.bat +``` + +The script is interactive and figures out the COM port itself. + +You can define the COM port manually using this format: `upload.bat COM7` if needed. + +The toolchain expects `arduino-cli` on PATH; `esptool.exe` is resolved from the Arduino15 cache automatically. + +### AtomS3 vs AtomS3 Lite (one universal binary) + +The AtomS3 (128×128 LCD) and the AtomS3 Lite (no screen, one RGB LED) are the same ESP32-S3 module and run the **same firmware** — the build detects the board at boot. Settings has two flash buttons: *Flash Firmware — AtomS3* and *Flash Firmware — AtomS3 Lite (no screen)*. They flash identical code; the Lite button only switches the flasher's manual-recovery wording (the Lite has no screen to watch). + +On a Lite, everything the LCD would show maps to the RGB LED instead. Every state is a distinct color + motion — smooth crossfades mark the calm "what am I / who has me" states, while sharper blinks and bursts mark action: + +| State | LED | +| --- | --- | +| Boot | white pulse | +| Idle — **BLE** mode | soft white↔blue crossfade | +| Idle — **Mesh** mode | soft white↔amber crossfade | +| (either idle) | a click flashes white then blinks N times for the slot position | +| Mode switched (5 s hold) | triple burst in the new accent — blue (BLE) / amber (Mesh) | +| Running a routine | steady green (brightens as text types; breathes during a delay) | +| Pause node | steady yellow (untimed) / yellow blink that speeds up in the last 3 s (timed) | +| Branch / loop selector | magenta / orange blink-bursts counting the current choice or value | +| Error | red triple-blink; failed check = red blink | +| Live joined (idle) | slow cyan breathe (heartbeat); flickers white as keystrokes arrive | +| Reconnecting to host | cyan 1 Hz blink | +| Lost the hub mid-session | cyan/red blink | +| Identify (host labeling it) | fast blue/white strobe | +| Mesh hub mode | steady purple | +| BLE variables exchange | blue breathe (brief, inside a routine) | + +Because the idle color now encodes the live transport (blue = BLE, amber = Mesh), you can tell a resting headless node's mode at a glance without a screen. A Lite still supports the full feature set including button-driven macro selection (click to cycle, hold to run; hold 5 s to toggle the transport), but with no screen it's best to keep your most-used routines in the first slots so the position blink-count stays easy to read. Lites shine as headless live-keyboard nodes. + +## Live keyboard — two transports + +The **Keyboard** window streams your laptop's keyboard and mouse to many devices at once. Clicking the toolbar's **Keyboard** button first asks which transport to use for the session; the picker shows each mode's soft device cap: + +- **Bluetooth (BLE)** — a direct BLE link to each device, no USB hub required. Windows only holds ~3-4 reliable concurrent BLE links, so this **soft-caps at 4 devices**. +- **ESP-NOW hub** — the M5Stack plugged into the laptop over USB is switched into an **ESP-NOW hub** and broadcasts the input stream to every other device over connectionless Wi-Fi — no router, no access point, no SoftAP. It sidesteps the BLE ceiling and **soft-caps at 12 devices** (the hub roster can track more; 12 is just where the UI starts warning). + +Both caps are soft: the window keeps working past them but shows a non-blocking warning, since throughput degrades. Streaming, recording, replay, the trackpad, and profiles behave identically in either mode. + +How it works in practice: + +- Open **Keyboard** and pick a mode. For hub mode, plug one device into the laptop over USB first — the app finds it and makes it the hub automatically. +- Click **Discover** — idle in-range devices answer (over the mesh in hub mode, or a BLE scan in Bluetooth mode) and you pick which to add. (Leave each node idle on its selector; a running routine owns its radio for HID.) +- The device list shows a **Lag** column: how many keystrokes each node is behind the live stream (hub mode only — BLE mode shows a dash). It should sit at 0; if it climbs across the fleet, the Wi-Fi channel is congested — change **Mesh Wi-Fi channel** in Settings (every device must share the same channel) to a clearer one. + +Zero input loss is guaranteed by the transport: every keystroke/mouse-button/wheel event carries a sequence number, the hub keeps a retransmit buffer, and nodes NACK any gap and ACK their progress. Pure mouse *moves* are the only thing that may be coalesced (absolute positions are self-correcting). Every frame is AES-256-GCM encrypted under a per-session key, the same crypto the BLE channels use. The hub holds no key — it only routes — and reverts to a normal node a few seconds after you close the window. + +The encryption keys are keyed by each device's MAC, which is the same MAC the mesh uses, so **no re-provisioning is needed** — devices you've already uploaded a profile to just work. Old BLE profiles are migrated automatically on load. + +## Wiping a device + +A flashed device has two persistent regions: NVS at `0x009000` (20 KB, runtime settings) and LittleFS at `0x670000` (1.5 MB, the uploaded project). To clear both without reflashing the firmware itself: + +``` +python wipe_device.py # auto-detect port +python wipe_device.py COM7 # or specify it +``` + +`wipe_device.py` finds the running device by USB VID `0x303A` and a JSON `ping` handshake, sends `{"cmd":"bootloader"}` to drop it into ROM download mode, waits for the USB-JTAG port to re-enumerate, then calls `esptool` to erase the two partitions. Useful before reflashing or when stale settings are causing weird behavior. + +## Running the editor + +``` +pip install -r requirements.txt +python main.py +``` + +Dependencies are: `pyserial` for USB CDC, `bleak` for BLE. Tkinter is stdlib. `GUI.bat` is just a double-clickable wrapper around `python main.py`. + +A connected device exposes one of two COM ports depending on what mode it's in: TinyUSB CDC while the firmware is running, USB-JTAG while the ROM bootloader is active. The Python app talks to the former, `esptool` talks to the latter. If the device is in USB-JTAG mode and you want to bring it back to USB-CDC a simple reset using the side button is safe. + +## Project layout + +``` +app.py MacroPadApp - main window, wires everything together +main.py entry point +serial_manager.py USB CDC discovery + JSON command protocol +ble_server.py bleak client; pushes variable dict on demand +live_protocol.py shared live-keyboard packing + mesh/hub framing +mesh_link.py serial bridge to the ESP-NOW hub device +mesh_manager.py mesh keyboard session (drop-in for the old BLE one) +wipe_device.py standalone NVS + LittleFS eraser +test_serial.py smoke-test for the JSON protocol without the GUI +node_editor/ canvas, node rendering, port routing +widgets/ MacroList, PropertiesPanel, Toolbar, dialogs +models/ Macro, NodeData, Project, Settings, profile/backup mgrs +utils/constants.py NODE_TYPES, HID lookup tables, canvas styling +firmware/MacroPad/ Arduino sketch (the on-device interpreter) +CompileAndUpload/ compile.bat / upload.bat / wipe_device.bat wrappers +``` + +## Communication model + +**USB (always-on).** JSON over TinyUSB CDC at 115200 baud. The host drives the conversation: `ping`, `bootloader`, profile upload/download, wipe. `serial_manager.py` is the single chokepoint for everything that goes over the wire. + +**BLE (on-demand).** The radio is off by default. When a running routine hits a BLE Variables node, the device brings up NimBLE, advertises, accepts a write to the variables characteristic, then tears the stack down. `ble_server.py` runs a continuous bleak scan loop on the host so it's ready the instant the device shows up. BLE and USB stacks fight each other on the ESP32-S3 so this was necessary. + +## Project / profile / backup model + +- A **Project** is `Settings` + the BLE variables dict + every routine across every profile, JSON-serialized. +- **Profiles** are saved to `config/profiles/` and auto-recovered on launch (`profile_manager.py`). +- **Backups** are timestamped snapshot copies (`backup_manager.py`); not git, just a versioned history of the project. +- The profile named **`Sub-Routines`** is reserved; routines there don't appear in the device menu and exist solely to be called from other routines via the Sub-Routine node. + +## Operators (node types) + +Routines are graphs. Every routine starts with a **Start** node and flows through any of the following: + +| Node | What it does | +|---|---| +| **Start** | Entry point. Owns the routine name and its label color in the device list. | +| **Type Text** | Sends a typed string. Pop-out editor for long blocks; supports cmd/PowerShell hinting. | +| **Key Combo** | Modifier+key chord. Per-node Custom Timings override the global pre/post hold (defaults are ~3× faster than device defaults). | +| **Pause** | Halts execution until the user clicks the device, or for a fixed duration. Display text is shown on the LCD. | +| **Delay** | Plain `delay(ms)`. | +| **Branch** | N-way fork rendered as a dropdown on the device. User picks a path. | +| **Loop** | Repeats N times. Has a tied loop-back input. Count can come from the last **Loop Selector** that ran instead of being static. | +| **Loop Selector** | Prompts on-device for a loop count (min/max/step/default). Optionally re-prompts each iteration. | +| **Iteration Branch** | Inside a loop, dispatches per-iteration. References a specific Loop node by ID. "Skip on final iteration" exists because some side-effect chains (e.g. "switch to next KVM target") have nothing meaningful to do on the last pass. | +| **Aggregator** | Many-to-one merge (1–16 inputs → 1 output). Keeps the canvas readable when several branches converge. | +| **Mouse Click** | left/right/middle × click/double/press/release. | +| **Media Key** | Volume, mute, play/pause, next/prev, stop, brightness. | +| **Macro** | Plays back a recorded HID stream — raw `[t_ms, action, usage_code]` tuples captured by `MacroRecorderDialog`. Unlike Type Text / Key Combo, this preserves real chords and exact timing because it doesn't re-synthesize from keysyms. | +| **RS232 Send** | Serial out from the device's UART pins: configurable baud / data / stop / parity / line-ending, optional response-wait with timeout, optional post-send delay (handy when the command kills USB power, e.g. a KVM switch — the device persists resume state first). | +| **PC Alive Check** | Toggles Num Lock and watches the LED report to decide whether the host is alive. Branches true/false; optional retry loop. | +| **Variables** | Reads/writes the on-device variable store; five modes. **Pull BLE** — device fetches the current dict from the host (scope `Universal` shared across devices, or `This device` keyed by eFuse MAC). **Push BLE** — device uploads its local store to the host, written into that device's profile only; Universal is never clobbered (use this to round-trip values that were set on-device back to the desktop app). **Request BLE** — device names the variables it wants and the host pops a fill-in dialog (optional notification sound) before returning the user's edits. **Set Variables** — local name=value writes, no radio. **Get Variables** — runs a host-side script via HID and decodes the answer from Num Lock toggle counts → outcome values, either Manual (raw PowerShell) or Semi-Auto (build a check sequence in a visual editor, up to 5 outcomes; an optional Win+R bootstrap can launch an elevated terminal first). All three BLE modes bring NimBLE up only for the exchange and tear it back down. Use `(VAR{name})` inside Type Text to interpolate a stored value (case-insensitive; `(BLE{name})` is still recognized as a legacy alias). | +| **Sub-Routine** | Calls a routine from the reserved `Sub-Routines` profile and resumes when it returns. | +| **Note** | Canvas annotation. Never serialized to the device. | + +green = input, red = output. Nodes can be visually flipped (ports swap sides) by selecting the node and pressing "f" which is purely cosmetic. + +## Things worth knowing + +- The HID lookup table lives in `utils/constants.py` as `TKKEYSYM_TO_HID`. That's how the recorder maps Tk keysyms to USB HID usage codes. Add entries there if you find a key that doesn't record correctly. +- `test_serial.py` is a quick smoke-test for the JSON protocol without launching the GUI. diff --git a/app.py b/app.py new file mode 100644 index 0000000..1733e2f --- /dev/null +++ b/app.py @@ -0,0 +1,548 @@ +"""Main application window for ATOMS3 MacroPad.""" + +import tkinter as tk +from tkinter import messagebox +from models.node_graph import Project +from models.profile_manager import ProfileManager +from models.backup_manager import BackupManager +from serial_manager import SerialManager +from node_editor.canvas import NodeCanvas +from widgets.macro_list import MacroListPanel +from widgets.properties_panel import PropertiesPanel +from widgets.toolbar import Toolbar +from widgets.ble_variables_window import BLEVariablesWindow +from widgets.backup_dialog import BackupDialog +from widgets.rs232_terminal import RS232Terminal +from ble_server import BLEVariableClient +from utils.constants import APP_NAME, APPDATA_DIR + +import os + + +class MacroPadApp(tk.Tk): + """Main application window with three-pane layout.""" + + def __init__(self): + super().__init__() + self.title(APP_NAME) + self.geometry("1200x700") + self.minsize(900, 500) + self.configure(bg="#1E1E2E") + + # Dark title bar on Windows + try: + self.update_idletasks() + import ctypes + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(ctypes.c_int(1)), ctypes.sizeof(ctypes.c_int)) + except Exception: + pass + + os.makedirs(APPDATA_DIR, exist_ok=True) + self.serial_manager = SerialManager() + self.serial_manager.set_callbacks( + on_connect=lambda port: self.after(0, lambda p=port: self.toolbar.set_connected(p)), + on_disconnect=lambda: self.after(0, self.toolbar.set_disconnected), + ) + self.profile_manager = ProfileManager() + self.project = self.profile_manager.startup_load() + self.backup_manager = BackupManager() + self._autosave_job = None + self._backup_job = None + self._rs232_terminal = None + self._port_watch_job = None + # Read by _scan_ports' exception path, which can fire on the very first + # call from _start_port_watcher before any successful scan + self._last_ports = frozenset() + self.ble_client = BLEVariableClient() + + self._build_ui() + self._load_project() + self._auto_connect() + self._start_ble_client() + self._start_port_watcher() + + self.protocol("WM_DELETE_WINDOW", self._on_close) + + def _build_ui(self): + self.toolbar = Toolbar( + self, + serial_manager=self.serial_manager, + on_settings_change=self._on_change, + on_profile_switch=self._switch_profile, + on_profile_new=self._new_profile, + on_profile_delete=self._delete_profile, + on_ble_variables=self._open_ble_variables, + on_backups=self._open_backups, + on_rs232_terminal=self._open_rs232_terminal, + on_bt_keyboard=self._open_bt_keyboard, + on_settings_open=self.pause_port_watcher, + on_settings_close=self.resume_port_watcher, + profile_manager=self.profile_manager, + ) + self.toolbar.pack(fill="x") + + content = tk.Frame(self, bg="#1E1E2E") + content.pack(fill="both", expand=True) + + self.macro_list = MacroListPanel( + content, + on_select=self._on_macro_selected, + on_change=self._on_change, + ) + self.macro_list.pack(side="left", fill="y") + + self.properties = PropertiesPanel( + content, + on_change=self._on_change, + ) + self.properties.pack(side="right", fill="y") + + self.node_canvas = NodeCanvas( + content, + on_node_select=self._on_node_selected, + on_change=self._on_change, + ) + self.node_canvas.pack(side="left", fill="both", expand=True) + + self.macro_list.set_profile_manager(self.profile_manager) + + self.properties.set_node_canvas(self.node_canvas) + self.properties.set_macro_list(self.macro_list) + self.properties.set_profile_manager(self.profile_manager) + self.properties.set_project(self.project) + + # Skipped when an Entry/Text/Combobox has focus so native copy/paste still works + self.bind_all("", self._on_ctrl_c, add="+") + self.bind_all("", self._on_ctrl_v, add="+") + + def _load_project(self): + self.toolbar.set_project(self.project) + self.toolbar.set_profiles( + self.profile_manager.profile_names(), + self.profile_manager.active_name, + ) + self.macro_list.set_project(self.project) + # Profile switch reassigns self.project, so panels that cached a + # Project handle at startup (text-node variable list, pause-editor + # margins) must be re-pointed at the new instance + self.properties.set_project(self.project) + self.ble_client.update_variables(self.project.ble_variables) + + if self.project.macros: + self._on_macro_selected(0) + else: + self.node_canvas.load_macro(None) + self.properties.show_node(None) + + def _on_macro_selected(self, index): + if index < 0 or index >= len(self.project.macros): + self.node_canvas.load_macro(None) + self.properties.show_node(None) + return + + macro = self.project.macros[index] + self.node_canvas.load_macro(macro) + self.properties.show_node(None) + + start_id = self.node_canvas.find_start_node() + if start_id: + self.node_canvas.scroll_to_node(start_id) + + def _on_node_selected(self, node_widget): + self.properties.show_node(node_widget) + + def _is_text_focus(self) -> bool: + """Return True if a text input widget currently has focus.""" + focused = self.focus_get() + if focused is None: + return False + cls = focused.__class__.__name__ + return cls in ("Entry", "Text", "Spinbox") or "Combobox" in cls + + def _on_ctrl_c(self, event): + if self._is_text_focus(): + return + self.node_canvas.copy_selected() + return "break" + + def _on_ctrl_v(self, event): + if self._is_text_focus(): + return + self.node_canvas.paste() + return "break" + + def _on_change(self): + """Schedule debounced autosave and throttled auto-backup.""" + if self._autosave_job: + self.after_cancel(self._autosave_job) + self._autosave_job = self.after(2000, self._autosave) + + # If a backup is already pending, let its existing timer fire — it + # will pick up the latest state. This caps backups at one per 5s. + if self._backup_job is None: + self._backup_job = self.after(5000, self._run_backup) + + def _autosave(self): + self._autosave_job = None + try: + self.profile_manager.save_current() + except Exception as e: + print(f"Autosave error: {e}") + + def _run_backup(self): + self._backup_job = None + try: + self.profile_manager.save_current() + self.backup_manager.create_backup() + except Exception as e: + print(f"Auto-backup error: {e}") + + def _open_backups(self): + BackupDialog(self, self.backup_manager, on_restore=self._restore_backup) + + def _open_rs232_terminal(self): + """Open the RS232 Terminal dialog (talks through the M5Stack over USB).""" + if not self.serial_manager.connected: + messagebox.showwarning( + "RS232 Terminal", + "Connect the MacroPad over USB first.", + ) + return + if self._is_rs232_terminal_open(): + self._rs232_terminal.lift() + self._rs232_terminal.focus_set() + return + + def cleared(): + self._rs232_terminal = None + + self._rs232_terminal = RS232Terminal(self, self.serial_manager, on_close=cleared) + + def _is_rs232_terminal_open(self) -> bool: + if self._rs232_terminal is None: + return False + try: + return bool(self._rs232_terminal.winfo_exists()) + except tk.TclError: + return False + + def _restore_backup(self, path) -> bool: + """Restore the app state from a backup zip.""" + # Cancel pending jobs so they don't write over the restored state + if self._autosave_job: + self.after_cancel(self._autosave_job) + self._autosave_job = None + if self._backup_job: + self.after_cancel(self._backup_job) + self._backup_job = None + + try: + ok = self.backup_manager.restore_backup(path) + if not ok: + return False + + self.profile_manager = ProfileManager() + self.project = self.profile_manager.startup_load() + + self.toolbar.profile_manager = self.profile_manager + self.properties.set_profile_manager(self.profile_manager) + self.macro_list.set_profile_manager(self.profile_manager) + + self._load_project() + + self.ble_client.update_variables(self.project.ble_variables) + return True + except Exception as e: + print(f"Restore error: {e}") + return False + + def _switch_profile(self, name: str): + if self._autosave_job: + self.after_cancel(self._autosave_job) + self._autosave_job = None + self.profile_manager.save_current() + self.project = self.profile_manager.switch(name) + self._load_project() + + def _new_profile(self, name: str, copy_current: bool): + try: + self.project = self.profile_manager.new_profile(name, copy_current) + except ValueError as e: + messagebox.showerror("New Profile", str(e)) + return + self._load_project() + + def _delete_profile(self, name: str): + try: + self.project = self.profile_manager.delete_profile(name) + except ValueError as e: + messagebox.showerror("Delete Profile", str(e)) + return + self._load_project() + + def _auto_connect(self): + """Try to connect to device on startup.""" + import threading + + def try_connect(): + self.serial_manager.scan_and_connect() + if self.serial_manager.connected: + self.after(0, lambda: self.toolbar.set_connected(self.serial_manager.port)) + + threading.Thread(target=try_connect, daemon=True).start() + + def _scan_ports(self): + """Return (set of device names, True if any Espressif-VID port present).""" + import serial.tools.list_ports + from utils.constants import ESPRESSIF_VID + try: + ports = list(serial.tools.list_ports.comports()) + except Exception: + return self._last_ports, False + devices = frozenset(p.device for p in ports) + has_esp = any(p.vid == ESPRESSIF_VID for p in ports) + return devices, has_esp + + def _start_port_watcher(self): + self._last_ports, _ = self._scan_ports() + self._reconnect_in_flight = False + self._port_watch_job = self.after(3000, self._poll_ports) + + def pause_port_watcher(self): + if self._port_watch_job is not None: + try: + self.after_cancel(self._port_watch_job) + except Exception: + pass + self._port_watch_job = None + + def resume_port_watcher(self): + if self._port_watch_job is None: + self._start_port_watcher() + + def _poll_ports(self): + self._port_watch_job = None + try: + # Upload and RS232 terminal both need exclusive serial access + gated = self.serial_manager.is_uploading or self._is_rs232_terminal_open() + if not gated: + current, has_esp_port = self._scan_ports() + + # `connected` only flips on a failed send, so if the user + # unplugged while idle the flag is still True. Force-clear it + # so the reconnect path below can run. + if (self.serial_manager.connected + and self.serial_manager.port + and self.serial_manager.port not in current): + self.serial_manager.disconnect() + + self._last_ports = current + + # Poll-retry rather than edge-trigger: the first attempt right + # after plug-in often races the ESP32's boot and times out, + # and no further port-set change would re-trigger us. + if (not self.serial_manager.connected + and has_esp_port + and not self._reconnect_in_flight): + self._attempt_reconnect() + finally: + self._port_watch_job = self.after(3000, self._poll_ports) + + def _attempt_reconnect(self): + import threading + self._reconnect_in_flight = True + + def worker(): + try: + self.serial_manager.scan_and_connect() + finally: + self.after(0, lambda: setattr(self, "_reconnect_in_flight", False)) + + threading.Thread(target=worker, daemon=True).start() + + def _start_ble_client(self): + def on_status(status): + self.after(0, lambda s=status: self.toolbar.set_ble_status(s)) + + # BLE callbacks run on the BLE thread — read-only ones use the + # project directly; mutating ones bounce through self.after for + # Tk-safe state changes. + def get_vars(scope, mac): + if scope == "device": + return dict(self.project.get_device(mac)) + return dict(self.project.get_universal()) + + def set_device_vars(mac, vars_): + def apply(): + self.project.set_device(mac, vars_) + self._on_change() + self.after(0, apply) + + def prompt_request(mac, names): + import threading + done = threading.Event() + result = {"value": None} + + def open_dialog(): + from widgets.request_variable_dialog import RequestVariableDialog + + def on_submit(edited): + result["value"] = edited + done.set() + + current = dict(self.project.get_device(mac)) + RequestVariableDialog( + self, mac, names, current, + on_submit=on_submit, play_sound=True, + ) + + self.after(0, open_dialog) + # No timeout — the device-side request_ble wait is also indefinite. + # Cancel via the device's side button if needed. + done.wait() + return result["value"] + + def on_device_seen(mac): + def announce(): + self.project.get_device(mac) + self._on_change() + self.after(0, announce) + + self.ble_client.start( + self.project.ble_variables, + on_status=on_status, + get_vars=get_vars, + set_device_vars=set_device_vars, + prompt_request=prompt_request, + on_device_seen=on_device_seen, + ) + + def make_ble_live_client(self): + """Factory for a fresh BLELiveKeystrokeClient. + + The macro recorder dialog calls this every time the user clicks + Open BLE. A new client per session keeps the lifecycle simple + and avoids any cross-session replay-state leakage on the worker. + """ + from ble_live import BLELiveKeystrokeClient + return BLELiveKeystrokeClient() + + def pause_var_sync_ble(self): + """Stop the variable-sync BLE client so the live-record client + has the radio to itself. Both clients filter by the same + SERVICE_UUID, so without this they race for connect attempts and + the var-sync side blocks waiting for a JSON hello that the live + flow never sends. + """ + try: + self.ble_client.stop() + except Exception as exc: + print(f"[BLE] pause var-sync error: {exc}") + + def resume_var_sync_ble(self): + """Re-start the variable-sync BLE client after a live-record + session ends. Re-wires the same callbacks _start_ble_client + installed at boot so device pushes/pulls keep flowing.""" + try: + # Drop the old client (which has a dead worker thread) and + # construct a fresh one; reuses BLEVariableClient.start's + # standard setup path. + from ble_server import BLEVariableClient + self.ble_client = BLEVariableClient() + self._start_ble_client() + except Exception as exc: + print(f"[BLE] resume var-sync error: {exc}") + + def _open_bt_keyboard(self): + """Open the multi-device live-keystroke streaming window. + + Prompts for a transport first (direct BLE fan-out, or ESP-NOW hub), + then opens the streamer in that mode. In hub mode the window borrows + our USB serial link to switch the plugged-in device into hub mode + and drives the whole fleet through it; in BLE mode it links to each + device directly. Either way we hand over the serial manager plus the + port-watcher and var-sync pause/resume hooks so nothing else + contends for the port (or the BLE radio) while it's live.""" + from widgets.bt_keyboard_window import ( + BtKeyboardWindow, choose_keyboard_mode, + ) + if (getattr(self, "_bt_kbd_window", None) is not None + and self._bt_kbd_window.winfo_exists()): + self._bt_kbd_window.lift() + self._bt_kbd_window.focus_set() + return + mode = choose_keyboard_mode(self) + if mode is None: + return # user cancelled the picker + self._bt_kbd_window = BtKeyboardWindow( + self, + serial_manager=self.serial_manager, + mode=mode, + pause_var_sync=self.pause_var_sync_ble, + resume_var_sync=self.resume_var_sync_ble, + pause_port_watcher=self.pause_port_watcher, + resume_port_watcher=self.resume_port_watcher, + ) + + def _open_ble_variables(self): + def on_save(variables: dict, comments: dict): + self.project.ble_variables = variables + self.project.ble_comments = comments + self.ble_client.update_variables(variables) + self._on_change() + + BLEVariablesWindow( + self, self.project.ble_variables, + on_save=on_save, + ble_comments=self.project.ble_comments, + ) + + def _on_close(self): + # Cancel pending jobs so they don't race shutdown + if self._port_watch_job: + try: + self.after_cancel(self._port_watch_job) + except Exception: + pass + self._port_watch_job = None + + if self._autosave_job: + try: + self.after_cancel(self._autosave_job) + except Exception: + pass + self._autosave_job = None + + pending_backup = self._backup_job is not None + if pending_backup: + try: + self.after_cancel(self._backup_job) + except Exception: + pass + self._backup_job = None + + try: + self.profile_manager.save_current() + except Exception: + pass + + # If a backup was pending, take one now to capture the final changes + if pending_backup: + try: + self.backup_manager.create_backup() + except Exception: + pass + + try: + self.ble_client.stop() + except Exception: + pass + + if self.serial_manager.connected: + self.serial_manager.disconnect() + + self.destroy() diff --git a/ble_debug_log.py b/ble_debug_log.py new file mode 100644 index 0000000..2c2184e --- /dev/null +++ b/ble_debug_log.py @@ -0,0 +1,84 @@ +"""Append-only structured log for host-side BLE events. + +One JSON line per event, in `config/.ble_debug.log`. Written from the BLE +thread; reads happen from anywhere. Trimmed to the most recent N lines on +each write so the file doesn't grow forever. + +This is independent of Python's `logging` module so it survives across +restarts and gets written even if the GUI crashes. +""" + +import json +import os +import threading +import time +from pathlib import Path + +from utils.constants import APPDATA_DIR + +_LOG_PATH = Path(APPDATA_DIR) / ".ble_debug.log" +_MAX_LINES = 2000 # trimmed when exceeded + +_lock = threading.Lock() + + +def _trim_locked() -> None: + try: + with open(_LOG_PATH, "r", encoding="utf-8") as f: + lines = f.readlines() + except FileNotFoundError: + return + if len(lines) <= _MAX_LINES: + return + keep = lines[-_MAX_LINES:] + tmp = _LOG_PATH.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as f: + f.writelines(keep) + os.replace(tmp, _LOG_PATH) + + +def event(_event_name: str, **fields) -> None: + """Append a structured event line. + + First positional arg is the event name (renamed to avoid collision with + a `kind` keyword that some call sites legitimately want to log). + `fields` are arbitrary JSON-serializable extras. + """ + rec = { + "t": time.time(), + "ts": time.strftime("%H:%M:%S", time.localtime()), + "kind": _event_name, + } + rec.update(fields) + line = json.dumps(rec, default=str) + "\n" + with _lock: + try: + _LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(_LOG_PATH, "a", encoding="utf-8") as f: + f.write(line) + # Cheap probabilistic trim: only check size every ~50 lines. + if int(rec["t"] * 1000) % 50 == 0: + _trim_locked() + except Exception: + # Logging failures must not break BLE. + pass + # Mirror to stdout for live debugging. The variable was previously + # named `kind` which raised NameError and silently suppressed every + # stdout mirror via the broad except — fixed to use the local arg. + try: + print(f"[BLE.dbg {rec['ts']}] {_event_name} " + f"{json.dumps(fields, default=str)}") + except Exception: + pass + + +def path() -> str: + return str(_LOG_PATH) + + +def clear() -> None: + with _lock: + try: + _LOG_PATH.unlink() + except FileNotFoundError: + pass diff --git a/ble_frame.py b/ble_frame.py new file mode 100644 index 0000000..c1cfb91 --- /dev/null +++ b/ble_frame.py @@ -0,0 +1,115 @@ +"""Encrypted-frame envelope shared by the variable-sync and live-keystroke +BLE channels. + +Wire format (identical for both channels): + byte tag_len 1 byte + bytes tag ASCII "M5Stack|AA:BB:CC:DD:EE:FF" + bytes nonce 12 bytes + bytes ciphertext+gcm_tag N + 16 bytes (AES-256-GCM, AAD = tag bytes) + +Each channel layers its own plaintext schema on top: the var-sync channel +uses JSON, the live-keystroke channel uses a compact binary protocol +defined in ble_live.py. Frames whose tag prefix isn't "M5Stack|" or whose +AEAD authentication fails are dropped silently by the parser. +""" + +import os +import re + +DEVICE_TAG_PREFIX = "M5Stack|" +MAC_RE = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$") + + +def build_frame(key: bytes, tag: str, plaintext: bytes) -> bytes: + """Build an encrypted, MAC-tagged frame. + + `tag` is the device-tag string ("M5Stack|AA:BB:..."); it is bound to + the ciphertext via GCM AAD and also written in plaintext at the head + of the frame so the receiver can read it before decrypting. + """ + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + tag_bytes = tag.encode("ascii") + if len(tag_bytes) > 255: + raise ValueError("device tag too long") + nonce = os.urandom(12) + ct = AESGCM(key).encrypt(nonce, plaintext, tag_bytes) + return bytes([len(tag_bytes)]) + tag_bytes + nonce + ct + + +def extract_tag(frame: bytes): + """Pull the device tag (e.g. "M5Stack|AA:BB:CC:DD:EE:FF") out of + the frame header WITHOUT decrypting. Returns the tag string or + None if the frame is malformed. + + Used by the BLE layer to select the right per-MAC key before + attempting AES-GCM authentication. + """ + if len(frame) < 1 + 12 + 16: + return None + tag_len = frame[0] + if tag_len == 0 or len(frame) < 1 + tag_len + 12 + 16: + return None + tag_bytes = bytes(frame[1:1 + tag_len]) + try: + tag_str = tag_bytes.decode("ascii") + except UnicodeDecodeError: + return None + if not tag_str.startswith(DEVICE_TAG_PREFIX): + return None + mac = tag_str[len(DEVICE_TAG_PREFIX):] + if not MAC_RE.match(mac): + return None + return tag_str + + +def parse_frame(key: bytes, frame: bytes): + """Return (tag_str, plaintext_bytes) on success, or None on any failure. + + Returns None silently for malformed / wrong-prefix / authentication- + failed frames so callers can drop them without leaking timing info. + """ + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.exceptions import InvalidTag + + tag_str = extract_tag(frame) + if tag_str is None: + return None + tag_len = frame[0] + tag_bytes = bytes(frame[1:1 + tag_len]) + nonce = bytes(frame[1 + tag_len:1 + tag_len + 12]) + ct = bytes(frame[1 + tag_len + 12:]) + try: + pt = AESGCM(key).decrypt(nonce, ct, tag_bytes) + except InvalidTag: + return None + except Exception: + return None + return tag_str, pt + + +def parse_frame_auto(frame: bytes): + """Decode a frame using the right per-MAC key automatically. + + Reads the tag from the frame header, asks ble_keystore for the + matching key (falling back to the legacy single-key file), then + AES-GCM-decrypts. Returns (tag_str, plaintext_bytes) on success, + or (tag_str, None) if a tag was readable but no matching key was + available (so callers can surface a clear "unknown device" error + instead of a silent auth fail), or None for fully malformed input. + """ + import ble_keystore + tag_str = extract_tag(frame) + if tag_str is None: + return None + mac = tag_str[len(DEVICE_TAG_PREFIX):] + key = ble_keystore.load_key_for_mac_or_default(mac) + if key is None: + return (tag_str, None) + parsed = parse_frame(key, frame) + if parsed is None: + # Key existed but didn't authenticate — surface as a separate + # signal (None for plaintext) so the live client can emit + # KEY_MISMATCH for this specific device. + return (tag_str, None) + return parsed diff --git a/ble_keystore.py b/ble_keystore.py new file mode 100644 index 0000000..0eef081 --- /dev/null +++ b/ble_keystore.py @@ -0,0 +1,158 @@ +"""Persistent storage for the 32-byte BLE payload-encryption key(s). + +Each M5Stack generates its own key on first boot (per-device, persisted +to LittleFS). The host pulls the device's key during every profile +upload and stores it locally so subsequent BLE frames can be decrypted. + +Storage: + - ``config/.blekeyfile`` — legacy single-key file. The most + recently uploaded key. Kept as a fallback for situations where a + frame's device tag isn't in the per-MAC store. + - ``config/.ble_keys.json`` — per-MAC key store, keyed by the + eFuse base MAC (the same MAC that appears in each device's tag + string "M5Stack|AA:BB:CC:DD:EE:FF"). Multiple ATOMS3s can coexist + here so the user doesn't have to re-upload to switch devices. + +Public API (back-compat preserved): + load_key() -> bytes | None (legacy file) + save_key(key) -> None (writes legacy file) + save_key_for_mac(mac, key) -> None (writes per-MAC store) + load_key_for_mac(mac) -> bytes | None + all_known_macs() -> list[str] +""" + +import json +import os +import threading +from utils.constants import APPDATA_DIR + +KEY_LEN = 32 +_KEY_FILE = os.path.join(APPDATA_DIR, ".blekeyfile") +_PER_MAC_FILE = os.path.join(APPDATA_DIR, ".ble_keys.json") + +_lock = threading.Lock() + + +def key_path() -> str: + return _KEY_FILE + + +# ---- Legacy single-key API ---- + +def load_key() -> bytes | None: + try: + with open(_KEY_FILE, "rb") as f: + data = f.read() + except OSError: + return None + return data if len(data) == KEY_LEN else None + + +def save_key(key: bytes) -> None: + if len(key) != KEY_LEN: + raise ValueError(f"BLE key must be {KEY_LEN} bytes, got {len(key)}") + os.makedirs(APPDATA_DIR, exist_ok=True) + + # Detect a key change so we can wipe replay-protection counters: a new + # key invalidates any captured ciphertext, so old counters carry no + # protection value AND would block legitimate frames from a re-flashed + # device until we manually reset. + previous = load_key() + + tmp = _KEY_FILE + ".tmp" + with open(tmp, "wb") as f: + f.write(key) + os.replace(tmp, _KEY_FILE) + + if previous != key: + try: + import ble_replay + ble_replay.ReplayState().reset() + except Exception: + # Best-effort — replay reset failure isn't fatal (worst case, + # the next BLE frame from the device gets rejected and the user + # has to re-upload or wipe). + pass + + +# ---- Per-MAC keystore ---- + +def _normalize_mac(mac: str) -> str: + """Canonicalize MAC string: uppercase, colon-separated. Accepts the + device tag "M5Stack|AA:BB:..." OR a bare MAC.""" + if mac.startswith("M5Stack|"): + mac = mac[len("M5Stack|"):] + return mac.upper() + + +def _load_per_mac_locked() -> dict: + try: + with open(_PER_MAC_FILE, "r", encoding="utf-8") as f: + d = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + if not isinstance(d, dict): + return {} + return d + + +def _save_per_mac_locked(d: dict) -> None: + os.makedirs(APPDATA_DIR, exist_ok=True) + tmp = _PER_MAC_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(d, f, indent=2) + os.replace(tmp, _PER_MAC_FILE) + + +def save_key_for_mac(mac: str, key: bytes) -> None: + """Persist ``key`` (32 raw bytes) under ``mac``. Replaces any + existing key for that MAC and resets replay counters if the key + actually changed.""" + if len(key) != KEY_LEN: + raise ValueError(f"BLE key must be {KEY_LEN} bytes, got {len(key)}") + mac = _normalize_mac(mac) + with _lock: + store = _load_per_mac_locked() + prev_hex = store.get(mac) + new_hex = key.hex() + if prev_hex == new_hex: + return + store[mac] = new_hex + _save_per_mac_locked(store) + try: + import ble_replay + ble_replay.ReplayState().reset() + except Exception: + pass + + +def load_key_for_mac(mac: str) -> bytes | None: + """Look up the key for ``mac`` (accepts bare MAC or full tag). + Returns None if no key is stored for this device.""" + mac = _normalize_mac(mac) + with _lock: + store = _load_per_mac_locked() + hex_key = store.get(mac) + if not hex_key: + return None + try: + b = bytes.fromhex(hex_key) + except ValueError: + return None + return b if len(b) == KEY_LEN else None + + +def load_key_for_mac_or_default(mac: str) -> bytes | None: + """Per-MAC lookup with the legacy single-key file as a fallback. + Use this in the BLE layer when receiving a frame: try the right + key for the device's MAC first, fall back to the legacy file for + users who haven't re-uploaded since multi-device support landed.""" + k = load_key_for_mac(mac) + if k is not None: + return k + return load_key() + + +def all_known_macs() -> list: + with _lock: + return sorted(_load_per_mac_locked().keys()) diff --git a/ble_live.py b/ble_live.py new file mode 100644 index 0000000..c8d844a --- /dev/null +++ b/ble_live.py @@ -0,0 +1,892 @@ +"""Persistent low-latency BLE channel for streaming live keystrokes to the +M5Stack during macro recording. + +The variable-sync client in ble_server.py is exchange-driven: connect, +swap one round of frames, disconnect. Live recording needs the opposite — +a connection that stays up for the entire recording session, with the +host emitting many small write-without-response frames as the user types. + +Architecture: + - Owned by widgets/macro_recorder.py. + - The M5Stack auto-advertises the live service whenever it is idle + (no routine running). This client scans for that advertisement, + connects, subscribes to LIVE_KEYS_NOTIFY, exchanges a START + handshake, then accepts send_event() calls from the Tk thread. + - send_event() is fire-and-forget: it queues onto the asyncio loop; + writes use BLE WRITE_NO_RESPONSE so they cost one L2CAP frame and + no ACK round-trip. + - Disconnect detection bubbles up via on_status("disconnected"). + +Security model: + BLE link-layer access is intentionally open — no pairing, no PIN. + That's deliberate so the host machine (running Python, possibly a + different physical computer than the one the M5Stack is plugged + into) can connect at any time without out-of-band setup. + + Confidentiality and integrity come from the application layer: + every frame in both directions is AES-256-GCM, with the device-tag + string ("M5Stack|") bound as AAD. The 32-byte key lives on the + device's LittleFS partition and on the host's `/config/.blekeyfile` + (see ble_keystore.py). The key was originally pulled from the device + over USB during initial profile setup; once provisioned, neither + side ever transmits it. + + Consequence: any BLE-range device can OPEN a connection to the + M5Stack, but every frame it sends fails GCM auth (no key) and is + dropped silently in onLiveWriteReceived. Replay-protection (per-MAC + session_id + monotonic seq) blocks captured frames being replayed + even by a key-holder, scoped to the current device boot session. + + This is the same security model the existing variable-sync channel + uses; live recording inherits it unchanged. + +Frame protocol (each direction, after AES-GCM unwrap of ble_frame.py): + + byte 0: msg_type + 0x01 = START (host -> device, no body) + 0x02 = KEYS (host -> device, body = event_count + events) + 0x03 = STOP (host -> device, no body) + 0x10 = ACK (device -> host) + 0x11 = ERROR (device -> host) + 0x12 = HELLO (device -> host, first frame after subscribe) + bytes 1..8: session_id (uint64 little-endian) + bytes 9..16:seq (uint64 little-endian) + byte 17: body per msg_type + +KEYS body: + byte 17: event_count (1..16) + bytes 18..: event_count × { uint8 action, uint8 hid_code, uint32 t_ms_le } + action: 0=DOWN, 1=UP + t_ms_le: host-clock ms since the first event of this + live session (uint32 little-endian). Device + uses this to preserve typing cadence on + emission (see live_keystroke.h). + +ACK body: + byte 17..24: ref_seq (uint64 LE) — seq of the frame being acknowledged + +ERROR body: + byte 17: err_code (1=BUFFER_FULL, 2=NOT_LIVE_MODE, 3=HID_FAILURE, + 4=BAD_MSG) + bytes 18..25: ref_seq (uint64 LE) + +Replay protection: same (session_id, seq) shape as the variable-sync +channel, validated via ble_replay.ReplayState. +""" + +import asyncio +import logging +import struct +import threading +import time + +import ble_keystore +import ble_replay +import ble_debug_log as _bled +from ble_frame import build_frame, parse_frame_auto, DEVICE_TAG_PREFIX + +log = logging.getLogger(__name__) + + +def _dbg(kind: str, **fields) -> None: + """Mirror live-mode lifecycle into the shared .ble_debug.log so the + file survives across runs and is grep-able alongside the var-sync + events. Each event is tagged so it's easy to filter from var-sync.""" + try: + _bled.event("live_" + kind, **fields) + except Exception: + pass + +# ---- Message type bytes ---- +MSG_START = 0x01 +MSG_KEYS = 0x02 +MSG_STOP = 0x03 +MSG_IDENTIFY = 0x04 # host -> device: show/hide the Bluetooth identify logo +MSG_MOUSE = 0x05 # host -> device: absolute pointer {buttons, x_u16, y_u16, wheel_i8} +MSG_LABEL = 0x06 # host -> device: friendly label (UTF-8 body) +MSG_ACK = 0x10 +MSG_ERROR = 0x11 +MSG_HELLO = 0x12 + +ACTION_DOWN = 0 +ACTION_UP = 1 + +ERR_LABELS = { + 1: "BUFFER_FULL", + 2: "NOT_LIVE_MODE", + 3: "HID_FAILURE", + 4: "BAD_MSG", +} + +# Status strings published via on_status callback. +ST_IDLE = "idle" +ST_SCANNING = "scanning" +ST_CONNECTING = "connecting" +ST_CONNECTED = "connected" # subscribed and START acked +ST_DISCONNECTED = "disconnected" +ST_ERROR = "error" + +# How long to scan for the device before giving up one cycle. +SCAN_TIMEOUT_S = 4 +# How long to wait for the device's hello frame after subscribing. +HELLO_TIMEOUT_S = 8 +# How long to wait for the START ack before giving up. +START_ACK_TIMEOUT_S = 5 +# Worker queue capacity. Keystrokes that overflow are dropped on the host +# side (with a warning) rather than waiting and growing latency. +SEND_QUEUE_MAX = 512 + + +def _pack_header(msg_type: int, session_id: int, seq: int) -> bytes: + return struct.pack("1 clients don't fight for the same advertisement. + None = match the first live-mode device found (existing + single-device behavior). + """ + self._target_address = target_address.upper() if target_address else None + self._running = False + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._client = None + self._key: bytes | None = None + self._device_tag: str | None = None + self._replay = ble_replay.ReplayState() + # Counts consecutive frames that failed AES-GCM auth — surfaced + # to the UI as "key mismatch" after a few in a row so the user + # gets a clear error instead of silent failure. + self._auth_fail_count = 0 + # Host clock anchor (time.monotonic_ns of the first send_event of + # the current live session). Per-event timestamps are computed + # relative to this so the device can preserve typing cadence. + self._session_t0_ns: int | None = None + # Per-session running totals exposed to the UI. + self.events_sent = 0 + self.bytes_sent = 0 + # Whether the device should be showing its Bluetooth "identify" + # logo. Latched here so it survives reconnects and is (re)sent the + # moment a session reaches the connected state. + self._identify_on = False + # Friendly label to display on the device; latched so it's re-sent + # on every (re)connect (a device reboot loses it from RAM). + self._device_label = "" + # Last mouse button mask we sent — used to decide which mouse frames + # warrant a reliable (response=True) write vs a fire-and-forget one. + self._last_mouse_buttons = 0 + # asyncio.Queue of pending outgoing payloads (List of (action, hid)) + # The worker batches whatever is ready when it wakes up so a burst + # of keystrokes pays one BLE radio cycle instead of N. + self._send_q: asyncio.Queue | None = None + # Set when the worker observes a clean disconnect. + self._disconnected_event: asyncio.Event | None = None + # Set when the device acks our most recent START. + self._start_acked: asyncio.Event | None = None + self._on_status = None + self._on_error = None + # Used to tag errors emitted from the worker with the seq they reference + # so the UI can correlate (mostly diagnostic). + self._last_status = ST_IDLE + + # ---- Public API (Tk thread) ---- + + def start(self, on_status=None, on_error=None) -> None: + """Begin scanning and connecting. Non-blocking. + + on_status(status_str) — fires on every state transition. + on_error(err_code:int, ref_seq:int|None, label:str) + """ + if self._running: + return + self._on_status = on_status + self._on_error = on_error + self._running = True + self._thread = threading.Thread(target=self._thread_main, daemon=True, + name="BLELiveKeystrokeClient") + self._thread.start() + + def stop(self, timeout: float = 4.0) -> None: + """Send STOP if connected, tear down the worker thread.""" + if not self._running: + return + self._running = False + loop = self._loop + if loop and loop.is_running(): + # Wake the worker so it observes _running=False and exits cleanly. + loop.call_soon_threadsafe(self._wake_for_shutdown) + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=timeout) + self._thread = None + self._loop = None + self._client = None + + def send_event(self, action: int, hid_code: int) -> bool: + """Queue one (action, hid_code) event for transmission. + + Timestamps are captured HERE (Tk thread, on the actual keypress) + so BLE/asyncio jitter doesn't pollute the recorded cadence. The + timestamp is host-monotonic ms relative to the first event of + the session. + + Returns True on enqueue, False if the channel is not open or + the queue is full. Safe to call from the Tk thread. + """ + if self._last_status != ST_CONNECTED: + return False + now_ns = time.monotonic_ns() + if self._session_t0_ns is None: + self._session_t0_ns = now_ns + t_ms = (now_ns - self._session_t0_ns) // 1_000_000 + if t_ms > 0xFFFFFFFF: + t_ms = 0xFFFFFFFF + return self.send_event_with_t(action, hid_code, int(t_ms)) + + def send_event_with_t(self, action: int, hid_code: int, + t_ms: int) -> bool: + """Like send_event but the caller supplies the timestamp. Used + by the Replay path so each event carries its ORIGINAL + recorded timestamp instead of "now".""" + loop = self._loop + q = self._send_q + if loop is None or q is None or not self._running: + return False + if self._last_status != ST_CONNECTED: + return False + try: + loop.call_soon_threadsafe(self._enqueue_send_threadsafe, + int(action) & 0xFF, + int(hid_code) & 0xFF, + int(t_ms) & 0xFFFFFFFF) + return True + except RuntimeError: + return False + + def reset_session_clock(self) -> None: + """Forget the current session anchor so the next send_event + becomes t=0. Called when a new recording session begins.""" + self._session_t0_ns = None + + def set_identify(self, on: bool) -> None: + """Ask the device to show (on=True) or hide (on=False) its + Bluetooth identify logo. Safe to call from the Tk thread. + + The desire is latched in self._identify_on so it's (re)applied on + every (re)connect; if we're already connected we also push the + change immediately. If we're not connected yet, the session sends + the current flag the moment it comes up.""" + self._identify_on = bool(on) + loop = self._loop + q = self._send_q + if loop is None or q is None or not self._running: + return + if self._last_status != ST_CONNECTED: + return # will be applied on connect by _session + try: + loop.call_soon_threadsafe(self._enqueue_identify_threadsafe, + bool(on)) + except RuntimeError: + pass + + def _enqueue_identify_threadsafe(self, on: bool) -> None: + if self._send_q is None: + return + try: + self._send_q.put_nowait(("identify", bool(on))) + except asyncio.QueueFull: + pass + + def send_mouse(self, buttons: int, x: float, y: float, + wheel: int = 0) -> bool: + """Send one absolute pointer update. ``x``/``y`` are normalized + screen coordinates in [0, 1] (so the remote cursor never desyncs — + every report fully specifies the position). ``buttons`` is a bitmask + (bit0 left, bit1 right, bit2 middle). ``wheel`` is a relative tick. + Safe to call from the Tk thread.""" + loop = self._loop + q = self._send_q + if loop is None or q is None or not self._running: + return False + if self._last_status != ST_CONNECTED: + return False + xi = int(max(0.0, min(1.0, x)) * 32767) + yi = int(max(0.0, min(1.0, y)) * 32767) + try: + loop.call_soon_threadsafe(self._enqueue_mouse_threadsafe, + int(buttons) & 0xFF, xi, yi, int(wheel)) + return True + except RuntimeError: + return False + + def _enqueue_mouse_threadsafe(self, buttons: int, x: int, y: int, + wheel: int) -> None: + if self._send_q is None: + return + try: + self._send_q.put_nowait(("mouse", buttons, x, y, wheel)) + except asyncio.QueueFull: + pass + + def set_device_label(self, label: str) -> None: + """Push a friendly label to show on the device's screen. Latched so + it survives reconnects (re-sent by _session on connect).""" + self._device_label = label or "" + loop = self._loop + q = self._send_q + if loop is None or q is None or not self._running: + return + if self._last_status != ST_CONNECTED: + return + try: + loop.call_soon_threadsafe(self._enqueue_label_threadsafe, + self._device_label) + except RuntimeError: + pass + + def _enqueue_label_threadsafe(self, label: str) -> None: + if self._send_q is None: + return + try: + self._send_q.put_nowait(("label", label)) + except asyncio.QueueFull: + pass + + def is_connected(self) -> bool: + return self._last_status == ST_CONNECTED + + def status(self) -> str: + return self._last_status + + # ---- Worker thread / asyncio glue ---- + + def _wake_for_shutdown(self) -> None: + # Drop a sentinel on the queue so the writer task unblocks. + if self._send_q is not None: + try: + self._send_q.put_nowait(None) + except asyncio.QueueFull: + pass + + def _enqueue_send_threadsafe(self, action: int, hid_code: int, + t_ms: int) -> None: + if self._send_q is None: + return + try: + self._send_q.put_nowait((action, hid_code, t_ms)) + except asyncio.QueueFull: + print(f"[live] send queue full — dropping event " + f"(action={action} hid=0x{hid_code:02X})") + _dbg("send_queue_full", action=action, hid=hid_code) + + def _thread_main(self) -> None: + try: + from bleak import BleakClient, BleakScanner # noqa + except ImportError: + self._set_status(ST_ERROR) + log.error("bleak not installed — pip install bleak") + return + self._BleakClient = BleakClient + self._BleakScanner = BleakScanner + + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._run()) + except RuntimeError: + pass + except Exception as exc: + log.warning("live worker error: %s", exc, exc_info=True) + finally: + try: + self._loop.close() + except Exception: + pass + + async def _run(self) -> None: + """Outer worker loop. Repeats scan → connect → session until the + caller calls stop(). The M5Stack advertises the live service + automatically whenever it is idle (no routine running, no USB + upload in progress), so the device is normally already + discoverable. The loop also recovers mid-session drops + (interference / range) by re-scanning and reconnecting; the device + keeps listening across drops so recording resumes seamlessly.""" + # We scan for LIVE_SERVICE_UUID specifically — the var-sync + # client uses SERVICE_UUID, and the device advertises one or + # the other depending on whether it's in live mode. This keeps + # the two clients from racing for the same BLE connection. + from ble_server import LIVE_SERVICE_UUID + + self._send_q = asyncio.Queue(maxsize=SEND_QUEUE_MAX) + self._start_acked = asyncio.Event() + + # Per-MAC keystore can supply the right key once we read the + # device tag from the first frame. We pre-load the legacy + # single-key file as a "any unknown device" fallback so + # existing single-device setups continue working. + self._key = ble_keystore.load_key() + # Note: no `if self._key is None: return` here — multi-device + # users may have only per-MAC keys, no legacy file. We resolve + # the actual key per-frame based on the tag in the frame. + + print("[live] worker started, scanning for live-mode device...") + _dbg("worker_start", scan_uuid=LIVE_SERVICE_UUID) + backoff_s = 0.5 + while self._running: + try: + await self._one_cycle(LIVE_SERVICE_UUID) + except Exception as exc: + print(f"[live] cycle error: {exc!r}") + _dbg("cycle_error", error=repr(exc)) + self._set_status(ST_DISCONNECTED) + if not self._running: + break + await asyncio.sleep(backoff_s) + backoff_s = min(backoff_s * 1.5, 3.0) + # Reset transient signalling for the next cycle. + self._start_acked = asyncio.Event() + print("[live] worker exiting") + _dbg("worker_exit") + + async def _one_cycle(self, service_uuid: str) -> None: + """One scan-connect-session pass. Returns whether or not it + succeeded — caller decides whether to retry.""" + self._set_status(ST_SCANNING) + _dbg("scan_start", timeout_s=SCAN_TIMEOUT_S, uuid=service_uuid) + + # Collect ALL device sightings (with their advertised service + # UUIDs) during the scan window, so the log shows what was + # actually nearby. The filter we return controls connection. + all_sightings: dict = {} + + def _filter(d, adv): + try: + addr = getattr(d, "address", None) + if addr and addr not in all_sightings: + all_sightings[addr] = { + "name": getattr(d, "name", None), + "uuids": list(adv.service_uuids or []), + } + except Exception: + pass + if service_uuid not in (adv.service_uuids or []): + return False + # If we were pinned to a specific BLE MAC (multi-device + # manager spawns one client per known device), only match + # that exact address. This lets multiple BLELiveKeystrokeClients + # coexist without racing for the first advertisement. + if self._target_address is not None: + addr = getattr(d, "address", "") + if not addr or addr.upper() != self._target_address: + return False + return True + + try: + device = await self._BleakScanner.find_device_by_filter( + _filter, timeout=SCAN_TIMEOUT_S, + ) + except Exception as exc: + print(f"[live] scan failed: {exc!r}") + _dbg("scan_error", error=repr(exc)) + self._set_status(ST_DISCONNECTED) + return + if not device: + self._set_status(ST_SCANNING) + _dbg("scan_idle", sighted=all_sightings) + return + if not self._running: + return + + print(f"[live] found device {getattr(device, 'name', '?')} " + f"({getattr(device, 'address', '?')}), connecting...") + _dbg("scan_found", + name=getattr(device, "name", None), + address=getattr(device, "address", None)) + self._set_status(ST_CONNECTING) + # Fresh per-cycle disconnect event so a previous cycle's set() + # doesn't immediately tear down this one. + self._disconnected_event = asyncio.Event() + + def on_disc(_client): + try: + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe( + self._disconnected_event.set) + except Exception: + pass + + try: + async with self._BleakClient(device, timeout=15.0, + disconnected_callback=on_disc) as client: + print("[live] BLE connected, starting session") + _dbg("connected", address=getattr(device, "address", None)) + self._client = client + await self._session(client) + except asyncio.TimeoutError: + print("[live] connect timed out") + _dbg("connect_timeout") + except Exception as exc: + print(f"[live] connect/session error: {exc!r}") + _dbg("connect_error", error=repr(exc)) + finally: + self._client = None + _dbg("session_end") + if self._last_status == ST_CONNECTED: + self._set_status(ST_DISCONNECTED) + + async def _session(self, client) -> None: + from ble_server import LIVE_KEYS_WRITE_UUID, LIVE_KEYS_NOTIFY_UUID + + hello_evt: asyncio.Future = self._loop.create_future() + + def handle_notify(_char, data: bytearray): + raw = bytes(data) + result = parse_frame_auto(raw) + if result is None: + # Fully malformed (no tag, bad length). Drop silently. + _dbg("malformed_frame", raw_len=len(raw)) + return + tag, plain = result + if plain is None: + # Tag was valid but no key worked. Either we don't have + # a key for this device (it was reflashed / never had a + # profile uploaded over USB), or the stored key is + # stale. Surface KEY_MISMATCH after a few in a row so + # the user gets a clear remediation. + self._auth_fail_count += 1 + _dbg("auth_fail", count=self._auth_fail_count, + raw_len=len(raw), tag=tag) + if self._auth_fail_count == 3 and self._on_error: + try: + self._on_error(0, None, "KEY_MISMATCH") + except Exception: + pass + return + # First good frame nails down the per-device key for + # outgoing writes too. + self._auth_fail_count = 0 + mac = tag[len(DEVICE_TAG_PREFIX):] + resolved = ble_keystore.load_key_for_mac_or_default(mac) + if resolved is not None: + self._key = resolved + hdr = _parse_header(plain) + if hdr is None: + return + msg_type, sid, seq, body = hdr + # Validate replay + ok, _reason = self._replay.accept_received(mac, sid, seq) + if not ok: + print(f"[live] replay reject seq={seq} sid={sid}") + return + if msg_type == MSG_HELLO: + self._device_tag = tag + if not hello_evt.done(): + hello_evt.set_result(True) + return + if msg_type == MSG_ACK: + if len(body) >= 8: + ref_seq, = struct.unpack("= 9: + err = body[0] + ref_seq, = struct.unpack(" None: + """Drain self._send_q to BLE writes. One BLE write per drain cycle — + we coalesce whatever is queued at wake-up to amortize radio time. + + Uses Write WITH response (response=True) for delivery guarantees. + Write Without Response can silently drop frames under congestion + — confirmed by dropped-keystroke reports during live recording. + The added ~15 ms per write is invisible behind the 100 ms + device-side replay buffer. + """ + # Each event is now 6 bytes (action,hid,t_ms_u32). With the 17-byte + # plain header + 1-byte count, a 16-event frame is 17+1+16*6 = 114 + # bytes plaintext, well under the negotiated MTU even on Windows. + MAX_BATCH = 16 + q = self._send_q + disc = self._disconnected_event + + while self._running and not disc.is_set(): + try: + first = await q.get() + except asyncio.CancelledError: + break + if first is None: + # Shutdown sentinel + break + # Control item (e.g. identify) is tagged with a str first + # element; keystroke items are (int action, int hid, int t). + if isinstance(first[0], str): + await self._handle_control_item(client, write_uuid, first) + continue + batch = [first] + pending_identify = None + while len(batch) < MAX_BATCH: + try: + nxt = q.get_nowait() + except asyncio.QueueEmpty: + break + if nxt is None: + self._running = False + break + if isinstance(nxt[0], str): + # A control item slipped in mid-batch — flush the + # keystrokes first, then handle it after the send. + pending_identify = nxt + break + batch.append(nxt) + + # Build KEYS frame (binary, per protocol in module docstring) + seq = self._replay.next_send_seq() + sid = self._replay.host_session_id() + body = bytes([len(batch)]) + b"".join( + struct.pack(" None: + """Dispatch a tagged control item pulled from the send queue.""" + kind = item[0] + if kind == "identify": + await self._send_identify(client, write_uuid, bool(item[1])) + elif kind == "mouse": + await self._send_mouse(client, write_uuid, + item[1], item[2], item[3], item[4]) + elif kind == "label": + await self._send_label(client, write_uuid, item[1]) + + async def _send_mouse(self, client, write_uuid: str, + buttons: int, x: int, y: int, wheel: int) -> bool: + """Send an absolute pointer report. Pure-move frames go out + fire-and-forget (write-without-response) since absolute positions + are self-correcting; button changes and scrolls use a reliable + write so a click / wheel tick is never lost.""" + seq = self._replay.next_send_seq() + sid = self._replay.host_session_id() + w = max(-127, min(127, int(wheel))) + body = struct.pack(" bool: + seq = self._replay.next_send_seq() + sid = self._replay.host_session_id() + body = (label or "").encode("utf-8")[:38] + plain = _pack_header(MSG_LABEL, sid, seq) + body + try: + frame = build_frame(self._key, + self._device_tag or self._scan_tag(), plain) + except Exception as exc: + log.warning("live: build_frame(label) failed: %s", exc) + return False + try: + await client.write_gatt_char(write_uuid, frame, response=True) + _dbg("label_sent", label=label) + return True + except Exception as exc: + _dbg("label_write_failed", error=repr(exc)) + return False + + async def _send_identify(self, client, write_uuid: str, + on: bool) -> bool: + """Tell the device to show (on) or hide (off) its Bluetooth + identify logo. Same AES-GCM envelope as every other frame.""" + seq = self._replay.next_send_seq() + sid = self._replay.host_session_id() + plain = (_pack_header(MSG_IDENTIFY, sid, seq) + + bytes([1 if on else 0])) + try: + frame = build_frame(self._key, + self._device_tag or self._scan_tag(), plain) + except Exception as exc: + log.warning("live: build_frame(identify) failed: %s", exc) + return False + try: + await client.write_gatt_char(write_uuid, frame, response=True) + _dbg("identify_sent", on=on) + return True + except Exception as exc: + _dbg("identify_write_failed", error=repr(exc)) + return False + + async def _send_control(self, client, write_uuid: str, + msg_type: int) -> bool: + """Send a control frame (START/STOP) with response=True so the + device's receive callback runs before we move on. Returns True on + wire-level success. + """ + seq = self._replay.next_send_seq() + sid = self._replay.host_session_id() + plain = _pack_header(msg_type, sid, seq) + try: + frame = build_frame(self._key, self._device_tag or self._scan_tag(), + plain) + except Exception as exc: + log.warning("live: build_frame(control) failed: %s", exc) + return False + try: + await client.write_gatt_char(write_uuid, frame, response=True) + return True + except Exception as exc: + log.warning("live: control write failed: %s", exc) + return False + + def _scan_tag(self) -> str: + # Should never be reached — hello sets _device_tag before control + # frames go out. Fallback to a sentinel so build_frame doesn't blow + # up on None during error paths. + return DEVICE_TAG_PREFIX + "00:00:00:00:00:00" + + def _set_status(self, status: str) -> None: + if status == self._last_status: + return + self._last_status = status + if self._on_status: + try: + self._on_status(status) + except Exception as exc: + log.warning("live: on_status callback error: %s", exc) diff --git a/ble_multi.py b/ble_multi.py new file mode 100644 index 0000000..2e4cedc --- /dev/null +++ b/ble_multi.py @@ -0,0 +1,298 @@ +"""Multi-device BLE keyboard streaming manager. + +Maintains up to N independent BLELiveKeystrokeClient instances, each +pinned to a distinct M5Stack by BLE MAC. Provides a single fan-out API +(:meth:`send_event`) so the caller (the BT Keyboard window) can stream +one stream of keystrokes to many devices simultaneously, with per-device +enable/disable so the user can selectively mute targets without +disconnecting them. + +Architecture: + - One BLELiveKeystrokeClient per slot. Each runs its own asyncio + worker thread and BLE link, so a stall on one device cannot + block another. + - Slots are added explicitly via :meth:`add_device` after a one- + shot scan discovers an in-range live-mode device. Removal is + explicit too. + - send_event(action, hid) walks all slots and forwards the event to + every enabled, connected client. Each client encrypts under that + device's own per-MAC key (see ble_keystore + ble_frame); nothing + about multi-device streaming changes the security model. + - send_event_with_t(action, hid, t_ms) is the replay path — caller + supplies the original recorded timestamp so the device-side + cadence-preserving scheduler reproduces the typing rhythm. + +Threading: + - Public API is Tk-thread safe (each call delegates to per-client + asyncio.call_soon_threadsafe). + - on_status / on_error callbacks fire on the worker thread for the + slot that changed; the UI marshals back to Tk. +""" + +from __future__ import annotations + +import asyncio +import threading +import time +from dataclasses import dataclass, field +from typing import Callable, Optional + +from ble_live import ( + BLELiveKeystrokeClient, + ST_CONNECTED, ST_CONNECTING, ST_SCANNING, + ST_DISCONNECTED, ST_ERROR, +) + + +# Soft warning threshold (NOT a hard cap). The manager accepts an unlimited +# number of devices; the UI shows a brief, non-blocking warning once more +# than this many are connected at once. BLE mode caps lower than the ESP-NOW +# hub (see mesh_manager.MAX_SLOTS) because Windows only holds ~3-4 reliable +# concurrent BLE links before latency/loss degrades. +MAX_SLOTS = 4 + + +@dataclass +class DeviceSlot: + address: str # Bleak MAC ("AA:BB:CC:DD:EE:FF") + label: str = "" # Friendly name (defaults to MAC) + enabled: bool = True # User toggle: stream to this device or not + client: BLELiveKeystrokeClient | None = None + status: str = ST_DISCONNECTED + last_status_change: float = field(default_factory=time.monotonic) + added_at: float = field(default_factory=time.monotonic) + + def display_label(self) -> str: + return self.label or self.address + + +class MultiBleKeyboardManager: + """Owns N BLELiveKeystrokeClient slots and fans events out across + every enabled, currently-connected slot.""" + + def __init__(self, max_slots: int = MAX_SLOTS): + self._max_slots = max_slots + self._slots: dict[str, DeviceSlot] = {} + self._lock = threading.Lock() + # External callbacks + self._on_status_change: Optional[Callable[[str, str], None]] = None + self._on_stats_change: Optional[Callable[[], None]] = None + + # ---- Public API ---- + + def set_callbacks(self, *, on_status_change=None, on_stats_change=None): + """on_status_change(address, status) — fires on every slot status change. + on_stats_change() — fires periodically when sent counters change.""" + self._on_status_change = on_status_change + self._on_stats_change = on_stats_change + + def slots(self) -> list[DeviceSlot]: + """Return a stable-ordered snapshot of current slots.""" + with self._lock: + return sorted(self._slots.values(), key=lambda s: s.added_at) + + def slot_count(self) -> int: + with self._lock: + return len(self._slots) + + def is_full(self) -> bool: + # Uncapped — there is no hard device limit anymore. + return False + + def over_soft_limit(self) -> bool: + """True when more devices than the soft threshold are tracked.""" + return self.slot_count() > self._max_slots + + def add_device(self, address: str, label: str = "", + board: str = "") -> DeviceSlot | None: + """Add a slot for ``address`` and start its BLE worker. + Returns the new slot, or None only if the address is already + tracked (the device count is uncapped). ``board`` is accepted for + call-surface parity with the mesh manager and ignored here (BLE + discovery doesn't report board type).""" + addr = address.upper() + with self._lock: + if addr in self._slots: + return None + slot = DeviceSlot(address=addr, label=label or addr) + slot.client = BLELiveKeystrokeClient(target_address=addr) + self._slots[addr] = slot + self._start_slot(slot) + return slot + + def remove_device(self, address: str) -> bool: + """Disconnect and forget ``address``. Returns True if it was + tracked.""" + addr = address.upper() + with self._lock: + slot = self._slots.pop(addr, None) + if slot is None: + return False + if slot.client is not None: + try: + slot.client.stop(timeout=2.0) + except Exception: + pass + return True + + def set_enabled(self, address: str, enabled: bool) -> None: + """Toggle whether send_event reaches this slot.""" + addr = address.upper() + with self._lock: + slot = self._slots.get(addr) + if slot is not None: + slot.enabled = bool(enabled) + + def get_slot(self, address: str) -> DeviceSlot | None: + with self._lock: + return self._slots.get(address.upper()) + + def set_label(self, address: str, label: str) -> None: + """Update a slot's friendly label (falls back to the MAC if empty) + and push it to the device so it shows on its screen too.""" + addr = address.upper() + with self._lock: + slot = self._slots.get(addr) + if slot is not None: + slot.label = label or slot.address + client = slot.client + else: + client = None + if client is not None: + try: + client.set_device_label(label or "") + except Exception: + pass + + def identify(self, address: str, on: bool = True) -> None: + """Ask one device to show (on) / hide (off) its Bluetooth identify + logo, so the user can see which physical M5Stack a slot maps to. + No-op if the slot/client isn't present.""" + slot = self.get_slot(address) + if slot is not None and slot.client is not None: + try: + slot.client.set_identify(on) + except Exception: + pass + + # ---- Streaming ---- + + def send_event(self, action: int, hid_code: int) -> int: + """Fan-out one event to every enabled, connected slot. + Returns the count of slots the event was delivered to. + + Timestamp is captured per-slot (each BLELiveKeystrokeClient + anchors its own session clock on first send), so each slot + sees consistent t=0...delta_t cadence even if they were added + at different times. + """ + n = 0 + for slot in self.slots(): + if not slot.enabled or slot.client is None: + continue + if slot.client.is_connected(): + if slot.client.send_event(action, hid_code): + n += 1 + return n + + def send_event_with_t(self, action: int, hid_code: int, + t_ms: int) -> int: + """Replay path: fan-out an event with its ORIGINAL timestamp + instead of "now". Used by the replay loop to send recorded + events in their original cadence.""" + n = 0 + for slot in self.slots(): + if not slot.enabled or slot.client is None: + continue + if slot.client.is_connected(): + if slot.client.send_event_with_t(action, hid_code, t_ms): + n += 1 + return n + + def send_mouse(self, buttons: int, x: float, y: float, + wheel: int = 0) -> int: + """Fan-out one absolute pointer update to every enabled, connected + slot. ``x``/``y`` are normalized [0,1] screen coordinates so every + device's cursor lands at the same relative position with no drift. + Returns the count of slots it reached.""" + n = 0 + for slot in self.slots(): + if not slot.enabled or slot.client is None: + continue + if slot.client.is_connected(): + if slot.client.send_mouse(buttons, x, y, wheel): + n += 1 + return n + + def reset_session_clocks(self) -> None: + """Reset every slot's session anchor. Called at the start of a + replay so each slot's t=0 corresponds to the first replay event.""" + for slot in self.slots(): + if slot.client is not None: + try: + slot.client.reset_session_clock() + except Exception: + pass + + # ---- Stats ---- + + def stats(self) -> list[dict]: + """Snapshot of per-slot stats for the UI.""" + out = [] + for slot in self.slots(): + client = slot.client + out.append({ + "address": slot.address, + "label": slot.display_label(), + "enabled": slot.enabled, + "status": slot.status, + "events_sent": getattr(client, "events_sent", 0) if client else 0, + "bytes_sent": getattr(client, "bytes_sent", 0) if client else 0, + }) + return out + + # ---- Shutdown ---- + + def shutdown(self) -> None: + """Stop every BLE worker. Called when the BT Keyboard window + closes or the app exits.""" + with self._lock: + slots = list(self._slots.values()) + self._slots.clear() + for slot in slots: + if slot.client is not None: + try: + slot.client.stop(timeout=2.0) + except Exception: + pass + + # ---- Internal ---- + + def _start_slot(self, slot: DeviceSlot) -> None: + client = slot.client + if client is None: + return + + def on_status(status: str, _addr=slot.address): + with self._lock: + s = self._slots.get(_addr) + if s is None: + return + s.status = status + s.last_status_change = time.monotonic() + if self._on_status_change: + try: + self._on_status_change(_addr, status) + except Exception: + pass + + def on_error(err_code, ref_seq, label, _addr=slot.address): + # Surface as a status update for now; the UI can decide to + # render it differently if it tracks the most recent error. + if self._on_status_change: + try: + self._on_status_change(_addr, f"error:{label}") + except Exception: + pass + + client.start(on_status=on_status, on_error=on_error) diff --git a/ble_replay.py b/ble_replay.py new file mode 100644 index 0000000..48e793e --- /dev/null +++ b/ble_replay.py @@ -0,0 +1,132 @@ +"""Replay-protection state with per-session resilience. + +Each side embeds (session_id, seq) in every frame. The session_id is a +random 64-bit value generated at boot/startup and stays constant for the +life of the process. The receiver tracks (last_session_id, max_seq) per +peer; a fresh session_id resets seq tracking gracefully (handles device +reflash, host wipe, etc.), while an unchanged session_id requires +strictly increasing seq (replay protection). + +Tradeoff: an attacker who captures frames from an old session can replay +them AFTER the receiver has accepted a different session_id from the same +peer. For the user's threat model (passive sniffer in a controlled room, +no physical device access) this is acceptable. For stronger protection, +extend `device_state` to store a set of all ever-seen session_ids. +""" + +import json +import os +import threading +import secrets +from pathlib import Path + +from utils.constants import APPDATA_DIR + +_REPLAY_STATE_PATH = Path(APPDATA_DIR) / ".ble_replay.json" + + +def _new_session_id() -> int: + return secrets.randbits(64) + + +class ReplayState: + """Per-MAC (session_id, max_seq) store + host's own session+counter.""" + + def __init__(self): + self._lock = threading.Lock() + # Host's outgoing identity. `host_session_id` is regenerated on + # every host restart so the device knows the host has reset and + # accepts the (new session, fresh seq) gracefully. + self._host_session_id: int = _new_session_id() + self._host_send_seq: int = 0 + # Per-device tracking: {mac: {"session_id": int, "max_seq": int}} + self._device_state: dict[str, dict] = {} + self._load() + + def _load(self) -> None: + try: + with open(_REPLAY_STATE_PATH, "r", encoding="utf-8") as f: + d = json.load(f) + # We DELIBERATELY do NOT restore host_session_id from disk: + # treating each Python process startup as a new session + # automatically heals "host has stale state" cases. + # We do persist host_send_seq within a session so a quick + # crash-restart in the same process doesn't reuse seqs (but + # since session_id changed, reuse is harmless anyway). + raw_dev = d.get("device_state") or {} + for mac, st in raw_dev.items(): + if isinstance(st, dict): + self._device_state[str(mac)] = { + "session_id": int(st.get("session_id", 0)), + "max_seq": int(st.get("max_seq", 0)), + } + # Legacy migration: pre-session_id schema had `device_seen`. + # We can't recover the old session_id (it didn't exist), so + # we drop it — first frame from each device will be accepted + # fresh under the new session_id. + except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError): + pass + + def _save_locked(self) -> None: + try: + _REPLAY_STATE_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = _REPLAY_STATE_PATH.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump({ + "host_send_seq": self._host_send_seq, + "device_state": self._device_state, + }, f) + os.replace(tmp, _REPLAY_STATE_PATH) + except Exception: + pass + + def host_session_id(self) -> int: + return self._host_session_id + + def next_send_seq(self) -> int: + """Reserve the next outgoing seq within the current host session.""" + with self._lock: + self._host_send_seq += 1 + seq = self._host_send_seq + self._save_locked() + return seq + + def accept_received(self, mac: str, session_id: int, seq: int) -> tuple[bool, str]: + """Validate an inbound (session_id, seq) from ``mac``. + + Returns (accepted, reason). ``reason`` is "fresh_session", + "monotonic", "regressed_seq", or "stale_session". + """ + if not isinstance(session_id, int) or not isinstance(seq, int): + return False, "bad_types" + with self._lock: + st = self._device_state.get(mac) + if st is None: + # Never-seen device — accept and remember. + self._device_state[mac] = { + "session_id": session_id, + "max_seq": seq, + } + self._save_locked() + return True, "fresh_device" + if st["session_id"] != session_id: + # Different session: device rebooted (or was reflashed). + # Accept fresh — this is the desync-recovery path. + st["session_id"] = session_id + st["max_seq"] = seq + self._save_locked() + return True, "fresh_session" + # Same session: seq must be strictly monotonic. + if seq <= st["max_seq"]: + return False, "regressed_seq" + st["max_seq"] = seq + self._save_locked() + return True, "monotonic" + + def reset(self) -> None: + """Wipe all counters. Call when the AES key changes.""" + with self._lock: + self._host_session_id = _new_session_id() + self._host_send_seq = 0 + self._device_state = {} + self._save_locked() diff --git a/ble_server.py b/ble_server.py new file mode 100644 index 0000000..3a72478 --- /dev/null +++ b/ble_server.py @@ -0,0 +1,487 @@ +"""BLE client for two-way encrypted variable sync with the ATOMS3. + +Architecture: + - ESP32 AtomS3 = BLE GATT Server (peripheral), advertises on-demand + when a Variables node in BLE mode (pull / push / request) is hit. + - Python app = BLE Client (central, using bleak), continuously scans, + connects when a device appears, subscribes to its notify characteristic. + +Wire framing (every BLE message, both directions): + byte tag_len 1 byte + bytes tag ASCII "M5Stack|AA:BB:CC:DD:EE:FF" + bytes nonce 12 bytes + bytes ciphertext+gcm_tag N + 16 bytes (AES-256-GCM, AAD = tag bytes) + +Plaintext is JSON with an `op` discriminator: + {"op":"hello","kind":"pull"|"push"|"request","scope":"device"|"universal","names":[...]} + Device's first notify after connect — declares what it wants. + {"op":"pull","scope":"device"|"universal","vars":{...}} + Host -> device. Variables to install in the chosen on-device store. + {"op":"push","vars":{...}} Device -> host. + {"op":"request","names":[...]} Device -> host. Followed by host pull. + {"op":"ack"} Either direction. End of exchange. + +Frames whose tag prefix isn't "M5Stack|" or whose AEAD authentication fails +are dropped silently — that's the "encrypted or thrown out" rule. +""" + +import asyncio +import json +import logging +import threading + +import ble_keystore +import ble_replay +import ble_debug_log as _bled +from ble_frame import build_frame as _build_frame, parse_frame as _parse_frame +from ble_frame import parse_frame_auto as _parse_frame_auto +from ble_frame import DEVICE_TAG_PREFIX, MAC_RE # re-exported for callers # noqa: F401 + +log = logging.getLogger(__name__) + +SERVICE_UUID = "4fafc201-1fb5-459e-8fcc-c5c9c331914b" +VARS_WRITE_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a8" # host -> device (write) +VARS_NOTIFY_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a9" # device -> host (notify) + +# Live keystroke streaming channel — see ble_live.py for the protocol. +# WRITE characteristic is Write-Without-Response for low latency. +LIVE_KEYS_WRITE_UUID = "4fafc202-1fb5-459e-8fcc-c5c9c331914b" # host -> device (WWR) +LIVE_KEYS_NOTIFY_UUID = "4fafc203-1fb5-459e-8fcc-c5c9c331914b" # device -> host (notify) + +# Advertised service UUID specifically for live mode. Disjoint from +# SERVICE_UUID so the var-sync scan filter and the live-keystroke scan +# filter never resolve to the same device — eliminates the race where +# both clients try to grab the BLE connection at once. +LIVE_SERVICE_UUID = "4fafc204-1fb5-459e-8fcc-c5c9c331914b" + +SCAN_TIMEOUT_S = 3 +SCAN_PAUSE_S = 1 +# How long to wait for the device's first frame (hello) after subscription. +# Bleak + NimBLE on ESP32-S3 with concurrent USB-CDC sometimes takes 10+ +# seconds to actually deliver a notification, so this is intentionally +# loose. The device-side timeout is what really gates the exchange. +EXCHANGE_TIMEOUT_S = 30 + + +class BLEVariableClient: + """BLE GATT client — scans, connects, exchanges encrypted op frames.""" + + def __init__(self): + self._running = False + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._on_status = None + # Caller-supplied callbacks (all invoked on the BLE thread): + # get_vars(scope, mac) -> dict + # set_device_vars(mac, vars_dict) — store push'd dict + # prompt_request(mac, names) -> dict|None — show modal in GUI thread, + # return user's edits or None + # on_device_seen(mac) — first time we see this MAC + self._get_vars = None + self._set_device_vars = None + self._prompt_request = None + self._on_device_seen = None + self._seen_macs: set[str] = set() + # Cached project ble_variables dict — used as a fallback when + # `_get_vars` callback isn't wired yet (early startup). + self._cached_vars = {"universal": {}, "devices": {}} + # Replay-protection counter store. Persists per-host send counter + # and per-device last-seen counter to disk. + self._replay = ble_replay.ReplayState() + + # Public API (Tk thread) + + def start(self, ble_variables, on_status=None, + get_vars=None, set_device_vars=None, + prompt_request=None, on_device_seen=None) -> None: + self._cached_vars = self._normalize(ble_variables) + self._on_status = on_status + self._get_vars = get_vars + self._set_device_vars = set_device_vars + self._prompt_request = prompt_request + self._on_device_seen = on_device_seen + self._running = True + _bled.event("session_start", + cached_universal=len(self._cached_vars.get("universal") or {}), + cached_devices=len(self._cached_vars.get("devices") or {})) + self._thread = threading.Thread(target=self._thread_main, daemon=True, + name="BLEVariableClient") + self._thread.start() + + def update_variables(self, ble_variables) -> None: + self._cached_vars = self._normalize(ble_variables) + + def stop(self) -> None: + self._running = False + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=4) + + @staticmethod + def _normalize(raw) -> dict: + if not isinstance(raw, dict): + return {"universal": {}, "devices": {}} + if "universal" in raw or "devices" in raw: + return { + "universal": dict(raw.get("universal") or {}), + "devices": {str(k): dict(v or {}) for k, v in (raw.get("devices") or {}).items()}, + } + return {"universal": dict(raw), "devices": {}} + + def _vars_for(self, scope: str, mac: str) -> dict: + if self._get_vars is not None: + try: + return dict(self._get_vars(scope, mac) or {}) + except Exception as e: + log.warning("get_vars callback error: %s", e) + if scope == "device": + return dict(self._cached_vars.get("devices", {}).get(mac, {})) + return dict(self._cached_vars.get("universal", {})) + + # Internal — runs on the BLE thread / event loop + + def _thread_main(self) -> None: + try: + from bleak import BleakClient, BleakScanner # noqa + except ImportError: + print("[BLE] bleak not installed. Run: pip install bleak") + self._set_status("error") + return + self._BleakClient = BleakClient + self._BleakScanner = BleakScanner + + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._run()) + except RuntimeError: + pass + except Exception as exc: + log.warning("BLE client error: %s", exc, exc_info=True) + finally: + try: + self._loop.close() + except Exception: + pass + + async def _run(self) -> None: + while self._running: + await self._scan_once() + if self._running: + await asyncio.sleep(SCAN_PAUSE_S) + + async def _scan_once(self) -> None: + BleakScanner = self._BleakScanner + BleakClient = self._BleakClient + + try: + _bled.event("scan_start", timeout_s=SCAN_TIMEOUT_S) + device = await BleakScanner.find_device_by_filter( + lambda d, adv: SERVICE_UUID in (adv.service_uuids or []), + timeout=SCAN_TIMEOUT_S, + ) + if not device: + _bled.event("scan_idle") + self._set_status("idle") + return + + # Per-MAC keystore. We don't pre-load a single key here — + # we don't know which device we connected to until we read + # the tag from its hello frame. ``_exchange`` resolves the + # per-MAC key on receipt of the first frame and uses it for + # all subsequent encrypts/decrypts in this session. + self._set_status("connecting") + _bled.event("scan_found", name=device.name, address=device.address) + print(f"[BLE Client] Connected to {device.name} ({device.address})") + + # Cap the connect itself. Without this, a device caught mid- + # reboot (e.g. firmware panicked on the previous exchange) or a + # wedged WinRT stack leaves us in `async with` indefinitely and + # the toolbar stays stuck on "Connecting...". + async with BleakClient(device, timeout=15.0) as client: + _bled.event("connected", address=device.address) + await self._exchange(client) + _bled.event("exchange_returned", address=device.address) + + _bled.event("disconnected", address=device.address) + self._set_status("synced") + + except asyncio.TimeoutError as exc: + _bled.event("connect_timeout", error=repr(exc)) + print(f"[BLE Client] Connect timed out: {exc}") + self._set_status("error") + except Exception as exc: + _bled.event("scan_exception", error=repr(exc)) + print(f"[BLE Client] Error: {exc}") + self._set_status("error") + + async def _exchange(self, client) -> None: + """One full exchange with a connected device. + + Subscribes to notifications, waits for the device's first frame + (which declares its `kind` via op=hello), responds appropriately, + then waits for either the device to disconnect or for a follow-up + frame, then returns. + + Uses per-MAC key resolution: parse_frame_auto picks the right + key from the keystore based on the tag in the frame, so a + host with multiple devices' keys stored picks the right one + automatically. ``session_key`` captures the resolved key for + outgoing frames in this exchange. + """ + first_frame: asyncio.Future = self._loop.create_future() + follow_frame: asyncio.Queue = asyncio.Queue() + # Resolved on first successful decrypt; used for sends below. + session_key_ref = {"key": None} + + def handle_notify(_char, data: bytearray): + raw = bytes(data) + result = _parse_frame_auto(raw) + if result is None: + _bled.event("notify_drop_malformed", bytes_len=len(raw)) + return + tag, plain = result + if plain is None: + _bled.event("notify_drop_auth", bytes_len=len(raw), tag=tag) + return + # Cache the resolved key for outgoing pull/ack frames. + if session_key_ref["key"] is None: + mac = tag[len(DEVICE_TAG_PREFIX):] + session_key_ref["key"] = ble_keystore.load_key_for_mac_or_default(mac) + parsed = (tag, plain) + _bled.event( + "notify_rx", + bytes_len=len(raw), + plain_len=len(plain), + tag=tag, + first=not first_frame.done(), + ) + if not first_frame.done(): + first_frame.set_result(parsed) + else: + try: + follow_frame.put_nowait(parsed) + except Exception: + pass + + try: + await client.start_notify(VARS_NOTIFY_UUID, handle_notify) + _bled.event("subscribed") + except Exception as exc: + _bled.event("start_notify_failed", error=repr(exc)) + print(f"[BLE Client] start_notify failed: {exc}") + return + + try: + tag_str, plaintext = await asyncio.wait_for(first_frame, timeout=EXCHANGE_TIMEOUT_S) + except asyncio.TimeoutError: + _bled.event("hello_timeout", timeout_s=EXCHANGE_TIMEOUT_S) + print("[BLE Client] Timed out waiting for device hello") + return + + mac = tag_str[len(DEVICE_TAG_PREFIX):] + # The per-MAC key for this device — resolved via the keystore + # on the first successful decrypt (handle_notify above). Use + # this for all outbound writes in this exchange so we encrypt + # with the right key when the user has multiple devices on + # file. + key = session_key_ref.get("key") + if key is None: + _bled.event("no_key_for_mac", mac=mac) + print(f"[BLE Client] No key on file for {mac} — upload the profile via USB first") + return + if mac not in self._seen_macs: + self._seen_macs.add(mac) + if self._on_device_seen: + try: + self._on_device_seen(mac) + except Exception as e: + log.warning("on_device_seen error: %s", e) + + try: + msg = json.loads(plaintext.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + _bled.event("hello_bad_json", error=repr(exc)) + return + + # Replay protection: every device frame carries (session_id, seq). + # A new session_id signals a device reboot/reflash and resets the + # window; same session_id requires strictly-increasing seq. + seq = msg.get("seq") + sid = msg.get("session_id") + ok, reason = self._replay.accept_received(mac, sid, seq) + if not ok: + _bled.event("hello_replay_reject", mac=mac, seq=seq, + session_id=sid, reason=reason) + print(f"[BLE Client] dropped {reason} from {mac}: seq={seq} sid={sid}") + return + + op = msg.get("op") + _bled.event("hello_ok", mac=mac, op=op, hello_kind=msg.get("kind"), + seq=seq, session_id=sid, accept_reason=reason) + + if op == "hello": + kind = msg.get("kind", "pull") + if kind == "pull": + scope = msg.get("scope", "universal") + if scope not in ("device", "universal"): + scope = "universal" + await self._send_pull(client, key, mac, scope, self._vars_for(scope, mac)) + elif kind == "push": + # Device immediately follows hello with the actual push frame. + await self._handle_push_followup(client, key, mac, follow_frame) + elif kind == "request": + names = list(msg.get("names") or []) + await self._handle_request(client, key, mac, names) + else: + _bled.event("hello_unknown_kind", kind=kind) + print(f"[BLE Client] Unknown hello kind: {kind}") + return + + # Some devices may skip the hello and send op=push directly. + if op == "push": + await self._apply_push(mac, msg) + await self._send_ack(client, key, mac) + return + if op == "request": + names = list(msg.get("names") or []) + await self._handle_request(client, key, mac, names) + return + + _bled.event("hello_unknown_op", op=op) + print(f"[BLE Client] Unknown op: {op}") + + async def _handle_push_followup(self, client, key: bytes, mac: str, + follow_frame: asyncio.Queue) -> None: + try: + tag_str, plaintext = await asyncio.wait_for( + follow_frame.get(), timeout=EXCHANGE_TIMEOUT_S) + except asyncio.TimeoutError: + _bled.event("push_followup_timeout", mac=mac) + print("[BLE Client] Timed out waiting for push payload") + return + if tag_str[len(DEVICE_TAG_PREFIX):] != mac: + _bled.event("push_followup_mac_mismatch", expected=mac, got=tag_str) + return + try: + msg = json.loads(plaintext.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + _bled.event("push_followup_bad_json") + return + seq = msg.get("seq") + sid = msg.get("session_id") + ok, reason = self._replay.accept_received(mac, sid, seq) + if not ok: + _bled.event("push_followup_replay_reject", mac=mac, seq=seq, + session_id=sid, reason=reason) + return + if msg.get("op") != "push": + _bled.event("push_followup_wrong_op", op=msg.get("op")) + return + _bled.event("push_received", mac=mac, seq=seq, session_id=sid, + accept_reason=reason, + var_count=len(msg.get("vars") or {})) + await self._apply_push(mac, msg) + await self._send_ack(client, key, mac) + + async def _apply_push(self, mac: str, msg: dict) -> None: + vars_ = msg.get("vars") or {} + if not isinstance(vars_, dict): + return + if self._set_device_vars: + try: + self._set_device_vars(mac, vars_) + except Exception as e: + log.warning("set_device_vars error: %s", e) + # Mirror into cache so subsequent _vars_for("device", mac) matches. + self._cached_vars.setdefault("devices", {})[mac] = dict(vars_) + print(f"[BLE Client] Pushed {len(vars_)} vars from {mac}") + + async def _handle_request(self, client, key: bytes, mac: str, + names: list) -> None: + edited = None + if self._prompt_request: + # Tell the UI we're waiting on the user so the toolbar text is + # distinguishable from a stuck-connect state. + self._set_status("awaiting") + # Run the blocking dialog wait in a thread so the asyncio loop + # keeps processing notifications, disconnects, and the eventual + # write_gatt_char back to the device. Calling the sync + # prompt_request directly would block the entire BLE event loop + # for as long as the dialog stays open. + try: + loop = asyncio.get_event_loop() + edited = await asyncio.wait_for( + loop.run_in_executor(None, self._prompt_request, mac, names), + timeout=300, + ) + except asyncio.TimeoutError: + _bled.event("prompt_request_timeout", mac=mac) + log.warning("prompt_request timed out after 300s") + edited = None + except Exception as e: + log.warning("prompt_request error: %s", e) + edited = None + if edited is None: + # User cancelled or no callback wired — fall back to current values. + current = self._vars_for("device", mac) + edited = {n: current.get(n, "") for n in names} + # Persist what the user entered so future Type Text expansions see it. + if self._set_device_vars: + current = self._vars_for("device", mac) + current.update(edited) + try: + self._set_device_vars(mac, current) + except Exception as e: + log.warning("set_device_vars error: %s", e) + self._cached_vars.setdefault("devices", {})[mac] = dict(current) + await self._send_pull(client, key, mac, "device", edited) + + async def _send_pull(self, client, key: bytes, mac: str, scope: str, + vars_: dict) -> None: + tag = DEVICE_TAG_PREFIX + mac + seq = self._replay.next_send_seq() + sid = self._replay.host_session_id() + plaintext = json.dumps({ + "op": "pull", + "scope": scope, + "vars": vars_, + "seq": seq, + "session_id": sid, + }).encode("utf-8") + frame = _build_frame(key, tag, plaintext) + _bled.event("send_pull", mac=mac, scope=scope, seq=seq, session_id=sid, + var_count=len(vars_), frame_len=len(frame)) + try: + await client.write_gatt_char(VARS_WRITE_UUID, frame, response=True) + _bled.event("send_pull_ok", mac=mac, seq=seq) + print(f"[BLE Client] Sent pull(scope={scope}) to {mac}: {len(vars_)} vars") + except Exception as exc: + _bled.event("send_pull_failed", mac=mac, seq=seq, error=repr(exc)) + print(f"[BLE Client] Pull write failed: {exc}") + + async def _send_ack(self, client, key: bytes, mac: str) -> None: + tag = DEVICE_TAG_PREFIX + mac + seq = self._replay.next_send_seq() + sid = self._replay.host_session_id() + plaintext = json.dumps({ + "op": "ack", + "seq": seq, + "session_id": sid, + }).encode("utf-8") + frame = _build_frame(key, tag, plaintext) + _bled.event("send_ack", mac=mac, seq=seq, session_id=sid, frame_len=len(frame)) + try: + await client.write_gatt_char(VARS_WRITE_UUID, frame, response=True) + _bled.event("send_ack_ok", mac=mac, seq=seq) + except Exception as exc: + _bled.event("send_ack_failed", mac=mac, seq=seq, error=repr(exc)) + print(f"[BLE Client] Ack write failed: {exc}") + + def _set_status(self, status: str) -> None: + if self._on_status: + try: + self._on_status(status) + except Exception: + pass diff --git a/bt_macros.py b/bt_macros.py new file mode 100644 index 0000000..e0818c4 --- /dev/null +++ b/bt_macros.py @@ -0,0 +1,161 @@ +"""Folder-organized macro library for the BT Keyboard streamer. + +A *macro* is a recorded sequence of keyboard + mouse + Ctrl-Alt-Del events +captured in the BT Keyboard window. Macros are grouped into user-managed +*folders*. Folders can be created / renamed / deleted; macros are NOT +renameable by design (re-record to change one). + +Event format (tagged arrays so a single list can mix keys and mouse): + key: ["k", t_ms, action, hid] action 0=down/1=up + mouse: ["m", t_ms, buttons, x, y, wheel] x/y absolute 0..32767 + +Stored as one JSON file in the app config dir (same convention as +bt_profiles.py — atomic write under a lock): + + { "folders": { "": { "": {"events":[...], + "duration_ms": int, + "created": } } } } +""" + +import json +import os +import threading +import time + +from utils.constants import APPDATA_DIR + +_FILE = os.path.join(APPDATA_DIR, "bt_kbd_macros.json") +_lock = threading.Lock() + +DEFAULT_FOLDER = "Default" + + +def _load_all() -> dict: + try: + with open(_FILE, "r", encoding="utf-8") as f: + d = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + folders = d.get("folders") if isinstance(d, dict) else None + return folders if isinstance(folders, dict) else {} + + +def _save_all(folders: dict) -> None: + os.makedirs(APPDATA_DIR, exist_ok=True) + tmp = _FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump({"folders": folders}, f, indent=2) + os.replace(tmp, _FILE) + + +def _clean(name: str) -> str: + return (name or "").strip() + + +# ---- Folders ---- + +def list_folders() -> list: + """All folder names, sorted, with DEFAULT_FOLDER guaranteed present.""" + with _lock: + folders = _load_all() + if DEFAULT_FOLDER not in folders: + folders[DEFAULT_FOLDER] = {} + _save_all(folders) + return sorted(folders.keys()) + + +def create_folder(name: str) -> bool: + name = _clean(name) + if not name: + return False + with _lock: + folders = _load_all() + if name in folders: + return False + folders[name] = {} + _save_all(folders) + return True + + +def rename_folder(old: str, new: str) -> bool: + old, new = _clean(old), _clean(new) + if not old or not new or old == new: + return False + with _lock: + folders = _load_all() + if old not in folders or new in folders: + return False + folders[new] = folders.pop(old) # moves all contained macros + _save_all(folders) + return True + + +def delete_folder(name: str) -> bool: + name = _clean(name) + with _lock: + folders = _load_all() + if name not in folders: + return False + del folders[name] + if not folders: + folders[DEFAULT_FOLDER] = {} + _save_all(folders) + return True + + +# ---- Macros ---- + +def list_macros(folder: str) -> list: + """Macro names in a folder, sorted.""" + folder = _clean(folder) + with _lock: + return sorted(_load_all().get(folder, {}).keys()) + + +def save_macro(folder: str, name: str, events, duration_ms: int = 0) -> bool: + """Create/overwrite ``name`` in ``folder`` (folder auto-created).""" + folder, name = _clean(folder) or DEFAULT_FOLDER, _clean(name) + if not name: + return False + with _lock: + folders = _load_all() + folders.setdefault(folder, {}) + folders[folder][name] = { + "events": [list(e) for e in events], + "duration_ms": int(duration_ms), + "created": time.time(), + } + _save_all(folders) + return True + + +def load_macro(folder: str, name: str) -> dict | None: + """Return {"events","duration_ms","created"} or None.""" + folder, name = _clean(folder), _clean(name) + with _lock: + m = _load_all().get(folder, {}).get(name) + if not isinstance(m, dict): + return None + evs = m.get("events") + return { + "events": evs if isinstance(evs, list) else [], + "duration_ms": int(m.get("duration_ms", 0) or 0), + "created": m.get("created", 0), + } + + +def macro_exists(folder: str, name: str) -> bool: + folder, name = _clean(folder), _clean(name) + with _lock: + return name in _load_all().get(folder, {}) + + +def delete_macro(folder: str, name: str) -> bool: + folder, name = _clean(folder), _clean(name) + with _lock: + folders = _load_all() + if name in folders.get(folder, {}): + del folders[folder][name] + _save_all(folders) + return True + return False diff --git a/bt_profiles.py b/bt_profiles.py new file mode 100644 index 0000000..0d6e8cb --- /dev/null +++ b/bt_profiles.py @@ -0,0 +1,90 @@ +"""Saved device profiles for the BT Keyboard streamer. + +A profile is a named list of devices, each ``{"address", "label"}``. Saving +records the MAC + friendly name of every device currently in the streamer; +loading re-adds and reconnects them in one click, naming each automatically. + +All profiles live in a single JSON file in the app config dir so they're in +one place and trivially portable: + + { "profiles": { "": [ {"address": "AA:..", "label": "Left PC"}, ... ] } } +""" + +import json +import os +import threading + +from utils.constants import APPDATA_DIR + +_FILE = os.path.join(APPDATA_DIR, "bt_kbd_profiles.json") +_lock = threading.Lock() + + +def _load_all() -> dict: + try: + with open(_FILE, "r", encoding="utf-8") as f: + d = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + profs = d.get("profiles") if isinstance(d, dict) else None + return profs if isinstance(profs, dict) else {} + + +def _save_all(profs: dict) -> None: + os.makedirs(APPDATA_DIR, exist_ok=True) + tmp = _FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump({"profiles": profs}, f, indent=2) + os.replace(tmp, _FILE) + + +def list_profiles() -> list: + """Names of all saved profiles, sorted.""" + with _lock: + return sorted(_load_all().keys()) + + +def load_profile(name: str) -> list: + """Return a profile's devices as a list of {"address","label"} dicts. + Tolerates a malformed/missing file by returning whatever is valid.""" + with _lock: + devs = _load_all().get(name, []) + out = [] + if isinstance(devs, list): + for d in devs: + if isinstance(d, dict) and d.get("address"): + out.append({"address": str(d["address"]), + "label": str(d.get("label") or "")}) + return out + + +def save_profile(name: str, devices, transport: str = "mesh") -> None: + """Save ``devices`` (iterable of (address, label) tuples or dicts) under + ``name``, replacing any existing profile of that name. + + ``transport`` is recorded per device ("mesh" for ESP-NOW STA MACs, + "ble" for legacy Bleak BT MACs) so the loader can migrate old + profiles. Extra keys are ignored by load_profile for back-compat.""" + norm = [] + for d in devices: + if isinstance(d, dict): + addr, lbl = d.get("address"), d.get("label") or "" + else: + addr, lbl = d + if addr: + norm.append({"address": str(addr), "label": str(lbl or ""), + "transport": transport}) + with _lock: + profs = _load_all() + profs[name] = norm + _save_all(profs) + + +def delete_profile(name: str) -> bool: + with _lock: + profs = _load_all() + if name in profs: + del profs[name] + _save_all(profs) + return True + return False diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/profiles/Example.json b/config/profiles/Example.json new file mode 100644 index 0000000..4c05979 --- /dev/null +++ b/config/profiles/Example.json @@ -0,0 +1,2475 @@ +{ + "version": 1, + "settings": { + "hold_ms": 500, + "type_delay": 15, + "orientation": 0, + "resume_delay": 0, + "combo_pre_ms": 500, + "combo_post_ms": 500, + "probe_timeout_ms": 300, + "media_hold_ms": 100, + "type_shift_extra_ms": 25, + "type_settle_ms": 150, + "pause_margin_left": 4, + "pause_margin_right": 4, + "pause_margin_top": 16, + "pause_margin_bottom": 12 + }, + "macros": [ + { + "name": "Example", + "label_color": "red", + "image_path": "", + "nodes": [ + { + "id": "fba20d14", + "type": "start", + "x": 100, + "y": 1800, + "data": {}, + "flipped": false + }, + { + "id": "36d4e2de", + "type": "note", + "x": -242.60315312873973, + "y": 1601.0958832220958, + "data": { + "text": "This Example routine demonstrates every available node type. The Branch below fans out to one demo of each - pick an option on the device to try it. Open the Settings dialog (top-right) for device tuning, RS232 Terminal, and Backups.", + "font_size": 14, + "color": "white", + "width": 380 + }, + "flipped": false + }, + { + "id": "e19ca4b8", + "type": "note", + "x": 146.00089515469722, + "y": 1626.0209419763205, + "data": { + "text": "Tips: zoom with the scroll wheel, pan by dragging on empty canvas, press F to flip a node, Ctrl+C / Ctrl+V to copy nodes.", + "font_size": 12, + "color": "white", + "width": 180 + }, + "flipped": false + }, + { + "id": "da1b3c38", + "type": "branch", + "x": 374, + "y": 1632, + "data": { + "choices": [ + { + "label": "Type Text", + "next": -1 + }, + { + "label": "Key Combo", + "next": -1 + }, + { + "label": "Pause", + "next": -1 + }, + { + "label": "Branch", + "next": -1 + }, + { + "label": "Loop (Repeat)", + "next": -1 + }, + { + "label": "Loop Selector", + "next": -1 + }, + { + "label": "Mouse Click", + "next": -1 + }, + { + "label": "Media Key", + "next": -1 + }, + { + "label": "Variables", + "next": -1 + }, + { + "label": "RS232 Send", + "next": -1 + }, + { + "label": "Sub-Routine", + "next": -1 + }, + { + "label": "PC Alive Check", + "next": -1 + }, + { + "label": "Macro Replay", + "next": -1 + } + ] + }, + "flipped": false + }, + { + "id": "0b0b0001", + "type": "note", + "x": 1084.720813913096, + "y": -132.37918889877432, + "data": { + "text": "Type Text - sends the string via USB HID. Insert (VAR{name}) tokens to interpolate variables at runtime.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "2851ffd9", + "type": "text", + "x": 1087.4367678003716, + "y": 6.173681454207928, + "data": { + "text": "Hello from your routine!", + "language": "none" + }, + "flipped": false + }, + { + "id": "0b0b0002", + "type": "note", + "x": 1082.872558116675, + "y": 150.5927612052953, + "data": { + "text": "Key Combo - press a modifier+key shortcut (Ctrl+C here). Custom Timings let you tune pre/post and per-key hold delays.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "8556445d", + "type": "combo", + "x": 1080.1957986188659, + "y": 280.8801867280503, + "data": { + "mods": [ + "ctrl" + ], + "key": "c", + "custom_timings": false, + "custom_pre_ms": 167, + "custom_post_ms": 167, + "custom_key_pre_ms": 3, + "custom_key_post_ms": 8 + }, + "flipped": false + }, + { + "id": "0b0b0003", + "type": "note", + "x": 1080.1957986188659, + "y": 497.5536355853536, + "data": { + "text": "Pause - halt until the user clicks the device or a timer fires. Adjustable font size, text color, and an on-device preview in the properties panel.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "49719853", + "type": "pause", + "x": 1080.1957986188659, + "y": 630.8836349076065, + "data": { + "wait": "click", + "text": "Click to continue", + "font_size": 12 + }, + "flipped": false + }, + { + "id": "0b0b0004", + "type": "note", + "x": 1074.8422796232478, + "y": 787.0164399484735, + "data": { + "text": "Branch - present an on-device menu. Each choice gets its own output port. Switch it to Variable mode to route on the value of a variable instead of asking the user.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "ca82e599", + "type": "branch", + "x": 1078.8443540734725, + "y": 932.2350794529999, + "data": { + "choices": [ + { + "label": "Yes", + "next": -1, + "match_value": "", + "color": "white" + }, + { + "label": "No", + "next": -1, + "match_value": "", + "color": "white" + } + ], + "mode": "manual", + "var_name": "", + "var_scope": "auto" + }, + "flipped": false + }, + { + "id": "0b0b0005", + "type": "note", + "x": 1491.0800009663621, + "y": 778.9861614550454, + "data": { + "text": "Delay - wait N ms before continuing.", + "font_size": 12, + "color": "white", + "width": 200 + }, + "flipped": false + }, + { + "id": "0a0a1111", + "type": "delay", + "x": 1505.8021782043122, + "y": 859.4661107455778, + "data": { + "ms": 500 + }, + "flipped": false + }, + { + "id": "0b0b0006", + "type": "note", + "x": 1789.8930136845931, + "y": 765.1320312924012, + "data": { + "text": "Aggregator - merge multiple incoming paths into one output. Adjustable input count in the properties panel.", + "font_size": 12, + "color": "white", + "width": 200 + }, + "flipped": false + }, + { + "id": "0a0a2222", + "type": "aggregator", + "x": 1792.5697731824027, + "y": 903.6326424594274, + "data": { + "input_count": 2 + }, + "flipped": false + }, + { + "id": "0b0b0007", + "type": "note", + "x": 1080.2088634153547, + "y": 1087.2908006747389, + "data": { + "text": "Loop - execute the body N times. Wire the body's tail back to Loop Back. The Done output fires once when N iterations finish.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "dcfb234b", + "type": "repeat", + "x": 1116.3320518392882, + "y": 1220.0328841549924, + "data": { + "count": 3, + "start_idx": 0, + "use_selector": false + }, + "flipped": false + }, + { + "id": "0b0b0008", + "type": "note", + "x": 1515.5946804413281, + "y": 1095.4778567260325, + "data": { + "text": "Iteration Branch - pick a different path on a specific iteration of the linked Loop. Optional 'skip on final iteration' for cleanup-style flows.", + "font_size": 12, + "color": "white", + "width": 200 + }, + "flipped": false + }, + { + "id": "0a0a3333", + "type": "iteration_branch", + "x": 1508.478937702121, + "y": 1184.8953259747636, + "data": { + "loop_node_id": "dcfb234b", + "choices": [ + { + "label": "Other iterations" + }, + { + "label": "First iteration" + } + ], + "skip_final_iteration": false + }, + "flipped": false + }, + { + "id": "0b0b0009", + "type": "note", + "x": 1071.2849013945813, + "y": 1374.517086297065, + "data": { + "text": "Loop Selector - prompt the user on-device for the loop count. Feed it into a Loop with 'use selector' enabled.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "96651684", + "type": "loop_selector", + "x": 1080.1957986188659, + "y": 1480.8801867280501, + "data": { + "min": 1, + "max": 10, + "step": 1, + "default": 1, + "prompt": "Loop count?", + "ask_start": false + }, + "flipped": false + }, + { + "id": "0b0b000a", + "type": "note", + "x": 1072.0871313465057, + "y": 1646.9821646722855, + "data": { + "text": "Mouse Click - send a left / right / middle button click via USB HID. Position is not controlled; clicks land wherever the host cursor is.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "3524d897", + "type": "mouse", + "x": 1096.4131331635867, + "y": 1772.7715194556897, + "data": { + "button": "left", + "action": "click" + }, + "flipped": false + }, + { + "id": "0b0b000b", + "type": "note", + "x": 1046.394457347465, + "y": 1882.7396458221874, + "data": { + "text": "Media Key - Play/Pause, Vol +/-, Mute, Next, Prev, etc. via USB HID", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "f5706152", + "type": "media", + "x": 1070.7204591645461, + "y": 1966.7905312788116, + "data": { + "action": "play_pause" + }, + "flipped": false + }, + { + "id": "0b0b000c", + "type": "note", + "x": 1002.1178471503137, + "y": 2496.1623880030493, + "data": { + "text": "Variables - unified node with 5 modes. The sub-branch below fans out to one demo of each mode. Define names in the Variables window (toolbar); scope is per-device by MAC or Universal.", + "font_size": 12, + "color": "white", + "width": 260 + }, + "flipped": false + }, + { + "id": "785a3445", + "type": "bluetooth", + "x": 1500, + "y": 2200, + "data": { + "mode": "pull_ble", + "scope": "universal", + "names": [], + "play_sound": true, + "assignments": [], + "script": "", + "script_language": "powershell", + "var_name": "", + "pre_listen_ms": 500, + "listen_window_ms": 5000, + "outcomes": [], + "script_mode": "manual", + "elevated_launch": { + "enabled": false, + "command": "powershell -Command \"Start-Process wt -Verb RunAs\"", + "win_r_wait_ms": 5000, + "post_type_wait_ms": 15000 + }, + "sequence": [] + }, + "flipped": false + }, + { + "id": "0b0b000d", + "type": "note", + "x": 904.9025895878517, + "y": 3090.9637316989774, + "data": { + "text": "RS232 Send - emit a serial message at the configured baud through the Atomic RS232 base. Supports (VAR{name}) expansion and an optional 'wait for response' check.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "6fcd57ee", + "type": "rs232", + "x": 905.5639025191554, + "y": 3275.1216205180726, + "data": { + "baud": 9600, + "data_bits": 8, + "stop_bits": "1", + "parity": "none", + "message": "Hello from RS232", + "line_ending": "crlf", + "wait_response": false, + "expected_response": "", + "timeout_ms": 5000, + "post_send_delay_ms": 0 + }, + "flipped": false + }, + { + "id": "0b0b000e", + "type": "note", + "x": 902.8610134283688, + "y": 3413.327029541044, + "data": { + "text": "Sub-Routine - call a reusable routine defined in the Sub-Routines profile. Edit that profile to manage your shared blocks.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "650c012a", + "type": "subroutine", + "x": 899.4968114062781, + "y": 3544.7861649536885, + "data": { + "name": "Open Run Box" + }, + "flipped": false + }, + { + "id": "0b0b000f", + "type": "note", + "x": 902.5015382799311, + "y": 3680.8134882671293, + "data": { + "text": "PC Alive Check - toggle Num Lock and watch the LED state to confirm the host PC is responding. Modes: PC Response (any change), Num Lock ON, Num Lock OFF. Optional polling loop.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "e5303f37", + "type": "pc_alive_check", + "x": 905.5639025191554, + "y": 3875.1216205180726, + "data": { + "condition": "pc_response", + "loop": true, + "poll_delay_ms": 500 + }, + "flipped": false + }, + { + "id": "0b0b0010", + "type": "note", + "x": 904.047129740936, + "y": 4023.8868016230053, + "data": { + "text": "Macro - record and replay an exact HID key sequence, including chord timings and per-key hold durations. Mouse input is never captured.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "75a52e31", + "type": "macro", + "x": 905.5639025191554, + "y": 4175.121620518077, + "data": { + "events": [], + "name": "" + }, + "flipped": false + }, + { + "id": "ac46db7a", + "type": "note", + "x": 1082.0141276583024, + "y": 346.0823132565328, + "data": { + "text": "Tip: inside Custom Timings, use Record to capture the timings of a real key combo on your keyboard.", + "font_size": 11, + "color": "white", + "width": 220 + }, + "flipped": false + }, + { + "id": "0c0c0001", + "type": "text", + "x": 1860.7356868011127, + "y": 1084.3911840024282, + "data": { + "text": "...continuing", + "language": "none" + }, + "flipped": false + }, + { + "id": "0d0d0001", + "type": "note", + "x": 1856.6813531649325, + "y": 1013.4489658181644, + "data": { + "text": "Runs every iteration except the first.", + "font_size": 12, + "color": "white", + "width": 200 + }, + "flipped": false + }, + { + "id": "0c0c0002", + "type": "text", + "x": 1858.032797710326, + "y": 1240.601622188037, + "data": { + "text": "First iteration!", + "language": "none" + }, + "flipped": false + }, + { + "id": "0d0d0002", + "type": "note", + "x": 1844.5183522563918, + "y": 1158.847847640626, + "data": { + "text": "Runs only on the first iteration.", + "font_size": 12, + "color": "white", + "width": 200 + }, + "flipped": false + }, + { + "id": "0c0c0003", + "type": "aggregator", + "x": 2130.195798618866, + "y": 1240.8801867280501, + "data": { + "input_count": 2 + }, + "flipped": false + }, + { + "id": "0d0d0003", + "type": "note", + "x": 2126.1414649826856, + "y": 1180.7495249069336, + "data": { + "text": "Merge both branches before looping back.", + "font_size": 12, + "color": "white", + "width": 200 + }, + "flipped": false + }, + { + "id": "0c0c0004", + "type": "repeat", + "x": 1610.1957986188665, + "y": 1480.8801867280501, + "data": { + "count": 1, + "start_idx": 0, + "use_selector": true + }, + "flipped": false + }, + { + "id": "0d0d0004", + "type": "note", + "x": 1599.3842422557193, + "y": 1371.1518549089512, + "data": { + "text": "When 'Use selector value' is enabled, the Loop takes its count from the upstream Loop Selector instead of the static count field.", + "font_size": 12, + "color": "white", + "width": 220 + }, + "flipped": false + }, + { + "id": "0c0c0005", + "type": "text", + "x": 1612.4168499422856, + "y": 1608.518718262276, + "data": { + "text": "Looping!", + "language": "none" + }, + "flipped": true + }, + { + "id": "0d0d0005", + "type": "note", + "x": 2130.195798618866, + "y": 1350.8801867280501, + "data": { + "text": "Body of the loop, runs N times where N is what the user picked.", + "font_size": 12, + "color": "white", + "width": 200 + }, + "flipped": false + }, + { + "id": "0c0c0006", + "type": "text", + "x": 2150, + "y": 2200, + "data": { + "text": "Hello (VAR{example})!", + "language": "none" + }, + "flipped": false + }, + { + "id": "0d0d0006", + "type": "note", + "x": 2150, + "y": 2080, + "data": { + "text": "Type Text with (VAR{name}) interpolates the variable at runtime. Define names in the Variables window - host edits are pushed over BLE.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "4b1c882b", + "type": "note", + "x": 1598.1544277194112, + "y": 1685.96021111963, + "data": { + "text": "Tip: select a node and press F to flip its input/output side. Useful for wires that would otherwise cross.", + "font_size": 11, + "color": "white", + "width": 160 + }, + "flipped": false + }, + { + "id": "0e0e0000", + "type": "branch", + "x": 1050, + "y": 2700, + "data": { + "choices": [ + { + "label": "Pull BLE", + "next": -1, + "match_value": "", + "color": "white" + }, + { + "label": "Push BLE", + "next": -1, + "match_value": "", + "color": "white" + }, + { + "label": "Request BLE", + "next": -1, + "match_value": "", + "color": "white" + }, + { + "label": "Set Local", + "next": -1, + "match_value": "", + "color": "white" + }, + { + "label": "Get Local", + "next": -1, + "match_value": "", + "color": "white" + } + ], + "mode": "manual", + "var_name": "", + "var_scope": "auto" + }, + "flipped": false + }, + { + "id": "0e0e1001", + "type": "note", + "x": 1495.4316519295037, + "y": 2110.9263613055678, + "data": { + "text": "Pull BLE - the M5Stack queries the Python app for updated variables.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "0e0e0002", + "type": "bluetooth", + "x": 1500, + "y": 2450, + "data": { + "mode": "push_ble", + "scope": "universal", + "names": [], + "play_sound": true, + "assignments": [], + "script": "", + "script_language": "powershell", + "var_name": "", + "pre_listen_ms": 500, + "listen_window_ms": 5000, + "outcomes": [], + "script_mode": "manual", + "elevated_launch": { + "enabled": false, + "command": "powershell -Command \"Start-Process wt -Verb RunAs\"", + "win_r_wait_ms": 5000, + "post_type_wait_ms": 15000 + }, + "sequence": [] + }, + "flipped": false + }, + { + "id": "0e0e1002", + "type": "note", + "x": 1483.5408309068825, + "y": 2354.87886549474, + "data": { + "text": "Push BLE - the M5Stack pushes its variables back to the host via its per-device BLE profile.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "0e0e0003", + "type": "bluetooth", + "x": 1500.0, + "y": 2741.796494031168, + "data": { + "mode": "request_ble", + "scope": "universal", + "names": [], + "play_sound": true, + "assignments": [], + "script": "", + "script_language": "powershell", + "var_name": "", + "pre_listen_ms": 500, + "listen_window_ms": 5000, + "outcomes": [], + "script_mode": "manual", + "elevated_launch": { + "enabled": false, + "command": "powershell -Command \"Start-Process wt -Verb RunAs\"", + "win_r_wait_ms": 5000, + "post_type_wait_ms": 15000 + }, + "sequence": [] + }, + "flipped": false + }, + { + "id": "0e0e1003", + "type": "note", + "x": 1482.5456762870904, + "y": 2570.0484538021, + "data": { + "text": "Request BLE - device prompts the technician (via a dialog in the Python app) to edit one or more variables. Blocks until the tech submits or cancels. Great for one-off inputs like 'enter ticket number'.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "0e0e0004", + "type": "bluetooth", + "x": 1512.9370100572658, + "y": 2964.927319296846, + "data": { + "mode": "set_local", + "scope": "universal", + "names": [], + "play_sound": true, + "assignments": [ + { + "name": "example", + "value": "hello" + } + ], + "script": "", + "script_language": "powershell", + "var_name": "", + "pre_listen_ms": 500, + "listen_window_ms": 5000, + "outcomes": [], + "script_mode": "manual", + "elevated_launch": { + "enabled": false, + "command": "powershell -Command \"Start-Process wt -Verb RunAs\"", + "win_r_wait_ms": 5000, + "post_type_wait_ms": 15000 + }, + "sequence": [] + }, + "flipped": false + }, + { + "id": "0e0e1004", + "type": "note", + "x": 1471.5989754694026, + "y": 2855.8740201145306, + "data": { + "text": "Set Variables - assign one or more variables on the M5Stack directly. This mode doesn't pull / push over BLE.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "0e0e0005", + "type": "bluetooth", + "x": 1502.985463859369, + "y": 3271.65113262486, + "data": { + "mode": "get_local", + "scope": "universal", + "names": [], + "play_sound": true, + "assignments": [], + "script": "", + "script_language": "powershell", + "var_name": "", + "pre_listen_ms": 500, + "listen_window_ms": 5000, + "outcomes": [], + "script_mode": "manual", + "elevated_launch": { + "enabled": false, + "command": "powershell -Command \"Start-Process wt -Verb RunAs\"", + "win_r_wait_ms": 5000, + "post_type_wait_ms": 15000 + }, + "sequence": [] + }, + "flipped": false + }, + { + "id": "0e0e1005", + "type": "note", + "x": 1470.6038208496143, + "y": 3094.927319296842, + "data": { + "text": "Get Variables - the M5Stack types a host-side script over USB (typically PowerShell) and decodes the answer by counting Scroll Lock toggles the script fires back. This mode doesn't pull / push over BLE.", + "font_size": 12, + "color": "white", + "width": 240 + }, + "flipped": false + }, + { + "id": "0ef8de98", + "type": "note", + "x": 1460.1938152084126, + "y": 3378.2035059688315, + "data": { + "text": "Note: you could use this for something like \"Is the C:\\Users\\admin directory present?\" or \"Is application x y and z installed?\"", + "font_size": 11, + "color": "white", + "width": 220 + }, + "flipped": false + } + ], + "connections": [ + { + "from": "fba20d14", + "from_port": "out", + "to": "da1b3c38", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_0", + "to": "2851ffd9", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_1", + "to": "8556445d", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_2", + "to": "49719853", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_3", + "to": "ca82e599", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_4", + "to": "dcfb234b", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_5", + "to": "96651684", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_6", + "to": "3524d897", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_7", + "to": "f5706152", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_9", + "to": "6fcd57ee", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_10", + "to": "650c012a", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_11", + "to": "e5303f37", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_12", + "to": "75a52e31", + "to_port": "in" + }, + { + "from": "ca82e599", + "from_port": "out_0", + "to": "0a0a1111", + "to_port": "in" + }, + { + "from": "0a0a1111", + "from_port": "out", + "to": "0a0a2222", + "to_port": "in_0" + }, + { + "from": "ca82e599", + "from_port": "out_1", + "to": "0a0a2222", + "to_port": "in_1" + }, + { + "from": "dcfb234b", + "from_port": "loop_body", + "to": "0a0a3333", + "to_port": "in" + }, + { + "from": "0a0a3333", + "from_port": "out_0", + "to": "0c0c0001", + "to_port": "in" + }, + { + "from": "0a0a3333", + "from_port": "out_1", + "to": "0c0c0002", + "to_port": "in" + }, + { + "from": "0c0c0001", + "from_port": "out", + "to": "0c0c0003", + "to_port": "in_0" + }, + { + "from": "0c0c0002", + "from_port": "out", + "to": "0c0c0003", + "to_port": "in_1" + }, + { + "from": "0c0c0003", + "from_port": "out", + "to": "dcfb234b", + "to_port": "loop_back" + }, + { + "from": "96651684", + "from_port": "out", + "to": "0c0c0004", + "to_port": "in" + }, + { + "from": "0c0c0004", + "from_port": "loop_body", + "to": "0c0c0005", + "to_port": "in" + }, + { + "from": "0c0c0005", + "from_port": "out", + "to": "0c0c0004", + "to_port": "loop_back" + }, + { + "from": "785a3445", + "from_port": "out", + "to": "0c0c0006", + "to_port": "in" + }, + { + "from": "da1b3c38", + "from_port": "out_8", + "to": "0e0e0000", + "to_port": "in" + }, + { + "from": "0e0e0000", + "from_port": "out_0", + "to": "785a3445", + "to_port": "in" + }, + { + "from": "0e0e0000", + "from_port": "out_1", + "to": "0e0e0002", + "to_port": "in" + }, + { + "from": "0e0e0000", + "from_port": "out_2", + "to": "0e0e0003", + "to_port": "in" + }, + { + "from": "0e0e0000", + "from_port": "out_3", + "to": "0e0e0004", + "to_port": "in" + }, + { + "from": "0e0e0000", + "from_port": "out_4", + "to": "0e0e0005", + "to_port": "in" + } + ] + }, + { + "name": "Test", + "label_color": "white", + "image_path": "", + "nodes": [ + { + "id": "ac45fd48", + "type": "start", + "x": 100, + "y": 100, + "data": {}, + "flipped": false + }, + { + "id": "53a6df6b", + "type": "macro", + "x": 339.42111444989905, + "y": 85.49173666913251, + "data": { + "events": [ + [ + 703, + 0, + 227 + ], + [ + 962, + 0, + 21 + ], + [ + 1078, + 1, + 21 + ], + [ + 1228, + 1, + 227 + ], + [ + 2175, + 0, + 26 + ], + [ + 2272, + 1, + 26 + ], + [ + 2373, + 0, + 23 + ], + [ + 2460, + 1, + 23 + ], + [ + 2580, + 0, + 225 + ], + [ + 2964, + 0, + 224 + ], + [ + 3154, + 0, + 40 + ], + [ + 3256, + 1, + 40 + ], + [ + 3331, + 1, + 225 + ], + [ + 3340, + 1, + 224 + ], + [ + 4481, + 0, + 80 + ], + [ + 4582, + 1, + 80 + ], + [ + 4609, + 0, + 40 + ], + [ + 4714, + 1, + 40 + ], + [ + 6670, + 0, + 26 + ], + [ + 6752, + 0, + 11 + ], + [ + 6773, + 1, + 26 + ], + [ + 6830, + 1, + 11 + ], + [ + 6892, + 0, + 18 + ], + [ + 6997, + 1, + 18 + ], + [ + 7087, + 0, + 4 + ], + [ + 7186, + 1, + 4 + ], + [ + 7197, + 0, + 16 + ], + [ + 7294, + 1, + 16 + ], + [ + 7371, + 0, + 12 + ], + [ + 7468, + 1, + 12 + ], + [ + 7608, + 0, + 40 + ], + [ + 7685, + 1, + 40 + ], + [ + 7981, + 0, + 11 + ], + [ + 8102, + 1, + 11 + ], + [ + 8184, + 0, + 18 + ], + [ + 8277, + 1, + 18 + ], + [ + 8291, + 0, + 22 + ], + [ + 8360, + 0, + 23 + ], + [ + 8402, + 1, + 22 + ], + [ + 8460, + 1, + 23 + ], + [ + 8544, + 0, + 17 + ], + [ + 8641, + 0, + 4 + ], + [ + 8648, + 1, + 17 + ], + [ + 8747, + 1, + 4 + ], + [ + 8764, + 0, + 16 + ], + [ + 8831, + 0, + 8 + ], + [ + 8862, + 1, + 16 + ], + [ + 8919, + 1, + 8 + ], + [ + 9008, + 0, + 40 + ], + [ + 9085, + 1, + 40 + ], + [ + 9508, + 0, + 23 + ], + [ + 9594, + 0, + 22 + ], + [ + 9639, + 1, + 23 + ], + [ + 9729, + 1, + 22 + ], + [ + 10037, + 0, + 42 + ], + [ + 10115, + 1, + 42 + ], + [ + 10162, + 0, + 42 + ], + [ + 10278, + 1, + 42 + ], + [ + 10377, + 0, + 42 + ], + [ + 10498, + 1, + 42 + ], + [ + 10610, + 0, + 8 + ], + [ + 10696, + 1, + 8 + ], + [ + 10837, + 0, + 11 + ], + [ + 10934, + 1, + 11 + ], + [ + 11109, + 0, + 42 + ], + [ + 11202, + 1, + 42 + ], + [ + 11423, + 0, + 11 + ], + [ + 11519, + 1, + 11 + ], + [ + 11714, + 0, + 18 + ], + [ + 11839, + 1, + 18 + ], + [ + 11851, + 0, + 44 + ], + [ + 11993, + 1, + 44 + ], + [ + 12299, + 0, + 42 + ], + [ + 12361, + 1, + 42 + ], + [ + 12416, + 0, + 42 + ], + [ + 12490, + 1, + 42 + ], + [ + 12524, + 0, + 42 + ], + [ + 12658, + 1, + 42 + ], + [ + 13471, + 0, + 6 + ], + [ + 13543, + 0, + 11 + ], + [ + 13564, + 1, + 6 + ], + [ + 13627, + 1, + 11 + ], + [ + 13700, + 0, + 18 + ], + [ + 13749, + 0, + 44 + ], + [ + 13821, + 1, + 18 + ], + [ + 13865, + 0, + 225 + ], + [ + 13885, + 1, + 44 + ], + [ + 13947, + 0, + 12 + ], + [ + 13973, + 0, + 44 + ], + [ + 14031, + 1, + 225 + ], + [ + 14074, + 1, + 12 + ], + [ + 14110, + 1, + 44 + ], + [ + 14145, + 0, + 4 + ], + [ + 14249, + 1, + 4 + ], + [ + 14265, + 0, + 16 + ], + [ + 14309, + 0, + 44 + ], + [ + 14369, + 1, + 16 + ], + [ + 14435, + 0, + 23 + ], + [ + 14447, + 1, + 44 + ], + [ + 14494, + 0, + 8 + ], + [ + 14513, + 0, + 22 + ], + [ + 14562, + 1, + 23 + ], + [ + 14631, + 0, + 23 + ], + [ + 14676, + 1, + 22 + ], + [ + 14709, + 1, + 8 + ], + [ + 14767, + 1, + 23 + ], + [ + 14801, + 0, + 12 + ], + [ + 14892, + 1, + 12 + ], + [ + 14963, + 0, + 17 + ], + [ + 15007, + 0, + 10 + ], + [ + 15051, + 1, + 17 + ], + [ + 15085, + 1, + 10 + ], + [ + 15094, + 0, + 44 + ], + [ + 15187, + 0, + 23 + ], + [ + 15196, + 1, + 44 + ], + [ + 15236, + 0, + 11 + ], + [ + 15289, + 0, + 8 + ], + [ + 15314, + 0, + 44 + ], + [ + 15364, + 1, + 11 + ], + [ + 15389, + 1, + 23 + ], + [ + 15428, + 1, + 8 + ], + [ + 15442, + 1, + 44 + ], + [ + 15614, + 0, + 21 + ], + [ + 15704, + 1, + 21 + ], + [ + 15855, + 0, + 42 + ], + [ + 15950, + 0, + 9 + ], + [ + 15959, + 1, + 42 + ], + [ + 16043, + 1, + 9 + ], + [ + 16063, + 0, + 24 + ], + [ + 16146, + 1, + 24 + ], + [ + 16201, + 0, + 17 + ], + [ + 16258, + 0, + 6 + ], + [ + 16299, + 1, + 17 + ], + [ + 16356, + 1, + 6 + ], + [ + 16431, + 0, + 23 + ], + [ + 16476, + 0, + 12 + ], + [ + 16509, + 1, + 23 + ], + [ + 16530, + 0, + 18 + ], + [ + 16578, + 1, + 12 + ], + [ + 16627, + 1, + 18 + ], + [ + 16660, + 0, + 17 + ], + [ + 16757, + 1, + 17 + ], + [ + 16768, + 0, + 4 + ], + [ + 16829, + 0, + 15 + ], + [ + 16853, + 1, + 4 + ], + [ + 16913, + 1, + 15 + ], + [ + 16956, + 0, + 12 + ], + [ + 17012, + 0, + 23 + ], + [ + 17053, + 1, + 12 + ], + [ + 17090, + 1, + 23 + ], + [ + 17151, + 0, + 28 + ], + [ + 17205, + 0, + 44 + ], + [ + 17216, + 1, + 28 + ], + [ + 17307, + 1, + 44 + ], + [ + 17330, + 0, + 18 + ], + [ + 17425, + 1, + 18 + ], + [ + 17438, + 0, + 9 + ], + [ + 17494, + 0, + 44 + ], + [ + 17521, + 1, + 9 + ], + [ + 17576, + 1, + 44 + ], + [ + 17587, + 0, + 23 + ], + [ + 17600, + 0, + 11 + ], + [ + 17655, + 0, + 8 + ], + [ + 17671, + 0, + 44 + ], + [ + 17728, + 1, + 23 + ], + [ + 17738, + 1, + 11 + ], + [ + 17768, + 1, + 8 + ], + [ + 17779, + 1, + 44 + ], + [ + 17910, + 0, + 225 + ], + [ + 18005, + 0, + 5 + ], + [ + 18090, + 1, + 5 + ], + [ + 18165, + 0, + 15 + ], + [ + 18235, + 0, + 8 + ], + [ + 18261, + 1, + 15 + ], + [ + 18279, + 0, + 44 + ], + [ + 18325, + 1, + 8 + ], + [ + 18426, + 1, + 225 + ], + [ + 18446, + 1, + 44 + ], + [ + 20235, + 0, + 14 + ], + [ + 20302, + 0, + 8 + ], + [ + 20327, + 1, + 14 + ], + [ + 20424, + 1, + 8 + ], + [ + 20433, + 0, + 28 + ], + [ + 20527, + 1, + 28 + ], + [ + 20602, + 0, + 5 + ], + [ + 20685, + 1, + 5 + ], + [ + 20764, + 0, + 19 + ], + [ + 20808, + 0, + 4 + ], + [ + 20839, + 1, + 19 + ], + [ + 20850, + 0, + 21 + ], + [ + 20926, + 1, + 4 + ], + [ + 20936, + 1, + 21 + ], + [ + 21010, + 0, + 7 + ], + [ + 21097, + 1, + 7 + ], + [ + 21205, + 0, + 42 + ], + [ + 21272, + 1, + 42 + ], + [ + 21334, + 0, + 42 + ], + [ + 21412, + 1, + 42 + ], + [ + 21455, + 0, + 42 + ], + [ + 21527, + 1, + 42 + ], + [ + 21569, + 0, + 42 + ], + [ + 21652, + 1, + 42 + ], + [ + 21708, + 0, + 42 + ], + [ + 21813, + 1, + 42 + ], + [ + 21966, + 0, + 18 + ], + [ + 22048, + 0, + 4 + ], + [ + 22063, + 1, + 18 + ], + [ + 22087, + 0, + 21 + ], + [ + 22140, + 1, + 4 + ], + [ + 22179, + 1, + 21 + ], + [ + 22248, + 0, + 7 + ], + [ + 22353, + 1, + 7 + ], + [ + 22364, + 0, + 44 + ], + [ + 22509, + 1, + 44 + ], + [ + 22767, + 0, + 42 + ], + [ + 22834, + 1, + 42 + ], + [ + 22888, + 0, + 42 + ], + [ + 22967, + 1, + 42 + ], + [ + 23003, + 0, + 42 + ], + [ + 23085, + 1, + 42 + ], + [ + 23146, + 0, + 42 + ], + [ + 23214, + 1, + 42 + ], + [ + 23278, + 0, + 42 + ], + [ + 23361, + 1, + 42 + ], + [ + 23569, + 0, + 5 + ], + [ + 23656, + 1, + 5 + ], + [ + 23824, + 0, + 18 + ], + [ + 23939, + 1, + 18 + ], + [ + 23991, + 0, + 4 + ], + [ + 24048, + 0, + 21 + ], + [ + 24126, + 1, + 4 + ], + [ + 24155, + 1, + 21 + ], + [ + 24225, + 0, + 7 + ], + [ + 24302, + 0, + 44 + ], + [ + 24318, + 1, + 7 + ], + [ + 24432, + 1, + 44 + ], + [ + 25567, + 0, + 12 + ], + [ + 25684, + 1, + 12 + ], + [ + 25743, + 0, + 17 + ], + [ + 25835, + 1, + 17 + ], + [ + 25912, + 0, + 19 + ], + [ + 25992, + 1, + 19 + ], + [ + 26046, + 0, + 24 + ], + [ + 26137, + 0, + 23 + ], + [ + 26158, + 1, + 24 + ], + [ + 26263, + 1, + 23 + ], + [ + 26864, + 0, + 55 + ], + [ + 26970, + 1, + 55 + ], + [ + 27034, + 0, + 55 + ], + [ + 27096, + 1, + 55 + ], + [ + 27141, + 0, + 55 + ], + [ + 27228, + 1, + 55 + ], + [ + 27331, + 0, + 40 + ], + [ + 27409, + 1, + 40 + ] + ], + "name": "" + }, + "flipped": false + } + ], + "connections": [ + { + "from": "ac45fd48", + "from_port": "out", + "to": "53a6df6b", + "to_port": "in" + } + ] + } + ] +} \ No newline at end of file diff --git a/config/profiles/Sub-Routines.json b/config/profiles/Sub-Routines.json new file mode 100644 index 0000000..a360779 --- /dev/null +++ b/config/profiles/Sub-Routines.json @@ -0,0 +1,205 @@ +{ + "version": 1, + "settings": { + "hold_ms": 500, + "type_delay": 15, + "orientation": 0, + "resume_delay": 0, + "combo_pre_ms": 500, + "combo_post_ms": 500, + "probe_timeout_ms": 300, + "media_hold_ms": 100, + "type_shift_extra_ms": 25, + "type_settle_ms": 150, + "pause_margin_left": 4, + "pause_margin_right": 4, + "pause_margin_top": 16, + "pause_margin_bottom": 12 + }, + "macros": [ + { + "name": "Open WT Admin", + "label_color": "white", + "image_path": null, + "nodes": [ + { + "id": "6d8515b0", + "type": "start", + "x": 100, + "y": 100, + "data": {}, + "flipped": false + }, + { + "id": "10bd84f2", + "type": "combo", + "x": 306.0, + "y": 96.0, + "data": { + "mods": [ + "gui" + ], + "key": "r", + "custom_timings": false, + "custom_pre_ms": 167, + "custom_post_ms": 167, + "custom_key_pre_ms": 3, + "custom_key_post_ms": 8 + }, + "flipped": false + }, + { + "id": "3189106f", + "type": "pause", + "x": 521.0, + "y": 95.0, + "data": { + "wait": 5000, + "text": "Wait for run box", + "font_size": 11, + "text_color": "white" + }, + "flipped": false + }, + { + "id": "c1efba35", + "type": "text", + "x": 751.0, + "y": 87.0, + "data": { + "text": "wt", + "language": "none" + }, + "flipped": false + }, + { + "id": "b9547d90", + "type": "combo", + "x": 977.0, + "y": 86.0, + "data": { + "mods": [ + "ctrl", + "shift" + ], + "key": "enter", + "custom_timings": false, + "custom_pre_ms": 167, + "custom_post_ms": 167, + "custom_key_pre_ms": 3, + "custom_key_post_ms": 8 + }, + "flipped": false + }, + { + "id": "51f9171c", + "type": "pause", + "x": 1200.0, + "y": 84.0, + "data": { + "wait": 7000, + "text": "Waiting for UAC", + "font_size": 12, + "text_color": "white" + }, + "flipped": false + }, + { + "id": "29ca8212", + "type": "combo", + "x": 1420, + "y": 80, + "data": { + "mods": [], + "key": "left", + "custom_timings": false, + "custom_pre_ms": 167, + "custom_post_ms": 167, + "custom_key_pre_ms": 3, + "custom_key_post_ms": 8 + }, + "flipped": false + }, + { + "id": "e45190f4", + "type": "combo", + "x": 1626.0, + "y": 77.0, + "data": { + "mods": [], + "key": "enter", + "custom_timings": false, + "custom_pre_ms": 167, + "custom_post_ms": 167, + "custom_key_pre_ms": 3, + "custom_key_post_ms": 8 + }, + "flipped": false + }, + { + "id": "52dfd1a8", + "type": "pause", + "x": 1894.0, + "y": 81.0, + "data": { + "wait": 10000, + "text": "Waiting for WT", + "font_size": 12, + "text_color": "white" + }, + "flipped": false + } + ], + "connections": [ + { + "from": "6d8515b0", + "from_port": "out", + "to": "10bd84f2", + "to_port": "in" + }, + { + "from": "10bd84f2", + "from_port": "out", + "to": "3189106f", + "to_port": "in" + }, + { + "from": "3189106f", + "from_port": "out", + "to": "c1efba35", + "to_port": "in" + }, + { + "from": "c1efba35", + "from_port": "out", + "to": "b9547d90", + "to_port": "in" + }, + { + "from": "b9547d90", + "from_port": "out", + "to": "51f9171c", + "to_port": "in" + }, + { + "from": "51f9171c", + "from_port": "out", + "to": "29ca8212", + "to_port": "in" + }, + { + "from": "29ca8212", + "from_port": "out", + "to": "e45190f4", + "to_port": "in" + }, + { + "from": "e45190f4", + "from_port": "out", + "to": "52dfd1a8", + "to_port": "in" + } + ] + } + ] +} \ No newline at end of file diff --git a/debug_connect.py b/debug_connect.py new file mode 100644 index 0000000..a103c45 --- /dev/null +++ b/debug_connect.py @@ -0,0 +1,134 @@ +"""Direct-connect troubleshooter for the live-mode BLE channel. + +Scans for the device advertising LIVE_SERVICE_UUID, connects, subscribes, +and prints EVERY notify received (whether or not it decrypts). Also +sends a START frame after a brief delay to exercise the device's +write callback path — if that triggers an ACK notify, we know the +write-callback plumbing is wired but the unsolicited-hello path is +the only thing broken. +""" +import asyncio +import os +import struct +import sys + +from bleak import BleakClient, BleakScanner + +import ble_keystore +import ble_replay +from ble_frame import build_frame, parse_frame, DEVICE_TAG_PREFIX +from ble_server import LIVE_SERVICE_UUID, LIVE_KEYS_NOTIFY_UUID, LIVE_KEYS_WRITE_UUID +from ble_live import MSG_START, ERR_LABELS + + +def hex16(b): + return " ".join(f"{x:02x}" for x in b[:16]) + (" ..." if len(b) > 16 else "") + + +async def main(): + key = ble_keystore.load_key() + print(f"[dbg] key loaded: {key is not None} len={len(key) if key else 0}") + + print("[dbg] scanning for LIVE_SERVICE_UUID...") + device = await BleakScanner.find_device_by_filter( + lambda d, adv: LIVE_SERVICE_UUID in (adv.service_uuids or []), + timeout=10.0, + ) + if not device: + print("[dbg] NO DEVICE FOUND advertising LIVE_SERVICE_UUID") + # Show what we DID see + print("[dbg] doing a broader scan to see what's around...") + devs = await BleakScanner.discover(timeout=5.0, return_adv=True) + for addr, (d, adv) in devs.items(): + name = d.name or adv.local_name + if name and "macro" in name.lower(): + print(f"[dbg] MATCH? {addr} name={name} uuids={adv.service_uuids}") + return + print(f"[dbg] found: {device.address} (name={device.name})") + + notify_count = 0 + + def handle_notify(char, data: bytearray): + nonlocal notify_count + notify_count += 1 + raw = bytes(data) + print(f"[dbg] NOTIFY #{notify_count} len={len(raw)} bytes={hex16(raw)}") + if key is None: + print("[dbg] (no key — can't decrypt)") + return + parsed = parse_frame(key, raw) + if parsed is None: + print("[dbg] parse_frame returned None — auth failed or malformed") + # Try to show tag prefix + if len(raw) > 1: + tag_len = raw[0] + if 0 < tag_len < len(raw): + tag = raw[1:1+tag_len] + try: + print(f"[dbg] tag bytes -> {tag.decode('ascii', errors='replace')}") + except Exception: + pass + return + tag, plain = parsed + print(f"[dbg] tag={tag} plain_len={len(plain)}") + print(f"[dbg] plain[0:17]={hex16(plain[:17])}") + if len(plain) >= 1: + msg_type = plain[0] + print(f"[dbg] msg_type=0x{msg_type:02x}") + + async with BleakClient(device, timeout=15.0) as client: + print(f"[dbg] connected. discovering services...") + for svc in client.services: + print(f"[dbg] svc {svc.uuid}") + for ch in svc.characteristics: + props = ",".join(ch.properties) + print(f"[dbg] ch {ch.uuid} ({props})") + + print(f"[dbg] subscribing to LIVE_KEYS_NOTIFY...") + await client.start_notify(LIVE_KEYS_NOTIFY_UUID, handle_notify) + print(f"[dbg] subscribed. waiting 5s for any unsolicited hello...") + for i in range(5): + await asyncio.sleep(1) + print(f"[dbg] after 5s: notify_count={notify_count}") + + # Find the device tag by reading any frame we already got (if any), + # otherwise reconstruct from the MAC. + mac_clean = device.address.upper() + tag = DEVICE_TAG_PREFIX + mac_clean + print(f"[dbg] sending START frame tag={tag}") + + # Use the host's replay-state — this writes to disk, which is + # fine for diagnostics. + replay = ble_replay.ReplayState() + seq = replay.next_send_seq() + sid = replay.host_session_id() + plain = struct.pack("10}] {m}") + + +if __name__ == "__main__": + main() diff --git a/firmware/MacroPad/MacroPad.ino b/firmware/MacroPad/MacroPad.ino new file mode 100644 index 0000000..27fc9cb --- /dev/null +++ b/firmware/MacroPad/MacroPad.ino @@ -0,0 +1,592 @@ +#include +#include "config.h" +#include "settings.h" +#include "usb_hid.h" +#include "led_ui.h" +#include "display_ui.h" +#include "macro_storage.h" +#include "ble_keystore.h" +#include "ble_manager.h" +#include "espnow_manager.h" +#include "macro_engine.h" +#include "serial_protocol.h" +#include "live_keystroke.h" + +SettingsManager settingsManager; +HIDController hid; +LedUI ledUI; +DisplayUI display; +MacroStorage storage; +DebugLog debugLog; +BLEKeyStore bleKeyStore; +BLEManager bleManager; +EspNowManager espnowManager; +MacroEngine engine; +SerialProtocol protocol; +LiveKeystrokeEngine liveEngine; +HardwareSerial rs232Serial(1); // UART1 for RS232 via Atomic RS232 Base + +// Passed to SerialProtocol so that when the host-side terminal reconfigures +// the RS232 port, any cached baud/config in the engine gets invalidated. +void onRS232Reconfig() { + engine.resetRS232Cache(); +} + +int currentMacroIdx = 0; + +// Track the last status string we drew to showLiveMode so we only +// re-render when the BLE state actually changes (avoids flicker and +// SPI traffic during heavy keystroke streaming). +static const char* _liveLastStatus = nullptr; +// True while the full-screen live-mode display is up (a host is +// connected). Lets us detect the connected->idle transition and +// repaint the macro selector exactly once. +static bool _liveScreenShown = false; +// True while the Bluetooth identify logo is up (host asked us to +// identify ourselves so the user can label this device). +static bool _liveIdentifyShown = false; +// Last device-label version we rendered, so a new label repaints the +// live-mode screen even when the status string hasn't changed. +static uint32_t _liveLabelVerShown = 0; +// Set true if the user cancels the auto-reconnect (power-loss resume) with a +// button hold. Suppresses BLE for the rest of this boot; a power cycle +// re-enables it. +static bool _liveResumeCancelled = false; +// True while the "Reconnecting..." screen is up (resume pending, not yet +// connected). Lets us repaint the selector exactly once when it clears. +static bool _resumeScreenShown = false; +// True while the "MESH HUB" screen is up; repainted when the hub's node +// roster changes (count shown on screen). +static bool _hubScreenShown = false; +static uint32_t _hubRosterVerShown = 0; +// Latches the 5-second transport-toggle so it fires exactly once per press +// (pressedFor() stays true for every update past the threshold). Cleared on +// button release. +static bool _modeSwitchArmed = false; + +void setup() { + // Disable DTR/RTS bootloader reboot ASAP — CDC_ON_BOOT means TinyUSB + // is already running before setup(). This prevents a crash-restart cycle + // from entering bootloader when the Python app has the port open. + USBSerial.enableReboot(false); + + // Initialize M5 WITHOUT touching USB serial + auto cfg = M5.config(); + cfg.serial_baudrate = 0; // Prevent M5.begin() from calling Serial.begin() + M5.begin(cfg); + + // Initialize USB composite device (HID + CDC) + hid.begin(); + + // Short serial timeout so readStringUntil doesn't block the loop + Serial.setTimeout(100); + + settingsManager.begin(); + + M5.BtnA.setHoldThresh(settingsManager.settings.holdMs); + M5.BtnA.setDebounceThresh(DEBOUNCE_MS); + + // Universal binary: M5GFX panel autodetect found an LCD on the AtomS3; + // on the AtomS3 Lite there is none and getBoard() reports the Lite. + // All screen output is gated inside DisplayUI; user feedback on the + // Lite comes from the RGB LED (LedUI, via M5.Led). + bool hasDisplay = (M5.getDisplayCount() > 0) && + (M5.getBoard() != m5::board_t::board_M5AtomS3Lite); + ledUI.begin(!hasDisplay); + display.begin(settingsManager.settings.orientation, hasDisplay, &ledUI); + display.showBoot(settingsManager.settings.liveTransport == LIVE_TX_BLE + ? "Mode: BLE" : "Mode: Mesh"); + + if (!storage.begin()) { + display.showMessage("FS Error!", TFT_RED); + delay(2000); + } + storage.loadSubIndex(); + + // BLE payload-encryption key — load from LittleFS or generate on first boot. + // Must come after storage.begin() since LittleFS is mounted there. + bleKeyStore.begin(); + + debugLog.begin(); + debugLog.log("Device booted"); + + engine.begin(&hid, &display, &storage, &settingsManager, &rs232Serial); + + // ESP-NOW mesh (live-keyboard transport). Radio stays OFF until the + // idle loop brings up node listening (or the host app switches us + // into hub mode over USB). + espnowManager.begin(&settingsManager, &bleKeyStore, &liveEngine, &debugLog); + + protocol.begin(&settingsManager, &storage, &display, &debugLog, + &rs232Serial, onRS232Reconfig, &bleKeyStore, &bleManager, + &espnowManager); + + // Give USB time to fully enumerate + delay(1500); + showCurrentMacro(); + + // BLE manager — only stores debug log pointer here. + // NimBLE is NOT started at boot; it's started on-demand when a + // bluetooth node is hit, then shut down after variables are received. + // This avoids all BLE/USB radio contention during normal operation. + bleManager.begin(&debugLog, &bleKeyStore); + engine.setBLEManager(&bleManager); + + // Wire up the live-keystroke engine. The BLE manager pushes events + // into it from its NimBLE write callback; we drain in the main loop. + liveEngine.begin(&hid); + bleManager.setLiveEngine(&liveEngine); + + // Check for saved execution state (power-loss recovery) + int resumeSlot = 0, resumeNode = 0; + if (engine.checkSavedState(resumeSlot, resumeNode)) { + uint16_t delaySeconds = settingsManager.settings.resumeDelay; + // resumeSlot is the actual LittleFS slot number (not display index). + // Validate it exists by checking if the slot appears in the order array. + bool slotValid = false; + for (int i = 0; i < storage.macroCount; i++) { + if (storage.order[i] == resumeSlot) { slotValid = true; break; } + } + + Serial.printf("[BOOT] resume: slot=%d node=%d slotValid=%d delaySec=%u\n", + resumeSlot, resumeNode, (int)slotValid, (unsigned)delaySeconds); + + if (delaySeconds > 0 && slotValid) { + // Drain stale button state. After a USB-power blip the M5.Btn + // driver can latch a "wasClicked" on the first update — if we + // peek at it during the countdown we'd cancel the resume the + // user is depending on. Burn ~200ms of updates so anything + // pending settles before we start watching for real input. + for (int i = 0; i < 20; i++) { M5.update(); delay(10); } + (void)M5.BtnA.wasClicked(); + (void)M5.BtnA.wasHold(); + + // Countdown with cancel option. A HOLD (long press) cancels; a + // single click is ignored — it's too easy to bump the button + // accidentally while watching imaging, and that would silently + // discard the resume. + bool cancelled = false; + uint16_t cancelledAt = delaySeconds; + for (uint16_t remaining = delaySeconds; remaining > 0; remaining--) { + char msg[64]; + snprintf(msg, sizeof(msg), "Resuming in %ds\nHold to cancel", remaining); + display.showMessage(msg, TFT_YELLOW); + // Poll button every 100ms during each second + for (int i = 0; i < 10; i++) { + M5.update(); + if (M5.BtnA.wasHold()) { + cancelled = true; + cancelledAt = remaining; + break; + } + delay(100); + } + if (cancelled) break; + } + + if (!cancelled) { + Serial.println("[BOOT] resume: countdown completed, resuming macro"); + if (!engine.resumeMacro(resumeSlot, resumeNode, + storage.macros[resumeSlot].name)) { + Serial.println("[BOOT] resume: resumeMacro() failed, clearing state"); + engine.clearExecutionState(); + showCurrentMacro(); + } + } else { + Serial.printf("[BOOT] resume: cancelled by hold at %us remaining\n", + (unsigned)cancelledAt); + engine.clearExecutionState(); + showCurrentMacro(); + } + } else { + Serial.printf("[BOOT] resume: skipped (delay=%u, slotValid=%d) — clearing state\n", + (unsigned)delaySeconds, (int)slotValid); + engine.clearExecutionState(); + } + } else { + Serial.println("[BOOT] no resume state to load"); + } +} + +// ---- Live-transport abstraction --------------------------------------- +// The device receives a live-keyboard session over exactly one radio, +// chosen by settings.liveTransport. These helpers hide which one is active +// so the idle loop's screen/reconnect logic is written once for both. + +static bool liveIsBle() { + return settingsManager.settings.liveTransport == LIVE_TX_BLE; +} + +static bool liveSessionActive() { + return liveIsBle() + ? (bleManager.exchangeKind() == BLEManager::EX_LIVE && + bleManager.isClientConnected()) + : espnowManager.nodeInSession(); +} + +static bool liveIdentifyActive() { + return liveIsBle() ? bleManager.liveIdentify() + : espnowManager.nodeIdentify(); +} + +static const char* liveStatusStr() { + return liveIsBle() ? bleManager.liveStatusText() + : espnowManager.nodeStatusText(); +} + +static const char* liveLabelStr() { + return liveIsBle() ? bleManager.liveLabel() : espnowManager.nodeLabel(); +} + +static uint32_t liveLabelVerNum() { + return liveIsBle() ? bleManager.liveLabelVer() + : espnowManager.nodeLabelVer(); +} + +static bool liveResumePending() { + return liveIsBle() ? bleManager.liveResumeRequested() + : espnowManager.resumeRequestedAtBoot(); +} + +static void liveConsumeResume() { + if (liveIsBle()) bleManager.consumeLiveResume(); + else espnowManager.consumeResume(); +} + +// Free both live radios. Safe to call when either/both are already down +// (each teardown is idempotent). Used before running a routine, on a +// transport toggle, and when a USB host claims the device. +static void liveShutdownRadios() { + espnowManager.shutdown(); + bleManager.stopLive(); + bleManager.shutdown(); +} + +// Flip the persisted live transport (mesh <-> BLE), tear the current radio +// down so the idle loop brings the new one up, and confirm on-screen. Called +// from the idle selector when the button is held for MODE_SWITCH_HOLD_MS. +static void toggleLiveTransport() { + uint8_t next = liveIsBle() ? LIVE_TX_MESH : LIVE_TX_BLE; + settingsManager.set("live_tx", next); // persists to NVS + + liveShutdownRadios(); + // Cancel any pending power-loss auto-reconnect for the old transport and + // re-arm listening for the new one. + espnowManager.consumeResume(); + bleManager.consumeLiveResume(); + _liveResumeCancelled = false; + _liveScreenShown = false; + _liveIdentifyShown = false; + _resumeScreenShown = false; + _liveLastStatus = nullptr; + + display.showModeSwitch(next == LIVE_TX_BLE); + // Hold the confirmation ~1.2 s. Pump the LED engine (no-op on an LCD + // board) so a screenless Lite actually animates its mode-switch burst. + // Deliberately do NOT call M5.update() here — the button release must + // be left for the main loop to observe so _modeSwitchArmed clears. + uint32_t until = millis() + 1200; + while ((int32_t)(millis() - until) < 0) { + ledUI.tick(); + delay(20); + } + showCurrentMacro(); +} + +void loop() { + M5.update(); + + // Safety net: re-assert reboot disable every loop iteration + USBSerial.enableReboot(false); + + // LED pattern engine (AtomS3 Lite only; no-op with a display). Cheap: + // recomputes the current pattern color and writes only on change. + ledUI.tick(); + + // Keyboard-priority gate: when the HID controller is in the middle + // of a synchronous USB-emitting operation (typeText, keyCombo, + // mediaKey, probe), defer non-USB-HID housekeeping. These polls + // don't touch the keyboard interface themselves, but they share the + // main-loop task with engine.tick() — and skipping them keeps the + // FreeRTOS scheduling latency on the typing path as low as possible + // for any future change that pumps the loop while typing. + // + // In current code the main loop is already blocked inside tick() + // for the duration of a keyboard op, so the flag is normally only + // observed BETWEEN ops. We still gate here so the contract holds. + bool hidCritical = hid.isCritical(); + + if (!hidCritical) { + // Print deferred BLE status on the main task (safe for TinyUSB CDC) + bleManager.pollStatus(); + } + + // Drain queued live-mode keystrokes outside the HID-critical window. + // The drain itself wraps in beginCritical/endCritical so any other + // gated work observes the new in-flight state — but we only START + // draining when the previous gate has lifted, so emissions don't + // stack on top of each other. + if (!hidCritical && liveEngine.isActive()) { + if (liveEngine.drainQueue() > 0) { + // Screenless boards flicker the LED so the user can see + // keystrokes flowing (no-op when a display is present). + ledUI.liveActivity(); + } + } + + // Drain the latest absolute-mouse report (BT Keyboard trackpad). Not + // cadence-buffered — emitted as soon as the HID-critical window is clear. + if (!hidCritical) { + liveEngine.drainMouse(); + } + + bool settingsChanged = protocol.handleSerial(); + + if (!hidCritical) { + // Drain RS232 RX into the terminal buffer when the host-side terminal is open + protocol.pollRS232(); + } + + if (settingsChanged) { + M5.BtnA.setHoldThresh(settingsManager.settings.holdMs); + } + + if (protocol.needsRefresh()) { + if (currentMacroIdx >= storage.macroCount) { + currentMacroIdx = 0; + } + _liveScreenShown = false; + _liveIdentifyShown = false; + showCurrentMacro(); + } + + // ---- ESP-NOW mesh (live-keyboard transport) ---- + // + // The mesh replaced the old per-device live-BLE channel. While idle + // the device passively listens on the mesh channel so the hub (the + // USB-attached unit the host app drives) can discover and JOIN it + // WITHOUT any on-device gesture. Policy mirrors the old BLE one: + // + // * Profile uploads over USB (isUploadActive) and a USB-connected + // host app keep the radio OFF — unless the host explicitly made + // us the hub (espnow_hub command), which overrides isHostConnected. + // * A running routine owns the device for HID, so the radio is torn + // down the moment a routine starts (below). This also guarantees + // the on-demand BLE variables exchange never coexists with WiFi. + // * 10-second boot grace, skipped when the power-loss resume flag is + // set so a host session reconnects without delay. A button hold + // cancels the resume for this boot (idle UI below). + // + // Security: JOIN must decrypt under this device's AES key and the + // keystroke stream under the session group key — a radio-local + // attacker can never inject input. + espnowManager.tick(); + + // Bring up whichever live radio settings.liveTransport selects (never + // both — BLE and WiFi contend on the S3). The hub path is unaffected: + // it's entered only over USB and owns the radio while active. + static const uint32_t LIVE_LISTEN_BOOT_GRACE_MS = 10000; + bool bleLiveUp = bleManager.isBLEActive() && + bleManager.exchangeKind() == BLEManager::EX_LIVE; + bool resumeReq = liveResumePending() && !_liveResumeCancelled; + if (espnowManager.isHub()) { + // Host-driven bridge. It auto-reverts to OFF if the host app goes + // quiet (see espnow_manager.h), so nothing to police here. + } else if (protocol.isHostConnected() || protocol.isUploadActive()) { + // A USB host app is talking to us — this is the configuring + // computer, not a remote-resume scenario. Drop the resume request + // and keep both live radios off for the rest of the boot. + espnowManager.consumeResume(); + bleManager.consumeLiveResume(); + if (espnowManager.isRadioActive() || bleLiveUp) { + liveShutdownRadios(); + _liveScreenShown = false; + _liveIdentifyShown = false; + _resumeScreenShown = false; + } + } else if (!engine.isRunning() && !_liveResumeCancelled && + (resumeReq || millis() > LIVE_LISTEN_BOOT_GRACE_MS)) { + if (liveIsBle()) { + // Ensure the mesh radio is down, then advertise BLE live. + if (espnowManager.isRadioActive()) espnowManager.shutdown(); + bleManager.startLive(); // idempotent + } else { + // Ensure BLE live is down, then idle-listen on the mesh. + if (bleLiveUp) bleManager.stopLive(); + espnowManager.startNodeListen(); // idempotent + } + } + + // Don't process button for macro selection while receiving data + if (protocol.isBusy()) return; + + if (engine.isRunning()) { + engine.tick(settingsManager.settings.typeDelay); + + if (M5.BtnA.wasClicked()) { + engine.onButtonClick(); + } + if (M5.BtnA.wasHold()) { + engine.onButtonHold(); + } + + // If engine just finished, return to macro selector + if (!engine.isRunning()) { + _liveScreenShown = false; + _liveIdentifyShown = false; + showCurrentMacro(); + } + } else { + // Idle. Hub mode first: the host app drives everything over USB; + // we only show which unit is the hub and how many nodes it sees. + if (espnowManager.isHub()) { + if (!_hubScreenShown || + espnowManager.hubNodesVer() != _hubRosterVerShown) { + display.showHubMode(espnowManager.hubNodeCount()); + _hubRosterVerShown = espnowManager.hubNodesVer(); + _hubScreenShown = true; + _liveScreenShown = false; + _liveIdentifyShown = false; + } + (void)M5.BtnA.wasClicked(); + (void)M5.BtnA.wasHold(); + return; + } + if (_hubScreenShown) { + _hubScreenShown = false; + showCurrentMacro(); + } + + // Joined a live session (on whichever transport is active): show + // the live-mode screen and let the host drive. The only on-device + // action is a long-press, which overrides into running the + // currently-selected routine. + bool liveConnected = liveSessionActive(); + if (liveConnected) { + // Joined — the power-loss resume is satisfied; clear the + // one-shot boot request so later grace logic is normal. + liveConsumeResume(); + _resumeScreenShown = false; + if (liveIdentifyActive()) { + // Host is asking us to identify ourselves so the user can + // label this specific device — draw the Bluetooth logo + // (blue/white LED flash on a Lite). + if (!_liveIdentifyShown) { + display.showBluetoothLogo(); + _liveIdentifyShown = true; + _liveScreenShown = false; // force a status repaint after + _liveLastStatus = nullptr; + } + } else { + _liveIdentifyShown = false; + const char* status = liveStatusStr(); + uint32_t lblVer = liveLabelVerNum(); + if (!_liveScreenShown || status != _liveLastStatus || + lblVer != _liveLabelVerShown) { + display.showLiveMode(status, liveLabelStr()); + _liveLastStatus = status; + _liveLabelVerShown = lblVer; + _liveScreenShown = true; + // Screenless mesh nodes: hub silence shows as the + // cyan/red "lost hub" pattern instead of live-idle. + if (!liveIsBle() && espnowManager.nodeLagging()) { + ledUI.lagging(); + } + } + } + (void)M5.BtnA.wasClicked(); // consumed, ignored while a host drives + if (M5.BtnA.wasHold() && storage.macroCount > 0) { + // Engine activity overrides a live session: free the radio, + // then run the selected routine. + liveShutdownRadios(); + _liveScreenShown = false; + _liveIdentifyShown = false; + int idx = currentMacroIdx % storage.macroCount; + int slot = storage.order[idx]; + engine.startMacro(slot, storage.macros[slot].name); + } + return; + } + + // No session yet. If we're auto-reconnecting after a power-loss + // (resume flag set, not cancelled), show the "Reconnecting..." + // screen and let a hold cancel it. + if (liveResumePending() && !_liveResumeCancelled) { + if (!_resumeScreenShown) { + display.showReconnecting(); + _resumeScreenShown = true; + _liveScreenShown = false; + _liveIdentifyShown = false; + } + (void)M5.BtnA.wasClicked(); + if (M5.BtnA.wasHold()) { + // Cancel the auto-reconnect: radios off for the rest of + // this boot. A power cycle re-enables it. + _liveResumeCancelled = true; + liveConsumeResume(); + liveShutdownRadios(); + _resumeScreenShown = false; + showCurrentMacro(); + } + return; + } + + // No session. Repaint the selector once if we were just showing + // the live screen, then handle the normal gestures. + if (_liveScreenShown || _liveIdentifyShown || _resumeScreenShown) { + _liveScreenShown = false; + _liveIdentifyShown = false; + _resumeScreenShown = false; + _liveLastStatus = nullptr; + showCurrentMacro(); + } + + // Screen-button gestures (idle selector / listening): + // click -> next macro + // hold 0.5s .. <5s -> run the selected routine + // hold >= 5s -> toggle the live transport (mesh <-> BLE) + // The run gesture is classified on RELEASE so a long transport-toggle + // hold has room to complete without the routine firing at ~0.5 s. + if (M5.BtnA.pressedFor(MODE_SWITCH_HOLD_MS)) { + if (!_modeSwitchArmed) { + _modeSwitchArmed = true; // fire once for this press + toggleLiveTransport(); + } + return; // hold still in progress + } + if (M5.BtnA.wasReleased()) { + _modeSwitchArmed = false; // ready for the next press + } + + if (M5.BtnA.wasClicked()) { + if (storage.macroCount > 0) { + currentMacroIdx = (currentMacroIdx + 1) % storage.macroCount; + } + showCurrentMacro(); + } + + if (M5.BtnA.wasReleasedAfterHold() && + !M5.BtnA.wasReleaseFor(MODE_SWITCH_HOLD_MS) && + storage.macroCount > 0) { + // Short hold released (not the 5 s toggle): tear both live radios + // down so WiFi/BLE is fully off for HID emission (and for any BLE + // variables node the routine may hit), then run. + liveShutdownRadios(); + _liveScreenShown = false; + int slot = storage.order[currentMacroIdx]; + engine.startMacro(slot, storage.macros[slot].name); + } + } +} + +void showCurrentMacro() { + if (storage.macroCount == 0) { + display.showMessage("No Macros"); + return; + } + if (currentMacroIdx >= storage.macroCount) currentMacroIdx = 0; + int slot = storage.order[currentMacroIdx]; + display.showMacroSelector(slot, storage.macros[slot].name, currentMacroIdx, storage.macroCount, + resolveColor(storage.macros[slot].labelColor), + settingsManager.settings.liveTransport == LIVE_TX_BLE); +} diff --git a/firmware/MacroPad/abs_mouse.h b/firmware/MacroPad/abs_mouse.h new file mode 100644 index 0000000..fe8615a --- /dev/null +++ b/firmware/MacroPad/abs_mouse.h @@ -0,0 +1,114 @@ +#pragma once + +#include "USBHID.h" +#include + +// Absolute-position mouse HID device. +// +// The stock USBHIDMouse is RELATIVE (it reports dx/dy deltas), which +// accumulates error and drifts the remote cursor out of sync over a lossy +// BLE link. This device reports ABSOLUTE coordinates (0..32767 spanning the +// target's screen) so every report fully specifies where the cursor is — a +// dropped report just means the next one re-pins the position, never drift. +// That's exactly what the BT Keyboard virtual trackpad needs to stay synced +// across many devices. +// +// Report payload (the 1-byte report ID is prepended by USBHID::SendReport): +// byte 0 : buttons bitmask (bit0 left, bit1 right, bit2 middle) +// bytes 1-2 : X (uint16 little-endian, 0..32767) +// bytes 3-4 : Y (uint16 little-endian, 0..32767) +// byte 5 : wheel (int8, relative scroll tick) + +#define ABS_MOUSE_REPORT_ID 0x0A +#define ABS_MOUSE_BTN_LEFT 0x01 +#define ABS_MOUSE_BTN_RIGHT 0x02 +#define ABS_MOUSE_BTN_MIDDLE 0x04 +#define ABS_MOUSE_MAX 32767 + +class AbsoluteMouse : public USBHIDDevice { +public: + AbsoluteMouse() : _hid(), _buttons(0), _x(0), _y(0) { + static bool initialized = false; + if (!initialized) { + initialized = true; + uint16_t len = 0; + _descriptor(&len); + _hid.addDevice(this, len); + } + } + + void begin() { _hid.begin(); } + + // Called by the TinyUSB stack to fetch our report descriptor. + uint16_t _onGetDescriptor(uint8_t* buffer) override { + uint16_t len = 0; + const uint8_t* d = _descriptor(&len); + memcpy(buffer, d, len); + return len; + } + + bool ready() { return _hid.ready(); } + + // Emit one absolute report. ``x``/``y`` are 0..ABS_MOUSE_MAX; ``wheel`` + // is a relative scroll tick. Returns the SendReport result. + bool report(uint8_t buttons, uint16_t x, uint16_t y, int8_t wheel) { + if (x > ABS_MOUSE_MAX) x = ABS_MOUSE_MAX; + if (y > ABS_MOUSE_MAX) y = ABS_MOUSE_MAX; + _buttons = buttons; + _x = x; + _y = y; + uint8_t r[6]; + r[0] = buttons; + r[1] = (uint8_t)(x & 0xFF); + r[2] = (uint8_t)((x >> 8) & 0xFF); + r[3] = (uint8_t)(y & 0xFF); + r[4] = (uint8_t)((y >> 8) & 0xFF); + r[5] = (uint8_t)wheel; + return _hid.SendReport(ABS_MOUSE_REPORT_ID, r, sizeof(r)); + } + +private: + USBHID _hid; + uint8_t _buttons; + uint16_t _x, _y; + + static const uint8_t* _descriptor(uint16_t* outLen) { + static const uint8_t d[] = { + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xA1, 0x01, // Collection (Application) + 0x85, ABS_MOUSE_REPORT_ID, // Report ID + 0x09, 0x01, // Usage (Pointer) + 0xA1, 0x00, // Collection (Physical) + 0x05, 0x09, // Usage Page (Button) + 0x19, 0x01, // Usage Minimum (1) + 0x29, 0x03, // Usage Maximum (3) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x95, 0x03, // Report Count (3) + 0x75, 0x01, // Report Size (1) + 0x81, 0x02, // Input (Data,Var,Abs) + 0x95, 0x01, // Report Count (1) + 0x75, 0x05, // Report Size (5) + 0x81, 0x03, // Input (Const) - padding + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x16, 0x00, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x7F, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x02, // Report Count (2) + 0x81, 0x02, // Input (Data,Var,Abs) + 0x09, 0x38, // Usage (Wheel) + 0x15, 0x81, // Logical Minimum (-127) + 0x25, 0x7F, // Logical Maximum (127) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x06, // Input (Data,Var,Rel) + 0xC0, // End Collection + 0xC0 // End Collection + }; + *outLen = sizeof(d); + return d; + } +}; diff --git a/firmware/MacroPad/ble_keystore.h b/firmware/MacroPad/ble_keystore.h new file mode 100644 index 0000000..fd2a5fd --- /dev/null +++ b/firmware/MacroPad/ble_keystore.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +// Persistent 32-byte AES-256-GCM key for BLE payload encryption. +// Generated on first boot, stored at /ble.keyfile, pulled to the host +// during every profile upload via the get_ble_key serial command. +class BLEKeyStore { +public: + static constexpr size_t KEY_LEN = 32; + static constexpr const char* KEY_PATH = "/ble.keyfile"; + + // Loads the key from LittleFS, or generates and persists a new one + // if no key exists. Assumes LittleFS is already mounted. + bool begin() { + if (LittleFS.exists(KEY_PATH)) { + File f = LittleFS.open(KEY_PATH, "r"); + if (f && f.size() == KEY_LEN && f.read(_key, KEY_LEN) == KEY_LEN) { + f.close(); + _loaded = true; + Serial.println("[BLE] Key loaded"); + return true; + } + if (f) f.close(); + // Corrupt or wrong-size file — regenerate. + } + + esp_fill_random(_key, KEY_LEN); + File f = LittleFS.open(KEY_PATH, "w"); + if (!f) { + Serial.println("[BLE] Failed to open key file for write"); + return false; + } + size_t wrote = f.write(_key, KEY_LEN); + f.close(); + if (wrote != KEY_LEN) { + Serial.println("[BLE] Failed to write full key"); + return false; + } + _loaded = true; + Serial.println("[BLE] Generated new key"); + return true; + } + + bool hasKey() const { return _loaded; } + const uint8_t* key() const { return _key; } + +private: + uint8_t _key[KEY_LEN] = {0}; + bool _loaded = false; +}; diff --git a/firmware/MacroPad/ble_manager.h b/firmware/MacroPad/ble_manager.h new file mode 100644 index 0000000..f04296b --- /dev/null +++ b/firmware/MacroPad/ble_manager.h @@ -0,0 +1,1557 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "config.h" +#include "frame_crypto.h" +#include "debug_log.h" +#include "ble_keystore.h" +#include "live_keystroke.h" + +struct BLEVariable { + char name[BLE_VAR_NAME_LEN]; + char value[BLE_VAR_VALUE_LEN]; +}; + +// In-memory ring buffer for BLE debug events. Safe to call from any task, +// including NimBLE callback context, because it does no I/O — just a +// memcpy under a spinlock. Drained on demand via the `get_ble_log` serial +// command (see serial_protocol.h). 128 entries × 96 bytes = ~12 KB heap- +// independent storage. +class BLERingLog { +public: + static constexpr int CAP = 128; + static constexpr int MSG_LEN = 96; + + void log(const char* msg) { + portENTER_CRITICAL(&_mux); + Entry& e = _entries[_head]; + e.t = millis(); + size_t n = 0; + for (; n < MSG_LEN - 1 && msg[n]; n++) e.msg[n] = msg[n]; + e.msg[n] = '\0'; + _head = (_head + 1) % CAP; + if (_count < CAP) _count++; + portEXIT_CRITICAL(&_mux); + // Mirror to serial for live monitoring. + Serial.printf("[BLE.dbg %lu] %s\n", millis(), msg); + } + + void logf(const char* fmt, ...) { + char buf[MSG_LEN]; + va_list a; + va_start(a, fmt); + vsnprintf(buf, sizeof(buf), fmt, a); + va_end(a); + log(buf); + } + + // Stream as JSON via the host serial protocol. Snapshots indices + // briefly under the spinlock, then iterates with per-entry brief + // locks; Serial I/O (which can block) happens outside the lock. + void dumpJson() { + Serial.print("{\"rsp\":\"ble_log\",\"entries\":["); + int count, start; + portENTER_CRITICAL(&_mux); + count = _count; + start = (_count == CAP) ? _head : 0; + portEXIT_CRITICAL(&_mux); + for (int i = 0; i < count; i++) { + Entry snap; + portENTER_CRITICAL(&_mux); + snap = _entries[(start + i) % CAP]; + portEXIT_CRITICAL(&_mux); + if (i > 0) Serial.print(","); + Serial.print("{\"t\":"); + Serial.print(snap.t); + Serial.print(",\"m\":\""); + for (const char* p = snap.msg; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '"' || c == '\\') { Serial.write('\\'); Serial.write(c); } + else if (c < 0x20) Serial.printf("\\u%04x", c); + else Serial.write(c); + } + Serial.print("\"}"); + } + Serial.println("]}"); + Serial.flush(); + } + + void clear() { + portENTER_CRITICAL(&_mux); + _head = 0; + _count = 0; + portEXIT_CRITICAL(&_mux); + } + + // ---- Flash persistence ---- + // + // Survival across reboots: we write the full ring to LittleFS at safe + // points (shutdown, before NimBLE init) and reload on boot. A panic + // mid-init then leaves the prior boot's events visible on the next + // pull. Path constant kept here for self-containment. + static constexpr const char* FLASH_PATH = "/ble_dbg.log"; + + void persistToDisk() { + // CRITICAL: do NOT hold the spinlock across flash I/O. LittleFS + // internally takes FreeRTOS mutexes which are illegal under + // portENTER_CRITICAL; doing so panics the chip (caused a boot + // loop in an earlier revision of this code). Snapshot indices + // briefly, snapshot one entry at a time briefly, write outside. + int count, start; + portENTER_CRITICAL(&_mux); + count = _count; + start = (_count == CAP) ? _head : 0; + portEXIT_CRITICAL(&_mux); + + File f = LittleFS.open(FLASH_PATH, "w"); + if (!f) return; + for (int i = 0; i < count; i++) { + Entry snap; + portENTER_CRITICAL(&_mux); + snap = _entries[(start + i) % CAP]; + portEXIT_CRITICAL(&_mux); + f.printf("%lu\t", (unsigned long)snap.t); + for (const char* p = snap.msg; *p; p++) { + char c = *p; + // Replace tab/newline so split-on-tab in load works. + f.write((c == '\t' || c == '\n' || c == '\r') ? ' ' : c); + } + f.write('\n'); + } + f.close(); + } + + void loadFromDisk() { + if (!LittleFS.exists(FLASH_PATH)) return; + File f = LittleFS.open(FLASH_PATH, "r"); + if (!f) return; + while (f.available()) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() == 0) continue; + int tab = line.indexOf('\t'); + if (tab < 0) continue; + uint32_t t = (uint32_t)line.substring(0, tab).toInt(); + const char* msg = line.c_str() + tab + 1; + portENTER_CRITICAL(&_mux); + Entry& e = _entries[_head]; + e.t = t; + size_t n = 0; + for (; n < MSG_LEN - 1 && msg[n]; n++) e.msg[n] = msg[n]; + e.msg[n] = '\0'; + _head = (_head + 1) % CAP; + if (_count < CAP) _count++; + portEXIT_CRITICAL(&_mux); + } + f.close(); + } + +private: + struct Entry { uint32_t t; char msg[MSG_LEN]; }; + Entry _entries[CAP]; + int _head = 0; + int _count = 0; + portMUX_TYPE _mux = portMUX_INITIALIZER_UNLOCKED; +}; + +class BLEManager; + +// NimBLE server callbacks (connect/disconnect) +class _BLEServerCB : public NimBLEServerCallbacks { +public: + BLEManager* mgr; + _BLEServerCB(BLEManager* m) : mgr(m) {} + void onConnect(NimBLEServer* server, NimBLEConnInfo& connInfo) override; + void onDisconnect(NimBLEServer* server, NimBLEConnInfo& connInfo, int reason) override; +}; + +// NimBLE write characteristic callback (host -> device) +class _BLEWriteCB : public NimBLECharacteristicCallbacks { +public: + BLEManager* mgr; + _BLEWriteCB(BLEManager* m) : mgr(m) {} + void onWrite(NimBLECharacteristic* ch, NimBLEConnInfo& connInfo) override; +}; + +// NimBLE notify characteristic callback (subscription tracker) +class _BLENotifyCB : public NimBLECharacteristicCallbacks { +public: + BLEManager* mgr; + _BLENotifyCB(BLEManager* m) : mgr(m) {} + void onSubscribe(NimBLECharacteristic* ch, NimBLEConnInfo& connInfo, + uint16_t subValue) override; +}; + +// Live-keystroke write callback (host -> device, Write Without Response). +// Different schema (binary, not JSON) so it's routed to a separate +// handler to avoid bloating the var-sync dispatch path. +class _BLELiveWriteCB : public NimBLECharacteristicCallbacks { +public: + BLEManager* mgr; + _BLELiveWriteCB(BLEManager* m) : mgr(m) {} + void onWrite(NimBLECharacteristic* ch, NimBLEConnInfo& connInfo) override; +}; + +class _BLELiveNotifyCB : public NimBLECharacteristicCallbacks { +public: + BLEManager* mgr; + _BLELiveNotifyCB(BLEManager* m) : mgr(m) {} + void onSubscribe(NimBLECharacteristic* ch, NimBLEConnInfo& connInfo, + uint16_t subValue) override; +}; + + +class BLEManager { +public: + enum ExchangeKind { + EX_NONE = 0, + EX_PULL = 1, // device asks host to push vars to it + EX_PUSH = 2, // device pushes its dev-vars up to host, awaits ack + EX_REQUEST = 3, // device asks host to prompt user, host then pulls back + EX_LIVE = 4, // persistent low-latency keystroke streaming + }; + + // Live-mode constants — must match host's ble_live.py. + static constexpr uint8_t LIVE_MSG_START = 0x01; + static constexpr uint8_t LIVE_MSG_KEYS = 0x02; + static constexpr uint8_t LIVE_MSG_STOP = 0x03; + static constexpr uint8_t LIVE_MSG_IDENTIFY = 0x04; // host: show BT logo (body[0]=1 on / 0 off) + static constexpr uint8_t LIVE_MSG_MOUSE = 0x05; // host: abs mouse {buttons, x_u16, y_u16, wheel_i8} + static constexpr uint8_t LIVE_MSG_LABEL = 0x06; // host: device label (UTF-8 body) + static constexpr uint8_t LIVE_MSG_ACK = 0x10; + static constexpr uint8_t LIVE_MSG_ERROR = 0x11; + static constexpr uint8_t LIVE_MSG_HELLO = 0x12; + + // (shared GCM envelope helpers live in frame_crypto.h) + static constexpr uint8_t LIVE_ERR_BUFFER_FULL = 1; + static constexpr uint8_t LIVE_ERR_NOT_LIVE = 2; + static constexpr uint8_t LIVE_ERR_HID_FAILURE = 3; + static constexpr uint8_t LIVE_ERR_BAD_MSG = 4; + + // Lightweight init — caches debug log + keystore, computes the device tag, + // and reloads any persisted variables from flash. + void begin(DebugLog* dlog = nullptr, BLEKeyStore* keystore = nullptr) { + _dlog = dlog; + _keystore = keystore; + _initDeviceTag(); + // Per-boot session ID. RAM-only — fresh on every boot/reflash. + // The host treats a new session as a desync-recovery signal and + // resets its replay window for this device gracefully. + _bootId = ((uint64_t)esp_random() << 32) | (uint64_t)esp_random(); + Serial.printf("[BLE] boot_id=%llu\n", (unsigned long long)_bootId); + loadDevFromDisk(); + loadUniFromDisk(); + loadReplayFromDisk(); + // Live-session resume flag: if it's set, we lost power mid-session + // and should immediately re-advertise on this boot so the host + // reconnects (the user can cancel with a button hold). + _resumeFlagOnDisk = _readResumeFlag(); + _liveResumeBoot = _resumeFlagOnDisk; + Serial.printf("[BLE] live resume flag on boot: %d\n", + (int)_liveResumeBoot); + // Carry forward any debug log entries from the previous boot so + // crashes mid-routine remain visible after the reset. + dbg.loadFromDisk(); + dbg.logf("BOOT bootId=%llu millis=%lu", + (unsigned long long)_bootId, millis()); + dbg.persistToDisk(); + } + + const char* deviceTag() const { return _deviceTag; } + + // ---- Local-only ops (no BLE) ---- + + // Set a single (name, value) on the chosen scope ("device" or "universal"). + bool setLocal(const char* scope, const char* name, const char* value) { + BLEVariable* arr; + int* count; + if (!_pickScope(scope, &arr, &count)) return false; + for (int i = 0; i < *count; i++) { + if (strcmp(arr[i].name, name) == 0) { + strlcpy(arr[i].value, value, BLE_VAR_VALUE_LEN); + saveScope(scope); + return true; + } + } + if (*count >= MAX_BLE_VARS) return false; + strlcpy(arr[*count].name, name, BLE_VAR_NAME_LEN); + strlcpy(arr[*count].value, value, BLE_VAR_VALUE_LEN); + (*count)++; + saveScope(scope); + return true; + } + + // Type Text (VAR{name}) lookup: device-scope first, universal fallback. + // Case-insensitive: a text node referencing (VAR{password}) finds a + // stored variable named "Password" or "PASSWORD" alike. + const char* getVariable(const char* name) const { + for (int i = 0; i < _devCount; i++) { + if (strcasecmp(_devVars[i].name, name) == 0) return _devVars[i].value; + } + for (int i = 0; i < _uniCount; i++) { + if (strcasecmp(_uniVars[i].name, name) == 0) return _uniVars[i].value; + } + return ""; + } + + // Lookup honoring an explicit scope hint: + // scope="device" -> device-only + // scope="universal" -> universal-only + // anything else -> device first, fall back to universal + // Same case-insensitive semantics as getVariable(). + const char* getVariableScoped(const char* name, const char* scope) const { + bool deviceOnly = scope && strcmp(scope, "device") == 0; + bool universalOnly = scope && strcmp(scope, "universal") == 0; + if (!universalOnly) { + for (int i = 0; i < _devCount; i++) { + if (strcasecmp(_devVars[i].name, name) == 0) return _devVars[i].value; + } + if (deviceOnly) return ""; + } + for (int i = 0; i < _uniCount; i++) { + if (strcasecmp(_uniVars[i].name, name) == 0) return _uniVars[i].value; + } + return ""; + } + + // ---- BLE-driven ops ---- + + // Start BLE and ask the host to push variables for the given scope. + void startPull(const char* scope) { + strlcpy(_pendingScope, scope ? scope : "universal", sizeof(_pendingScope)); + _exchangeKind = EX_PULL; + _exchangeDone = false; + _authFailed = false; + dbg.logf("startPull scope=%s sendSeq=%llu hostSeen=%llu", + _pendingScope, + (unsigned long long)_sendSeq, + (unsigned long long)_hostSeen); + _startBLE(); + } + + // Start BLE and push the device's full _devVars map up to the host. + void startPush() { + _exchangeKind = EX_PUSH; + _exchangeDone = false; + _authFailed = false; + dbg.logf("startPush devCount=%d sendSeq=%llu hostSeen=%llu", + _devCount, + (unsigned long long)_sendSeq, + (unsigned long long)_hostSeen); + _startBLE(); + } + + // Start BLE and ask the host to prompt for variable values. ``names`` + // is a JsonArray-compatible shape; we serialize it on the fly. + void startRequest(JsonArray names) { + _requestNamesJson.clear(); + _requestNamesJson.reserve(64); + _requestNamesJson += "["; + bool first = true; + for (JsonVariant v : names) { + const char* n = v.as(); + if (!n) continue; + if (!first) _requestNamesJson += ","; + first = false; + _requestNamesJson += "\""; + // Best-effort escape — variable names should be bare identifiers. + for (const char* p = n; *p; p++) { + if (*p == '"' || *p == '\\') _requestNamesJson += '\\'; + _requestNamesJson += *p; + } + _requestNamesJson += "\""; + } + _requestNamesJson += "]"; + _exchangeKind = EX_REQUEST; + _exchangeDone = false; + _authFailed = false; + dbg.logf("startRequest names=%s", _requestNamesJson.c_str()); + dbg.persistToDisk(); + _startBLE(); + } + + bool isExchangeDone() const { return _exchangeDone; } + bool isBLEActive() const { return _bleActive; } + bool isClientConnected() const { return _clientConnected; } + ExchangeKind exchangeKind() const { return _exchangeKind; } + + // True while the host has asked this device to draw a Bluetooth + // "identify" logo (so the user can tell which physical M5Stack they + // are labeling in the BT Keyboard window). Auto-expires after a + // safety timeout in case the host never sends the off frame. + bool liveIdentify() const { return _liveIdentify; } + + // Friendly device label set by the host (LIVE_MSG_LABEL) and shown on + // the live-mode screen. liveLabelVer() bumps on every change so the + // display can detect when to repaint. + const char* liveLabel() const { return _liveLabel; } + uint32_t liveLabelVer() const { return _liveLabelVer; } + + // True for this boot if we lost power mid live-session and should + // immediately re-advertise to reconnect. consumeLiveResume() clears the + // in-RAM request (e.g. the user cancelled, or a USB host appeared). + bool liveResumeRequested() const { return _liveResumeBoot; } + void consumeLiveResume() { _liveResumeBoot = false; } + + // One-line summary of the live-mode connection state for the + // display. Phrased so the main loop can pass it straight into + // showLiveMode() without any conditional fanout. + const char* liveStatusText() const { + if (_exchangeKind != EX_LIVE) return "Idle"; + if (!_clientConnected) return "Waiting for host..."; + if (!_liveSubscribed) return "Connecting..."; + if (!_liveSawStart) return "Ready"; + return "Recording"; + } + + // Wire the LiveKeystrokeEngine in. Must be called once at boot + // after both objects are constructed; the BLE write callback uses + // it to enqueue keystroke events. + void setLiveEngine(LiveKeystrokeEngine* eng) { _liveEngine = eng; } + + // ---- Live-keystroke streaming control ---- + + // Bring BLE up in live mode (advertise the live service). Idempotent — + // calling while already advertising live is a no-op, so the main loop + // can call it every idle iteration. There is NO on-device gesture to + // enter live mode anymore: the device listens automatically while idle + // and only starts emitting keystrokes once the host sends a valid + // AES-GCM START frame. + void startLive() { + // Called every main-loop iteration when idle, so the already-live + // path must be silent — logging here would spam the dbg ring and + // grind flash on the periodic persist. + if (_exchangeKind == EX_LIVE && _bleActive) { + return; + } + _exchangeKind = EX_LIVE; + _exchangeDone = false; + _authFailed = false; + _liveSawStart = false; + _liveIdentify = false; + if (_liveEngine) _liveEngine->stop(); // ensure clean state + dbg.log("startLive"); + dbg.persistToDisk(); + _startBLE(); + } + + // Exit recording — called by the live-write path on receipt of a STOP + // frame (host clicked Close BLE), and from MacroPad.ino right before a + // routine starts. Stops the keystroke engine but keeps the device + // advertising so the host can reconnect. + void stopLive() { + if (_exchangeKind != EX_LIVE) { + // No-op, silent (idempotent path). + return; + } + // Exit "recording" — stop the engine (releases any held keys) and + // clear the START latch — but DON'T tear BLE down. We stay in + // EX_LIVE and keep advertising so the host can immediately + // reconnect / re-record. Full radio teardown happens only when a + // routine starts (MacroPad.ino calls shutdown() in that path). + if (_liveEngine) _liveEngine->stop(); + _liveSawStart = false; + // Clean end of the session — clear the resume flag so a later + // power-cycle doesn't auto-reconnect (deferred flash write). + _resumeWantState = 0; + dbg.log("stopLive (exit recording, keep listening)"); + } + + // Soft shutdown after an exchange completes (or times out). + // + // We deliberately do NOT call NimBLEDevice::deinit() here. Repeated + // deinit/init cycles on ESP32-S3 with concurrent USB-CDC have been + // observed to panic the chip ~mid-second-init, which manifested as + // the device rebooting between consecutive BLE nodes. Instead we just + // stop advertising and reset per-exchange flags; the next BLE node + // restarts advertising on the existing stack. + void shutdown() { + if (!_bleActive) return; + dbg.logf("shutdown begin (exchangeDone=%d)", (int)_exchangeDone); + // A deliberate teardown (routine start / USB upload) is a clean end + // of any live session — clear the resume flag so the next boot + // doesn't auto-reconnect. Safe to write flash here (main task). + _resumeWantState = -1; + if (_resumeFlagOnDisk) _writeResumeFlag(false); + if (_pendingPersistScope[0]) { + saveScope(_pendingPersistScope); + _pendingPersistScope[0] = '\0'; + } + if (_replayDirty) { + _replayDirty = false; + saveReplayToDisk(); + } + NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); + if (adv) adv->stop(); + // Force-disconnect any peer still on the radio before we touch the + // stack further. We can't trust the host to tear down promptly — + // bleak's BleakClient.__aexit__ on Windows can take 2+ seconds to + // actually drop the link (observed in the dbg ring log: a 500 ms + // passive wait still saw connected=1). If the engine reaches the + // next BLE node and calls _startBLE() -> adv->start() while a peer + // is connected, NimBLE on ESP32-S3 panics and the chip resets, + // which is the entire pull_ble -> request_ble hang we're chasing. + NimBLEServer* server = NimBLEDevice::getServer(); + uint16_t connectedAtEntry = server ? server->getConnectedCount() : 0; + if (server && connectedAtEntry > 0) { + for (uint16_t handle : server->getPeerDevices()) { + server->disconnect(handle); + } + uint32_t deadline = millis() + 500; + while (server->getConnectedCount() > 0 && + (int32_t)(millis() - deadline) < 0) { + delay(10); + } + } + dbg.logf("shutdown disconnected entered=%u settled=%u", + (unsigned)connectedAtEntry, + (unsigned)(server ? server->getConnectedCount() : 0)); + _clientConnected = false; + _clientSubscribed = false; + _liveSubscribed = false; + _liveSawStart = false; + _helloSent = false; + _pushSent = false; + _connectMs = 0; + _liveFallbackHelloSent = false; + _liveHelloAfterSub = false; + _liveIdentify = false; + if (_liveEngine) _liveEngine->stop(); + _exchangeKind = EX_NONE; + _lastShutdownMs = millis(); + Serial.println("[BLE] Soft shutdown — advertising stopped, stack stays up"); + if (_dlog) _dlog->log("BLE: soft shutdown"); + dbg.logf("shutdown done sendSeq=%llu hostSeen=%llu", + (unsigned long long)_sendSeq, + (unsigned long long)_hostSeen); + // Persist the debug log to flash so crashes that happen before + // the next persist still leave a trace pullable on next boot. + dbg.persistToDisk(); + } + + // ---- NimBLE callback handlers ---- + + void onClientConnect() { + // Defense in depth: clear all per-session flags on every fresh + // connect. We've observed cases where NimBLE's onDisconnect + // callback didn't fire for a prior dropped peer, leaving + // _helloSent / _liveFallbackHelloSent stuck at true — which + // then suppressed the hello for the next connection. + _clientConnected = true; + _clientSubscribed = false; + _liveSubscribed = false; + _helloSent = false; + _pushSent = false; + _liveFallbackHelloSent = false; + _liveHelloAfterSub = false; + _liveSawStart = false; + _liveIdentify = false; + _connectMs = millis(); + dbg.logf("client connected (exchangeKind=%d millis=%lu)", + (int)_exchangeKind, (unsigned long)_connectMs); + } + void onClientDisconnect() { + bool exDone = _exchangeDone; + _clientConnected = false; + _clientSubscribed = false; + _liveSubscribed = false; + _helloSent = false; + _pushSent = false; + _connectMs = 0; + _liveFallbackHelloSent = false; + _liveHelloAfterSub = false; + // A disconnect during live mode is an unrecoverable session end — + // stop the engine (releases any held keys defensively) and mark + // the exchange done so the main loop reaps BLE. + if (_exchangeKind == EX_LIVE) { + // Live link dropped (clean close after a STOP, or interference). + // Exit recording but KEEP listening: re-advertise so the host + // can reconnect and resume. We deliberately do NOT mark the + // exchange done — that would tear the whole stack down. The + // device stays available for the next/again connection. + if (_liveEngine) _liveEngine->stop(); + _liveSawStart = false; + _liveIdentify = false; + _needsReAdvertise = true; + dbg.log("live: client disconnected — re-advertising (keep listening)"); + } else if (!exDone) { + _needsReAdvertise = true; + } + dbg.logf("client disconnected (exDone=%d -> reAdv=%d)", + (int)exDone, (int)!exDone); + } + + void onClientSubscribe(uint16_t subValue) { + _clientSubscribed = (subValue != 0); + dbg.logf("client subscribe subValue=%u", (unsigned)subValue); + } + + void onLiveSubscribe(uint16_t subValue) { + _liveSubscribed = (subValue != 0); + dbg.logf("live subscribe subValue=%u helloSent=%d clientConn=%d", + (unsigned)subValue, (int)_helloSent, (int)_clientConnected); + } + + // Live-write callback. Runs on the NimBLE host task — same no-blocking-IO + // rules as onWriteReceived. Decrypts the AES-GCM envelope, validates + // replay counters, then dispatches by msg_type. Keystroke events are + // enqueued for the main loop to drain; no HID work happens here. + void onLiveWriteReceived(const uint8_t* data, size_t len) { + if (!_keystore || !_keystore->hasKey()) { + _authFailed = true; + return; + } + static uint8_t plain[BLE_VAR_BUF_SIZE]; + size_t plainLen = 0; + char tag[BLE_DEVICE_TAG_MAX]; + if (!_parseFrame(data, len, tag, sizeof(tag), plain, &plainLen)) { + _authFailed = true; + return; + } + if (strcmp(tag, _deviceTag) != 0) { + _wrongTag = true; + return; + } + // Binary header: msg_type(1) + sid(8) + seq(8) + if (plainLen < 17) { + _sendLiveError(LIVE_ERR_BAD_MSG, 0); + return; + } + uint8_t msgType = plain[0]; + uint64_t sid = _readLE64(plain + 1); + uint64_t seq = _readLE64(plain + 9); + const uint8_t* body = plain + 17; + size_t bodyLen = plainLen - 17; + + const char* reason = "?"; + if (!acceptReceived(sid, seq, &reason)) { + dbg.logf("live: replay reject %s sid=%llu seq=%llu", + reason, + (unsigned long long)sid, + (unsigned long long)seq); + return; + } + + if (_exchangeKind != EX_LIVE) { + _sendLiveError(LIVE_ERR_NOT_LIVE, seq); + return; + } + + switch (msgType) { + case LIVE_MSG_START: + if (_liveEngine) _liveEngine->start(); + _liveSawStart = true; + // We're now in an active session — request the resume flag + // be persisted (pollStatus does the actual flash write on + // the main task; we mustn't block on flash here). + _resumeWantState = 1; + _sendLiveAck(seq); + dbg.logf("live: START seq=%llu", (unsigned long long)seq); + break; + case LIVE_MSG_KEYS: { + if (!_liveSawStart) { + _sendLiveError(LIVE_ERR_NOT_LIVE, seq); + break; + } + if (bodyLen < 1) { + _sendLiveError(LIVE_ERR_BAD_MSG, seq); + break; + } + uint8_t count = body[0]; + // Each event is { uint8 action, uint8 hid, uint32 t_ms_le } = 6 bytes + if (bodyLen < 1u + (size_t)count * 6u) { + _sendLiveError(LIVE_ERR_BAD_MSG, seq); + break; + } + bool anyDrop = false; + for (uint8_t i = 0; i < count; i++) { + const uint8_t* ev = body + 1 + i * 6; + uint8_t action = ev[0]; + uint8_t hidCode = ev[1]; + uint32_t tMs = (uint32_t)ev[2] + | ((uint32_t)ev[3] << 8) + | ((uint32_t)ev[4] << 16) + | ((uint32_t)ev[5] << 24); + if (_liveEngine && + !_liveEngine->enqueue(action, hidCode, tMs)) { + anyDrop = true; + } + } + if (anyDrop) { + _sendLiveError(LIVE_ERR_BUFFER_FULL, seq); + } + // KEYS frames don't get an explicit ACK — too chatty. + // Errors are the only feedback the host receives. + break; + } + case LIVE_MSG_IDENTIFY: { + // Display-only — does not require a prior START. Toggles the + // on-screen Bluetooth identify logo so the user can see + // which device they're labeling. + uint8_t on = (bodyLen >= 1) ? body[0] : 1; + _liveIdentify = (on != 0); + _liveIdentifyMs = millis(); + _sendLiveAck(seq); + dbg.logf("live: IDENTIFY %u seq=%llu", + (unsigned)on, (unsigned long long)seq); + break; + } + case LIVE_MSG_MOUSE: { + // Absolute pointer — applied immediately (no cadence buffer). + // body: buttons(1), x(u16 LE), y(u16 LE), wheel(i8) = 6 bytes + if (bodyLen < 6) { + _sendLiveError(LIVE_ERR_BAD_MSG, seq); + break; + } + uint8_t buttons = body[0]; + uint16_t x = (uint16_t)body[1] | ((uint16_t)body[2] << 8); + uint16_t y = (uint16_t)body[3] | ((uint16_t)body[4] << 8); + int8_t wheel = (int8_t)body[5]; + if (_liveEngine) _liveEngine->enqueueMouse(buttons, x, y, wheel); + // No ACK — mouse is high-rate; errors are the only feedback. + break; + } + case LIVE_MSG_LABEL: { + // Friendly label to show on this device's screen so the user + // can tell which physical M5Stack a host-side slot maps to. + size_t n = bodyLen; + if (n >= sizeof(_liveLabel)) n = sizeof(_liveLabel) - 1; + memcpy(_liveLabel, body, n); + _liveLabel[n] = '\0'; + _liveLabelVer++; + _sendLiveAck(seq); + dbg.logf("live: LABEL '%s'", _liveLabel); + break; + } + case LIVE_MSG_STOP: + stopLive(); + _sendLiveAck(seq); + dbg.logf("live: STOP seq=%llu", (unsigned long long)seq); + break; + default: + _sendLiveError(LIVE_ERR_BAD_MSG, seq); + break; + } + } + + // Host wrote to the write characteristic. Decrypt + dispatch. + // + // Runs on the NimBLE host task. Keep this fast — no flash writes, + // no Serial.printf with large buffers, no malloc-heavy ops. + void onWriteReceived(const uint8_t* data, size_t len) { + dbg.logf("write rx %u bytes", (unsigned)len); + if (!_keystore || !_keystore->hasKey()) { + _authFailed = true; + dbg.log("write rx: no key — drop"); + return; + } + static uint8_t plain[BLE_VAR_BUF_SIZE]; + size_t plainLen = 0; + char tag[BLE_DEVICE_TAG_MAX]; + if (!_parseFrame(data, len, tag, sizeof(tag), plain, &plainLen)) { + _authFailed = true; + dbg.log("write rx: parseFrame failed"); + return; + } + if (strcmp(tag, _deviceTag) != 0) { + // Frame addressed to a different device. With a single-key + // shared population this is rare — usually means the host has + // multiple devices in range and routed to the wrong one. + // Silent drop is correct; flag it so the main loop can log. + _wrongTag = true; + dbg.logf("write rx: wrong tag '%s'", tag); + return; + } + plain[plainLen] = '\0'; + _dispatchPlaintext((const char*)plain); + } + + // ---- Main-loop poll ---- + + void pollStatus() { + // Safety net: drop the identify logo if the host never sent the + // off frame (dialog crashed, link hiccup, etc.). + if (_liveIdentify && + (millis() - _liveIdentifyMs) > IDENTIFY_TIMEOUT_MS) { + _liveIdentify = false; + } + + // Flush a deferred live-resume-flag write requested from the NimBLE + // callback (START / STOP). Flash writes are safe here on the main + // task; they're illegal in the callback context. + if (_resumeWantState != -1) { + bool want = (_resumeWantState == 1); + _resumeWantState = -1; + if (want != _resumeFlagOnDisk) _writeResumeFlag(want); + } + + // Flush deferred replay-counter writes regardless of whether BLE is + // currently up. acceptReceivedSeq sets this from the NimBLE host + // task (where we mustn't block on flash); we drain it here on the + // main task where blocking is fine. + if (_replayDirty) { + _replayDirty = false; + saveReplayToDisk(); + } + + // (No periodic dbg flush.) An earlier revision flushed every 2 s + // while _bleEverActive was true, but that flag stays true for the + // rest of the boot once any BLE node has run, so it amounted to a + // ~14 KB flash write every 2 s indefinitely — about 16 M writes/yr, + // well above NOR-flash endurance (~100 K cycles per sector even + // with LittleFS wear leveling). The strategic flushes — at boot, + // before each NimBLE init, and on every shutdown — already capture + // the failure-relevant moments. A crash mid-exchange will lose + // only that exchange's events, but boot history and pre-init state + // remain visible after reset. + + if (!_bleActive) return; + + if (_needsReAdvertise) { + _needsReAdvertise = false; + NimBLEDevice::getAdvertising()->start(); + Serial.println("[BLE] Re-advertising (exchange not finished)"); + } + + // Send the appropriate hello as soon as the host subscribes. + // For EX_LIVE we wait for the live-notify subscription (different + // characteristic); for everything else we wait on the var-sync + // notify subscription. + // + // EX_LIVE special case: Bleak on Windows takes 1.5-2 s between + // connect and finishing the CCCD enable on the host side, so we + // may have fired a "fallback hello" before the host was actually + // subscribed (NimBLE drops the notify when sub=0). Don't gate + // the subscribe-triggered hello on _helloSent in live mode — + // re-send unconditionally when the subscribe finally fires. The + // host's hello_evt is one-shot so the extra hello is harmless. + bool helloSubscribed = + (_exchangeKind == EX_LIVE) ? _liveSubscribed : _clientSubscribed; + bool sendHelloNow; + if (_exchangeKind == EX_LIVE) { + sendHelloNow = _clientConnected && _liveSubscribed && !_liveHelloAfterSub; + } else { + sendHelloNow = _clientConnected && helloSubscribed && !_helloSent; + } + if (sendHelloNow) { + _sendHello(); + _helloSent = true; + if (_exchangeKind == EX_LIVE) _liveHelloAfterSub = true; + // Push immediately follows the hello with the actual data frame. + if (_exchangeKind == EX_PUSH && !_pushSent) { + _sendPushPayload(); + _pushSent = true; + } + } + + // EX_LIVE fallback: if we somehow never observe a subscribe + // (NimBLE version quirk where onSubscribe doesn't fire even + // though Bleak set up the CCCD), still send hello after a + // grace period. The host gets the hello if its CCCD is + // enabled, otherwise the frame is dropped and the subscribe- + // triggered path above will retry once we do see the subscribe. + if (_exchangeKind == EX_LIVE && _clientConnected && + !_liveFallbackHelloSent && _connectMs != 0 && + (millis() - _connectMs) >= 1500) { + dbg.logf("live: hello fallback (sub=%d after %lums)", + (int)_liveSubscribed, + (unsigned long)(millis() - _connectMs)); + _sendHello(); + _helloSent = true; + _liveFallbackHelloSent = true; + } + + if (_authFailed) { + _authFailed = false; + Serial.println("[BLE] auth failed — payload rejected (key mismatch or tampered frame)"); + if (_dlog) _dlog->log("BLE: auth failed"); + } + + if (_wrongTag) { + _wrongTag = false; + Serial.println("[BLE] frame for a different device — dropped"); + } + } + +private: + // ---- Device tag (eFuse base MAC) ---- + void _initDeviceTag() { + uint8_t mac[6] = {0}; + // Factory-burned base MAC; stable across reflashes. + if (esp_efuse_mac_get_default(mac) != ESP_OK) { + esp_read_mac(mac, ESP_MAC_BT); + } + snprintf(_deviceTag, sizeof(_deviceTag), + "%s%02X:%02X:%02X:%02X:%02X:%02X", + BLE_DEVICE_TAG_PREFIX, + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + Serial.printf("[BLE] device tag: %s\n", _deviceTag); + } + + bool _pickScope(const char* scope, BLEVariable** outArr, int** outCount) { + if (scope && strcmp(scope, "device") == 0) { + *outArr = _devVars; *outCount = &_devCount; return true; + } + if (scope && strcmp(scope, "universal") == 0) { + *outArr = _uniVars; *outCount = &_uniCount; return true; + } + return false; + } + + // Which service UUID we advertise depends on what flavor of + // exchange is currently active. Live mode advertises its own UUID + // so the host's var-sync scanner (which filters on BLE_SERVICE_UUID) + // can't see — and race for — the device. The actual GATT service + // and characteristics are unchanged; the host's BleakClient finds + // the characteristics by UUID regardless of what was advertised. + const char* _advertisedUuidForExchange() const { + return (_exchangeKind == EX_LIVE) ? BLE_LIVE_SERVICE_UUID + : BLE_SERVICE_UUID; + } + + void _applyAdvertisingData(NimBLEAdvertising* adv) { + if (!adv) return; + // Replace whatever the advertisement currently carries with a + // fresh data payload for the current exchange kind. Using + // setAdvertisementData (rather than add/remove of individual + // UUIDs) avoids accumulating stale UUIDs across re-advertise + // cycles, which would defeat the whole separation. + NimBLEAdvertisementData data; + const char* uuid = _advertisedUuidForExchange(); + data.setCompleteServices(NimBLEUUID(uuid)); + if (_exchangeKind == EX_LIVE) { + data.setName("MacroPad-Live"); + } else { + data.setName("MacroPad"); + } + adv->setAdvertisementData(data); + } + + // ---- BLE startup ---- + void _startBLE() { + if (_bleActive) { + // NimBLE already initialized from a prior exchange. Just + // restart advertising on the existing stack — no init/deinit + // dance, which is what crashed the chip on ESP32-S3. + _helloSent = false; + _pushSent = false; + _clientSubscribed = false; + _liveSubscribed = false; + _connectMs = 0; + _liveFallbackHelloSent = false; + _liveHelloAfterSub = false; + // Gate the re-advertise on the prior connection actually being + // gone. shutdown() force-disconnects, but if a peer reconnected + // in the gap (e.g. host's scan loop is fast) we still want to + // kick it before adv->start() — adv->start() on a connected + // NimBLE stack panics the chip on ESP32-S3. + NimBLEServer* server = NimBLEDevice::getServer(); + uint16_t connectedAtEntry = server ? server->getConnectedCount() : 0; + if (server && connectedAtEntry > 0) { + for (uint16_t handle : server->getPeerDevices()) { + server->disconnect(handle); + } + uint32_t deadline = millis() + 500; + while (server->getConnectedCount() > 0 && + (int32_t)(millis() - deadline) < 0) { + delay(10); + } + } + // Persist BEFORE adv->start so the re-advertise path leaves a + // forensic trail. Without this, a crash in NimBLE's adv->start + // wipes the in-RAM dbg ring and we lose all evidence between + // shutdown() (already persisted) and the next first-time init. + dbg.logf("startBLE: pre-readvertise connected entered=%u settled=%u", + (unsigned)connectedAtEntry, + (unsigned)(server ? server->getConnectedCount() : 0)); + dbg.persistToDisk(); + NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); + if (adv) { + _applyAdvertisingData(adv); + adv->start(); + } + dbg.logf("startBLE: re-advertising on existing stack (uuid=%s)", + _advertisedUuidForExchange()); + if (_dlog) _dlog->log("BLE: re-advertising"); + return; + } + Serial.println("[BLE] Starting on-demand (first init)..."); + dbg.log("startBLE: NimBLEDevice::init (first time)"); + dbg.persistToDisk(); // capture pre-init state in case of panic + NimBLEDevice::init("MacroPad"); + + NimBLEServer* server = NimBLEDevice::createServer(); + if (!_serverCB) _serverCB = new _BLEServerCB(this); + server->setCallbacks(_serverCB); + + NimBLEService* service = server->createService(BLE_SERVICE_UUID); + _writeChar = service->createCharacteristic( + BLE_VARS_CHAR_UUID, + NIMBLE_PROPERTY::WRITE, + BLE_FRAME_BUF_SIZE); + if (!_writeCB) _writeCB = new _BLEWriteCB(this); + _writeChar->setCallbacks(_writeCB); + + _notifyChar = service->createCharacteristic( + BLE_VARS_NOTIFY_UUID, + NIMBLE_PROPERTY::NOTIFY, + BLE_FRAME_BUF_SIZE); + if (!_notifyCB) _notifyCB = new _BLENotifyCB(this); + _notifyChar->setCallbacks(_notifyCB); + + // Live-keystroke characteristics. WRITE_NR (Write Without Response) + // is what makes streaming low-latency — no L2CAP ACK round-trip + // per host write. We still authenticate via AES-GCM at the + // application layer. + _liveWriteChar = service->createCharacteristic( + BLE_LIVE_KEYS_WRITE_UUID, + NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR, + BLE_FRAME_BUF_SIZE); + if (!_liveWriteCB) _liveWriteCB = new _BLELiveWriteCB(this); + _liveWriteChar->setCallbacks(_liveWriteCB); + + _liveNotifyChar = service->createCharacteristic( + BLE_LIVE_KEYS_NOTIFY_UUID, + NIMBLE_PROPERTY::NOTIFY, + BLE_FRAME_BUF_SIZE); + if (!_liveNotifyCB) _liveNotifyCB = new _BLELiveNotifyCB(this); + _liveNotifyChar->setCallbacks(_liveNotifyCB); + + service->start(); + + NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); + _applyAdvertisingData(adv); + adv->enableScanResponse(true); + adv->start(); + + _bleActive = true; + _bleEverActive = true; + _helloSent = false; + _pushSent = false; + _clientSubscribed = false; + Serial.println("[BLE] Advertising"); + if (_dlog) _dlog->log("BLE: advertising"); + dbg.log("startBLE: advertising"); + } + + // ---- Hello / push frame senders ---- + void _sendHello() { + // Bail before consuming a seq if we don't actually have an + // exchange to send. Can happen briefly between shutdown() and + // the next start* call. + if (_exchangeKind == EX_NONE) { + dbg.log("sendHello: EX_NONE — skipped"); + return; + } + if (_exchangeKind == EX_LIVE) { + // Live channel uses the binary protocol on the dedicated + // notify characteristic — JSON would balloon the per-frame + // overhead for no benefit. + _sendLiveHello(); + return; + } + char plaintext[BLE_VAR_BUF_SIZE]; + uint64_t seq = nextSendSeq(); + const char* kindStr = "?"; + switch (_exchangeKind) { + case EX_PULL: + kindStr = "pull"; + snprintf(plaintext, sizeof(plaintext), + "{\"op\":\"hello\",\"kind\":\"pull\",\"scope\":\"%s\"," + "\"seq\":%llu,\"session_id\":%llu}", + _pendingScope, + (unsigned long long)seq, + (unsigned long long)_bootId); + break; + case EX_PUSH: + kindStr = "push"; + snprintf(plaintext, sizeof(plaintext), + "{\"op\":\"hello\",\"kind\":\"push\"," + "\"seq\":%llu,\"session_id\":%llu}", + (unsigned long long)seq, + (unsigned long long)_bootId); + break; + case EX_REQUEST: + kindStr = "request"; + snprintf(plaintext, sizeof(plaintext), + "{\"op\":\"hello\",\"kind\":\"request\",\"names\":%s," + "\"seq\":%llu,\"session_id\":%llu}", + _requestNamesJson.c_str(), + (unsigned long long)seq, + (unsigned long long)_bootId); + break; + default: return; + } + dbg.logf("sendHello kind=%s seq=%llu sid=%llu", + kindStr, + (unsigned long long)seq, + (unsigned long long)_bootId); + _notifyEncrypted(plaintext); + } + + void _sendPushPayload() { + // Serialize _devVars as JSON object, embed in {"op":"push","vars":{...}} + JsonDocument doc; + doc["op"] = "push"; + uint64_t seq = nextSendSeq(); + doc["seq"] = seq; + doc["session_id"] = _bootId; + JsonObject vars = doc["vars"].to(); + for (int i = 0; i < _devCount; i++) { + vars[_devVars[i].name] = _devVars[i].value; + } + char buf[BLE_VAR_BUF_SIZE]; + size_t n = serializeJson(doc, buf, sizeof(buf)); + if (n == 0 || n >= sizeof(buf)) { + dbg.log("push payload: serialize failed"); + return; + } + dbg.logf("sendPush seq=%llu vars=%d bytes=%u", + (unsigned long long)seq, _devCount, (unsigned)n); + _notifyEncrypted(buf); + } + + // ---- Live-mode binary helpers ---- + + static uint64_t _readLE64(const uint8_t* p) { + uint64_t v = 0; + for (int i = 0; i < 8; i++) v |= ((uint64_t)p[i]) << (i * 8); + return v; + } + + static void _writeLE64(uint8_t* p, uint64_t v) { + for (int i = 0; i < 8; i++) p[i] = (uint8_t)(v >> (i * 8)); + } + + void _notifyEncryptedBin(NimBLECharacteristic* ch, + const uint8_t* plain, size_t plainLen) { + if (!ch) { + dbg.log("live notify: char is null"); + return; + } + uint8_t frame[BLE_FRAME_BUF_SIZE]; + size_t frameLen = 0; + if (!_buildFrame(plain, plainLen, frame, sizeof(frame), &frameLen)) { + dbg.log("live notify: buildFrame failed"); + return; + } + ch->setValue(frame, frameLen); + bool ok = ch->notify(); + dbg.logf("live notify: sent %u bytes (ok=%d connected=%d sub=%d)", + (unsigned)frameLen, (int)ok, + (int)_clientConnected, (int)_liveSubscribed); + } + + void _sendLiveHello() { + if (!_liveNotifyChar) return; + uint8_t buf[17]; + uint64_t seq = nextSendSeq(); + buf[0] = LIVE_MSG_HELLO; + _writeLE64(buf + 1, _bootId); + _writeLE64(buf + 9, seq); + dbg.logf("sendLiveHello seq=%llu", (unsigned long long)seq); + _notifyEncryptedBin(_liveNotifyChar, buf, sizeof(buf)); + } + + void _sendLiveAck(uint64_t refSeq) { + if (!_liveNotifyChar) return; + uint8_t buf[25]; + uint64_t seq = nextSendSeq(); + buf[0] = LIVE_MSG_ACK; + _writeLE64(buf + 1, _bootId); + _writeLE64(buf + 9, seq); + _writeLE64(buf + 17, refSeq); + _notifyEncryptedBin(_liveNotifyChar, buf, sizeof(buf)); + } + + void _sendLiveError(uint8_t errCode, uint64_t refSeq) { + if (!_liveNotifyChar) return; + uint8_t buf[26]; + uint64_t seq = nextSendSeq(); + buf[0] = LIVE_MSG_ERROR; + _writeLE64(buf + 1, _bootId); + _writeLE64(buf + 9, seq); + buf[17] = errCode; + _writeLE64(buf + 18, refSeq); + _notifyEncryptedBin(_liveNotifyChar, buf, sizeof(buf)); + } + + void _notifyEncrypted(const char* plaintext) { + if (!_notifyChar) { + dbg.log("notify: no char"); + return; + } + uint8_t frame[BLE_FRAME_BUF_SIZE]; + size_t frameLen = 0; + if (!_buildFrame((const uint8_t*)plaintext, strlen(plaintext), + frame, sizeof(frame), &frameLen)) { + dbg.log("notify: buildFrame failed"); + return; + } + _notifyChar->setValue(frame, frameLen); + bool ok = _notifyChar->notify(); + dbg.logf("notify: sent %u bytes (ok=%d connected=%d sub=%d)", + (unsigned)frameLen, (int)ok, + (int)_clientConnected, (int)_clientSubscribed); + } + + // ---- Plaintext dispatch (host -> device) ---- + // + // Runs on the NimBLE host task — see acceptReceivedSeq for the + // no-blocking-IO rule. + void _dispatchPlaintext(const char* json) { + JsonDocument doc; + DeserializationError jerr = deserializeJson(doc, json); + if (jerr) { + dbg.logf("dispatch: bad JSON (%s)", jerr.c_str()); + return; + } + if (doc["seq"].isNull() || doc["session_id"].isNull()) { + dbg.log("dispatch: missing seq/session_id"); + return; + } + uint64_t seq = doc["seq"].as(); + uint64_t sid = doc["session_id"].as(); + const char* reason = "?"; + if (!acceptReceived(sid, seq, &reason)) { + dbg.logf("dispatch: reject %s sid=%llu seq=%llu (haveSid=%llu seen=%llu)", + reason, + (unsigned long long)sid, + (unsigned long long)seq, + (unsigned long long)_hostSessionId, + (unsigned long long)_hostSeen); + return; + } + if (strcmp(reason, "fresh_session") == 0) { + dbg.logf("dispatch: fresh_session sid=%llu (window reset)", + (unsigned long long)sid); + } + const char* op = doc["op"] | ""; + if (strcmp(op, "pull") == 0) { + const char* scope = doc["scope"] | "universal"; + JsonObject vars = doc["vars"].as(); + int varCount = 0; + for (JsonPair _p : vars) { (void)_p; varCount++; } + _applyPullPayload(scope, vars); + _exchangeDone = true; + dbg.logf("dispatch: pull(%s) seq=%llu vars=%d -> exDone", + scope, (unsigned long long)seq, varCount); + } else if (strcmp(op, "ack") == 0) { + _exchangeDone = true; + dbg.logf("dispatch: ack seq=%llu -> exDone", + (unsigned long long)seq); + } else { + dbg.logf("dispatch: unknown op '%s'", op); + } + } + + void _applyPullPayload(const char* scope, JsonObject vars) { + BLEVariable* arr; + int* count; + if (!_pickScope(scope, &arr, &count)) return; + *count = 0; + for (JsonPair kv : vars) { + if (*count >= MAX_BLE_VARS) break; + strlcpy(arr[*count].name, kv.key().c_str(), BLE_VAR_NAME_LEN); + const char* v = kv.value().as(); + strlcpy(arr[*count].value, v ? v : "", BLE_VAR_VALUE_LEN); + (*count)++; + } + // Defer the disk write to shutdown() so we don't block the NimBLE + // host task. + strlcpy(_pendingPersistScope, scope, sizeof(_pendingPersistScope)); + } + + // ---- Frame build / parse with GCM + AAD ---- + // Thin wrappers over frame_crypto.h (the format is shared with the + // ESP-NOW mesh layer, which encrypts under a session group key). + bool _buildFrame(const uint8_t* plaintext, size_t plainLen, + uint8_t* out, size_t outCap, size_t* outLen) { + if (!_keystore || !_keystore->hasKey()) return false; + return frameCryptoBuild(_keystore->key(), _deviceTag, + plaintext, plainLen, out, outCap, outLen); + } + + // Verify and decrypt an inbound frame. Writes the recovered tag (NUL-term) + // and plaintext into the caller's buffers. Returns false silently on any + // malformed/auth-failed input. + bool _parseFrame(const uint8_t* in, size_t inLen, + char* outTag, size_t outTagCap, + uint8_t* outPlain, size_t* outPlainLen) { + if (!_keystore || !_keystore->hasKey()) return false; + return frameCryptoParse(_keystore->key(), in, inLen, + outTag, outTagCap, + outPlain, BLE_VAR_BUF_SIZE, outPlainLen); + } + + // ---- Persistence ---- + void saveScope(const char* scope) { + BLEVariable* arr; + int* count; + if (!_pickScope(scope, &arr, &count)) return; + const char* path = (strcmp(scope, "device") == 0) + ? BLE_DEV_VARS_PATH : BLE_UNI_VARS_PATH; + File f = LittleFS.open(path, "w"); + if (!f) { + Serial.printf("[BLE] save %s: open failed\n", path); + return; + } + JsonDocument doc; + for (int i = 0; i < *count; i++) { + doc[arr[i].name] = arr[i].value; + } + if (serializeJson(doc, f) == 0) { + Serial.printf("[BLE] save %s: serialize failed\n", path); + } + f.close(); + } + + void loadDevFromDisk() { _loadFile(BLE_DEV_VARS_PATH, _devVars, &_devCount); } + void loadUniFromDisk() { _loadFile(BLE_UNI_VARS_PATH, _uniVars, &_uniCount); } + + // ---- Live-session resume flag ---- + bool _readResumeFlag() { + if (!LittleFS.exists(BLE_LIVE_RESUME_PATH)) return false; + File f = LittleFS.open(BLE_LIVE_RESUME_PATH, "r"); + if (!f) return false; + int c = f.read(); + f.close(); + return c == '1'; + } + void _writeResumeFlag(bool on) { + File f = LittleFS.open(BLE_LIVE_RESUME_PATH, "w"); + if (!f) return; + f.write(on ? '1' : '0'); + f.close(); + _resumeFlagOnDisk = on; + } + + // ---- Replay-protection counters ---- + void loadReplayFromDisk() { + _sendSeq = 0; + _hostSeen = 0; + _hostSessionId = 0; + if (!LittleFS.exists(BLE_REPLAY_STATE_PATH)) return; + File f = LittleFS.open(BLE_REPLAY_STATE_PATH, "r"); + if (!f) return; + JsonDocument doc; + DeserializationError err = deserializeJson(doc, f); + f.close(); + if (err) return; + _sendSeq = doc["send_seq"].as(); + _hostSeen = doc["host_seen"].as(); + _hostSessionId = doc["host_session_id"].as(); + Serial.printf("[BLE] replay state: send=%llu hostSeen=%llu hostSid=%llu\n", + (unsigned long long)_sendSeq, + (unsigned long long)_hostSeen, + (unsigned long long)_hostSessionId); + } + + void saveReplayToDisk() { + File f = LittleFS.open(BLE_REPLAY_STATE_PATH, "w"); + if (!f) { + Serial.println("[BLE] save replay: open failed"); + return; + } + JsonDocument doc; + doc["send_seq"] = _sendSeq; + doc["host_seen"] = _hostSeen; + doc["host_session_id"] = _hostSessionId; + serializeJson(doc, f); + f.close(); + } + + // Reserve the next outgoing seq. This is called from the main-loop + // task (via _sendHello / _sendPushPayload in pollStatus), so we can + // safely block on the flash write here — it persists BEFORE the + // frame is even built, so a power loss can't reuse a counter on + // next boot. + uint64_t nextSendSeq() { + _sendSeq++; + saveReplayToDisk(); + return _sendSeq; + } + + // Validate an inbound (host_session_id, seq). Returns false silently + // for replays or stale-session frames. Accepts gracefully when the + // host's session_id changes (host restart / wipe), resetting the + // seq window for that new session. + // + // CRITICAL: this runs on the NimBLE host task (callback context). + // We MUST NOT block on a LittleFS write here — flash GC can take + // seconds and that would either trigger the task watchdog (panic + // reset / "random crash") or break BLE protocol timing (dropped + // frames / "vars not found"). Update RAM only and set a dirty flag + // for the main loop to flush in pollStatus(). + bool acceptReceived(uint64_t hostSid, uint64_t seq, const char** reason) { + if (hostSid == 0) { + // Pre-session-id frames are no longer accepted — forces + // both sides onto the new schema. Caller will log. + *reason = "no_session_id"; + return false; + } + if (hostSid != _hostSessionId) { + // Host restarted/wiped — accept fresh. + _hostSessionId = hostSid; + _hostSeen = seq; + _replayDirty = true; + *reason = "fresh_session"; + return true; + } + // Same session: enforce monotonic seq. + if (seq <= _hostSeen) { + *reason = "regressed_seq"; + return false; + } + _hostSeen = seq; + _replayDirty = true; + *reason = "monotonic"; + return true; + } + + void _loadFile(const char* path, BLEVariable* arr, int* count) { + *count = 0; + if (!LittleFS.exists(path)) return; + File f = LittleFS.open(path, "r"); + if (!f) return; + JsonDocument doc; + DeserializationError err = deserializeJson(doc, f); + f.close(); + if (err) return; + for (JsonPair kv : doc.as()) { + if (*count >= MAX_BLE_VARS) break; + strlcpy(arr[*count].name, kv.key().c_str(), BLE_VAR_NAME_LEN); + const char* v = kv.value().as(); + strlcpy(arr[*count].value, v ? v : "", BLE_VAR_VALUE_LEN); + (*count)++; + } + Serial.printf("[BLE] Restored %d vars from %s\n", *count, path); + } + + // ---- State ---- + char _deviceTag[BLE_DEVICE_TAG_MAX] = {0}; + BLEVariable _devVars[MAX_BLE_VARS]; + BLEVariable _uniVars[MAX_BLE_VARS]; + int _devCount = 0; + int _uniCount = 0; + + // In-memory debug ring (publicly accessible so the serial protocol + // handler can call dumpJson/clear). +public: + BLERingLog dbg; +private: + + // Per-boot session ID for THIS device. RAM-only. Sent on every + // outgoing frame so the host can detect device reset / reflash. + uint64_t _bootId = 0; + // The host's session ID we last accepted, plus the highest seq within + // that session. Persisted to flash; on new host session we reset + // _hostSeen and accept the first frame from the new session. + uint64_t _hostSessionId = 0; + // Replay-protection counters (persisted to LittleFS). + uint64_t _sendSeq = 0; // largest seq we've ever sent + uint64_t _hostSeen = 0; // largest seq we've ever accepted from host + // Set true by acceptReceivedSeq (NimBLE callback context); cleared by + // the main-loop pollStatus() after flushing to flash. See the comment + // on acceptReceivedSeq for why this isn't written inline. + volatile bool _replayDirty = false; + + // millis() at last shutdown — startBLE waits 1 s past this before + // re-init'ing NimBLE so the radio fully tears down. Rapid init/deinit + // cycles on ESP32-S3 with concurrent USB-CDC are a known instability. + uint32_t _lastShutdownMs = 0; + + bool _bleActive = false; + bool _bleEverActive = false; // true once NimBLE has been initialized + volatile bool _clientConnected = false; + volatile bool _clientSubscribed = false; + volatile bool _exchangeDone = false; + volatile bool _authFailed = false; + volatile bool _wrongTag = false; + volatile bool _needsReAdvertise = false; + bool _helloSent = false; + bool _pushSent = false; + + ExchangeKind _exchangeKind = EX_NONE; + char _pendingScope[16] = {0}; + String _requestNamesJson; + char _pendingPersistScope[16] = {0}; + + DebugLog* _dlog = nullptr; + BLEKeyStore* _keystore = nullptr; + + NimBLECharacteristic* _writeChar = nullptr; + NimBLECharacteristic* _notifyChar = nullptr; + NimBLECharacteristic* _liveWriteChar = nullptr; + NimBLECharacteristic* _liveNotifyChar = nullptr; + _BLEServerCB* _serverCB = nullptr; + _BLEWriteCB* _writeCB = nullptr; + _BLENotifyCB* _notifyCB = nullptr; + _BLELiveWriteCB* _liveWriteCB = nullptr; + _BLELiveNotifyCB* _liveNotifyCB = nullptr; + + LiveKeystrokeEngine* _liveEngine = nullptr; + volatile bool _liveSubscribed = false; + volatile bool _liveSawStart = false; + // millis() at last onClientConnect — used by the EX_LIVE pollStatus + // path to send a fallback hello if NimBLE's onSubscribe callback + // doesn't fire (we've observed this with Bleak on Windows even + // though the host has clearly enabled the CCCD on its side). + volatile uint32_t _connectMs = 0; + // Set true once the EX_LIVE fallback hello has fired so we don't + // spam the channel. + volatile bool _liveFallbackHelloSent = false; + // Set true once we've sent the hello AFTER observing onLiveSubscribe. + // This is the "canonical" hello — the one Bleak is guaranteed to + // have CCCD-enabled by the time we send it. Separate from _helloSent + // because the fallback (which may fire before subscribe) sets that + // one; we want to re-send when subscribe eventually arrives. + volatile bool _liveHelloAfterSub = false; + // Identify-logo state: set by a LIVE_MSG_IDENTIFY frame, auto-expires + // IDENTIFY_TIMEOUT_MS after the last on/off frame as a safety net. + volatile bool _liveIdentify = false; + volatile uint32_t _liveIdentifyMs = 0; + static constexpr uint32_t IDENTIFY_TIMEOUT_MS = 60000; + // Host-assigned label shown on the live screen. Persists in RAM across + // reconnects; the host re-sends it on connect so a reboot recovers it. + char _liveLabel[40] = {0}; + volatile uint32_t _liveLabelVer = 0; + // Live-session resume flag. _resumeFlagOnDisk mirrors the LittleFS file; + // _liveResumeBoot is the value read at boot (consumed by the main loop); + // _resumeWantState is a deferred write request from the NimBLE callback + // (-1 = none, 0 = clear, 1 = set), flushed to flash in pollStatus(). + bool _resumeFlagOnDisk = false; + bool _liveResumeBoot = false; + volatile int _resumeWantState = -1; +}; + +// --- NimBLE callback implementations --- + +inline void _BLEServerCB::onConnect(NimBLEServer*, NimBLEConnInfo&) { + mgr->onClientConnect(); +} +inline void _BLEServerCB::onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) { + mgr->onClientDisconnect(); +} +inline void _BLEWriteCB::onWrite(NimBLECharacteristic* ch, NimBLEConnInfo&) { + const NimBLEAttValue& val = ch->getValue(); + mgr->onWriteReceived(val.data(), val.size()); +} +inline void _BLENotifyCB::onSubscribe(NimBLECharacteristic*, NimBLEConnInfo&, + uint16_t subValue) { + mgr->onClientSubscribe(subValue); +} +inline void _BLELiveWriteCB::onWrite(NimBLECharacteristic* ch, NimBLEConnInfo&) { + const NimBLEAttValue& val = ch->getValue(); + mgr->onLiveWriteReceived(val.data(), val.size()); +} +inline void _BLELiveNotifyCB::onSubscribe(NimBLECharacteristic*, NimBLEConnInfo&, + uint16_t subValue) { + mgr->onLiveSubscribe(subValue); +} diff --git a/firmware/MacroPad/config.h b/firmware/MacroPad/config.h new file mode 100644 index 0000000..5edfe39 --- /dev/null +++ b/firmware/MacroPad/config.h @@ -0,0 +1,184 @@ +#pragma once + +#define FW_VERSION "1.1.0" +#define DEVICE_ID "ATOMS3-MACROPAD" + +// Universal binary: the same build runs on the AtomS3 (LCD) and the +// AtomS3 Lite (no LCD, SK6812 RGB LED on GPIO 35 — driven by M5.Led, +// which M5Unified wires up from its board pin table). The board is +// detected at boot via M5GFX panel autodetect + M5.getBoard(); these +// names are what cmdPing reports to the host app. +#define BOARD_NAME_ATOMS3 "atoms3" +#define BOARD_NAME_ATOMS3_LITE "atoms3_lite" + +// Display (AtomS3; all rendering is gated off on the Lite) +#define SCREEN_W 128 +#define SCREEN_H 128 +#define IMG_SIZE (SCREEN_W * SCREEN_H * 2) // RGB565 = 32768 bytes + +// Default settings +#define DEFAULT_HOLD_MS 500 +#define DEFAULT_TYPE_DELAY 15 +#define DEFAULT_ORIENTATION 0 + +// Serial protocol +#define SERIAL_BAUD 115200 +#define CMD_BUF_SIZE 4096 +#define JSON_DOC_SIZE 8192 + +// LittleFS paths +#define CONFIG_PATH "/config.json" +#define MACRO_DIR_PREFIX "/m" + +// Timing +#define DEBOUNCE_MS 10 +#define COMBO_KEY_PRE_DELAY 10 // ms between individual modifier presses within a combo +#define COMBO_KEY_POST_DELAY 25 // ms hold time after all keys pressed before release + +// Default timing settings (user-configurable via GUI) +#define DEFAULT_COMBO_PRE_MS 500 // ms delay before sending a key combo +#define DEFAULT_COMBO_POST_MS 500 // ms delay after sending a key combo +#define DEFAULT_PROBE_TIMEOUT_MS 300 // ms to wait for host LED response +#define DEFAULT_MEDIA_HOLD_MS 100 // ms to hold media key before release +#define DEFAULT_TYPE_SHIFT_EXTRA_MS 25 // extra ms per shifted char (uppercase, !@#$ etc.) +#define DEFAULT_TYPE_SETTLE_MS 150 // ms to wait after the last char to let HID reports drain +#define DEFAULT_TYPE_HOLD_MIN_MS 8 // floor for keydown-to-keyup hold; raised when a host needs longer poll-cycle observation +#define DEFAULT_TYPE_INTER_CHAR_MS 5 // floor for gap between consecutive characters; raised for slow/remote hosts that drop fast input + +// Pause-screen text margins (host-configurable via GUI). Padding the text +// box from each screen edge — used by drawWrapped() in display_ui.h. +#define DEFAULT_PAUSE_MARGIN_LEFT 4 +#define DEFAULT_PAUSE_MARGIN_RIGHT 4 +#define DEFAULT_PAUSE_MARGIN_TOP 16 +#define DEFAULT_PAUSE_MARGIN_BOTTOM 12 + +// Max limits +#define MAX_MACROS 40 +#define MAX_NODES_PER_MACRO 200 +#define MAX_BRANCH_CHOICES 20 +#define MAX_LOOPS 16 // per-macro max number of Loop nodes (for iteration tracking) + +// RS232 via Atomic RS232 Base (MAX232) +#define RS232_RX_PIN 5 +#define RS232_TX_PIN 6 +#define RS232_BUF_SIZE 256 + +// Resume settings +#define DEFAULT_RESUME_DELAY 0 // seconds, 0 = disabled +// Legacy single-file path — kept ONLY so clearExecutionState() can sweep any +// stale file left over from older firmware. The live save path uses the +// A/B double-buffer + sentinel below. +#define RESUME_STATE_PATH "/resume.json" +// Double-buffer + sentinel scheme: each save writes the inactive slot +// (A or B), then flips the 1-byte sentinel. There is no window where zero +// valid resume files exist on disk. Both files carry a monotonic `seq` and +// a CRC32; reader prefers the sentinel-chosen file but falls back to "any +// file that deserializes AND CRC-matches, highest seq wins" if the +// sentinel is missing/garbage. +#define RESUME_STATE_A_PATH "/resume.a.json" +#define RESUME_STATE_B_PATH "/resume.b.json" +#define RESUME_STATE_IDX_PATH "/resume.idx" // 1 byte: 'A' or 'B' + +// Sub-routines +#define MAX_SUBROUTINES 20 +#define MAX_SUB_CALL_DEPTH 4 +#define SUB_DIR_PREFIX "/sub/s" + +// BLE variable sync +#define BLE_SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b" +#define BLE_VARS_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" // host -> device (write) +#define BLE_VARS_NOTIFY_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a9" // device -> host (notify) + +// BLE live-keystroke streaming (separate characteristics on the same +// service so a single advertisement covers both protocols; see +// ble_live.py for the binary frame format) +#define BLE_LIVE_KEYS_WRITE_UUID "4fafc202-1fb5-459e-8fcc-c5c9c331914b" // host -> device (WWR) +#define BLE_LIVE_KEYS_NOTIFY_UUID "4fafc203-1fb5-459e-8fcc-c5c9c331914b" // device -> host (notify) + +// Advertised service UUID specifically for live mode. The device advertises +// THIS UUID (instead of BLE_SERVICE_UUID) when it's in EX_LIVE so the host's +// var-sync and live-keystroke scanners filter to disjoint device sets — +// they never race for the same connection. The actual GATT characteristics +// still live in the same internal service (Bleak discovers characteristics +// by UUID regardless of advertised service). +#define BLE_LIVE_SERVICE_UUID "4fafc204-1fb5-459e-8fcc-c5c9c331914b" +#define MAX_BLE_VARS 32 +#define BLE_VAR_NAME_LEN 32 +#define BLE_VAR_VALUE_LEN 256 +#define BLE_VAR_BUF_SIZE 512 // max plaintext+overhead per frame +#define BLE_FRAME_BUF_SIZE 768 // tag(<=64) + nonce(12) + ct+tag(BLE_VAR_BUF_SIZE+16) +#define BLE_DEVICE_TAG_PREFIX "M5Stack|" +#define BLE_DEVICE_TAG_MAX 40 // "M5Stack|AA:BB:CC:DD:EE:FF" + slack +#define BLE_DEV_VARS_PATH "/ble_dev_vars.json" +#define BLE_UNI_VARS_PATH "/ble_uni_vars.json" +#define BLE_REPLAY_STATE_PATH "/ble_replay.json" +// 1-byte flag: '1' while a live keystroke session is active. If power is +// lost mid-session it survives to the next boot, which uses it to skip the +// BLE boot grace and immediately re-advertise for the host to reconnect. +#define BLE_LIVE_RESUME_PATH "/live_resume.flag" + +// --------------------------------------------------------------------------- +// ESP-NOW mesh (live keyboard transport) +// --------------------------------------------------------------------------- +// All nodes idle-listen on a fixed WiFi channel (STA mode, no AP +// association anywhere). One USB-attached device is switched into hub +// mode by the host app and bridges USB-CDC <-> ESP-NOW broadcast. +#define DEFAULT_MESH_CHANNEL 1 // 1-13, persisted in NVS ("mesh_ch") + +// --------------------------------------------------------------------------- +// Live keyboard transport selection +// --------------------------------------------------------------------------- +// Which radio a device brings up when idle to receive a live-keyboard +// session. Only one is ever up at a time (BLE and WiFi/ESP-NOW contend on +// the ESP32-S3). Persisted in NVS ("live_tx") and toggled on-device by +// holding the screen button for MODE_SWITCH_HOLD_MS while idle. +// LIVE_TX_MESH — idle-listen on the ESP-NOW mesh (the hub broadcasts). +// LIVE_TX_BLE — advertise the BLE live service for direct host links. +#define LIVE_TX_MESH 0 +#define LIVE_TX_BLE 1 +#define DEFAULT_LIVE_TRANSPORT LIVE_TX_MESH +// Screen-button hold time (ms) that toggles the transport while idle. Well +// above the routine-run hold (settings.holdMs, ~500 ms) so the two gestures +// are unambiguous — the run gesture is classified on release, below 5 s. +#define MODE_SWITCH_HOLD_MS 5000 + +// Transport header (plaintext, precedes the encrypted ble_frame envelope) +#define MESH_MAGIC 0xE5 +#define MESH_HDR_LEN 14 +// Hub -> nodes (broadcast) +#define MESH_T_DATA 0x01 // reliable lane (group-key ble_frame) +#define MESH_T_DATA_U 0x02 // unreliable lane: pure mouse moves +#define MESH_T_JOIN 0x03 // per-device-key ble_frame (session invite) +#define MESH_T_POLL 0x04 // discovery poll (plaintext) +// Nodes -> hub (unicast) +#define MESH_T_BEACON 0x81 // discovery reply (plaintext identity) +#define MESH_T_ACK 0x82 // cumulative ack (group key) +#define MESH_T_JOIN_ACK 0x83 // join accepted (per-device key) +#define MESH_T_NACK 0x84 // missing-range report (group key) +#define MESH_T_ERR 0x85 // error report (group key) +// Transport flags +#define MESH_F_RETX 0x01 // retransmission + +// Reliability tuning (see espnow_manager.h) +#define MESH_RING_FRAMES 128 // hub retransmit ring (power of two) +#define MESH_RING_SLOT 256 // max cached DATA frame size +#define MESH_REORDER_SLOTS 32 // node-side out-of-order buffer +#define MESH_ACK_EVERY_N 16 // ack at least every N frames... +#define MESH_ACK_MAX_DELAY_MS 50 // ...or this long after first unacked +#define MESH_NACK_AFTER_MS 8 // gap age before first NACK +#define MESH_NACK_REPEAT_MS 30 // re-NACK while gap persists +#define MESH_RETX_MIN_GAP_MS 15 // hub per-seq retransmit rate limit +#define MESH_STALL_REBCAST_MS 60 // hub proactive rebroadcast on stall +#define MESH_NODE_OFFLINE_MS 1500 // hub marks node offline after silence +#define MESH_HUB_HOST_TIMEOUT_MS 5000 // hub reverts to node w/o host traffic +#define MESH_BEACON_LABEL_LEN 24 + +// CDC binary bridge framing (host <-> hub); JSON lines keep working in +// parallel — the dispatcher peeks at the first byte. +#define HUB_MAGIC0 0xC8 +#define HUB_MAGIC1 0x35 +#define HUB_H2D_SEND 0x01 // payload = complete mesh frame +#define HUB_D2H_RX 0x81 // src_mac[6] + received node frame +#define HUB_D2H_ACKTAB 0x82 // periodic per-node ack table +#define HUB_ACKTAB_PERIOD_MS 250 +#define HUB_MAX_FRAME 1500 diff --git a/firmware/MacroPad/debug_log.h b/firmware/MacroPad/debug_log.h new file mode 100644 index 0000000..b137678 --- /dev/null +++ b/firmware/MacroPad/debug_log.h @@ -0,0 +1,140 @@ +#pragma once + +#include +#include "config.h" + +// Rolling debug log stored in LittleFS at /debug.log +// Format: one JSON line per entry: {"t":,"m":"message"} +// Max MAX_LOG_ENTRIES entries; oldest are trimmed on save. + +#define LOG_FILE "/debug.log" +#define MAX_LOG_ENTRIES 250 +#define MAX_LOG_MSG 128 + +class DebugLog { +public: + void begin() { + _entryCount = 0; + if (LittleFS.exists(LOG_FILE)) { + File f = LittleFS.open(LOG_FILE, "r"); + if (f) { + while (f.available()) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() > 0) _entryCount++; + } + f.close(); + } + } + } + + void log(const char* msg) { + // Mirror to serial for live debugging + Serial.printf("[LOG] %s\n", msg); + + if (_entryCount >= MAX_LOG_ENTRIES) { + trimLog(); + } + + File f = LittleFS.open(LOG_FILE, "a"); + if (!f) return; + + char escaped[MAX_LOG_MSG * 2]; + escapeJson(msg, escaped, sizeof(escaped)); + + f.printf("{\"t\":%lu,\"m\":\"%s\"}\n", millis(), escaped); + f.close(); + _entryCount++; + } + + void logf(const char* fmt, ...) { + char buf[MAX_LOG_MSG]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + log(buf); + } + + // Send the entire log as a JSON array over serial (handles "get_log" cmd). + void sendOverSerial() { + Serial.print("{\"rsp\":\"log\",\"entries\":["); + Serial.flush(); + + if (LittleFS.exists(LOG_FILE)) { + File f = LittleFS.open(LOG_FILE, "r"); + if (f) { + bool first = true; + while (f.available()) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() == 0) continue; + if (!first) Serial.print(","); + Serial.print(line); + first = false; + Serial.flush(); + } + f.close(); + } + } + + Serial.println("]}"); + Serial.flush(); + } + + void clear() { + LittleFS.remove(LOG_FILE); + _entryCount = 0; + } + +private: + int _entryCount = 0; + + // Drop the oldest half of the log when MAX_LOG_ENTRIES is hit. + void trimLog() { + File f = LittleFS.open(LOG_FILE, "r"); + if (!f) return; + + String lines[MAX_LOG_ENTRIES]; + int count = 0; + while (f.available() && count < MAX_LOG_ENTRIES) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() > 0) { + lines[count++] = line; + } + } + f.close(); + + int keepFrom = count / 2; + File out = LittleFS.open(LOG_FILE, "w"); + if (!out) return; + for (int i = keepFrom; i < count; i++) { + out.println(lines[i]); + } + out.close(); + _entryCount = count - keepFrom; + } + + void escapeJson(const char* input, char* output, size_t maxLen) { + size_t o = 0; + for (const char* p = input; *p && o < maxLen - 7; p++) { + unsigned char c = (unsigned char)*p; + if (c == '"' || c == '\\') { + output[o++] = '\\'; + output[o++] = c; + } else if (c == '\n') { + output[o++] = '\\'; output[o++] = 'n'; + } else if (c == '\r') { + output[o++] = '\\'; output[o++] = 'r'; + } else if (c == '\t') { + output[o++] = '\\'; output[o++] = 't'; + } else if (c < 0x20) { + o += snprintf(output + o, maxLen - o, "\\u%04x", c); + } else { + output[o++] = c; + } + } + output[o] = '\0'; + } +}; diff --git a/firmware/MacroPad/display_ui.h b/firmware/MacroPad/display_ui.h new file mode 100644 index 0000000..05e9440 --- /dev/null +++ b/firmware/MacroPad/display_ui.h @@ -0,0 +1,1118 @@ +#pragma once + +#include +#include +#include "config.h" +#include "led_ui.h" + +inline uint16_t resolveColor(const char* name) { + if (strcmp(name, "red") == 0) return TFT_RED; + if (strcmp(name, "green") == 0) return TFT_GREEN; + if (strcmp(name, "blue") == 0) return TFT_BLUE; + if (strcmp(name, "yellow") == 0) return TFT_YELLOW; + if (strcmp(name, "cyan") == 0) return TFT_CYAN; + if (strcmp(name, "magenta") == 0) return TFT_MAGENTA; + if (strcmp(name, "orange") == 0) return TFT_ORANGE; + return TFT_WHITE; +} + +// All rendering for the AtomS3's 128x128 LCD. On a screenless AtomS3 Lite +// (universal binary, detected at boot) every public method gates on +// _present and forwards a semantic state to LedUI instead — macro_engine.h +// and MacroPad.ino call the same methods on both boards and never branch. +class DisplayUI { +public: + void begin(uint8_t orientation, bool present = true, LedUI* led = nullptr) { + _present = present; + _led = led; + if (!_present) return; // no panel: M5.Display must not be touched + M5.Display.setRotation(orientation); + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + } + + bool present() const { return _present; } + + void setOrientation(uint8_t orientation) { + // Accepted-but-ignored on the Lite so the host's orientation + // setting never errors against a screenless device. + if (!_present) return; + M5.Display.setRotation(orientation); + } + + // ========================================================================= + // Macro selector / idle screens + // ========================================================================= + + void showMacroSelector(int slot, const char* name, int current, int total, + uint16_t textColor = TFT_WHITE, bool bleMode = false) { + if (!_present) { if (_led) _led->macroSelector(current, bleMode); return; } + M5.Display.fillScreen(TFT_BLACK); + + char path[32]; + snprintf(path, sizeof(path), "/m%d/icon.raw", slot); + + if (LittleFS.exists(path)) { + drawRawImageBanner(path); + int barH = 20; + M5.Display.fillRect(0, SCREEN_H - barH, SCREEN_W, barH, TFT_BLACK); + M5.Display.setTextColor(textColor, TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + setFontForSize(8); + M5.Display.drawString(name, SCREEN_W / 2, SCREEN_H - barH / 2); + } else { + M5.Display.setTextColor(textColor, TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + setFontForSize(16); + drawWrapped(name, SCREEN_W / 2, SCREEN_H / 2 - 20, SCREEN_W - 8, 16, 4, true); + } + } + + // Full-screen live-mode display, shown while a host is connected over + // the live-keystroke channel. ``status`` is a short string the main + // loop updates as the BLE state machine changes (connected, recording). + // The host drives the whole session; the only on-device action is a + // long-press to override into running the selected routine. + void showLiveMode(const char* status, const char* label = nullptr) { + if (!_present) { if (_led) _led->liveIdle(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + if (label && label[0]) { + // Host gave this device a friendly label — show it prominently + // so the user can tell which physical M5Stack this is. + M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); + setFontForSize(12); + drawWrapped(label, SCREEN_W / 2, 22, SCREEN_W - 8, 12, 2, true); + M5.Display.setTextColor(TFT_RED, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("LIVE MODE", SCREEN_W / 2, 52); + } else { + M5.Display.setTextColor(TFT_RED, TFT_BLACK); + setFontForSize(12); + M5.Display.drawString("LIVE MODE", SCREEN_W / 2, 20); + } + + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(10); + drawWrapped(status, SCREEN_W / 2, SCREEN_H / 2 + 12, SCREEN_W - 8, 10, 2, true); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Hold: run routine", SCREEN_W / 2, SCREEN_H - 12); + } + + // Static Bluetooth "identify" logo. Drawn while the host is asking the + // user to label this specific device in the BT Keyboard window, so the + // user can tell which physical M5Stack is selected. The classic angular + // Bluetooth rune: a vertical spine with two crossing diagonals out to + // the right knees. + void showBluetoothLogo() { + if (!_present) { if (_led) _led->identify(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + const int cx = SCREEN_W / 2; + const int cy = SCREEN_H / 2 - 8; // nudge up to leave room for caption + const int hv = 34; // half-height (top/bottom from center) + const int q = hv / 2; // quarter offset (knee height) + const int hw = 22; // horizontal knee extent + const uint16_t BT_BLUE = TFT_CYAN; + const float w = 5.0f; // line width + + // Spine + M5.Display.drawWideLine(cx, cy - hv, cx, cy + hv, w, BT_BLUE); + // Upper: top tip -> upper-right knee -> lower-left (crosses spine) + M5.Display.drawWideLine(cx, cy - hv, cx + hw, cy - q, w, BT_BLUE); + M5.Display.drawWideLine(cx + hw, cy - q, cx - hw, cy + q, w, BT_BLUE); + // Lower: bottom tip -> lower-right knee -> upper-left (crosses spine) + M5.Display.drawWideLine(cx, cy + hv, cx + hw, cy + q, w, BT_BLUE); + M5.Display.drawWideLine(cx + hw, cy + q, cx - hw, cy - q, w, BT_BLUE); + + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Label this one", SCREEN_W / 2, SCREEN_H - 9); + } + + // Shown on boot when we lost power mid live-session and are immediately + // re-advertising so the host reconnects. A button hold cancels. + void showReconnecting() { + if (!_present) { if (_led) _led->reconnecting(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); + setFontForSize(12); + M5.Display.drawString("Reconnecting", SCREEN_W / 2, SCREEN_H / 2 - 16); + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("to host...", SCREEN_W / 2, SCREEN_H / 2 + 4); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Hold to cancel", SCREEN_W / 2, SCREEN_H - 12); + } + + // modeLabel (optional) names the persisted live transport ("Mesh" / + // "BLE") so the user can see, at a glance on boot, which mode this + // device is in. Passed nullptr to omit it. + void showBoot(const char* modeLabel = nullptr) { + if (!_present) { if (_led) _led->boot(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); + setFontForSize(14); + M5.Display.drawString("MacroPad", SCREEN_W / 2, SCREEN_H / 2 - 18); + setFontForSize(10); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + M5.Display.drawString(FW_VERSION, SCREEN_W / 2, SCREEN_H / 2 + 2); + if (modeLabel) { + setFontForSize(8); + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + M5.Display.drawString(modeLabel, SCREEN_W / 2, SCREEN_H - 14); + } + } + + // Brief confirmation shown when the user toggles the live transport with + // a long button hold. ``ble`` picks the color/name of the new mode. + void showModeSwitch(bool ble) { + if (!_present) { if (_led) _led->modeSwitch(ble); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(10); + M5.Display.drawString("Live mode", SCREEN_W / 2, SCREEN_H / 2 - 18); + M5.Display.setTextColor(ble ? TFT_BLUE : TFT_CYAN, TFT_BLACK); + setFontForSize(14); + M5.Display.drawString(ble ? "Bluetooth" : "ESP-NOW", SCREEN_W / 2, + SCREEN_H / 2 + 6); + } + + void showExecuting(const char* name) { + if (!_present) { if (_led) _led->executing(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); + setFontForSize(10); + M5.Display.drawString("Running...", SCREEN_W / 2, 10); + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(14); + drawWrapped(name, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 14, 3, true); + } + + void showMessage(const char* msg, uint16_t color = TFT_WHITE) { + if (!_present) { if (_led) _led->message(color); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextColor(color, TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + setFontForSize(12); + drawWrapped(msg, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 12, 4, true); + } + + // ========================================================================= + // Pause screen + // ========================================================================= + + // Pause screen — text-box geometry is driven by the four host-configurable + // margins so users can tune wrapping/truncation without re-flashing. + // marginL/R → text width = SCREEN_W - L - R + // marginT/B → text vertical band = [marginT, SCREEN_H - marginB) + // The progress bar (when timed) sits inside the bottom margin so the + // wrapping box never overlaps it. + void showPauseScreen(const char* text, int fontSize, bool timed, + uint32_t remainMs, uint32_t totalMs, + uint16_t textColor = TFT_WHITE, + uint8_t marginL = DEFAULT_PAUSE_MARGIN_LEFT, + uint8_t marginR = DEFAULT_PAUSE_MARGIN_RIGHT, + uint8_t marginT = DEFAULT_PAUSE_MARGIN_TOP, + uint8_t marginB = DEFAULT_PAUSE_MARGIN_BOTTOM) { + if (!_present) { if (_led) _led->pauseScreen(timed, remainMs); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextColor(textColor, TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + setFontForSize(fontSize); + + int boxW = SCREEN_W - marginL - marginR; + if (boxW < 16) boxW = 16; // sanity floor + int boxTop = marginT; + int boxBot = SCREEN_H - marginB; + int boxH = boxBot - boxTop; + if (boxH < fontSize + 4) boxH = fontSize + 4; + + int cx = marginL + boxW / 2; + int cy = boxTop + boxH / 2; + + // Compute how many lines fit so truncation respects the margins. + int lineH = fontSize + 6; + int maxLines = boxH / lineH; + if (maxLines < 1) maxLines = 1; + if (maxLines > 12) maxLines = 12; + + drawWrapped(text, cx, cy, boxW, fontSize, maxLines, true); + + if (timed && totalMs > 0) { + // Place the progress bar inside the bottom margin (so the text + // band above it stays clear). Bar gets at most marginB - 2 px + // of vertical room. + int barH = marginB - 2; + if (barH < 2) barH = 2; + if (barH > 8) barH = 8; + int barY = SCREEN_H - barH - 1; + int barW = (int)((float)remainMs / totalMs * boxW); + if (barW < 0) barW = 0; + M5.Display.fillRect(marginL, barY, boxW, barH, TFT_DARKGREY); + M5.Display.fillRect(marginL, barY, barW, barH, TFT_CYAN); + } + } + + // ========================================================================= + // Iteration Branch (path selected by current loop iteration) + // ========================================================================= + + void showIterationBranch(int iter, const char* label, int pathIdx, int numPaths) { + if (!_present) { if (_led) _led->iterationBranch(pathIdx); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_MAGENTA, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Iteration Branch", SCREEN_W / 2, 10); + + // Iteration number (highlighted, middle-sized) + char ibuf[24]; + snprintf(ibuf, sizeof(ibuf), "Iteration %d", iter); + M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); + setFontForSize(10); + M5.Display.drawString(ibuf, SCREEN_W / 2, 32); + + // Path label (most prominent) + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(14); + drawWrapped(label, SCREEN_W / 2, SCREEN_H / 2 + 8, SCREEN_W - 8, 14, 2, true); + + // Path indicator at bottom + char pbuf[24]; + snprintf(pbuf, sizeof(pbuf), "Path %d / %d", pathIdx + 1, numPaths); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString(pbuf, SCREEN_W / 2, SCREEN_H - 10); + } + + // ========================================================================= + // Loop Start Selector (phase 2 — user picks which iteration to start at) + // ========================================================================= + + void showLoopStartSelector(const char* prompt, int current, int mn, int mx, int totalCount) { + if (!_present) { if (_led) _led->loopSelector(current); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + // Green header to distinguish from the count-selector (which is yellow) + M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Start Iteration", SCREEN_W / 2, 10); + + // Subheader showing total count so user knows the range context + char ctxBuf[32]; + snprintf(ctxBuf, sizeof(ctxBuf), "(of %d)", totalCount); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString(ctxBuf, SCREEN_W / 2, 26); + + // Prompt text + if (prompt && prompt[0]) { + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(8); + drawWrapped(prompt, SCREEN_W / 2, 44, SCREEN_W - 8, 8, 2, true); + } + + // Large current value + char buf[16]; + snprintf(buf, sizeof(buf), "%d", current); + M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); + setFontForSize(20); + M5.Display.drawString(buf, SCREEN_W / 2, SCREEN_H / 2 + 10); + + // Progress bar + int span = mx - mn; + if (span > 0) { + int barX = 8; + int barY = SCREEN_H - 32; + int barW = SCREEN_W - 16; + int barH = 3; + M5.Display.fillRect(barX, barY, barW, barH, TFT_DARKGREY); + int fillW = (int)((float)(current - mn) / span * barW); + if (fillW < 0) fillW = 0; + if (fillW > barW) fillW = barW; + M5.Display.fillRect(barX, barY, fillW, barH, TFT_GREEN); + } + + // Range label + char rangeBuf[24]; + snprintf(rangeBuf, sizeof(rangeBuf), "%d - %d", mn, mx); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString(rangeBuf, SCREEN_W / 2, SCREEN_H - 22); + + // Footer + M5.Display.setTextColor(0x7BCF, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Click:+ Hold:OK", SCREEN_W / 2, SCREEN_H - 10); + } + + // ========================================================================= + // Loop Selector (user picks a count via button) + // ========================================================================= + + void showLoopSelector(const char* prompt, int current, int mn, int mx) { + if (!_present) { if (_led) _led->loopSelector(current); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + // Header + M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Loop Count", SCREEN_W / 2, 10); + + // Prompt (small text below header) + if (prompt && prompt[0]) { + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(8); + drawWrapped(prompt, SCREEN_W / 2, 30, SCREEN_W - 8, 8, 2, true); + } + + // Large current value in the center + char buf[16]; + snprintf(buf, sizeof(buf), "%d", current); + M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); + setFontForSize(20); + M5.Display.drawString(buf, SCREEN_W / 2, SCREEN_H / 2 + 4); + + // Progress bar showing position within range + int span = mx - mn; + if (span > 0) { + int barX = 8; + int barY = SCREEN_H - 32; + int barW = SCREEN_W - 16; + int barH = 3; + M5.Display.fillRect(barX, barY, barW, barH, TFT_DARKGREY); + int fillW = (int)((float)(current - mn) / span * barW); + if (fillW < 0) fillW = 0; + if (fillW > barW) fillW = barW; + M5.Display.fillRect(barX, barY, fillW, barH, TFT_CYAN); + } + + // Range label + char rangeBuf[24]; + snprintf(rangeBuf, sizeof(rangeBuf), "%d - %d", mn, mx); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString(rangeBuf, SCREEN_W / 2, SCREEN_H - 22); + + // Footer hint + M5.Display.setTextColor(0x7BCF, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Click:+ Hold:OK", SCREEN_W / 2, SCREEN_H - 10); + } + + // ========================================================================= + // Branch selector + // ========================================================================= + + // Branch selector with scrolling + per-choice colors. + // + // ``colors`` may be nullptr — falls back to white for every choice. + // ``scrollOffset`` is the index of the first visible row; the engine + // adjusts it so the selected row stays inside the visible band. + // + // Layout: at item height 24 the 128px screen fits ~5 rows. When count + // exceeds the visible window we draw a small triangle at the top and/or + // bottom edge to indicate more items above/below, plus a 2px scroll + // ribbon on the right side that shows the proportion / position of the + // viewport within the full list. + void showBranchSelector(const char* choices[], const uint16_t* colors, + int count, int selected, int scrollOffset = 0) { + if (!_present) { if (_led) _led->branchSelector(selected); return; } + M5.Display.fillScreen(TFT_BLACK); + setFontForSize(12); + + const int itemH = 24; + int visible = SCREEN_H / itemH; // 128 / 24 = 5 + if (visible < 1) visible = 1; + if (visible > count) visible = count; + + // Sanity-clamp the scroll offset so first..first+visible-1 is in range. + if (scrollOffset < 0) scrollOffset = 0; + if (scrollOffset > count - visible) scrollOffset = count - visible; + + // Whether we'll need scroll affordances on the side. + bool hasMore = (count > visible); + const int ribbonW = hasMore ? 3 : 0; + const int rightEdge = SCREEN_W - ribbonW; + + int startY = (SCREEN_H - visible * itemH) / 2; + if (startY < 0) startY = 0; + + for (int row = 0; row < visible; row++) { + int idx = scrollOffset + row; + if (idx < 0 || idx >= count) continue; + int y = startY + row * itemH; + uint16_t fg = (colors != nullptr) ? colors[idx] : TFT_WHITE; + if (idx == selected) { + // Selected: filled in the choice's color, text inverted to + // black so it stays legible regardless of which color the + // user picked. + M5.Display.fillRect(0, y, rightEdge, itemH, fg); + M5.Display.setTextColor(TFT_BLACK, fg); + } else { + M5.Display.setTextColor(fg, TFT_BLACK); + } + // Center the text within the row, leaving room for the scroll + // ribbon on the right when present. + M5.Display.drawString(choices[idx], rightEdge / 2, y + itemH / 2); + } + + if (hasMore) { + // Up/down arrow indicators at the very top/bottom of the list + // band, drawn as small filled triangles inside the right ribbon. + int rx = SCREEN_W - 1; + // Background ribbon (faint), then thumb (bright) sized to the + // visible fraction. + M5.Display.fillRect(rx - ribbonW + 1, startY, ribbonW, + visible * itemH, TFT_DARKGREY); + int thumbH = (visible * (visible * itemH)) / count; + if (thumbH < 4) thumbH = 4; + int thumbY = startY + (scrollOffset * (visible * itemH)) / count; + M5.Display.fillRect(rx - ribbonW + 1, thumbY, ribbonW, + thumbH, TFT_CYAN); + + if (scrollOffset > 0) { + M5.Display.fillTriangle(2, startY + 4, + 9, startY + 4, + 5, startY - 2, TFT_CYAN); + } + if (scrollOffset + visible < count) { + int by = startY + visible * itemH; + M5.Display.fillTriangle(2, by - 4, + 9, by - 4, + 5, by + 2, TFT_CYAN); + } + } + } + + // ========================================================================= + // Per-node execution displays + // ========================================================================= + + // --- Text node: scrolling ribbon --- + // Called once per character as text is typed. + // charIdx = index of character currently being typed (0-based). + void showTextRibbon(const char* fullText, int charIdx) { + int len = strlen(fullText); + if (len == 0) return; + if (!_present) { if (_led) _led->typing(charIdx, len); return; } + + M5.Display.fillScreen(TFT_BLACK); + + // Header + M5.Display.setTextDatum(MC_DATUM); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Type Text", SCREEN_W / 2, 12); + + // Progress fraction text + char prog[16]; + snprintf(prog, sizeof(prog), "%d/%d", charIdx + 1, len); + M5.Display.drawString(prog, SCREEN_W / 2, SCREEN_H - 12); + + // Small progress bar at very bottom + int barY = SCREEN_H - 4; + int barFill = (len > 1) ? (int)((float)charIdx / (len - 1) * (SCREEN_W - 8)) : (SCREEN_W - 8); + M5.Display.fillRect(4, barY, SCREEN_W - 8, 3, TFT_DARKGREY); + M5.Display.fillRect(4, barY, barFill, 3, TFT_GREEN); + + // Ribbon: large current char in center, smaller surrounding chars + int ribbonY = SCREEN_H / 2; + int centerX = SCREEN_W / 2; + + // Measure character widths for both font sizes + setFontForSize(20); + int bigW = M5.Display.textWidth("W"); + setFontForSize(8); + int smallW = M5.Display.textWidth("W"); + + // How many small chars fit on each side (from edge of big char to screen edge) + int sideSpace = (SCREEN_W / 2) - (bigW / 2) - 4; // 4px padding + int halfSlots = sideSpace / smallW + 1; + + M5.Display.setTextDatum(MC_DATUM); + + // Draw surrounding chars LEFT of center (right-to-left so closest is drawn last) + for (int offset = -halfSlots; offset <= -1; offset++) { + int idx = charIdx + offset; + if (idx < 0 || idx >= len) continue; + + char display[2] = { fullText[idx], '\0' }; + if (display[0] == '\n' || display[0] == '\r' || display[0] == '\t') display[0] = ' '; + else if (display[0] < 32) display[0] = '?'; + + // Position: small chars packed to the left of the big char + int x = centerX - (bigW / 2) - 4 + (offset + 1) * smallW - smallW / 2; + if (x < -smallW || x > SCREEN_W + smallW) continue; + + int dist = abs(offset); + uint16_t grey; + if (dist <= 1) grey = 0x9CF3; // bright grey + else if (dist <= 3) grey = 0x7BCF; // medium grey + else if (dist <= 5) grey = 0x52AA; // dim grey + else grey = 0x39C7; // dark grey + + setFontForSize(8); + M5.Display.setTextColor(grey, TFT_BLACK); + M5.Display.drawString(display, x, ribbonY); + } + + // Draw surrounding chars RIGHT of center (left-to-right) + for (int offset = 1; offset <= halfSlots; offset++) { + int idx = charIdx + offset; + if (idx < 0 || idx >= len) continue; + + char display[2] = { fullText[idx], '\0' }; + if (display[0] == '\n' || display[0] == '\r' || display[0] == '\t') display[0] = ' '; + else if (display[0] < 32) display[0] = '?'; + + int x = centerX + (bigW / 2) + 4 + (offset - 1) * smallW + smallW / 2; + if (x < -smallW || x > SCREEN_W + smallW) continue; + + int dist = abs(offset); + uint16_t grey; + if (dist <= 1) grey = 0x9CF3; + else if (dist <= 3) grey = 0x7BCF; + else if (dist <= 5) grey = 0x52AA; + else grey = 0x39C7; + + setFontForSize(8); + M5.Display.setTextColor(grey, TFT_BLACK); + M5.Display.drawString(display, x, ribbonY); + } + + // Draw current character LAST (on top) in large font with highlight + { + char display[2] = { fullText[charIdx], '\0' }; + if (display[0] == '\n' || display[0] == '\r' || display[0] == '\t') display[0] = ' '; + else if (display[0] < 32) display[0] = '?'; + + setFontForSize(20); + M5.Display.fillRect(centerX - bigW / 2 - 2, ribbonY - 16, bigW + 4, 32, 0x1082); + M5.Display.setTextColor(TFT_WHITE, 0x1082); + M5.Display.drawString(display, centerX, ribbonY); + } + } + + // --- Key Combo node --- + void showKeyCombo(const char* comboStr) { + if (!_present) { if (_led) _led->activityPulse(TFT_YELLOW); return; } + M5.Display.fillScreen(TFT_BLACK); + + // Header + M5.Display.setTextDatum(MC_DATUM); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Key Combo", SCREEN_W / 2, 14); + + // Combo text, large and centered + M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); + setFontForSize(14); + drawWrapped(comboStr, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 14, 3, true); + } + + // --- Delay node: progress bar with time --- + // Called every tick while in ENGINE_DELAY state. + void showDelayProgress(uint32_t totalMs, uint32_t remainMs) { + if (!_present) { if (_led) _led->breathe(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + // Header + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Delay", SCREEN_W / 2, 14); + + // Large time remaining in center + char timeStr[16]; + if (totalMs >= 1000) { + float secs = remainMs / 1000.0f; + snprintf(timeStr, sizeof(timeStr), "%.1fs", secs); + } else { + snprintf(timeStr, sizeof(timeStr), "%lums", (unsigned long)remainMs); + } + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(20); + M5.Display.drawString(timeStr, SCREEN_W / 2, SCREEN_H / 2 - 4); + + // Large progress bar + int barH = 12; + int barY = SCREEN_H - 28; + int barX = 8; + int barMaxW = SCREEN_W - 16; + float fraction = (totalMs > 0) ? (float)(totalMs - remainMs) / totalMs : 1.0f; + int barFill = (int)(fraction * barMaxW); + + M5.Display.fillRect(barX, barY, barMaxW, barH, TFT_DARKGREY); + M5.Display.fillRect(barX, barY, barFill, barH, TFT_CYAN); + M5.Display.drawRect(barX, barY, barMaxW, barH, 0x4208); + } + + // --- Mouse click node --- + void showMouseAction(const char* action, const char* button) { + if (!_present) { if (_led) _led->activityPulse(TFT_ORANGE); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Mouse", SCREEN_W / 2, 14); + + char desc[48]; + snprintf(desc, sizeof(desc), "%s %s", action, button); + M5.Display.setTextColor(TFT_ORANGE, TFT_BLACK); + setFontForSize(14); + M5.Display.drawString(desc, SCREEN_W / 2, SCREEN_H / 2); + } + + // --- Media key node --- + void showMediaKey(const char* action) { + if (!_present) { if (_led) _led->activityPulse(TFT_CYAN); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Media Key", SCREEN_W / 2, 14); + + M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); + setFontForSize(14); + M5.Display.drawString(action, SCREEN_W / 2, SCREEN_H / 2); + } + + // --- BLE node: connection state --- + void showBLEStatus(const char* msg) { + if (!_present) { if (_led) _led->bleStatus(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); + setFontForSize(10); + M5.Display.drawString("Bluetooth", SCREEN_W / 2, 20); + + // BLE icon hint + M5.Display.setTextColor(TFT_BLUE, TFT_BLACK); + setFontForSize(20); + M5.Display.drawString("*", SCREEN_W / 2, SCREEN_H / 2 - 8); + + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(8); + drawWrapped(msg, SCREEN_W / 2, SCREEN_H / 2 + 20, SCREEN_W - 8, 8, 3, true); + } + + // --- RS232 node: sending state --- + void showRS232Sending(const char* message, int baud) { + if (!_present) { if (_led) _led->activityPulse(TFT_GREEN); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + char header[32]; + snprintf(header, sizeof(header), "RS232 @ %d", baud); + M5.Display.drawString(header, SCREEN_W / 2, 12); + + M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Sending...", SCREEN_W / 2, 30); + + // Show truncated message + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(10); + drawWrapped(message, SCREEN_W / 2, SCREEN_H / 2 + 8, SCREEN_W - 12, 10, 3, true); + } + + // --- RS232 node: safe to lose power (post-send idle) --- + void showRS232SafeIdle(const char* message, int delayMs) { + if (!_present) { if (_led) _led->executing(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("RS232 Sent", SCREEN_W / 2, 12); + + // Big yellow-green "safe" indicator + M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); + setFontForSize(14); + M5.Display.drawString("Safe to", SCREEN_W / 2, SCREEN_H / 2 - 14); + M5.Display.drawString("lose power", SCREEN_W / 2, SCREEN_H / 2 + 6); + + // Duration hint + char info[32]; + snprintf(info, sizeof(info), "Idling %dms", delayMs); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H - 20); + + // Truncated message + M5.Display.setTextColor(0x4208, TFT_BLACK); + setFontForSize(8); + char msgBuf[24]; + snprintf(msgBuf, sizeof(msgBuf), "%.20s", message); + M5.Display.drawString(msgBuf, SCREEN_W / 2, SCREEN_H - 10); + } + + // --- RS232 node: waiting for response --- + void showRS232Waiting(const char* expected, uint32_t remainMs, int rxBytes) { + if (!_present) { if (_led) _led->waiting(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("RS232 Waiting", SCREEN_W / 2, 12); + + // Timeout remaining + char timeStr[24]; + snprintf(timeStr, sizeof(timeStr), "Timeout: %.1fs", remainMs / 1000.0f); + M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString(timeStr, SCREEN_W / 2, 30); + + // Bytes received counter + char rxStr[24]; + snprintf(rxStr, sizeof(rxStr), "RX: %d bytes", rxBytes); + M5.Display.setTextColor(TFT_CYAN, TFT_BLACK); + M5.Display.drawString(rxStr, SCREEN_W / 2, SCREEN_H / 2); + + // Expected string (truncated) + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + char expLabel[80]; + snprintf(expLabel, sizeof(expLabel), "Expect: %.20s", expected); + M5.Display.drawString(expLabel, SCREEN_W / 2, SCREEN_H - 20); + } + + // --- Macro recording playback --- + // Shown while a "macro" node is replaying its captured key sequence. + void showMacroPlayback(const char* name, int nEvents, uint32_t totalMs) { + if (!_present) { if (_led) _led->executing(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Macro", SCREEN_W / 2, 10); + + const char* displayName = (name && name[0]) ? name : "(unnamed)"; + M5.Display.setTextColor(0x3CBE, TFT_BLACK); // teal-ish, matches node color + setFontForSize(12); + drawWrapped(displayName, SCREEN_W / 2, SCREEN_H / 2 - 6, SCREEN_W - 8, 12, 2, true); + + char info[40]; + float secs = totalMs / 1000.0f; + snprintf(info, sizeof(info), "%d evts %.1fs", nEvents, secs); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H - 14); + } + + void showSubroutineCall(const char* name) { + if (!_present) { if (_led) _led->activityPulse(TFT_MAGENTA); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Sub-Routine", SCREEN_W / 2, 14); + + M5.Display.setTextColor(TFT_MAGENTA, TFT_BLACK); + setFontForSize(14); + drawWrapped(name, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 14, 2, true); + } + + // --- Loop node --- + void showLoopStatus(int iteration, int total) { + if (!_present) { if (_led) _led->activityPulse(TFT_GREEN); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Loop", SCREEN_W / 2, 14); + + char info[32]; + snprintf(info, sizeof(info), "%d / %d", iteration, total); + M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); + setFontForSize(20); + M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H / 2); + } + + // --- PC Alive Check: probing step display --- + void showNumLockProbing(const char* phase) { + if (!_present) { if (_led) _led->probe(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("PC Alive Check", SCREEN_W / 2, 14); + + M5.Display.setTextColor(0x4208, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Probing host...", SCREEN_W / 2, 34); + + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(10); + M5.Display.drawString(phase, SCREEN_W / 2, SCREEN_H / 2); + + M5.Display.setTextColor(0x4208, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Toggle > Wait > Compare", SCREEN_W / 2, SCREEN_H - 16); + } + + // --- PC Alive Check: result display --- + void showPCAliveResult(bool condMet, int attempt, const char* condition) { + if (!_present) { if (_led) _led->aliveResult(condMet); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("PC Alive Check", SCREEN_W / 2, 14); + + // Show which condition we're checking + const char* condLabel = "PC Response"; + if (strcmp(condition, "numlock_on") == 0) condLabel = "Num Lock ON"; + else if (strcmp(condition, "numlock_off") == 0) condLabel = "Num Lock OFF"; + M5.Display.setTextColor(0x7BCF, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString(condLabel, SCREEN_W / 2, 32); + + if (condMet) { + M5.Display.setTextColor(TFT_GREEN, TFT_BLACK); + setFontForSize(20); + M5.Display.drawString("TRUE", SCREEN_W / 2, SCREEN_H / 2 - 2); + } else { + M5.Display.setTextColor(TFT_RED, TFT_BLACK); + setFontForSize(14); + M5.Display.drawString("Waiting...", SCREEN_W / 2, SCREEN_H / 2 - 2); + } + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + char info[32]; + snprintf(info, sizeof(info), "Attempt %d", attempt); + M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H - 16); + } + + // --- Generic fallback for any other node type --- + // FAIL screen for Get Variables when both the initial run and the silent + // retry produced no matching outcome. Big red "FAIL" with a 30-second + // countdown bar; when paused, the bar freezes and a "PAUSED" hint shows. + void showFailScreen(uint32_t remainMs, uint32_t totalMs, bool paused) { + if (!_present) { if (_led) _led->failWait(paused); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_RED, TFT_BLACK); + setFontForSize(20); + M5.Display.drawString("FAIL", SCREEN_W / 2, SCREEN_H / 2 - 14); + + if (paused) { + M5.Display.setTextColor(TFT_YELLOW, TFT_BLACK); + setFontForSize(10); + M5.Display.drawString("PAUSED", SCREEN_W / 2, SCREEN_H / 2 + 14); + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Click: retry", SCREEN_W / 2, SCREEN_H - 22); + } else { + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Click: pause", SCREEN_W / 2, SCREEN_H / 2 + 16); + } + + if (totalMs > 0) { + int barH = 6; + int barY = SCREEN_H - 10; + int barX = 8; + int barMaxW = SCREEN_W - 16; + int barFill = (int)((float)remainMs / totalMs * barMaxW); + if (barFill < 0) barFill = 0; + if (barFill > barMaxW) barFill = barMaxW; + M5.Display.fillRect(barX, barY, barMaxW, barH, TFT_DARKGREY); + M5.Display.fillRect(barX, barY, barFill, barH, + paused ? TFT_YELLOW : TFT_RED); + } + } + + void showGenericNode(const char* typeName) { + if (!_present) { if (_led) _led->activityPulse(TFT_WHITE); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Executing", SCREEN_W / 2, 14); + + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(14); + drawWrapped(typeName, SCREEN_W / 2, SCREEN_H / 2, SCREEN_W - 8, 14, 2, true); + } + + // --- ESP-NOW mesh hub mode --- + // Shown while this device is the USB-attached bridge between the host + // app and the rest of the mesh. The host drives everything; the screen + // (or purple LED on a Lite) just tells the user which unit is the hub. + void showHubMode(int nodeCount) { + if (!_present) { if (_led) _led->hubMode(); return; } + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextDatum(MC_DATUM); + + M5.Display.setTextColor(TFT_MAGENTA, TFT_BLACK); + setFontForSize(14); + M5.Display.drawString("MESH HUB", SCREEN_W / 2, SCREEN_H / 2 - 18); + + char info[32]; + snprintf(info, sizeof(info), "%d node%s", nodeCount, + nodeCount == 1 ? "" : "s"); + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + setFontForSize(10); + M5.Display.drawString(info, SCREEN_W / 2, SCREEN_H / 2 + 10); + + M5.Display.setTextColor(TFT_DARKGREY, TFT_BLACK); + setFontForSize(8); + M5.Display.drawString("Bridging USB <-> radio", SCREEN_W / 2, SCREEN_H - 12); + } + +private: + bool _present = true; + LedUI* _led = nullptr; + + void drawRawImage(const char* path) { + if (!_present) return; + File f = LittleFS.open(path, "r"); + if (!f) return; + + uint16_t lineBuf[SCREEN_W]; + for (int y = 0; y < SCREEN_H; y++) { + f.read((uint8_t*)lineBuf, SCREEN_W * 2); + M5.Display.pushImage(0, y, SCREEN_W, 1, lineBuf); + } + f.close(); + } + + void drawRawImageBanner(const char* path) { + drawRawImage(path); + } + + // ========================================================================= + // Text utilities: word-wrapping, truncation + // ========================================================================= + + // Draw text with word-wrapping and optional truncation. + // cx, cy: center point for the text block + // maxW: maximum pixel width for a line + // fontSize: font size hint (passed to setFontForSize) + // maxLines: maximum number of lines before truncating with "..." + // center: if true, vertically center the block around cy + void drawWrapped(const char* text, int cx, int cy, int maxW, int fontSize, + int maxLines = 4, bool center = true) { + if (!_present) return; + setFontForSize(fontSize); + int lineH = fontSize + 6; + + // Split into lines (respecting \n and word-wrap) + struct Line { const char* start; int len; }; + Line lines[12]; // max 12 lines + int lineCount = 0; + + const char* p = text; + while (*p && lineCount < 12) { + // Find the end of this line (newline or word-wrap boundary) + const char* lineStart = p; + const char* lastBreak = nullptr; + int linePixW = 0; + + while (*p && *p != '\n') { + char ch[2] = { *p, '\0' }; + int chW = M5.Display.textWidth(ch); + + if (linePixW + chW > maxW && lastBreak) { + // Wrap at last space + break; + } + if (*p == ' ') lastBreak = p; + linePixW += chW; + p++; + } + + // Determine actual line end + const char* lineEnd; + if (*p == '\n') { + lineEnd = p; + p++; // skip newline + } else if (*p == '\0') { + lineEnd = p; + } else if (lastBreak && lastBreak > lineStart) { + lineEnd = lastBreak; + p = lastBreak + 1; // skip space + } else { + // No space to break at, hard-break at maxW + lineEnd = p; + } + + lines[lineCount].start = lineStart; + lines[lineCount].len = lineEnd - lineStart; + lineCount++; + } + + // Apply maxLines truncation + bool truncated = false; + if (lineCount > maxLines) { + lineCount = maxLines; + truncated = true; + } + + // Vertically center + int totalH = lineCount * lineH; + int startY = center ? (cy - totalH / 2 + lineH / 2) : cy; + + M5.Display.setTextDatum(MC_DATUM); + + for (int i = 0; i < lineCount; i++) { + char buf[128]; + int copyLen = lines[i].len; + if (copyLen >= (int)sizeof(buf)) copyLen = sizeof(buf) - 1; + memcpy(buf, lines[i].start, copyLen); + buf[copyLen] = '\0'; + + // On the last line, if truncated, add ellipsis + if (truncated && i == lineCount - 1) { + // Truncate to fit "..." within maxW + int dotsW = M5.Display.textWidth("..."); + while (copyLen > 0 && M5.Display.textWidth(buf) + dotsW > maxW) { + copyLen--; + buf[copyLen] = '\0'; + } + strcat(buf, "..."); + } + + M5.Display.drawString(buf, cx, startY + i * lineH); + } + } + + void setFontForSize(int fontSize) { + if (!_present) return; + if (fontSize >= 20) { + M5.Display.setFont(&fonts::FreeSansBold18pt7b); + } else if (fontSize >= 14) { + M5.Display.setFont(&fonts::FreeSansBold12pt7b); + } else if (fontSize >= 10) { + M5.Display.setFont(&fonts::FreeSansBold9pt7b); + } else { + M5.Display.setFont(&fonts::Font2); + } + } +}; diff --git a/firmware/MacroPad/espnow_manager.h b/firmware/MacroPad/espnow_manager.h new file mode 100644 index 0000000..39a8a22 --- /dev/null +++ b/firmware/MacroPad/espnow_manager.h @@ -0,0 +1,1119 @@ +#pragma once + +// EspNowManager — the live-keyboard mesh transport. +// +// Replaces the per-device BLE GATT live channel: one USB-attached device +// is switched into HUB mode by the host app and bridges USB-CDC <-> +// ESP-NOW; every other device idle-listens as a NODE. ESP-NOW is pure +// connectionless WiFi (STA mode, fixed channel, no AP/SoftAP anywhere), +// so 10-30 mixed AtomS3 / AtomS3 Lite units can share one room with a +// single airtime slot per keystroke batch (hub broadcasts). +// +// Roles (one state machine, same universal binary everywhere): +// +// OFF ──────────► NODE_LISTEN ──JOIN──► NODE_JOINED +// ▲ │ ▲ │ +// └──── radio off ◄──┘ └────── STOP ◄─────┘ +// ▲ +// └── HUB (entered ONLY via the espnow_hub serial command; reverts +// to OFF when the host goes quiet for MESH_HUB_HOST_TIMEOUT_MS) +// +// Zero-loss design (keystrokes / mouse buttons / wheel): +// * The host assigns every reliable frame a transport seq and the hub +// caches it in a 128-slot ring. DATA frames are broadcast. +// * Nodes deliver strictly in cum order; out-of-order frames sit in a +// 32-slot reorder buffer; gaps older than MESH_NACK_AFTER_MS trigger +// unicast NACKs; the hub rebroadcasts from the ring (RETX flag). +// * Nodes ACK cumulatively (every MESH_ACK_EVERY_N frames or +// MESH_ACK_MAX_DELAY_MS, jittered per-node so 30 ACKs don't collide). +// * The hub proactively rebroadcasts when a node's cum stalls — lost +// NACKs can't wedge the stream. +// * The device-side 100 ms cadence buffer (LiveKeystrokeEngine) absorbs +// the whole retransmit RTT, so recovered keystrokes still emit on the +// host's original typing cadence. +// * Pure absolute mouse moves ride the unreliable DATA_U lane — +// latest-wins (self-correcting), never retransmitted. Anything that +// must not be lost (buttons, wheel) goes on the reliable lane. +// +// Crypto: identical AES-256-GCM envelope as BLE (frame_crypto.h). +// * JOIN / JOIN_ACK — under this node's per-device key (the same key +// config/.ble_keys.json already holds, keyed by the STA MAC). JOIN +// carries the per-session group key. +// * DATA / DATA_U — under the session group key, tag = the hub's +// device tag. One ciphertext serves every node (broadcast). +// * The hub itself never holds the group key — it routes on the +// plaintext transport header only. ACK/NACK/BEACON/ERR are plaintext +// (sequence numbers and liveness only; a radio-local attacker could +// at worst provoke retransmits, never inject or read input). +// +// Threading: ESP-NOW callbacks run on the WiFi task. They ONLY copy the +// frame into a FreeRTOS queue; everything else (decrypt, dispatch, flash +// writes, Serial) happens in tick() on the main loop — the same pattern +// the BLE manager uses for NimBLE callbacks. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "config.h" +#include "settings.h" +#include "frame_crypto.h" +#include "ble_keystore.h" +#include "live_keystroke.h" +#include "debug_log.h" + +// Mesh frames are capped to the ESP-NOW v1 payload (250 B) so the design +// never depends on v2 long frames; a 16-event KEYS batch is ~183 B. +#define MESH_MAX_FRAME 250 + +// Inner (encrypted) live-protocol message types. 0x01-0x06 are byte-for- +// byte the BLE live protocol (ble_manager.h / ble_live.py); 0x07/0x08 are +// mesh-only per-node mute control. +#define MESH_IN_START 0x01 +#define MESH_IN_KEYS 0x02 +#define MESH_IN_STOP 0x03 +#define MESH_IN_IDENTIFY 0x04 +#define MESH_IN_MOUSE 0x05 +#define MESH_IN_LABEL 0x06 +#define MESH_IN_PAUSE 0x07 +#define MESH_IN_RESUME 0x08 + +// Plaintext node->hub error codes (mirrors LIVE_ERR_* in ble_manager.h) +#define MESH_ERR_BUFFER_FULL 1 +#define MESH_ERR_BAD_MSG 4 + +class EspNowManager { +public: + enum Role : uint8_t { OFF = 0, NODE_LISTEN, NODE_JOINED, HUB }; + + // Host-bound sink for hub mode: SerialProtocol wraps the payload in + // the binary CDC framing (magic + len + CRC16) and writes it out. + typedef void (*HostSink)(uint8_t htype, const uint8_t* payload, size_t len); + + void begin(SettingsManager* settings, BLEKeyStore* keystore, + LiveKeystrokeEngine* engine, DebugLog* dlog) { + _settings = settings; + _keystore = keystore; + _engine = engine; + _dlog = dlog; + + // Identity: eFuse base MAC == WiFi STA MAC == the MAC inside the + // AES device tag the host already keys encryption by. + if (esp_efuse_mac_get_default(_myMac) != ESP_OK) { + esp_read_mac(_myMac, ESP_MAC_WIFI_STA); + } + snprintf(_deviceTag, sizeof(_deviceTag), + "%s%02X:%02X:%02X:%02X:%02X:%02X", BLE_DEVICE_TAG_PREFIX, + _myMac[0], _myMac[1], _myMac[2], + _myMac[3], _myMac[4], _myMac[5]); + + _rxQueue = xQueueCreate(32, sizeof(RxItem)); + _resumeAtBoot = LittleFS.exists(BLE_LIVE_RESUME_PATH) && + _readFlagFile(); + _instance = this; + } + + // True if power was lost mid-session: skip the boot grace and listen + // immediately so the host's session rejoins without user action. + bool resumeRequestedAtBoot() const { return _resumeAtBoot; } + void consumeResume() { _resumeAtBoot = false; } + + Role role() const { return _role; } + bool isHub() const { return _role == HUB; } + bool isRadioActive() const { return _role != OFF; } + bool nodeInSession() const { return _role == NODE_JOINED; } + bool nodeIdentify() const { return _identify; } + const char* nodeLabel() const { return _label; } + uint32_t nodeLabelVer() const { return _labelVer; } + bool nodeLagging() const { return _lagging; } + int hubNodeCount() const { return _rosterCount; } + uint32_t hubNodesVer() const { return _rosterVer; } + + const char* nodeStatusText() const { + if (_role != NODE_JOINED) return "Listening"; + if (_lagging) return "Reconnecting..."; + if (_paused) return "Muted by host"; + return _engineSawKeys ? "Receiving" : "Connected"; + } + + // ------------------------------------------------------------------ + // Role control (called from MacroPad.ino / SerialProtocol, main task) + // ------------------------------------------------------------------ + + // Idempotent: bring the radio up in passive node-listen. Safe to call + // every idle loop iteration (mirrors bleManager.startLive()). + void startNodeListen() { + if (_role == NODE_LISTEN || _role == NODE_JOINED || _role == HUB) return; + if (!_radioUp()) return; + _role = NODE_LISTEN; + _resetNodeSession(); + if (_dlog) _dlog->log("mesh: node listening"); + } + + // Tear everything down (routine starting, USB upload, etc.). + void shutdown() { + if (_role == OFF) return; + bool wasJoined = (_role == NODE_JOINED); + if (wasJoined && _engine) _engine->stop(); + _role = OFF; + _resetNodeSession(); + _radioDown(); + if (_dlog) _dlog->logf("mesh: shutdown (wasJoined=%d)", (int)wasJoined); + } + + // Enter hub mode (espnow_hub serial command). The caller is + // responsible for shutting BLE down first. + bool hubStart(HostSink sink) { + shutdown(); + if (!_radioUp()) return false; + _hostSink = sink; + _role = HUB; + _hubReset(); + _lastHostMs = millis(); + if (_dlog) _dlog->log("mesh: HUB on"); + return true; + } + + void hubStop() { + if (_role != HUB) return; + _role = OFF; + _hostSink = nullptr; + _radioDown(); + if (_dlog) _dlog->log("mesh: HUB off"); + } + + // Any traffic from the host app (binary frame or hub_ping JSON). + void notifyHostActivity() { _lastHostMs = millis(); } + + // Host pushed a complete mesh frame (H2D_SEND). Reliable DATA frames + // get cached in the retransmit ring before broadcast. + void hubSendFromHost(const uint8_t* frame, size_t len) { + if (_role != HUB || len < MESH_HDR_LEN || len > MESH_MAX_FRAME) return; + _lastHostMs = millis(); + if (frame[0] != MESH_MAGIC) return; + uint8_t type = frame[1]; + uint32_t seq = _rdU32(frame + 4); + if (type == MESH_T_DATA) { + RingSlot& slot = _ring[seq & (MESH_RING_FRAMES - 1)]; + slot.seq = seq; + slot.len = (uint16_t)len; + slot.lastRetxMs = 0; + memcpy(slot.data, frame, len); + if ((int32_t)(seq - _hubMaxSeq) > 0) _hubMaxSeq = seq; + } + _txEnqueue(_BCAST, frame, len); + } + + // ------------------------------------------------------------------ + // Main-loop tick: drain RX, run timers, pump TX + // ------------------------------------------------------------------ + void tick() { + if (_role == OFF) return; + uint32_t now = millis(); + + // Drain the WiFi-task RX queue (bounded per tick). + RxItem item; + int drained = 0; + while (drained < 8 && _rxQueue && + xQueueReceive(_rxQueue, &item, 0) == pdTRUE) { + drained++; + if (_role == HUB) _hubOnRx(item, now); + else _nodeOnRx(item, now); + } + + if (_role == HUB) { + _hubTimers(now); + } else if (_role == NODE_JOINED || _role == NODE_LISTEN) { + _nodeTimers(now); + } + + // Deferred resume-flag writes (flash is main-task-only by policy). + if (_flagWant != -1) { + int want = _flagWant; + _flagWant = -1; + _writeFlagFile(want == 1); + } + + _txPump(); + } + +private: + // ---- Wire helpers ------------------------------------------------- + static constexpr uint8_t _BCAST[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + + static uint32_t _rdU32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + } + static void _wrU32(uint8_t* p, uint32_t v) { + p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); + } + static bool _macEq(const uint8_t* a, const uint8_t* b) { + return memcmp(a, b, 6) == 0; + } + static bool _isBcast(const uint8_t* m) { + return _macEq(m, _BCAST); + } + + void _mkHdr(uint8_t* out, uint8_t type, uint8_t flags, uint32_t seq, + const uint8_t* dest) { + out[0] = MESH_MAGIC; + out[1] = type; + out[2] = flags; + out[3] = 0; + _wrU32(out + 4, seq); + memcpy(out + 8, dest, 6); + } + + // ---- Radio bring-up / teardown ------------------------------------ + bool _radioUp() { + if (_radioActive) return true; + WiFi.mode(WIFI_STA); + WiFi.disconnect(true, false); // never associate with any AP + uint8_t ch = _settings ? _settings->settings.meshChannel + : DEFAULT_MESH_CHANNEL; + esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE); + if (esp_now_init() != ESP_OK) { + WiFi.mode(WIFI_OFF); + if (_dlog) _dlog->log("mesh: esp_now_init FAILED"); + return false; + } + esp_now_register_recv_cb(&EspNowManager::_recvCbStatic); + esp_now_register_send_cb(&EspNowManager::_sendCbStatic); + _addPeer(_BCAST); + _radioActive = true; + _txInflight = false; + _txHead = _txTail = _txCount = 0; + return true; + } + + void _radioDown() { + if (!_radioActive) return; + esp_now_deinit(); + WiFi.mode(WIFI_OFF); + _radioActive = false; + _txInflight = false; + _txHead = _txTail = _txCount = 0; + if (_rxQueue) xQueueReset(_rxQueue); + } + + void _addPeer(const uint8_t* mac) { + if (esp_now_is_peer_exist(mac)) return; + esp_now_peer_info_t p = {}; + memcpy(p.peer_addr, mac, 6); + p.channel = 0; // current channel + p.ifidx = WIFI_IF_STA; + p.encrypt = false; // crypto is app-layer AES-256-GCM + esp_now_add_peer(&p); + } + + // ---- ESP-NOW callbacks (WiFi task — copy & return) ----------------- + struct RxItem { + uint8_t src[6]; + uint16_t len; + uint8_t data[MESH_MAX_FRAME]; + }; + + static void _recvCbStatic(const esp_now_recv_info_t* info, + const uint8_t* data, int len) { + EspNowManager* self = _instance; + if (!self || !self->_rxQueue) return; + if (len < MESH_HDR_LEN || len > MESH_MAX_FRAME) return; + if (data[0] != MESH_MAGIC) return; + RxItem item; + memcpy(item.src, info->src_addr, 6); + item.len = (uint16_t)len; + memcpy(item.data, data, len); + // Drop-oldest would reorder; drop-newest keeps the NACK machinery + // simple (the gap is recovered like any other loss). + xQueueSend(self->_rxQueue, &item, 0); + } + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + static void _sendCbStatic(const esp_now_send_info_t* tx_info, + esp_now_send_status_t status) { +#else + static void _sendCbStatic(const uint8_t* mac_addr, + esp_now_send_status_t status) { +#endif + EspNowManager* self = _instance; + if (self) self->_txInflight = false; + (void)status; // MAC-level only; app-level ACKs carry the truth + } + + // ---- TX queue (serialized sends; esp_now_send dislikes bursts) ----- + struct TxItem { + uint8_t mac[6]; + uint16_t len; + uint8_t data[MESH_MAX_FRAME]; + }; + static constexpr int TXQ_CAP = 24; + + void _txEnqueue(const uint8_t* mac, const uint8_t* data, size_t len) { + if (len > MESH_MAX_FRAME) return; + if (_txCount >= TXQ_CAP) { _txDrops++; return; } + TxItem& t = _txq[_txTail]; + memcpy(t.mac, mac, 6); + t.len = (uint16_t)len; + memcpy(t.data, data, len); + _txTail = (_txTail + 1) % TXQ_CAP; + _txCount++; + } + + void _txPump() { + if (_txInflight || _txCount == 0 || !_radioActive) return; + TxItem& t = _txq[_txHead]; + // Unicast peers are added lazily (nodes learn the hub's MAC from + // its first frame; the hub never unicasts except via peers that + // already wrote to it — but add defensively either way). + if (!_isBcast(t.mac)) _addPeer(t.mac); + _txInflight = true; + if (esp_now_send(t.mac, t.data, t.len) != ESP_OK) { + _txInflight = false; // dropped; reliability layer recovers + } + _txHead = (_txHead + 1) % TXQ_CAP; + _txCount--; + } + + // ================================================================== + // NODE side + // ================================================================== + + void _resetNodeSession() { + _hasSession = false; + _paused = false; + _identify = false; + _lagging = false; + _engineSawKeys = false; + _label[0] = '\0'; + _cumSeq = 0; + _highSeen = 0; + _lastMoveSeq = 0; + _authFails = 0; + _framesSinceAck = 0; + _unackedSinceMs = 0; + _gapSinceMs = 0; + _lastNackMs = 0; + _lastHubFrameMs = 0; + _beaconDueMs = 0; + for (int i = 0; i < MESH_REORDER_SLOTS; i++) _reorder[i].used = false; + } + + void _nodeOnRx(const RxItem& item, uint32_t now) { + const uint8_t* h = item.data; + uint8_t type = h[1]; + uint8_t flags = h[2]; + uint32_t seq = _rdU32(h + 4); + const uint8_t* dest = h + 8; + const uint8_t* payload = item.data + MESH_HDR_LEN; + size_t payLen = item.len - MESH_HDR_LEN; + (void)flags; + + // Only hub->node types are meaningful here; ignore other nodes' + // unicast ACK/BEACON chatter that we happen to overhear. + if (type != MESH_T_DATA && type != MESH_T_DATA_U && + type != MESH_T_JOIN && type != MESH_T_POLL) { + return; + } + + _lastHubFrameMs = now; + if (_lagging && _hasSession) _lagging = false; + + switch (type) { + case MESH_T_POLL: { + // Hub keepalive doubles as discovery: reply with a BEACON + // (jittered so 30 nodes don't collide). + memcpy(_hubMac, item.src, 6); + uint16_t jitter = _hasSession + ? (uint16_t)(_nodeIdx * 2) + : (uint16_t)(esp_random() % 40); + if (_beaconDueMs == 0) _beaconDueMs = now + jitter + 1; + break; + } + + case MESH_T_JOIN: { + if (!_isBcast(dest) && !_macEq(dest, _myMac)) return; + _nodeHandleJoin(item.src, payload, payLen, now); + break; + } + + case MESH_T_DATA: { + if (!_hasSession) return; + if (!_macEq(item.src, _hubMac)) return; + _nodeHandleData(seq, dest, payload, payLen, item.len, item.data, now); + break; + } + + case MESH_T_DATA_U: { + if (!_hasSession) return; + if (!_macEq(item.src, _hubMac)) return; + // Latest-wins lane: strictly newer move seqs only. + if ((int32_t)(seq - _lastMoveSeq) <= 0) return; + _lastMoveSeq = seq; + if (!_isBcast(dest) && !_macEq(dest, _myMac)) return; + static uint8_t plain[MESH_MAX_FRAME]; + size_t plainLen = 0; + char tag[BLE_DEVICE_TAG_MAX]; + if (!frameCryptoParse(_groupKey, payload, payLen, + tag, sizeof(tag), + plain, sizeof(plain), &plainLen)) return; + if (strcmp(tag, _hubTag) != 0) return; + _dispatchInner(plain, plainLen, now); + break; + } + } + } + + void _nodeHandleJoin(const uint8_t* src, const uint8_t* payload, + size_t payLen, uint32_t now) { + if (!_keystore || !_keystore->hasKey()) return; + static uint8_t plain[MESH_MAX_FRAME]; + size_t plainLen = 0; + char tag[BLE_DEVICE_TAG_MAX]; + if (!frameCryptoParse(_keystore->key(), payload, payLen, + tag, sizeof(tag), plain, sizeof(plain), + &plainLen)) { + return; // not for us / wrong key — silent like BLE + } + if (strcmp(tag, _deviceTag) != 0) return; + plain[plainLen] = '\0'; + + JsonDocument doc; + if (deserializeJson(doc, (const char*)plain) != DeserializationError::Ok) return; + const char* gkeyHex = doc["gkey"] | ""; + if (strlen(gkeyHex) != FRAME_KEY_LEN * 2) return; + for (size_t i = 0; i < FRAME_KEY_LEN; i++) { + char b[3] = { gkeyHex[i * 2], gkeyHex[i * 2 + 1], 0 }; + _groupKey[i] = (uint8_t)strtoul(b, nullptr, 16); + } + _sessionId = doc["sid"].as(); + _nodeIdx = doc["idx"] | 0; + uint32_t base = doc["base"] | 0; + + memcpy(_hubMac, src, 6); + snprintf(_hubTag, sizeof(_hubTag), "%s%02X:%02X:%02X:%02X:%02X:%02X", + BLE_DEVICE_TAG_PREFIX, src[0], src[1], src[2], src[3], + src[4], src[5]); + + bool rejoin = _hasSession; + _hasSession = true; + _role = NODE_JOINED; + _cumSeq = base; + _highSeen = base; + _lastMoveSeq = 0; + _authFails = 0; + _paused = false; + _lagging = false; + for (int i = 0; i < MESH_REORDER_SLOTS; i++) _reorder[i].used = false; + _lastHubFrameMs = now; + + if (_engine) _engine->start(); + _flagWant = 1; // persist live-resume flag (survives power loss) + + // JOIN_ACK back under the per-device key so the host (via the + // hub) gets cryptographic confirmation this exact device joined. + uint8_t ack[MESH_MAX_FRAME]; + _mkHdr(ack, MESH_T_JOIN_ACK, 0, base, _hubMac); + uint8_t body[24]; + size_t bn = 0; + body[bn++] = 1; // ok + body[bn++] = (uint8_t)_nodeIdx; + size_t encLen = 0; + if (frameCryptoBuild(_keystore->key(), _deviceTag, body, bn, + ack + MESH_HDR_LEN, + sizeof(ack) - MESH_HDR_LEN, &encLen)) { + _txEnqueue(_hubMac, ack, MESH_HDR_LEN + encLen); + } + if (_dlog) _dlog->logf("mesh: %sjoined idx=%d base=%lu", + rejoin ? "re" : "", _nodeIdx, + (unsigned long)base); + } + + void _nodeHandleData(uint32_t seq, const uint8_t* dest, + const uint8_t* payload, size_t payLen, + uint16_t rawLen, const uint8_t* rawFrame, + uint32_t now) { + if ((int32_t)(seq - _highSeen) > 0) _highSeen = seq; + + if ((int32_t)(seq - _cumSeq) <= 0) { + // Duplicate (retransmit we already have) — re-ACK promptly so + // the hub stops resending. + _framesSinceAck = MESH_ACK_EVERY_N; + return; + } + + if (seq == _cumSeq + 1) { + _deliverData(dest, payload, payLen, now); + _cumSeq = seq; + if (_unackedSinceMs == 0) _unackedSinceMs = now; + _framesSinceAck++; + _drainReorder(now); + if (_cumSeq >= _highSeen) _gapSinceMs = 0; + } else { + // Out of order: stash and let the NACK timer fill the gap. + int slot = -1; + for (int i = 0; i < MESH_REORDER_SLOTS; i++) { + if (_reorder[i].used && _reorder[i].seq == seq) return; // dup + if (slot < 0 && !_reorder[i].used) slot = i; + } + if (slot >= 0) { + _reorder[slot].used = true; + _reorder[slot].seq = seq; + _reorder[slot].len = rawLen; + memcpy(_reorder[slot].data, rawFrame, rawLen); + } + if (_gapSinceMs == 0) _gapSinceMs = now; + } + } + + void _drainReorder(uint32_t now) { + bool advanced = true; + while (advanced) { + advanced = false; + for (int i = 0; i < MESH_REORDER_SLOTS; i++) { + if (!_reorder[i].used) continue; + if (_reorder[i].seq == _cumSeq + 1) { + const uint8_t* f = _reorder[i].data; + _deliverData(f + 8, f + MESH_HDR_LEN, + _reorder[i].len - MESH_HDR_LEN, now); + _cumSeq++; + _framesSinceAck++; + _reorder[i].used = false; + advanced = true; + } else if ((int32_t)(_reorder[i].seq - _cumSeq) <= 0) { + _reorder[i].used = false; // stale + } + } + } + if (_cumSeq >= _highSeen) _gapSinceMs = 0; + } + + void _deliverData(const uint8_t* dest, const uint8_t* payload, + size_t payLen, uint32_t now) { + // Per-node frames still consume a seq for everyone (single shared + // stream); only the addressee decrypts. + if (!_isBcast(dest) && !_macEq(dest, _myMac)) return; + + static uint8_t plain[MESH_MAX_FRAME]; + size_t plainLen = 0; + char tag[BLE_DEVICE_TAG_MAX]; + if (!frameCryptoParse(_groupKey, payload, payLen, tag, sizeof(tag), + plain, sizeof(plain), &plainLen)) { + // Wrong group key — stale session (host restarted without a + // re-JOIN reaching us). After a few strikes drop the session + // and beacon as available; the host re-JOINs automatically. + if (++_authFails >= 3) { + if (_dlog) _dlog->log("mesh: group-key mismatch, leaving session"); + _leaveSession(false); + } + return; + } + _authFails = 0; + if (strcmp(tag, _hubTag) != 0) return; + _dispatchInner(plain, plainLen, now); + } + + // Inner live-protocol dispatch — the mesh twin of BLEManager:: + // onLiveWriteReceived. Replay/ordering/ACK live at the transport + // layer, so this only interprets content. Runs on the main task. + void _dispatchInner(const uint8_t* plain, size_t plainLen, uint32_t now) { + if (plainLen < 17) return; + uint8_t msgType = plain[0]; + // sid sanity: ignore frames from another session generation. + uint64_t sid = 0; + for (int i = 0; i < 8; i++) sid |= ((uint64_t)plain[1 + i]) << (i * 8); + if (sid != _sessionId) return; + const uint8_t* body = plain + 17; + size_t bodyLen = plainLen - 17; + + switch (msgType) { + case MESH_IN_START: + if (_engine) _engine->start(); + _engineSawKeys = false; + break; + + case MESH_IN_KEYS: { + if (_paused) break; + if (bodyLen < 1) break; + uint8_t count = body[0]; + if (bodyLen < 1u + (size_t)count * 6u) break; + bool anyDrop = false; + for (uint8_t i = 0; i < count; i++) { + const uint8_t* ev = body + 1 + i * 6; + uint32_t tMs = (uint32_t)ev[2] | ((uint32_t)ev[3] << 8) | + ((uint32_t)ev[4] << 16) | + ((uint32_t)ev[5] << 24); + if (_engine && !_engine->enqueue(ev[0], ev[1], tMs)) { + anyDrop = true; + } + } + _engineSawKeys = true; + if (anyDrop) _sendErr(MESH_ERR_BUFFER_FULL, _cumSeq); + break; + } + + case MESH_IN_MOUSE: { + if (_paused || bodyLen < 6) break; + uint16_t x = (uint16_t)body[1] | ((uint16_t)body[2] << 8); + uint16_t y = (uint16_t)body[3] | ((uint16_t)body[4] << 8); + if (_engine) { + _engine->enqueueMouse(body[0], x, y, (int8_t)body[5]); + } + break; + } + + case MESH_IN_IDENTIFY: + _identify = (bodyLen >= 1) ? (body[0] != 0) : true; + _identifyMs = now; + break; + + case MESH_IN_LABEL: { + size_t n = bodyLen; + if (n >= sizeof(_label)) n = sizeof(_label) - 1; + memcpy(_label, body, n); + _label[n] = '\0'; + _labelVer++; + break; + } + + case MESH_IN_PAUSE: + _paused = true; + // Defensive: release anything held so a muted node can't + // wedge keys down on its target machine. + if (_engine) { _engine->stop(); _engine->start(); } + break; + + case MESH_IN_RESUME: + _paused = false; + break; + + case MESH_IN_STOP: + _leaveSession(true); + break; + + default: + _sendErr(MESH_ERR_BAD_MSG, _cumSeq); + break; + } + } + + void _leaveSession(bool explicitStop) { + if (_engine) _engine->stop(); + _hasSession = false; + _paused = false; + _identify = false; + _lagging = false; + _label[0] = '\0'; + _labelVer++; + if (_role == NODE_JOINED) _role = NODE_LISTEN; + if (explicitStop) _flagWant = 0; // clean end: no power-loss resume + } + + void _nodeTimers(uint32_t now) { + // Scheduled BEACON reply (jittered) + if (_beaconDueMs != 0 && (int32_t)(now - _beaconDueMs) >= 0) { + _beaconDueMs = 0; + _sendBeacon(); + } + + if (_role != NODE_JOINED) return; + + // Cumulative ACK timer + bool ackDue = + (_framesSinceAck >= MESH_ACK_EVERY_N) || + (_unackedSinceMs != 0 && + (now - _unackedSinceMs) >= (uint32_t)(MESH_ACK_MAX_DELAY_MS + _nodeIdx * 2)); + if (ackDue) _sendAck(now); + + // Gap NACK timer + if (_gapSinceMs != 0 && (now - _gapSinceMs) >= MESH_NACK_AFTER_MS && + (now - _lastNackMs) >= MESH_NACK_REPEAT_MS) { + _sendNack(now); + } + + // Hub-silence watchdog. The hub keepalive-POLLs every second, so + // multi-second silence means we're out of range / hub gone. Stop + // emitting (release held keys), flag the LED/screen, and after a + // long quiet period drop back to plain listening (the resume flag + // stays set so a returning host re-JOINs us seamlessly). + if (_lastHubFrameMs != 0) { + uint32_t quiet = now - _lastHubFrameMs; + if (quiet > 3000 && !_lagging) { + _lagging = true; + if (_engine) { _engine->stop(); _engine->start(); } + } else if (quiet > 30000) { + if (_dlog) _dlog->log("mesh: hub silent 30s, leaving session"); + _leaveSession(false); + } + } + + // Identify safety timeout (host crashed mid-label-dialog) + if (_identify && (now - _identifyMs) > 30000) _identify = false; + } + + void _sendBeacon() { + uint8_t f[MESH_HDR_LEN + 4 + MESH_BEACON_LABEL_LEN]; + _mkHdr(f, MESH_T_BEACON, 0, 0, _hubMac); + uint8_t* b = f + MESH_HDR_LEN; + b[0] = 1; // proto version + b[1] = _boardIsLite() ? 1 : 0; + b[2] = _hasSession ? 1 : 0; + b[3] = _paused ? 1 : 0; + size_t ln = strlen(_label); + if (ln > MESH_BEACON_LABEL_LEN - 1) ln = MESH_BEACON_LABEL_LEN - 1; + memcpy(b + 4, _label, ln); + b[4 + ln] = '\0'; + _txEnqueue(_hubMac, f, MESH_HDR_LEN + 4 + ln + 1); + } + + void _sendAck(uint32_t now) { + uint8_t f[MESH_HDR_LEN + 10]; + _mkHdr(f, MESH_T_ACK, 0, _cumSeq, _hubMac); + uint8_t* b = f + MESH_HDR_LEN; + _wrU32(b, _cumSeq); + _wrU32(b + 4, _lastMoveSeq); + uint8_t flags = 0; + if (_engine && _engine->takeOverflowFlag()) flags |= 0x01; + if (_paused) flags |= 0x02; + b[8] = flags; + b[9] = (uint8_t)_nodeIdx; + _txEnqueue(_hubMac, f, MESH_HDR_LEN + 10); + _framesSinceAck = 0; + _unackedSinceMs = 0; + (void)now; + } + + void _sendNack(uint32_t now) { + // Report up to 8 missing ranges between cum+1 and highSeen, + // skipping seqs already parked in the reorder buffer. + uint8_t ranges = 0; + uint8_t f[MESH_HDR_LEN + 1 + 8 * 8]; + uint8_t* b = f + MESH_HDR_LEN + 1; + uint32_t s = _cumSeq + 1; + while (ranges < 8 && (int32_t)(_highSeen - s) >= 0) { + if (_inReorder(s)) { s++; continue; } + uint32_t from = s; + while ((int32_t)(_highSeen - s) >= 0 && !_inReorder(s)) s++; + uint32_t to = s - 1; + _wrU32(b + ranges * 8, from); + _wrU32(b + ranges * 8 + 4, to); + ranges++; + } + if (ranges == 0) { _gapSinceMs = 0; return; } + _mkHdr(f, MESH_T_NACK, 0, _cumSeq, _hubMac); + f[MESH_HDR_LEN] = ranges; + _txEnqueue(_hubMac, f, MESH_HDR_LEN + 1 + ranges * 8); + _lastNackMs = now; + } + + bool _inReorder(uint32_t seq) const { + for (int i = 0; i < MESH_REORDER_SLOTS; i++) { + if (_reorder[i].used && _reorder[i].seq == seq) return true; + } + return false; + } + + void _sendErr(uint8_t code, uint32_t refSeq) { + uint8_t f[MESH_HDR_LEN + 2]; + _mkHdr(f, MESH_T_ERR, 0, refSeq, _hubMac); + f[MESH_HDR_LEN] = code; + f[MESH_HDR_LEN + 1] = (uint8_t)_nodeIdx; + _txEnqueue(_hubMac, f, MESH_HDR_LEN + 2); + } + + bool _boardIsLite() const { + return M5.getBoard() == m5::board_t::board_M5AtomS3Lite; + } + + // ================================================================== + // HUB side + // ================================================================== + + struct RingSlot { + uint32_t seq = 0; + uint16_t len = 0; + uint32_t lastRetxMs = 0; + uint8_t data[MESH_MAX_FRAME]; + }; + + struct NodeInfo { + uint8_t mac[6]; + uint32_t cum = 0; + uint32_t moveSeq = 0; + uint32_t lastAckMs = 0; + uint32_t lastProgressMs = 0; + uint8_t flags = 0; + bool used = false; + }; + static constexpr int ROSTER_CAP = 32; + + void _hubReset() { + _hubMaxSeq = 0; + _rosterCount = 0; + _rosterVer++; + for (int i = 0; i < ROSTER_CAP; i++) _roster[i].used = false; + for (int i = 0; i < MESH_RING_FRAMES; i++) _ring[i].len = 0; + _lastAcktabMs = 0; + _lastPollMs = 0; + _pollActive = false; + } + + NodeInfo* _findNode(const uint8_t* mac, bool create) { + for (int i = 0; i < ROSTER_CAP; i++) { + if (_roster[i].used && _macEq(_roster[i].mac, mac)) return &_roster[i]; + } + if (!create) return nullptr; + for (int i = 0; i < ROSTER_CAP; i++) { + if (!_roster[i].used) { + _roster[i].used = true; + memcpy(_roster[i].mac, mac, 6); + _roster[i].cum = 0; + _roster[i].moveSeq = 0; + _roster[i].lastAckMs = millis(); + _roster[i].lastProgressMs = millis(); + _rosterCount++; + _rosterVer++; + return &_roster[i]; + } + } + return nullptr; + } + + void _hubOnRx(const RxItem& item, uint32_t now) { + const uint8_t* h = item.data; + uint8_t type = h[1]; + const uint8_t* payload = item.data + MESH_HDR_LEN; + size_t payLen = item.len - MESH_HDR_LEN; + + switch (type) { + case MESH_T_ACK: { + if (payLen < 10) return; + NodeInfo* n = _findNode(item.src, true); + if (!n) return; + uint32_t cum = _rdU32(payload); + if ((int32_t)(cum - n->cum) > 0) { + n->cum = cum; + n->lastProgressMs = now; + } + n->moveSeq = _rdU32(payload + 4); + n->flags = payload[8]; + n->lastAckMs = now; + break; + } + + case MESH_T_NACK: { + if (payLen < 1) return; + NodeInfo* n = _findNode(item.src, true); + if (n) n->lastAckMs = now; + uint8_t ranges = payload[0]; + if (payLen < 1u + (size_t)ranges * 8u) return; + for (uint8_t r = 0; r < ranges && r < 8; r++) { + uint32_t from = _rdU32(payload + 1 + r * 8); + uint32_t to = _rdU32(payload + 1 + r * 8 + 4); + // Bound the burst: a node 100+ behind recovers via + // repeated NACK rounds rather than one storm. + if (to - from > 16) to = from + 16; + for (uint32_t s = from; (int32_t)(to - s) >= 0; s++) { + _retxSeq(s, now); + } + } + break; + } + + case MESH_T_BEACON: + case MESH_T_JOIN_ACK: + case MESH_T_ERR: { + if (type == MESH_T_BEACON) { + NodeInfo* n = _findNode(item.src, true); + if (n) n->lastAckMs = now; + } + // Forward to the host: src_mac + raw frame. + if (_hostSink) { + uint8_t buf[6 + MESH_MAX_FRAME]; + memcpy(buf, item.src, 6); + memcpy(buf + 6, item.data, item.len); + _hostSink(HUB_D2H_RX, buf, 6 + item.len); + } + break; + } + + default: + break; + } + } + + void _retxSeq(uint32_t seq, uint32_t now) { + RingSlot& slot = _ring[seq & (MESH_RING_FRAMES - 1)]; + if (slot.len == 0 || slot.seq != seq) return; // evicted + if (slot.lastRetxMs != 0 && + (now - slot.lastRetxMs) < MESH_RETX_MIN_GAP_MS) return; + slot.lastRetxMs = now; + uint8_t f[MESH_MAX_FRAME]; + memcpy(f, slot.data, slot.len); + f[2] |= MESH_F_RETX; + _txEnqueue(_BCAST, f, slot.len); + } + + void _hubTimers(uint32_t now) { + // Host heartbeat: a dead/closed host app must not leave an + // orphaned hub running forever. + if ((now - _lastHostMs) > MESH_HUB_HOST_TIMEOUT_MS) { + if (_dlog) _dlog->log("mesh: host silent, hub auto-off"); + hubStop(); + return; + } + + // Keepalive / discovery POLL every second. Nodes treat ANY hub + // frame as liveness; idle nodes use it to beacon their presence. + if ((now - _lastPollMs) >= 1000) { + _lastPollMs = now; + uint8_t f[MESH_HDR_LEN + 5]; + _mkHdr(f, MESH_T_POLL, 0, 0, _BCAST); + _wrU32(f + MESH_HDR_LEN, ++_pollId); + f[MESH_HDR_LEN + 4] = _pollActive ? 1 : 0; + _txEnqueue(_BCAST, f, MESH_HDR_LEN + 5); + } + + // Stall safety: a node whose cum lags maxSeq with no progress for + // MESH_STALL_REBCAST_MS gets its next frame rebroadcast even if + // its NACKs are getting lost. + if (_hubMaxSeq != 0) { + for (int i = 0; i < ROSTER_CAP; i++) { + NodeInfo& n = _roster[i]; + if (!n.used) continue; + if ((now - n.lastAckMs) > MESH_NODE_OFFLINE_MS) continue; + if ((int32_t)(_hubMaxSeq - n.cum) > 0 && + (now - n.lastProgressMs) > MESH_STALL_REBCAST_MS) { + _retxSeq(n.cum + 1, now); + } + } + } + + // Periodic per-node ACK table up to the host. + if (_hostSink && (now - _lastAcktabMs) >= HUB_ACKTAB_PERIOD_MS) { + _lastAcktabMs = now; + uint8_t buf[1 + ROSTER_CAP * 17]; + uint8_t cnt = 0; + for (int i = 0; i < ROSTER_CAP && cnt < ROSTER_CAP; i++) { + NodeInfo& n = _roster[i]; + if (!n.used) continue; + uint8_t* e = buf + 1 + cnt * 17; + memcpy(e, n.mac, 6); + _wrU32(e + 6, n.cum); + _wrU32(e + 10, n.moveSeq); + uint32_t age = now - n.lastAckMs; + uint16_t age16 = (age > 0xFFFF) ? 0xFFFF : (uint16_t)age; + e[14] = (uint8_t)age16; + e[15] = (uint8_t)(age16 >> 8); + e[16] = n.flags; + cnt++; + } + buf[0] = cnt; + _hostSink(HUB_D2H_ACKTAB, buf, 1 + (size_t)cnt * 17); + } + } + +public: + // Host-driven discovery toggle (mesh_poll command): when on, the + // keepalive POLL asks idle nodes to beacon. + void hubSetPollActive(bool on) { _pollActive = on; } + +private: + // ---- Wiring ---- + SettingsManager* _settings = nullptr; + BLEKeyStore* _keystore = nullptr; + LiveKeystrokeEngine* _engine = nullptr; + DebugLog* _dlog = nullptr; + static EspNowManager* _instance; + + // ---- Identity ---- + uint8_t _myMac[6] = {0}; + char _deviceTag[BLE_DEVICE_TAG_MAX] = {0}; + + // ---- Role / radio ---- + volatile Role _role = OFF; + bool _radioActive = false; + QueueHandle_t _rxQueue = nullptr; + + // ---- TX ---- + TxItem _txq[TXQ_CAP]; + int _txHead = 0, _txTail = 0; + volatile int _txCount = 0; + volatile bool _txInflight = false; + uint32_t _txDrops = 0; + + // ---- Node session ---- + bool _hasSession = false; + uint64_t _sessionId = 0; + uint8_t _groupKey[FRAME_KEY_LEN] = {0}; + uint8_t _hubMac[6] = {0}; + char _hubTag[BLE_DEVICE_TAG_MAX] = {0}; + int _nodeIdx = 0; + bool _paused = false; + bool _identify = false; + uint32_t _identifyMs = 0; + bool _lagging = false; + bool _engineSawKeys = false; + char _label[40] = {0}; + uint32_t _labelVer = 0; + int _authFails = 0; + + uint32_t _cumSeq = 0; + uint32_t _highSeen = 0; + uint32_t _lastMoveSeq = 0; + int _framesSinceAck = 0; + uint32_t _unackedSinceMs = 0; + uint32_t _gapSinceMs = 0; + uint32_t _lastNackMs = 0; + uint32_t _lastHubFrameMs = 0; + uint32_t _beaconDueMs = 0; + + struct ReorderSlot { + bool used = false; + uint32_t seq = 0; + uint16_t len = 0; + uint8_t data[MESH_MAX_FRAME]; + }; + ReorderSlot _reorder[MESH_REORDER_SLOTS]; + + // ---- Hub state ---- + HostSink _hostSink = nullptr; + RingSlot _ring[MESH_RING_FRAMES]; + NodeInfo _roster[ROSTER_CAP]; + int _rosterCount = 0; + uint32_t _rosterVer = 0; + uint32_t _hubMaxSeq = 0; + uint32_t _lastHostMs = 0; + uint32_t _lastAcktabMs = 0; + uint32_t _lastPollMs = 0; + uint32_t _pollId = 0; + bool _pollActive = false; + + // ---- Live-resume flag (same file the BLE path used) ---- + bool _resumeAtBoot = false; + volatile int _flagWant = -1; + + bool _readFlagFile() { + File f = LittleFS.open(BLE_LIVE_RESUME_PATH, "r"); + if (!f) return false; + int c = f.read(); + f.close(); + return c == '1'; + } + void _writeFlagFile(bool on) { + File f = LittleFS.open(BLE_LIVE_RESUME_PATH, "w"); + if (!f) return; + f.write(on ? '1' : '0'); + f.close(); + } +}; + +inline EspNowManager* EspNowManager::_instance = nullptr; diff --git a/firmware/MacroPad/frame_crypto.h b/firmware/MacroPad/frame_crypto.h new file mode 100644 index 0000000..44ebc18 --- /dev/null +++ b/firmware/MacroPad/frame_crypto.h @@ -0,0 +1,110 @@ +#pragma once + +// Shared AES-256-GCM frame envelope — the single wire format used by the +// BLE variables/live channels AND the ESP-NOW mesh payloads. Mirrors +// ble_frame.py on the host exactly: +// +// [0] tag_len +// [1..tag_len] device tag, e.g. "M5Stack|AA:BB:CC:DD:EE:FF" (also AAD) +// [+12] nonce (random, per frame) +// [...] ciphertext + 16-byte GCM tag +// +// Extracted from BLEManager so the mesh layer can encrypt under a +// per-session group key while BLE keeps using the per-device key — the +// only difference between the callers is which 32-byte key they pass in. + +#include +#include +#include +#include "config.h" + +static constexpr size_t FRAME_NONCE_LEN = 12; +static constexpr size_t FRAME_GCM_TAG_LEN = 16; +static constexpr size_t FRAME_KEY_LEN = 32; // AES-256 + +// Encrypt `plaintext` under `key` with `tag` as both header and AAD. +inline bool frameCryptoBuild(const uint8_t* key, const char* tag, + const uint8_t* plaintext, size_t plainLen, + uint8_t* out, size_t outCap, size_t* outLen) { + if (!key || !tag) return false; + size_t tagStrLen = strlen(tag); + if (tagStrLen == 0 || tagStrLen > 255) return false; + + size_t total = 1 + tagStrLen + FRAME_NONCE_LEN + plainLen + FRAME_GCM_TAG_LEN; + if (total > outCap) return false; + + out[0] = (uint8_t)tagStrLen; + memcpy(out + 1, tag, tagStrLen); + uint8_t* nonce = out + 1 + tagStrLen; + uint8_t* ct = nonce + FRAME_NONCE_LEN; + uint8_t* gcmTag = ct + plainLen; + // 12-byte nonce sourced from esp_random (CSPRNG). + for (size_t i = 0; i < FRAME_NONCE_LEN; i += 4) { + uint32_t r = esp_random(); + for (size_t b = 0; b < 4 && i + b < FRAME_NONCE_LEN; b++) { + nonce[i + b] = (uint8_t)(r >> (b * 8)); + } + } + + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + int rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, + FRAME_KEY_LEN * 8); + if (rc == 0) { + rc = mbedtls_gcm_crypt_and_tag( + &gcm, MBEDTLS_GCM_ENCRYPT, plainLen, + nonce, FRAME_NONCE_LEN, + (const uint8_t*)tag, tagStrLen, + plaintext, ct, + FRAME_GCM_TAG_LEN, gcmTag); + } + mbedtls_gcm_free(&gcm); + if (rc != 0) return false; + *outLen = total; + return true; +} + +// Verify and decrypt an inbound frame under `key`. Writes the recovered +// tag (NUL-terminated) and plaintext into the caller's buffers. Returns +// false silently on any malformed or auth-failed input. +inline bool frameCryptoParse(const uint8_t* key, + const uint8_t* in, size_t inLen, + char* outTag, size_t outTagCap, + uint8_t* outPlain, size_t outPlainCap, + size_t* outPlainLen) { + if (!key) return false; + if (inLen < 1 + FRAME_NONCE_LEN + FRAME_GCM_TAG_LEN) return false; + size_t tagLen = in[0]; + if (tagLen == 0 || tagLen >= outTagCap) return false; + if (inLen < 1 + tagLen + FRAME_NONCE_LEN + FRAME_GCM_TAG_LEN) return false; + memcpy(outTag, in + 1, tagLen); + outTag[tagLen] = '\0'; + // Reject anything not starting with our prefix early so we don't + // burn cycles on adversarial input. + if (strncmp(outTag, BLE_DEVICE_TAG_PREFIX, + sizeof(BLE_DEVICE_TAG_PREFIX) - 1) != 0) { + return false; + } + + const uint8_t* nonce = in + 1 + tagLen; + size_t ctLen = inLen - 1 - tagLen - FRAME_NONCE_LEN - FRAME_GCM_TAG_LEN; + if (ctLen >= outPlainCap) return false; + const uint8_t* ct = nonce + FRAME_NONCE_LEN; + const uint8_t* gcmTag = ct + ctLen; + + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + int rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, + FRAME_KEY_LEN * 8); + if (rc == 0) { + rc = mbedtls_gcm_auth_decrypt(&gcm, ctLen, + nonce, FRAME_NONCE_LEN, + (const uint8_t*)outTag, tagLen, + gcmTag, FRAME_GCM_TAG_LEN, + ct, outPlain); + } + mbedtls_gcm_free(&gcm); + if (rc != 0) return false; + *outPlainLen = ctLen; + return true; +} diff --git a/firmware/MacroPad/led_ui.h b/firmware/MacroPad/led_ui.h new file mode 100644 index 0000000..206c184 --- /dev/null +++ b/firmware/MacroPad/led_ui.h @@ -0,0 +1,380 @@ +#pragma once + +// LedUI — RGB-LED status feedback for screenless boards (AtomS3 Lite). +// +// The universal firmware binary runs on both the AtomS3 (128x128 LCD) and +// the AtomS3 Lite (no LCD, one SK6812 RGB LED on GPIO 35, driven by +// M5Unified's M5.Led which M5.begin() wires up automatically from the +// board pin table). DisplayUI gates every screen call on the Lite and +// forwards a semantic state here instead, so macro_engine.h and +// MacroPad.ino never have to know which board they're on. +// +// Design rules: +// +// 1. Non-blocking. tick() is called every main-loop iteration and +// computes the LED color from millis(); no delay() anywhere. The +// strip is only rewritten when the computed color changes (an RMT +// refresh per loop would be pure waste). +// +// 2. Idempotent state setters. Many display calls repaint every engine +// tick (showDelayProgress, showPauseScreen, showFailScreen). Setting +// the same base pattern again must NOT reset the blink phase, or the +// LED would freeze at "on". setBase() compares against the current +// pattern and keeps the phase when nothing changed. +// +// 3. Base + overlay. The base pattern is the persistent state (idle +// color, executing, live mode). An overlay is a short transient +// (click flash, position burst, alive-check result) that plays once +// and reveals the base again. Overlays never change the base. +// +// LED vocabulary (see the project README for the user-facing table). Every +// state is a distinct (color, motion) pair; smooth crossfades mark the calm +// "what am I / who has me" states, sharper blinks and bursts mark action: +// boot white single pulse +// idle — BLE soft white<->blue crossfade (transport at rest) +// idle — mesh soft white<->amber crossfade (transport at rest) +// ...both: a click adds a white flash + N-blink slot count +// mode switched triple burst in the new accent (blue BLE / amber mesh) +// executing steady green; typing ramps brightness with progress; +// delay nodes breathe green +// pause steady yellow (untimed) / yellow blink, 1 Hz +// accelerating to 4 Hz in the last 3 s (timed) +// branch selector magenta burst, count = selected choice + 1, repeating +// loop selector orange burst, count = current value (capped at 10) +// error red triple-blink repeating +// fail wait red blink (steady red if paused) +// live joined slow cyan breathe (heartbeat); keystrokes = white flicker +// reconnecting cyan 1 Hz blink (seeking the host) +// lagging/lost hub cyan/red 2 Hz alternating +// identify fast blue/white strobe (which physical unit is this) +// hub mode steady purple +// BLE variables blue breathe (on-demand exchange inside a routine) +// host probe fast white blink; waiting-on-user = slow white blink + +#include + +class LedUI { +public: + void begin(bool enabled) { + _enabled = enabled; + if (!_enabled) return; + M5.Led.setBrightness(255); // we scale in software per-pattern + setBase(Mode::OFF, 0, 0, 0); + } + + bool enabled() const { return _enabled; } + + // Palette (0xRRGGBB) — one place to tune the whole vocabulary. Each hue + // owns a phase: green = running, red = failure, yellow = pause, magenta = + // branch, orange = loop, cyan = live session, purple = hub, and the two + // transport accents (blue = BLE, amber = mesh) morph out of white. + static constexpr uint32_t COL_WHITE = 0xFFFFFF; + static constexpr uint32_t COL_BLE = 0x0060FF; // BLE transport accent + static constexpr uint32_t COL_MESH = 0xFFB000; // ESP-NOW mesh accent (amber) + static constexpr uint32_t COL_GREEN = 0x00FF00; // executing / running + static constexpr uint32_t COL_RED = 0xFF0000; // error / failure + static constexpr uint32_t COL_YELLOW = 0xFFDD00; // pause node + static constexpr uint32_t COL_MAGENTA = 0xFF00FF; // branch selector + static constexpr uint32_t COL_ORANGE = 0xFF6000; // loop selector + static constexpr uint32_t COL_CYAN = 0x00E0FF; // live session (joined) + static constexpr uint32_t COL_PURPLE = 0x9000FF; // hub mode + + // Called every main-loop iteration. Cheap when nothing changes. + void tick() { + if (!_enabled) return; + uint32_t now = millis(); + + uint32_t rgb; + if (_ovActive) { + if (now - _ovStartMs >= _ovDurationMs) { + _ovActive = false; + rgb = _baseColorAt(now); + } else { + rgb = _patternColorAt(_ov, now - _ovStartMs); + } + } else { + rgb = _baseColorAt(now); + } + + if (rgb != _lastWritten) { + _lastWritten = rgb; + M5.Led.setAllColor((uint8_t)(rgb >> 16), (uint8_t)(rgb >> 8), + (uint8_t)rgb); + } + } + + // --------------------------------------------------------------------- + // Semantic states (all no-ops when disabled) + // --------------------------------------------------------------------- + + void off() { setBase(Mode::OFF, 0, 0, 0); } + + void boot() { + setBase(Mode::OFF, 0, 0, 0); + overlayPulse(0xFFFFFF, 35, 800); + } + + // Idle selector / listening. The persistent base is a soft crossfade in + // the device's live transport: white<->blue = BLE, white<->amber = ESP-NOW + // mesh, so a resting headless node shows which mode it's in at a glance. + // A click/redraw still plays a white flash + position burst so the user + // can count which slot they're on. + void macroSelector(int displayIdx, bool bleMode) { + if (!_enabled) return; + setBase(Mode::FADE, COL_WHITE, 45, 2600, 0, + bleMode ? COL_BLE : COL_MESH); + int blinks = (displayIdx % 5) + 1; + overlayBurst(COL_WHITE, 70, blinks, 90, 120); + } + + // Running a routine: steady green. typing() brightens it with progress + // (dim -> bright as the string types); a delay node breathes it. All three + // are "green = running", distinguished by motion. + void executing() { setBase(Mode::STEADY, COL_GREEN, 60); } + void typing(int charIdx, int len) { + if (len < 1) len = 1; + if (charIdx < 0) charIdx = 0; + if (charIdx >= len) charIdx = len - 1; + uint8_t scale = 20 + (uint8_t)((80 * charIdx) / len); + setBase(Mode::STEADY, COL_GREEN, scale); + } + void breathe() { setBase(Mode::BREATHE, COL_GREEN, 60, 2000); } + + // Pause node: yellow. Steady = untimed (waiting on a click); blink that + // accelerates as the timer runs out = timed. + void pauseScreen(bool timed, uint32_t remainMs) { + if (!timed) { + setBase(Mode::STEADY, COL_YELLOW, 60); + } else if (remainMs > 3000) { + setBase(Mode::BLINK, COL_YELLOW, 60, 500, 500); + } else { + setBase(Mode::BLINK, COL_YELLOW, 60, 125, 125); + } + } + + // Selectors count with blink-bursts: magenta = branch choice, orange = + // loop value. Both are the burst count + 1s gap, repeating. + void branchSelector(int selectedIdx) { + if (selectedIdx < 0) selectedIdx = 0; + setBaseBurst(COL_MAGENTA, 60, (uint8_t)(selectedIdx + 1), 1000); + } + + void iterationBranch(int pathIdx) { + if (!_enabled) return; + overlayBurst(COL_MAGENTA, 60, (uint8_t)((pathIdx < 0 ? 0 : pathIdx) + 1), + 120, 150); + } + + void loopSelector(int value) { + if (value < 1) value = 1; + if (value > 10) value = 10; + setBaseBurst(COL_ORANGE, 60, (uint8_t)value, 1000); + } + + // Error = red triple-blink. Resume countdown = fast green blink ("about to + // auto-run a routine; hold to cancel") — green, not yellow, so it can't be + // mistaken for a timed pause. + void errorPattern() { setBaseBurst(COL_RED, 80, 3, 700); } + void resumeCountdown(){ setBase(Mode::BLINK, COL_GREEN, 70, 150, 150); } + + // Live-session states, all cyan-based and told apart by motion: + // reconnecting cyan blink (seeking the host after a power loss) + // liveIdle slow cyan breathe (joined & ready — a live "heartbeat") + // lagging cyan<->red alt (in a session but losing the hub) + void reconnecting() { setBase(Mode::BLINK, COL_CYAN, 55, 500, 500); } + void liveIdle() { setBase(Mode::BREATHE, COL_CYAN, 45, 3200); } + void lagging() { setBase(Mode::ALT, COL_CYAN, 60, 250, 250, COL_RED); } + + // Identify ("which physical unit is this?") — a fast, deliberate + // white<->blue strobe, unmistakable against the slow BLE idle fade. + void identify() { setBase(Mode::ALT, COL_BLE, 70, 160, 160, COL_WHITE); } + + void hubMode() { setBase(Mode::STEADY, COL_PURPLE, 60); } + + // Live-transport toggle confirmation on a screenless Lite: a triple burst + // in the NEW mode's accent (blue = BLE, amber = mesh), matching the idle + // crossfade the node will now rest in. + void modeSwitch(bool ble) { + setBaseBurst(ble ? COL_BLE : COL_MESH, 75, 3, 500); + } + + // White blinks: slow = waiting on the user (RS232/pause prompts), + // fast = actively probing the host (Num Lock alive check). + void waiting() { setBase(Mode::BLINK, COL_WHITE, 45, 500, 500); } + void probe() { setBase(Mode::BLINK, COL_WHITE, 50, 100, 100); } + + // On-demand BLE variables exchange inside a routine: a blue breathe + // ("working on Bluetooth") — distinct from the BLE idle white<->blue fade. + void bleStatus() { setBase(Mode::BREATHE, COL_BLE, 50, 1400); } + + void failWait(bool paused) { + if (paused) setBase(Mode::STEADY, COL_RED, 40); + else setBase(Mode::BLINK, COL_RED, 60, 250, 250); + } + + // Short white flicker over the live base — played as keystrokes drain so + // the user can see traffic flowing on a screenless node. + void liveActivity() { overlayPulse(COL_WHITE, 60, 30); } + + // Generic per-node transient (key combo, mouse, media key, sub-call...) + void activityPulse(uint16_t color565) { overlayPulse(from565(color565), 60, 90); } + + void aliveResult(bool ok) { + overlayBurst(ok ? COL_GREEN : COL_RED, 80, 2, 100, 120); + } + + // showMessage mapping: red = persistent error pattern, anything else a + // steady dim tint (covers "No Macros", boot status text, etc.). + void message(uint16_t color565) { + if (color565 == TFT_RED) errorPattern(); + else setBase(Mode::STEADY, from565(color565), 35); + } + +private: + enum class Mode : uint8_t { OFF, STEADY, BLINK, ALT, BURST, BREATHE, FADE }; + + struct Pattern { + Mode mode = Mode::OFF; + uint32_t rgb = 0; // primary color, 0xRRGGBB + uint32_t rgb2 = 0; // ALT second color + uint8_t scale = 100; // brightness percent + uint16_t onMs = 0; // BLINK/ALT phase length; BREATHE period + uint16_t offMs = 0; + uint8_t count = 0; // BURST blink count + uint16_t gapMs = 0; // BURST gap after the blinks + }; + + bool _enabled = false; + Pattern _base; + uint32_t _baseStartMs = 0; + Pattern _ov; + bool _ovActive = false; + uint32_t _ovStartMs = 0; + uint32_t _ovDurationMs = 0; + uint32_t _lastWritten = 0xFFFFFFFF; // sentinel forces first write + + static uint32_t from565(uint16_t c) { + uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3); + uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2); + uint8_t b = (uint8_t)((c & 0x1F) << 3); + return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b; + } + + static uint32_t scaleRgb(uint32_t rgb, uint8_t pct) { + uint8_t r = (uint8_t)((((rgb >> 16) & 0xFF) * pct) / 100); + uint8_t g = (uint8_t)((((rgb >> 8) & 0xFF) * pct) / 100); + uint8_t b = (uint8_t)(((rgb & 0xFF) * pct) / 100); + return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b; + } + + // Linear per-channel blend: f=0 -> c1, f=100 -> c2. Used by FADE for a + // smooth crossfade between two colors (e.g. white <-> blue). + static uint32_t mix(uint32_t c1, uint32_t c2, uint32_t f) { + if (f > 100) f = 100; + uint32_t g = 100 - f; + uint8_t r = (uint8_t)((((c1 >> 16) & 0xFF) * g + ((c2 >> 16) & 0xFF) * f) / 100); + uint8_t gr = (uint8_t)((((c1 >> 8) & 0xFF) * g + ((c2 >> 8) & 0xFF) * f) / 100); + uint8_t b = (uint8_t)(((c1 & 0xFF) * g + (c2 & 0xFF) * f) / 100); + return ((uint32_t)r << 16) | ((uint32_t)gr << 8) | b; + } + + static bool samePattern(const Pattern& a, const Pattern& b) { + return a.mode == b.mode && a.rgb == b.rgb && a.rgb2 == b.rgb2 && + a.scale == b.scale && a.onMs == b.onMs && a.offMs == b.offMs && + a.count == b.count && a.gapMs == b.gapMs; + } + + void setBase(Mode mode, uint32_t rgb, uint8_t scale, + uint16_t onMs = 0, uint16_t offMs = 0, uint32_t rgb2 = 0) { + if (!_enabled) return; + Pattern p; + p.mode = mode; p.rgb = rgb; p.rgb2 = rgb2; p.scale = scale; + p.onMs = onMs; p.offMs = offMs; + if (samePattern(p, _base)) return; // keep blink phase + _base = p; + _baseStartMs = millis(); + } + + void setBaseBurst(uint32_t rgb, uint8_t scale, uint8_t count, uint16_t gapMs) { + if (!_enabled) return; + Pattern p; + p.mode = Mode::BURST; p.rgb = rgb; p.scale = scale; + p.onMs = 120; p.offMs = 150; p.count = count; p.gapMs = gapMs; + if (samePattern(p, _base)) return; + _base = p; + _baseStartMs = millis(); + } + + void overlayPulse(uint32_t rgb, uint8_t scale, uint16_t durMs) { + if (!_enabled) return; + _ov.mode = Mode::STEADY; _ov.rgb = rgb; _ov.scale = scale; + _ovActive = true; + _ovStartMs = millis(); + _ovDurationMs = durMs; + } + + void overlayBurst(uint32_t rgb, uint8_t scale, uint8_t count, + uint16_t onMs, uint16_t offMs) { + if (!_enabled) return; + _ov.mode = Mode::BURST; _ov.rgb = rgb; _ov.scale = scale; + _ov.onMs = onMs; _ov.offMs = offMs; _ov.count = count; _ov.gapMs = 0; + _ovActive = true; + _ovStartMs = millis(); + _ovDurationMs = (uint32_t)count * (onMs + offMs); + } + + uint32_t _baseColorAt(uint32_t now) { + return _patternColorAt(_base, now - _baseStartMs); + } + + uint32_t _patternColorAt(const Pattern& p, uint32_t t) { + switch (p.mode) { + case Mode::OFF: + return 0; + case Mode::STEADY: + return scaleRgb(p.rgb, p.scale); + case Mode::BLINK: { + uint32_t period = (uint32_t)p.onMs + p.offMs; + if (period == 0) return scaleRgb(p.rgb, p.scale); + return (t % period) < p.onMs ? scaleRgb(p.rgb, p.scale) : 0; + } + case Mode::ALT: { + uint32_t period = (uint32_t)p.onMs + p.offMs; + if (period == 0) return scaleRgb(p.rgb, p.scale); + return (t % period) < p.onMs ? scaleRgb(p.rgb, p.scale) + : scaleRgb(p.rgb2, p.scale); + } + case Mode::BURST: { + uint32_t blinkLen = (uint32_t)p.onMs + p.offMs; + uint32_t period = (uint32_t)p.count * blinkLen + p.gapMs; + if (period == 0) return 0; + uint32_t ph = t % period; + if (ph >= (uint32_t)p.count * blinkLen) return 0; // gap + return (ph % blinkLen) < p.onMs ? scaleRgb(p.rgb, p.scale) : 0; + } + case Mode::BREATHE: { + // Triangle wave between 10% and the pattern's scale. + uint32_t period = p.onMs ? p.onMs : 2000; + uint32_t ph = t % period; + uint32_t half = period / 2; + uint32_t frac100 = (ph < half) ? (ph * 100) / half + : ((period - ph) * 100) / half; + uint8_t lo = 10; + uint8_t span = (p.scale > lo) ? (p.scale - lo) : 0; + uint8_t s = lo + (uint8_t)((span * frac100) / 100); + return scaleRgb(p.rgb, s); + } + case Mode::FADE: { + // Smooth crossfade rgb <-> rgb2 on a triangle wave (period in + // onMs). Constant brightness (scale) — only the hue morphs. + uint32_t period = p.onMs ? p.onMs : 2600; + uint32_t ph = t % period; + uint32_t half = period / 2; + uint32_t f = (ph < half) ? (ph * 100) / half + : ((period - ph) * 100) / half; + return scaleRgb(mix(p.rgb, p.rgb2, f), p.scale); + } + } + return 0; + } +}; diff --git a/firmware/MacroPad/live_keystroke.h b/firmware/MacroPad/live_keystroke.h new file mode 100644 index 0000000..f8404ae --- /dev/null +++ b/firmware/MacroPad/live_keystroke.h @@ -0,0 +1,250 @@ +#pragma once + +// LiveKeystrokeEngine — device-side queue and dispatcher for the +// "host streams keystrokes via BLE during macro recording" feature. +// +// Design rules: +// +// 1. BLE write callback runs on the NimBLE host task. It must NOT +// touch USB HID directly — radio contention with TinyUSB causes +// panics on ESP32-S3 (the very issue this whole subsystem is +// designed around). The callback only enqueues parsed events. +// +// 2. The main loop calls drainQueue() each iteration. The HID +// critical-section gate in usb_hid.h means BLE polling is +// already deferred while keystrokes emit, so by the time we +// get here it's safe to take over the USB radio. +// +// 3. pressRaw / releaseRaw are wrapped in beginCritical/endCritical +// so the main loop's gating logic observes the in-flight HID +// operation and defers BLE work — the existing protection +// pattern, reused. +// +// 4. Queue is small and fixed-size; if full we set a flag the +// BLE manager exposes as an ERROR notify back to the host. + +#include +#include +#include "usb_hid.h" + +class LiveKeystrokeEngine { +public: + static constexpr int QUEUE_CAP = 256; + // Fixed device-side replay buffer. The host sends each event with a + // host-monotonic timestamp; we emit at host_t + this offset so BLE + // jitter is absorbed and the cadence the user typed on the host is + // reproduced exactly on the target USB HID side. + static constexpr uint32_t REPLAY_BUFFER_MS = 100; + + struct Event { + uint8_t action; // 0 = down, 1 = up + uint8_t hid; // raw USB HID usage code + uint32_t scheduledMs; // device millis() when this event should + // emit. Computed at enqueue time from the + // host's relative timestamp and the + // session anchor. + }; + + void begin(HIDController* hid) { _hid = hid; } + + // Start/stop are called from the BLE manager when the host sends + // START / STOP control frames (or from MacroPad.ino on emergency + // exits). idempotent. + void start() { + portENTER_CRITICAL(&_mux); + _head = _tail = _count = 0; + _overflow = false; + _anchorSet = false; + _active = true; + portEXIT_CRITICAL(&_mux); + } + + void stop() { + portENTER_CRITICAL(&_mux); + _active = false; + _head = _tail = _count = 0; + _anchorSet = false; + // Release any held mouse buttons defensively — queue a buttons=0 + // report for the main loop to emit (don't touch USB HID here, this + // can run on the NimBLE callback task). + _mouseButtons = 0; + _mouseWheel = 0; + _mousePending = true; + portEXIT_CRITICAL(&_mux); + // releaseAll is best-effort defensive cleanup. Wrap in + // critical so the BLE poll gate observes it even though we + // ourselves are already on the main task here. + if (_hid) { + _hid->beginCritical(); + _hid->keyboard.releaseAll(); + _hid->endCritical(); + } + } + + bool isActive() const { + // Volatile read, no mutex — _active is set under mutex but + // read-only here. Worst case: one extra loop iteration. + return _active; + } + + // Enqueue one event with the host's relative timestamp. Called from + // the NimBLE host task — keep it fast. Returns false if the queue + // is full (caller should ERROR-notify the host). + // + // We anchor on the first event of the session: _anchorMs becomes + // the device-millis() value that corresponds to host_t = 0. Each + // event's scheduledMs is then _anchorMs + host_t_ms + REPLAY_BUFFER_MS, + // which preserves the host's typing cadence and adds a small jitter + // buffer so events that arrive slightly out of cadence still emit + // smoothly. + bool enqueue(uint8_t action, uint8_t hid_code, uint32_t host_t_ms) { + bool ok = false; + portENTER_CRITICAL(&_mux); + if (_active && _count < QUEUE_CAP) { + if (!_anchorSet) { + // First event of the session: pin the anchor so this + // event's scheduled time is exactly now + buffer. + _anchorMs = millis() - host_t_ms; + _anchorSet = true; + } + _q[_tail].action = action; + _q[_tail].hid = hid_code; + _q[_tail].scheduledMs = _anchorMs + host_t_ms + REPLAY_BUFFER_MS; + _tail = (_tail + 1) % QUEUE_CAP; + _count++; + ok = true; + } else if (!_active) { + // not live; signal NOT_LIVE_MODE upstream + } else { + _overflow = true; + } + portEXIT_CRITICAL(&_mux); + return ok; + } + + // Called from the main loop when !hid->isCritical(). Drains events + // whose scheduled time has passed; events with future scheduledMs + // stay in the queue so the host's typing cadence is preserved on + // emission. Up to maxPerTick events per call so a flood doesn't + // starve other main-loop work. + // + // We must not delay() here — the main loop owns timing for the + // 3-second button-hold exit, button polling, BLE callback flush. + // Anything not ready yet stays queued until the next loop iter. + // Returns the number of events emitted (drives the activity flicker + // on the screenless AtomS3 Lite). + int drainQueue(int maxPerTick = 32) { + if (!_hid || !_active) return 0; + if (_count == 0) return 0; + + uint32_t now = millis(); + + // Peek head first — if it's not ready, nothing else is either + // (events are enqueued in monotonic schedule order). + bool headReady = false; + portENTER_CRITICAL(&_mux); + if (_count > 0) { + headReady = (int32_t)(_q[_head].scheduledMs - now) <= 0; + } + portEXIT_CRITICAL(&_mux); + if (!headReady) return 0; + + _hid->beginCritical(); + int emitted = 0; + while (emitted < maxPerTick) { + Event ev; + bool got = false; + portENTER_CRITICAL(&_mux); + if (_count > 0 && (int32_t)(_q[_head].scheduledMs - now) <= 0) { + ev = _q[_head]; + _head = (_head + 1) % QUEUE_CAP; + _count--; + got = true; + } + portEXIT_CRITICAL(&_mux); + if (!got) break; + if (ev.action == 0) { + _hid->keyboard.pressRaw(ev.hid); + } else { + _hid->keyboard.releaseRaw(ev.hid); + } + emitted++; + } + _hid->endCritical(); + return emitted; + } + + // ---- Absolute mouse (BT Keyboard trackpad) ---- + // + // Mouse state is kept separate from the keystroke queue and is NOT + // cadence-buffered — the trackpad wants low latency. We coalesce: only + // the latest position/buttons matter, and wheel ticks accumulate. The + // main loop drains the latest state with drainMouse(). Because absolute + // positions are self-correcting, dropping intermediate moves is fine. + // Callable from the NimBLE task — no USB HID here, just state under the + // spinlock. + void enqueueMouse(uint8_t buttons, uint16_t x, uint16_t y, int8_t wheel) { + portENTER_CRITICAL(&_mux); + _mouseButtons = buttons; + _mouseX = x; + _mouseY = y; + _mouseWheel += wheel; + _mousePending = true; + portEXIT_CRITICAL(&_mux); + } + + // Called from the main loop (outside the HID-critical window). Emits the + // latest absolute pointer report if one is pending. No delay/scheduling. + void drainMouse() { + if (!_hid) return; + bool pending; + uint8_t buttons; + uint16_t x, y; + int wheel; + portENTER_CRITICAL(&_mux); + pending = _mousePending; + buttons = _mouseButtons; + x = _mouseX; + y = _mouseY; + wheel = _mouseWheel; + _mousePending = false; + _mouseWheel = 0; + portEXIT_CRITICAL(&_mux); + if (!pending) return; + int8_t w = (wheel > 127) ? 127 : (wheel < -127 ? -127 : (int8_t)wheel); + _hid->absMouseReport(buttons, x, y, w); + } + + // Drained by the BLE manager when it builds the next status frame. + bool takeOverflowFlag() { + bool was; + portENTER_CRITICAL(&_mux); + was = _overflow; + _overflow = false; + portEXIT_CRITICAL(&_mux); + return was; + } + +private: + HIDController* _hid = nullptr; + Event _q[QUEUE_CAP]; + int _head = 0; + int _tail = 0; + int _count = 0; + volatile bool _active = false; + volatile bool _overflow = false; + // Anchor mapping host_t=0 to a specific device millis() value. + // Set on the first event of each session so subsequent events can + // schedule emissions relative to the host's typing cadence. + bool _anchorSet = false; + uint32_t _anchorMs = 0; + + // Absolute mouse state (coalesced; see enqueueMouse/drainMouse). + volatile bool _mousePending = false; + uint8_t _mouseButtons = 0; + uint16_t _mouseX = 0; + uint16_t _mouseY = 0; + int _mouseWheel = 0; + + portMUX_TYPE _mux = portMUX_INITIALIZER_UNLOCKED; +}; diff --git a/firmware/MacroPad/macro_engine.h b/firmware/MacroPad/macro_engine.h new file mode 100644 index 0000000..a2155c7 --- /dev/null +++ b/firmware/MacroPad/macro_engine.h @@ -0,0 +1,1908 @@ +#pragma once + +#include +#include +#include "config.h" +#include "usb_hid.h" +#include "display_ui.h" +#include "macro_storage.h" +#include "ble_manager.h" +#include "settings.h" +#include "rs232_util.h" + +enum EngineState { + ENGINE_IDLE, + ENGINE_EXECUTING, + ENGINE_PAUSED, + ENGINE_BRANCHING, + ENGINE_DELAY, + ENGINE_BLE_WAITING, // waiting for variables to arrive (with timeout) + ENGINE_BLE_WAIT, // variables never arrived, waiting for user button press to skip + ENGINE_RS232_WAITING, // waiting for expected RS232 response (with timeout) + ENGINE_LOOP_SELECTING, // waiting for user to pick a count via the Loop Selector node + ENGINE_LOOP_START_SELECTING, // waiting for user to pick the starting iteration (phase 2) + ENGINE_GET_LOCAL_FAIL, // get_local failed twice — FAIL screen up, 10s countdown + ENGINE_GET_LOCAL_FAIL_PAUSED // user tapped main button — countdown frozen, wait for retry tap +}; + +class MacroEngine { +public: + EngineState state = ENGINE_IDLE; + + void begin(HIDController* hid, DisplayUI* display, MacroStorage* storage, + SettingsManager* settingsMgr = nullptr, HardwareSerial* rs232 = nullptr) { + _hid = hid; + _display = display; + _storage = storage; + _settingsMgr = settingsMgr; + _rs232Serial = rs232; + } + + void setBLEManager(BLEManager* ble) { + _bleManager = ble; + } + + bool startMacro(int slot, const char* name) { + if (state != ENGINE_IDLE) return false; + + _nodesDoc.clear(); + if (!_storage->loadNodes(slot, _nodesDoc)) return false; + + // Wipe any stale resume-state files left behind by a previous run + // whose boot-time resume was cancelled or failed. This guarantees + // the first save of THIS run starts from seq=0 in slot A and is + // never out-ranked by orphaned higher-seq files from before. + clearExecutionState(); + + _nodes = _nodesDoc.as(); + _nodeCount = _nodes.size(); + _currentNode = 0; + _currentSlot = slot; + strlcpy(_macroName, name, sizeof(_macroName)); + _repeatCounters[0] = 0; + _repeatDepth = 0; + + for (int i = 0; i < MAX_LOOPS; i++) _loopIterations[i] = 0; + + // Reset loop-selector commit flags so any selector on this run starts + // fresh (prevents a prior run's value from leaking into a new one). + _loopSelectorSet = false; + _loopSelectorStartSet = false; + + Serial.printf("[ENGINE] startMacro slot=%d name=%s nodes=%d\n", slot, name, _nodeCount); + for (int i = 0; i < _nodeCount; i++) { + const char* t = _nodes[i]["type"] | "?"; + Serial.printf("[ENGINE] node[%d] type='%s'\n", i, t); + } + + _display->showExecuting(name); + state = ENGINE_EXECUTING; + return true; + } + + void stop() { + state = ENGINE_IDLE; + _hid->keyboard.releaseAll(); + _hid->mouse.release(MOUSE_ALL); + clearExecutionState(); + + _callDepth = 0; + _inSubroutine = false; + _currentSubSlot = -1; + _repeatDepth = 0; + _branchSelected = 0; + _branchCount = 0; + _branchScroll = 0; + _rs232BufPos = 0; + _rs232Buf[0] = '\0'; + _rs232Expected[0] = '\0'; + _numLockCheckAttempt = 0; + _loopSelectorSet = false; + _loopSelectorStartSet = false; + _failNodeIdx = -1; + _failContinueTarget = -1; + } + + // Called every main-loop iteration + void tick(uint8_t typeDelay) { + if (state == ENGINE_IDLE) return; + + if (state == ENGINE_DELAY) { + if (millis() >= _waitUntil) { + _currentNode++; + state = ENGINE_EXECUTING; + } else { + // Update progress bar display ~10 times per second to avoid flicker + uint32_t now = millis(); + if (now - _delayLastDisplayUpdate >= 100) { + _delayLastDisplayUpdate = now; + uint32_t elapsed = now - _delayStartMs; + uint32_t remain = (_delayTotalMs > elapsed) ? (_delayTotalMs - elapsed) : 0; + _display->showDelayProgress(_delayTotalMs, remain); + } + } + return; + } + + if (state == ENGINE_PAUSED) { + if (_pauseTimed) { + if (millis() >= _waitUntil) { + _currentNode++; + state = ENGINE_EXECUTING; + } else { + updatePauseDisplay(); + } + } + return; + } + + if (state == ENGINE_BRANCHING) { + // Handled externally via onButtonClick() and onButtonHold() + return; + } + + if (state == ENGINE_LOOP_SELECTING) { + // Handled externally via onButtonClick() (cycle) and onButtonHold() (confirm) + return; + } + + if (state == ENGINE_LOOP_START_SELECTING) { + // Same pattern — phase 2 of the loop selector (start iteration picker) + return; + } + + if (state == ENGINE_GET_LOCAL_FAIL) { + uint32_t now = millis(); + if (now >= _waitUntil) { + // Timeout — take the Fail wire. + Serial.println("[ENGINE] get_local: FAIL timeout -> fail path"); + _currentNode = _failContinueTarget; + state = ENGINE_EXECUTING; + } else if (now - _failLastDisplayUpdate >= 200) { + _failLastDisplayUpdate = now; + _display->showFailScreen(_waitUntil - now, _failTotalMs, false); + } + return; + } + + if (state == ENGINE_GET_LOCAL_FAIL_PAUSED) { + // Frozen — countdown does not advance. Wait for second button + // press (handled in onButtonClick) which re-enters EXECUTING and + // replays the get_local node from scratch. + return; + } + + if (state == ENGINE_BLE_WAITING) { + // Polling for the exchange the device kicked off (pull / push / + // request) to complete. ``isExchangeDone()`` is set by the BLE + // manager when the matching reply has been received and applied. + if (_bleManager) _bleManager->pollStatus(); + if (_bleManager && _bleManager->isExchangeDone()) { + _bleManager->shutdown(); + _display->showBLEStatus("Vars ready!"); + delay(400); + _currentNode++; + _bleNoTimeout = false; + state = ENGINE_EXECUTING; + } else if (!_bleNoTimeout && millis() >= _waitUntil) { + if (_bleManager) _bleManager->shutdown(); + _display->showBLEStatus("No vars. Click>"); + _bleNoTimeout = false; + state = ENGINE_BLE_WAIT; + } + return; + } + + if (state == ENGINE_BLE_WAIT) { + // Waiting for user short-press to continue after BLE failure + return; + } + + if (state == ENGINE_RS232_WAITING) { + // Accumulate incoming RS232 data and check for expected response + if (_rs232Serial && _rs232Serial->available()) { + while (_rs232Serial->available() && _rs232BufPos < RS232_BUF_SIZE - 1) { + _rs232Buf[_rs232BufPos++] = _rs232Serial->read(); + _rs232Buf[_rs232BufPos] = '\0'; + } + // Check if expected response is found in accumulated buffer + if (_rs232Expected[0] != '\0' && strstr(_rs232Buf, _rs232Expected) != nullptr) { + Serial.printf("[ENGINE] RS232: got expected response\n"); + _currentNode++; + state = ENGINE_EXECUTING; + return; + } + } + uint32_t now = millis(); + if (now >= _waitUntil) { + Serial.printf("[ENGINE] RS232: timeout waiting for response\n"); + _currentNode++; + state = ENGINE_EXECUTING; + } else { + // Update RS232 waiting display ~5 times per second + if (now - _delayLastDisplayUpdate >= 200) { + _delayLastDisplayUpdate = now; + uint32_t remain = _waitUntil - now; + _display->showRS232Waiting(_rs232Expected, remain, _rs232BufPos); + } + } + return; + } + + if (state == ENGINE_EXECUTING) { + if (_currentNode >= _nodeCount) { + // End of body — if we're inside a sub-routine call, return to caller + if (_callDepth > 0) { + _callDepth--; + CallFrame& frame = _callStack[_callDepth]; + + // Reload the caller's nodes from LittleFS + _nodesDoc.clear(); + bool loaded = false; + if (frame.returnSlot >= 0) { + // Returning to a main macro + loaded = _storage->loadNodes(frame.returnSlot, _nodesDoc); + _currentSlot = frame.returnSlot; + _inSubroutine = false; + _currentSubSlot = -1; + } else { + // Returning to another sub-routine (nested call) + loaded = _storage->loadSubNodes(frame.returnSubSlot, _nodesDoc); + _currentSubSlot = frame.returnSubSlot; + _inSubroutine = true; + } + + if (!loaded) { + Serial.println("[ENGINE] Failed to reload caller nodes, stopping"); + stop(); + return; + } + + _nodes = _nodesDoc.as(); + _nodeCount = _nodes.size(); + _currentNode = frame.returnNode; + + Serial.printf("[ENGINE] Returned from sub-routine (depth=%d, resuming node=%d/%d)\n", + _callDepth, _currentNode, _nodeCount); + return; + } + stop(); + return; + } + executeCurrentNode(typeDelay); + } + } + + // Button handlers during execution + void onButtonClick() { + if (state == ENGINE_BLE_WAITING || state == ENGINE_BLE_WAIT) { + if (_bleManager) _bleManager->shutdown(); // user skipped — kill BLE + _currentNode++; + _bleNoTimeout = false; + state = ENGINE_EXECUTING; + return; + } else if (state == ENGINE_PAUSED) { + _currentNode++; + state = ENGINE_EXECUTING; + } else if (state == ENGINE_BRANCHING) { + _branchSelected = (_branchSelected + 1) % _branchCount; + // Keep selection inside the visible window. With itemH=24 and + // SCREEN_H=128 the selector shows 5 rows at a time. + const int visible = 128 / 24; + if (_branchSelected < _branchScroll) { + _branchScroll = _branchSelected; + } else if (_branchSelected >= _branchScroll + visible) { + _branchScroll = _branchSelected - visible + 1; + } + // Wrap-around: when selection cycles back to 0, snap viewport too. + if (_branchSelected == 0) _branchScroll = 0; + updateBranchDisplay(); + } else if (state == ENGINE_LOOP_SELECTING || state == ENGINE_LOOP_START_SELECTING) { + // Cycle through values with wrap-around at max + _loopSelectorCurrent += _loopSelectorStep; + if (_loopSelectorCurrent > _loopSelectorMax) { + _loopSelectorCurrent = _loopSelectorMin; + } + if (state == ENGINE_LOOP_SELECTING) { + updateLoopSelectorDisplay(); + } else { + updateLoopStartSelectorDisplay(); + } + } else if (state == ENGINE_GET_LOCAL_FAIL) { + // First tap during FAIL countdown: freeze the timer and show + // PAUSED. Snapshot remaining ms so a future un-pause path could + // resume it; for now a second tap just retries from scratch. + uint32_t now = millis(); + _failRemainSnapshot = (now < _waitUntil) ? (_waitUntil - now) : 0; + Serial.printf("[ENGINE] get_local FAIL: paused (%u ms left)\n", + (unsigned)_failRemainSnapshot); + state = ENGINE_GET_LOCAL_FAIL_PAUSED; + _display->showFailScreen(_failRemainSnapshot, _failTotalMs, true); + } else if (state == ENGINE_GET_LOCAL_FAIL_PAUSED) { + // Second tap: re-run the same get_local node from scratch. + Serial.println("[ENGINE] get_local FAIL: user retry -> replay node"); + _currentNode = _failNodeIdx; + state = ENGINE_EXECUTING; + } + } + + void onButtonHold() { + if (state == ENGINE_BRANCHING) { + JsonObject node = _nodes[_currentNode].as(); + JsonArray choices = node["data"]["choices"].as(); + int target = choices[_branchSelected]["next"] | (_currentNode + 1); + _currentNode = target; + state = ENGINE_EXECUTING; + } else if (state == ENGINE_LOOP_SELECTING) { + _loopSelectorValue = _loopSelectorCurrent; + _loopSelectorSet = true; + Serial.printf("[ENGINE] loop_selector committed: %d\n", _loopSelectorValue); + + if (_loopSelectorAskStart) { + // Phase 2: prompt for which iteration to start at (1..count) + _loopSelectorMin = 1; + _loopSelectorMax = _loopSelectorValue; + _loopSelectorStep = 1; + _loopSelectorCurrent = 1; + strlcpy(_loopSelectorPrompt, "Start at?", sizeof(_loopSelectorPrompt)); + state = ENGINE_LOOP_START_SELECTING; + updateLoopStartSelectorDisplay(); + } else { + // No second phase — clear any stale start setting and advance + _loopSelectorStart = 1; + _loopSelectorStartSet = false; + _currentNode++; + state = ENGINE_EXECUTING; + } + } else if (state == ENGINE_LOOP_START_SELECTING) { + _loopSelectorStart = _loopSelectorCurrent; + _loopSelectorStartSet = true; + Serial.printf("[ENGINE] loop_selector start committed: %d (of %d)\n", + _loopSelectorStart, _loopSelectorValue); + _currentNode++; + state = ENGINE_EXECUTING; + } + } + + bool isRunning() const { + return state != ENGINE_IDLE; + } + + // Called by SerialProtocol when the host-side terminal reconfigures the + // RS232 port behind our back, so the next rs232 macro node re-initializes. + void resetRS232Cache() { + _rs232LastBaud = 0; + _rs232LastConfig = 0; + } + + // --- Execution state persistence for power-loss recovery --- + + // ---- Resume-state persistence (double-buffered, CRC-checked) ---- + // + // Failure model: the M5Stack runs through a KVM that cuts USB power when + // told to switch input via RS232. Power can vanish at any byte boundary + // of any save, so the on-disk format must NEVER have a window where zero + // valid resume files exist. + // + // Scheme: + // /resume.a.json — copy A of the saved state (header + JSON) + // /resume.b.json — copy B + // /resume.idx — 1 byte: 'A' or 'B' — which copy is freshest + // + // Each file is written as: + // <8 hex CRC32> + // CRC covers the JSON bytes only. Read side verifies CRC before parsing. + // + // Save flow: + // 1. Decide which slot is INACTIVE (the one we won't clobber). + // 2. Open that slot file for write, emit "CRC\nJSON", flush, close. + // Power loss here leaves the other slot file untouched — recovery + // reads it via the sentinel. + // 3. Flip the sentinel to point at the slot we just wrote. + // Sentinel write is also tmp+rename (1 byte), so a half-written + // sentinel is rejected and the reader falls back to "highest-seq + // file that CRC-matches" across both slots. + // + // Read flow (checkSavedState): + // - Try sentinel; if it names a slot whose file parses + CRC-matches, + // use it. + // - Otherwise, scan both A and B; pick the highest-seq one that + // parses + CRC-matches. This handles every partial-write failure + // mode (mid-payload, mid-sentinel, missing sentinel). + + static uint32_t _crc32(const uint8_t* data, size_t len) { + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int j = 0; j < 8; j++) { + crc = (crc >> 1) ^ (0xEDB88320u & (-(int32_t)(crc & 1))); + } + } + return ~crc; + } + + // Read the on-disk sentinel byte. Returns 'A', 'B', or 0 if missing/garbage. + static char _readSentinel() { + if (!LittleFS.exists(RESUME_STATE_IDX_PATH)) return 0; + File f = LittleFS.open(RESUME_STATE_IDX_PATH, "r"); + if (!f) return 0; + int c = f.read(); + f.close(); + if (c == 'A' || c == 'B') return (char)c; + return 0; + } + + // Write the sentinel byte atomically. We write to a .tmp then rename; + // if the rename doesn't overwrite the destination, we fall back to + // remove + rename — but unlike the payload file the sentinel is 1 byte + // and if it's briefly missing the reader recovers via the dual-file scan. + static void _writeSentinel(char slot) { + const char* tmpPath = "/resume.idx.tmp"; + if (LittleFS.exists(tmpPath)) LittleFS.remove(tmpPath); + File f = LittleFS.open(tmpPath, "w"); + if (!f) return; + f.write((uint8_t)slot); + f.flush(); + f.close(); + // Try the overwrite-rename first; LittleFS in newer Arduino cores + // supports it. If it fails, remove + rename. The "no sentinel" + // window between these two ops is harmless: checkSavedState falls + // back to the dual-file scan. + if (!LittleFS.rename(tmpPath, RESUME_STATE_IDX_PATH)) { + if (LittleFS.exists(RESUME_STATE_IDX_PATH)) { + LittleFS.remove(RESUME_STATE_IDX_PATH); + } + LittleFS.rename(tmpPath, RESUME_STATE_IDX_PATH); + } + } + + // Try to read+verify one resume-state slot file. On success, parses the + // payload into ``doc`` and returns the file's seq value. Returns 0 on + // any failure (missing, short, CRC mismatch, JSON parse error). + static uint32_t _readSlotFile(const char* path, JsonDocument& doc) { + if (!LittleFS.exists(path)) return 0; + File f = LittleFS.open(path, "r"); + if (!f) return 0; + // Header is exactly 9 bytes: 8 hex digits + LF. + char header[10]; + size_t n = f.readBytes(header, 9); + if (n != 9 || header[8] != '\n') { f.close(); return 0; } + header[8] = '\0'; + uint32_t expectedCrc = strtoul(header, nullptr, 16); + String body = f.readString(); + f.close(); + if (body.length() == 0) return 0; + uint32_t actualCrc = _crc32((const uint8_t*)body.c_str(), body.length()); + if (actualCrc != expectedCrc) return 0; + if (deserializeJson(doc, body) != DeserializationError::Ok) return 0; + uint32_t seq = doc["seq"] | 0u; + return seq ? seq : 1; // seq=0 is technically valid; map to 1 to mean "found" + } + + // Write one slot file with header+JSON payload. Sets doc["seq"] to the + // monotonic counter before writing so a later reader can compare. + bool _writeSlotFile(const char* path, JsonDocument& doc) { + ++_resumeSeq; + doc["seq"] = _resumeSeq; + String body; + serializeJson(doc, body); + uint32_t crc = _crc32((const uint8_t*)body.c_str(), body.length()); + + File f = LittleFS.open(path, "w"); + if (!f) { + Serial.printf("[RESUME] save: failed to open %s for write\n", path); + return false; + } + char header[10]; + snprintf(header, sizeof(header), "%08X\n", crc); + size_t hw = f.write((const uint8_t*)header, 9); + size_t bw = f.write((const uint8_t*)body.c_str(), body.length()); + f.flush(); + f.close(); + if (hw != 9 || bw != body.length()) { + Serial.printf("[RESUME] save: short write to %s (hdr %u/9, body %u/%u)\n", + path, (unsigned)hw, (unsigned)bw, (unsigned)body.length()); + return false; + } + Serial.printf("[RESUME] save: wrote %s seq=%u crc=%08X bytes=%u\n", + path, (unsigned)_resumeSeq, (unsigned)crc, (unsigned)body.length()); + return true; + } + + void saveExecutionState() { + JsonDocument doc; + doc["active"] = true; + doc["slot"] = _currentSlot; + + if (_inSubroutine && _callDepth > 0) { + // Inside a sub-routine: save the parent's return node so we + // resume from the subroutine call (re-running the sub is safe; + // resuming mid-sub without the full call stack is not). + CallFrame& bottom = _callStack[0]; // outermost caller + doc["node"] = bottom.returnNode - 1; // the subroutine call node itself + } else { + doc["node"] = _currentNode; + } + + // Persist loop selector state so any downstream Loop still uses + // the previously-chosen count (and start iteration) after a power loss. + doc["loop_sel"] = _loopSelectorValue; + doc["loop_sel_set"] = _loopSelectorSet; + doc["loop_sel_start"] = _loopSelectorStart; + doc["loop_sel_start_set"] = _loopSelectorStartSet; + + // Persist repeat stack + per-loop iteration counters so iteration_branch + // still resolves to the correct path after a power-loss resume. + doc["repeat_depth"] = _repeatDepth; + JsonArray rc = doc["repeat_counters"].to(); + JsonArray rt = doc["repeat_targets"].to(); + JsonArray rlid = doc["repeat_loop_ids"].to(); + for (int i = 0; i < 8; i++) { + rc.add(_repeatCounters[i]); + rt.add(_repeatTargets[i]); + rlid.add(_repeatLoopIds[i]); + } + JsonArray li = doc["loop_iters"].to(); + for (int i = 0; i < MAX_LOOPS; i++) { + li.add(_loopIterations[i]); + } + + // Pick the INACTIVE slot. First save of a fresh boot reads the + // sentinel on disk to avoid clobbering the only good file. + if (_resumeActiveSlot != 'A' && _resumeActiveSlot != 'B') { + char onDisk = _readSentinel(); + _resumeActiveSlot = (onDisk == 'A') ? 'A' : (onDisk == 'B' ? 'B' : 0); + } + char writeSlot = (_resumeActiveSlot == 'A') ? 'B' : 'A'; + const char* path = (writeSlot == 'A') ? RESUME_STATE_A_PATH + : RESUME_STATE_B_PATH; + + if (!_writeSlotFile(path, doc)) { + // Payload write failed — do NOT flip the sentinel, leaving the + // previous good copy authoritative. + return; + } + + // Flip the sentinel to point at the slot we just wrote. From here + // on, _resumeActiveSlot tracks what we last committed. + _writeSentinel(writeSlot); + _resumeActiveSlot = writeSlot; + } + + void clearExecutionState() { + if (LittleFS.exists(RESUME_STATE_IDX_PATH)) LittleFS.remove(RESUME_STATE_IDX_PATH); + if (LittleFS.exists(RESUME_STATE_A_PATH)) LittleFS.remove(RESUME_STATE_A_PATH); + if (LittleFS.exists(RESUME_STATE_B_PATH)) LittleFS.remove(RESUME_STATE_B_PATH); + // Sweep legacy single-file path left by older firmware. + if (LittleFS.exists(RESUME_STATE_PATH)) LittleFS.remove(RESUME_STATE_PATH); + if (LittleFS.exists("/resume.tmp")) LittleFS.remove("/resume.tmp"); + _resumeActiveSlot = 0; + _resumeSeq = 0; + } + + bool checkSavedState(int& slot, int& nodeIdx) { + JsonDocument doc; + uint32_t pickedSeq = 0; + char pickedSlot = 0; + + // Always scan BOTH slots and pick the highest-seq valid file. The + // sentinel is read only for the diagnostic log; it is NOT load- + // bearing. Why: consider the sequence + // + // save N — writes A, flips sentinel to 'A' (sentinel=A, A=seq=N) + // save N+1 — writes B fully, CRASH before flipping (sentinel=A, A=seq=N, B=seq=N+1) + // + // Both files are on disk and CRC-valid. The sentinel-trust path + // picks A (seq=N) — losing the most recent save. The scan-all + // path picks B (seq=N+1) — correct. The cost of scanning is two + // small file reads (<1 KB total), negligible at boot. + char sentinel = _readSentinel(); + JsonDocument docA, docB; + uint32_t sA = _readSlotFile(RESUME_STATE_A_PATH, docA); + uint32_t sB = _readSlotFile(RESUME_STATE_B_PATH, docB); + Serial.printf("[RESUME] load: sentinel=%c scan A=%u B=%u\n", + sentinel ? sentinel : '?', (unsigned)sA, (unsigned)sB); + + if (sA > 0 && sA >= sB) { + doc = docA; + pickedSeq = sA; + pickedSlot = 'A'; + } else if (sB > 0) { + doc = docB; + pickedSeq = sB; + pickedSlot = 'B'; + } else { + // Neither slot is readable. One last shot: a legacy single- + // file save from older firmware. + if (LittleFS.exists(RESUME_STATE_PATH)) { + File f = LittleFS.open(RESUME_STATE_PATH, "r"); + if (f) { + if (deserializeJson(doc, f) == DeserializationError::Ok) { + f.close(); + pickedSeq = 1; + pickedSlot = 'L'; + Serial.println("[RESUME] load: legacy resume.json accepted"); + } else { + f.close(); + } + } + } + if (pickedSeq == 0) return false; + } + + bool active = doc["active"] | false; + if (!active) { + Serial.println("[RESUME] load: doc says inactive"); + return false; + } + + slot = doc["slot"] | 0; + nodeIdx = doc["node"] | 0; + _resumeSeq = pickedSeq; + _resumeActiveSlot = (pickedSlot == 'A' || pickedSlot == 'B') ? pickedSlot : 0; + + // Restore loop selector state so a Loop that reads from it + // still gets the correct value after a power-loss resume. + _loopSelectorValue = doc["loop_sel"] | 1; + _loopSelectorSet = doc["loop_sel_set"] | false; + _loopSelectorStart = doc["loop_sel_start"] | 1; + _loopSelectorStartSet = doc["loop_sel_start_set"] | false; + + // Restore repeat stack + per-loop iteration counters. + _repeatDepth = doc["repeat_depth"] | 0; + if (_repeatDepth < 0 || _repeatDepth > 8) _repeatDepth = 0; + JsonArray rc = doc["repeat_counters"].as(); + JsonArray rt = doc["repeat_targets"].as(); + JsonArray rlid = doc["repeat_loop_ids"].as(); + for (int i = 0; i < 8; i++) { + _repeatCounters[i] = (i < (int)rc.size()) ? (int)rc[i] : 0; + _repeatTargets[i] = (i < (int)rt.size()) ? (int)rt[i] : 0; + _repeatLoopIds[i] = (i < (int)rlid.size()) ? (int)rlid[i] : 0; + } + JsonArray li = doc["loop_iters"].as(); + for (int i = 0; i < MAX_LOOPS; i++) { + _loopIterations[i] = (i < (int)li.size()) ? (int)li[i] : 0; + } + + Serial.printf("[RESUME] load: slot=%c seq=%u macro_slot=%d node=%d depth=%d\n", + pickedSlot, (unsigned)pickedSeq, slot, nodeIdx, _repeatDepth); + return true; + } + + bool resumeMacro(int slot, int nodeIdx, const char* name) { + if (state != ENGINE_IDLE) { + Serial.printf("[ENGINE] resumeMacro: refused — engine not idle (state=%d)\n", + (int)state); + return false; + } + + _nodesDoc.clear(); + if (!_storage->loadNodes(slot, _nodesDoc)) { + Serial.printf("[ENGINE] resumeMacro: loadNodes(%d) failed\n", slot); + return false; + } + + _nodes = _nodesDoc.as(); + _nodeCount = _nodes.size(); + + if (nodeIdx >= _nodeCount) { + Serial.printf("[ENGINE] resumeMacro: node %d out of range (count=%d) — clearing state\n", + nodeIdx, _nodeCount); + clearExecutionState(); + return false; + } + + _currentNode = nodeIdx; + _currentSlot = slot; + strlcpy(_macroName, name, sizeof(_macroName)); + // NOTE: we INTENTIONALLY do NOT reset the repeat stack or + // _loopIterations here — those were already restored from NVRAM + // by checkSavedState(). Resetting them would break iteration_branch + // after a power-loss resume mid-loop. + + Serial.printf("[ENGINE] resumeMacro slot=%d name=%s node=%d/%d depth=%d\n", + slot, name, nodeIdx, _nodeCount, _repeatDepth); + _display->showExecuting(name); + state = ENGINE_EXECUTING; + return true; + } + +private: + HIDController* _hid; + DisplayUI* _display; + MacroStorage* _storage; + SettingsManager* _settingsMgr = nullptr; + BLEManager* _bleManager = nullptr; + HardwareSerial* _rs232Serial = nullptr; + + // RS232 response waiting state + char _rs232Expected[128]; + char _rs232Buf[RS232_BUF_SIZE]; + int _rs232BufPos = 0; + int _rs232LastBaud = 0; + uint32_t _rs232LastConfig = 0; + + // Num Lock check state + int _numLockCheckAttempt = 0; + + // Loop Selector state — persists across nodes so Loop can read it later + int _loopSelectorValue = 1; // last committed count (also restored from NVRAM) + bool _loopSelectorSet = false; // true once a selector node has committed at least once + int _loopSelectorStart = 1; // committed start iteration (defaults to 1) + bool _loopSelectorStartSet = false; // true if user picked a start-iteration + + // Transient state while in ENGINE_LOOP_SELECTING / ENGINE_LOOP_START_SELECTING + int _loopSelectorMin = 1; + int _loopSelectorMax = 10; + int _loopSelectorStep = 1; + int _loopSelectorCurrent = 1; + char _loopSelectorPrompt[64]; + bool _loopSelectorAskStart = false; // whether to prompt for start iter after count commit + + // Delay display state + uint32_t _delayTotalMs = 0; + uint32_t _delayStartMs = 0; + uint32_t _delayLastDisplayUpdate = 0; + + JsonDocument _nodesDoc; + JsonArray _nodes; + int _nodeCount = 0; + int _currentNode = 0; + int _currentSlot = 0; + char _macroName[64]; + + // Get Variables FAIL screen state (used when both attempts produce no + // matching outcome). On entry: showFailScreen, 10s countdown ticking + // via _waitUntil. Single click → frozen state, snapshot remaining; + // second click → reset _currentNode to _failNodeIdx and re-enter + // ENGINE_EXECUTING so executeCurrentNode() re-runs the get_local node + // from scratch. Timeout → take the Fail wire (fail_target). + int _failNodeIdx = -1; + int _failContinueTarget = -1; + uint32_t _failStartMs = 0; + uint32_t _failTotalMs = 0; + uint32_t _failRemainSnapshot = 0; + uint32_t _failLastDisplayUpdate = 0; + + // Pause state + bool _pauseTimed = false; + uint32_t _waitUntil = 0; + // When set, ENGINE_BLE_WAITING ignores _waitUntil and just keeps polling. + // Used by request_ble where the host dialog can take arbitrarily long + // to fill in. Cancel path is the side-button handler. + bool _bleNoTimeout = false; + uint32_t _pauseTotalMs = 0; + uint32_t _pauseStartMs = 0; + char _pauseText[128]; + int _pauseFontSize = 12; + uint16_t _pauseTextColor = TFT_WHITE; + + // Branch state + int _branchSelected = 0; + int _branchCount = 0; + int _branchScroll = 0; // index of first visible row in showBranchSelector + const char* _branchLabels[MAX_BRANCH_CHOICES]; + char _branchLabelBuf[MAX_BRANCH_CHOICES][32]; + uint16_t _branchColors[MAX_BRANCH_CHOICES]; + + // Repeat state + int _repeatCounters[8]; + int _repeatTargets[8]; + int _repeatLoopIds[8]; // which loop_id corresponds to each stack level + int _repeatDepth = 0; + + // Per-loop iteration counter (1-indexed), keyed by loop_id. + // Accessible by iteration_branch regardless of stack depth. + int _loopIterations[MAX_LOOPS] = {0}; + + // Resume-state double-buffer cursor: which slot file the most recent save + // wrote to ('A' or 'B'). The NEXT save writes the opposite slot. Reset to + // 0 at boot; on first save we read the on-disk sentinel (if any) to pick + // the inactive slot so we don't clobber the only valid file we have. + char _resumeActiveSlot = 0; + uint32_t _resumeSeq = 0; + + // Sub-routine call stack — stores only indices, not JsonDocuments. + // On return, the parent macro's nodes are reloaded from LittleFS. + struct CallFrame { + int returnSlot; // Macro slot to reload on return (-1 = sub-routine slot) + int returnSubSlot; // Sub-routine slot to reload on return (-1 = macro slot) + int returnNode; // Node index to resume at after return + int calledSubSlot; // Which sub was called (for recursion detection) + }; + CallFrame _callStack[MAX_SUB_CALL_DEPTH]; + int _callDepth = 0; + bool _inSubroutine = false; // true when executing a sub-routine (vs main macro) + int _currentSubSlot = -1; // which sub-routine slot we're in (-1 = main macro) + + // Static callback for numlock probe step display update + static void _onProbeStep(const char* phase, void* userData) { + DisplayUI* disp = (DisplayUI*)userData; + if (disp) { + disp->showNumLockProbing(phase); + } + } + + // Static callback for typeText per-character display update + static void _onTextChar(const char* fullText, int charIdx, void* userData) { + DisplayUI* disp = (DisplayUI*)userData; + if (disp) { + disp->showTextRibbon(fullText, charIdx); + } + } + + void executeCurrentNode(uint8_t typeDelay) { + JsonObject node = _nodes[_currentNode].as(); + const char* type = node["type"] | ""; + JsonObject data = node["data"].as(); + + // Persist execution state so we can resume after power loss. + // RS232 nodes handle their own save (pre-commit style) because the + // payload may cause an imminent power cut to the M5Stack (e.g. a + // KVM switch that also cuts USB power). See the rs232 handler below. + if (strcmp(type, "rs232") != 0) { + saveExecutionState(); + } + + if (strcmp(type, "text") == 0) { + const char* raw = data["text"] | ""; + char expanded[512]; + expandVariables(raw, expanded, sizeof(expanded)); + uint16_t shiftExtra = _settingsMgr ? _settingsMgr->settings.typeShiftExtraMs : DEFAULT_TYPE_SHIFT_EXTRA_MS; + uint16_t settleMs = _settingsMgr ? _settingsMgr->settings.typeSettleMs : DEFAULT_TYPE_SETTLE_MS; + uint16_t holdMin = _settingsMgr ? _settingsMgr->settings.typeHoldMinMs : DEFAULT_TYPE_HOLD_MIN_MS; + uint16_t interChar = _settingsMgr ? _settingsMgr->settings.typeInterCharMs : DEFAULT_TYPE_INTER_CHAR_MS; + _hid->typeText(expanded, typeDelay, _onTextChar, _display, shiftExtra, settleMs, holdMin, interChar); + _currentNode++; + + } else if (strcmp(type, "combo") == 0) { + JsonArray mods = data["mods"].as(); + const char* keyStr = data["key"] | ""; + + // Build display string for the combo (also resolves modifier keycodes) + char comboStr[64] = ""; + uint8_t modKeys[8]; + uint8_t modCount = 0; + for (JsonVariant m : mods) { + if (modCount < 8) { + const char* modName = m.as(); + modKeys[modCount] = _hid->resolveModifier(modName); + if (modKeys[modCount] != 0) { + if (comboStr[0] != '\0') strcat(comboStr, "+"); + char cap[16]; + strlcpy(cap, modName, sizeof(cap)); + if (cap[0] >= 'a' && cap[0] <= 'z') cap[0] -= 32; + strcat(comboStr, cap); + modCount++; + } + } + } + if (keyStr[0] != '\0') { + if (comboStr[0] != '\0') strcat(comboStr, "+"); + char capKey[16]; + strlcpy(capKey, keyStr, sizeof(capKey)); + if (capKey[0] >= 'a' && capKey[0] <= 'z') capKey[0] -= 32; + strcat(comboStr, capKey); + } + _display->showKeyCombo(comboStr); + + uint8_t key = _hid->resolveKey(keyStr); + + // Start with the device-wide defaults from Settings + uint16_t preMs = _settingsMgr ? _settingsMgr->settings.comboPreMs : DEFAULT_COMBO_PRE_MS; + uint16_t postMs = _settingsMgr ? _settingsMgr->settings.comboPostMs : DEFAULT_COMBO_POST_MS; + uint16_t keyPreMs = COMBO_KEY_PRE_DELAY; + uint16_t keyPostMs = COMBO_KEY_POST_DELAY; + + // Per-combo custom_timings overrides all four ms values + bool customTimings = data["custom_timings"] | false; + if (customTimings) { + preMs = data["custom_pre_ms"] | 167; + postMs = data["custom_post_ms"] | 167; + keyPreMs = data["custom_key_pre_ms"] | 3; + keyPostMs = data["custom_key_post_ms"] | 8; + } else { + // Legacy fallback: if an older macro still has "fast" set, + // treat it exactly like the pre-migration behavior (divide + // everything by 3). Macros loaded through the Python app + // will have been migrated already; this catches any that + // were uploaded directly with the legacy flag. + bool legacyFast = data["fast"] | false; + if (legacyFast) { + preMs = preMs / 3; + postMs = postMs / 3; + keyPreMs = COMBO_KEY_PRE_DELAY / 3; + keyPostMs = COMBO_KEY_POST_DELAY / 3; + } + } + + // Floor every delay at 1ms so we never starve the USB HID stack. + if (preMs < 1) preMs = 1; + if (postMs < 1) postMs = 1; + if (keyPreMs < 1) keyPreMs = 1; + if (keyPostMs < 1) keyPostMs = 1; + + delay(preMs); + _hid->keyCombo(modKeys, modCount, key, keyPreMs, keyPostMs); + delay(postMs); + _currentNode++; + + } else if (strcmp(type, "delay") == 0) { + int ms = data["ms"] | 100; + _delayTotalMs = (uint32_t)ms; + _delayStartMs = millis(); + _waitUntil = _delayStartMs + _delayTotalMs; + _delayLastDisplayUpdate = 0; + _display->showDelayProgress(_delayTotalMs, _delayTotalMs); + state = ENGINE_DELAY; + + } else if (strcmp(type, "pause") == 0) { + const char* rawPause = data["text"] | "Press to continue"; + expandVariables(rawPause, _pauseText, sizeof(_pauseText)); + _pauseFontSize = data["font_size"] | 12; + _pauseTextColor = resolveColor(data["text_color"] | "white"); + + // "wait" is either the string "click" or an integer (ms) + bool isClick = false; + int ms = 0; + + if (data["wait"].is()) { + const char* waitStr = data["wait"].as(); + isClick = (strcmp(waitStr, "click") == 0); + if (!isClick) ms = atoi(waitStr); + } else { + ms = data["wait"] | 1000; + } + + if (isClick || ms <= 0) { + _pauseTimed = false; + _display->showPauseScreen(_pauseText, _pauseFontSize, false, 0, 0, + _pauseTextColor, + _pauseMarginL(), _pauseMarginR(), + _pauseMarginT(), _pauseMarginB()); + } else { + _pauseTimed = true; + _pauseTotalMs = (uint32_t)ms; + _pauseStartMs = millis(); + _waitUntil = _pauseStartMs + _pauseTotalMs; + _display->showPauseScreen(_pauseText, _pauseFontSize, true, + _pauseTotalMs, _pauseTotalMs, + _pauseTextColor, + _pauseMarginL(), _pauseMarginR(), + _pauseMarginT(), _pauseMarginB()); + } + state = ENGINE_PAUSED; + + } else if (strcmp(type, "branch") == 0) { + JsonArray choices = data["choices"].as(); + _branchCount = choices.size(); + if (_branchCount > MAX_BRANCH_CHOICES) _branchCount = MAX_BRANCH_CHOICES; + _branchSelected = 0; + _branchScroll = 0; + + const char* bmode = data["mode"] | "manual"; + if (strcmp(bmode, "by_variable") == 0 && _bleManager) { + // Resolve immediately by variable lookup; no on-device prompt. + const char* vname = data["var_name"] | ""; + const char* vscope = data["var_scope"] | "auto"; + const char* val = _bleManager->getVariableScoped(vname, vscope); + int picked = _branchCount - 1; // default: last choice = else + for (int i = 0; i < _branchCount - 1; i++) { + const char* m = choices[i]["match_value"] | ""; + // Case-insensitive to match getVariable() and Type Text + // (VAR{...}) expansion semantics. "true" / "True" / "TRUE" + // all match — saves an entire class of silent-fail bugs. + if (strcasecmp(m, val) == 0) { + picked = i; + break; + } + } + int target = choices[picked]["next"] | (_currentNode + 1); + Serial.printf("[ENGINE] branch by var '%s'='%s' -> choice %d\n", + vname, val, picked); + _currentNode = target; + } else { + for (int i = 0; i < _branchCount; i++) { + strlcpy(_branchLabelBuf[i], choices[i]["label"] | "?", sizeof(_branchLabelBuf[i])); + _branchLabels[i] = _branchLabelBuf[i]; + // Per-choice color sent by host as a name string. Default + // to white so legacy macros (no "color" field) render + // exactly like before. + const char* cname = choices[i]["color"] | "white"; + _branchColors[i] = resolveColor(cname); + } + state = ENGINE_BRANCHING; + updateBranchDisplay(); + } + + } else if (strcmp(type, "repeat") == 0) { + // Legacy repeat handler (for old-format macros) + int count = data["count"] | 1; + int startIdx = data["start_idx"] | (_currentNode + 1); + + if (_repeatDepth < 8) { + if (_repeatCounters[_repeatDepth] == 0) { + _repeatCounters[_repeatDepth] = count; + _repeatTargets[_repeatDepth] = _currentNode; + _currentNode = startIdx; + } else { + _repeatCounters[_repeatDepth]--; + if (_repeatCounters[_repeatDepth] > 0) { + _currentNode = startIdx; + } else { + _repeatCounters[_repeatDepth] = 0; + _currentNode++; + } + } + } else { + _currentNode++; + } + + } else if (strcmp(type, "_loop_start") == 0) { + // New loop model: initialize counter and jump to body + int count = data["count"] | 1; + int bodyStart = data["body"] | (_currentNode + 1); + int doneTarget = data["done"] | (_currentNode + 1); + bool useSelector = data["use_selector"] | false; + int loopId = data["loop_id"] | 0; + + // If this loop is configured to read from the Loop Selector, + // override count with the last committed selector value. + // If no selector has run yet, fall back to the configured count. + if (useSelector && _loopSelectorSet) { + count = _loopSelectorValue; + Serial.printf("[ENGINE] loop uses selector value: %d\n", count); + } + if (count < 1) count = 1; + + // Optional: start iteration (skip the first K-1 body passes). + // Only applied when the loop reads from the selector AND the + // selector was configured to ask for a start iteration. + int startIter = 1; + if (useSelector && _loopSelectorStartSet) { + startIter = _loopSelectorStart; + if (startIter < 1) startIter = 1; + if (startIter > count) startIter = count; + Serial.printf("[ENGINE] loop starts at iteration %d\n", startIter); + } + int remaining = count - startIter + 1; + if (remaining < 1) remaining = 1; + + if (_repeatDepth < 8) { + _repeatCounters[_repeatDepth] = remaining; + _repeatTargets[_repeatDepth] = doneTarget; + _repeatLoopIds[_repeatDepth] = loopId; + if (loopId >= 0 && loopId < MAX_LOOPS) { + _loopIterations[loopId] = startIter; + } + _display->showLoopStatus(startIter, count); + _repeatDepth++; + _currentNode = bodyStart; + } else { + _currentNode = doneTarget; // Too deep, skip loop + } + + } else if (strcmp(type, "iteration_branch") == 0) { + int loopId = data["loop_id"] | -1; + JsonArray choices = data["choices"].as(); + int numChoices = choices.size(); + if (numChoices <= 0) { + _currentNode++; + return; + } + + int iter = 1; + if (loopId >= 0 && loopId < MAX_LOOPS) { + int v = _loopIterations[loopId]; + if (v >= 1) iter = v; + } + + // Skip-on-final-iteration: if the node is configured to no-op on + // the tied loop's last pass, check the repeat stack for that + // loop_id and compare its remaining counter. remaining == 1 means + // _loop_back hasn't fired yet for the last iteration — this IS + // the last iteration. Jump directly to skip_target (the merge + // point after all paths) so nothing in the branch executes. + bool skipFinal = data["skip_final_iteration"] | false; + if (skipFinal && loopId >= 0) { + int depth = -1; + for (int d = 0; d < _repeatDepth; d++) { + if (_repeatLoopIds[d] == loopId) { depth = d; break; } + } + if (depth >= 0 && _repeatCounters[depth] == 1) { + int skipTo = data["skip_target"] | (_currentNode + 1); + Serial.printf("[ENGINE] iteration_branch: loop=%d iter=%d is final — skipping to node %d\n", + loopId, iter, skipTo); + _currentNode = skipTo; + return; + } + } + + // Cycle through paths: iter 1 → 0, iter 2 → 1, ..., wrap. + int pathIdx = (iter - 1) % numChoices; + if (pathIdx < 0) pathIdx = 0; + + const char* label = choices[pathIdx]["label"] | "?"; + int nextNode = choices[pathIdx]["next"] | (_currentNode + 1); + + Serial.printf("[ENGINE] iteration_branch: loop=%d iter=%d -> path %d '%s'\n", + loopId, iter, pathIdx, label); + _display->showIterationBranch(iter, label, pathIdx, numChoices); + delay(400); // brief display so the user can see which path was chosen + _currentNode = nextNode; + + } else if (strcmp(type, "loop_selector") == 0) { + int mn = data["min"] | 1; + int mx = data["max"] | 10; + int step = data["step"] | 1; + if (step <= 0) step = 1; + if (mx < mn) mx = mn; + int dflt = data["default"] | mn; + if (dflt < mn) dflt = mn; + if (dflt > mx) dflt = mx; + const char* prompt = data["prompt"] | "Loop count?"; + bool askStart = data["ask_start"] | false; + + _loopSelectorMin = mn; + _loopSelectorMax = mx; + _loopSelectorStep = step; + _loopSelectorCurrent = dflt; + strlcpy(_loopSelectorPrompt, prompt, sizeof(_loopSelectorPrompt)); + _loopSelectorAskStart = askStart; + // Clear any previous start setting — this selector will supply + // a new one (or leave it default=1 if ask_start is false). + _loopSelectorStartSet = false; + + state = ENGINE_LOOP_SELECTING; + updateLoopSelectorDisplay(); + + } else if (strcmp(type, "_loop_back") == 0) { + // End of loop body: decrement counter, loop or exit + int loopStartIdx = data["target"] | (_currentNode + 1); + + if (_repeatDepth > 0) { + int loopId = _repeatLoopIds[_repeatDepth - 1]; + _repeatCounters[_repeatDepth - 1]--; + if (_repeatCounters[_repeatDepth - 1] > 0) { + // Read original count from the _loop_start node for display + JsonObject loopNode = _nodes[loopStartIdx].as(); + int totalCount = loopNode["data"]["count"] | 1; + bool useSelector = loopNode["data"]["use_selector"] | false; + if (useSelector && _loopSelectorSet) totalCount = _loopSelectorValue; + int remaining = _repeatCounters[_repeatDepth - 1]; + int iteration = totalCount - remaining + 1; + _display->showLoopStatus(iteration, totalCount); + if (loopId >= 0 && loopId < MAX_LOOPS) { + _loopIterations[loopId] = iteration; + } + // Loop again: jump back to body start (loop_start + 1) + _currentNode = loopStartIdx + 1; + } else { + // Done: jump to done target and pop stack + _repeatDepth--; + _currentNode = _repeatTargets[_repeatDepth]; + // Leave _loopIterations[loopId] intact so nodes later in + // the macro can still inspect the final iteration count. + } + } else { + _currentNode++; + } + + } else if (strcmp(type, "mouse") == 0) { + const char* btn = data["button"] | "left"; + const char* action = data["action"] | "click"; + _display->showMouseAction(action, btn); + uint8_t button = _hid->resolveMouseButton(btn); + + if (strcmp(action, "click") == 0) _hid->mouseClick(button); + else if (strcmp(action, "double") == 0) _hid->mouseDoubleClick(button); + else if (strcmp(action, "press") == 0) _hid->mousePress(button); + else if (strcmp(action, "release") == 0) _hid->mouseRelease(button); + _currentNode++; + + } else if (strcmp(type, "media") == 0) { + const char* action = data["action"] | ""; + _display->showMediaKey(action); + uint16_t key = _hid->resolveMediaKey(action); + uint16_t mediaMs = _settingsMgr ? _settingsMgr->settings.mediaHoldMs : DEFAULT_MEDIA_HOLD_MS; + if (key != 0) _hid->mediaKey(key, mediaMs); + _currentNode++; + + } else if (strcmp(type, "bluetooth") == 0) { + const char* mode = data["mode"] | "pull_ble"; + Serial.printf("[ENGINE] variables node hit (mode=%s)\n", mode); + + if (strcmp(mode, "pull_ble") == 0) { + if (!_bleManager) { + _display->showBLEStatus("No BLE! Click>"); + state = ENGINE_BLE_WAIT; + } else { + const char* scope = data["scope"] | "universal"; + _bleManager->startPull(scope); + _display->showBLEStatus("Pulling vars..."); + _waitUntil = millis() + 30000; + _bleNoTimeout = false; + state = ENGINE_BLE_WAITING; + } + + } else if (strcmp(mode, "push_ble") == 0) { + if (!_bleManager) { + _display->showBLEStatus("No BLE! Click>"); + state = ENGINE_BLE_WAIT; + } else { + _bleManager->startPush(); + _display->showBLEStatus("Pushing vars..."); + _waitUntil = millis() + 30000; + _bleNoTimeout = false; + state = ENGINE_BLE_WAITING; + } + + } else if (strcmp(mode, "request_ble") == 0) { + if (!_bleManager) { + _display->showBLEStatus("No BLE! Click>"); + state = ENGINE_BLE_WAIT; + } else { + JsonArray names = data["names"].as(); + _bleManager->startRequest(names); + _display->showBLEStatus("Requesting..."); + // No timeout: the user fills in the host dialog at their + // own pace. Cancel via the side button (handler below). + _bleNoTimeout = true; + state = ENGINE_BLE_WAITING; + } + + } else if (strcmp(mode, "set_local") == 0) { + if (_bleManager) { + const char* scope = data["scope"] | "universal"; + JsonArray assigns = data["assignments"].as(); + int written = 0; + for (JsonVariant a : assigns) { + const char* n = a["name"] | ""; + const char* v = a["value"] | ""; + if (n[0] && _bleManager->setLocal(scope, n, v)) written++; + } + Serial.printf("[ENGINE] set_local: wrote %d (scope=%s)\n", written, scope); + } + _currentNode++; + + } else if (strcmp(mode, "get_local") == 0) { + runGetLocal(data); + + } else { + Serial.printf("[ENGINE] unknown variables mode: %s\n", mode); + _currentNode++; + } + + } else if (strcmp(type, "ble_refresh") == 0) { + // Legacy node — no longer emitted by the host but kept as a no-op + // for projects that pre-date the unified Variables node. + _currentNode++; + + } else if (strcmp(type, "pc_alive_check") == 0) { + const char* condition = data["condition"] | "pc_response"; + bool loopEnabled = data["loop"] | true; + int pollDelayMs = data["poll_delay_ms"] | 500; + int trueTarget = data["true_target"] | (_currentNode + 1); + int falseTarget = data["false_target"] | (_currentNode + 1); + + _numLockCheckAttempt = 0; + bool conditionMet = false; + + do { + _numLockCheckAttempt++; + + // Probe: toggle num lock, check if host responds + uint16_t probeMs = _settingsMgr ? _settingsMgr->settings.probeTimeoutMs : DEFAULT_PROBE_TIMEOUT_MS; + bool pcAlive = _hid->probeNumLock(probeMs, _onProbeStep, _display); + bool numState = _hid->numLockOn; + + Serial.printf("[ENGINE] pc_alive_check: attempt=%d alive=%s numlock=%s cond=%s\n", + _numLockCheckAttempt, pcAlive ? "Y" : "N", + numState ? "ON" : "OFF", condition); + + // Evaluate the selected condition + if (strcmp(condition, "pc_response") == 0) { + conditionMet = pcAlive; + } else if (strcmp(condition, "numlock_on") == 0) { + conditionMet = pcAlive && numState; + } else if (strcmp(condition, "numlock_off") == 0) { + conditionMet = pcAlive && !numState; + } + + if (conditionMet) { + _display->showPCAliveResult(true, _numLockCheckAttempt, condition); + delay(300); + break; + } + + _display->showPCAliveResult(false, _numLockCheckAttempt, condition); + + if (loopEnabled) { + delay(pollDelayMs); + } + } while (loopEnabled); + + _numLockCheckAttempt = 0; + _currentNode = conditionMet ? trueTarget : falseTarget; + + } else if (strcmp(type, "subroutine") == 0) { + // IMPORTANT: ``data["name"]`` returns a pointer into the + // parent macro's _nodesDoc memory pool. The pool gets reset + // by _nodesDoc.clear() / loadSubNodes() further down, after + // which any const char* into it is dangling — reading it + // can crash the device (LoadProhibited if the new content + // doesn't null-terminate where the old string did) and was + // the root cause of the "screen freaks out then reboot" + // symptom on sub-routine entry. Copy the name into a small + // stack buffer up front so every later use is safe. + char subName[40]; + { + const char* raw = data["name"] | ""; + strlcpy(subName, raw, sizeof(subName)); + } + _display->showSubroutineCall(subName); + int subSlot = _storage->findSubByName(subName); + + if (subSlot < 0) { + Serial.printf("[ENGINE] Sub-routine '%s' not found, skipping\n", subName); + _currentNode++; + return; + } + + if (_callDepth >= MAX_SUB_CALL_DEPTH) { + Serial.printf("[ENGINE] Sub-routine call depth exceeded, skipping\n"); + _currentNode++; + return; + } + + // Recursive-call guard (prevent infinite loops) + for (int d = 0; d < _callDepth; d++) { + if (_callStack[d].calledSubSlot == subSlot) { + Serial.printf("[ENGINE] Recursive sub-routine '%s' detected, skipping\n", subName); + _currentNode++; + return; + } + } + + // Push return info onto call stack (just indices, no JsonDocument) + CallFrame& frame = _callStack[_callDepth]; + frame.returnSlot = _inSubroutine ? -1 : _currentSlot; + frame.returnSubSlot = _inSubroutine ? _currentSubSlot : -1; + frame.returnNode = _currentNode + 1; // resume after the subroutine node + frame.calledSubSlot = subSlot; + _callDepth++; + + // Load sub-routine nodes (replaces current _nodesDoc completely). + // From this point on, ``data`` and any const char* derived from + // it (other than the subName stack copy above) is invalid. + // + // Snapshot whether we're currently in a sub BEFORE we clobber + // _nodesDoc, so the failure path below can reload the right + // parent (a sub may call another sub). + int parentSlot = _currentSlot; + int parentSubSlot = _currentSubSlot; + bool parentInSub = _inSubroutine; + _nodesDoc.clear(); + if (!_storage->loadSubNodes(subSlot, _nodesDoc)) { + Serial.printf("[ENGINE] Failed to load sub-routine nodes — restoring parent\n"); + // Critical: at this point _nodesDoc is empty. If we return + // without reloading the parent, the next tick() reads + // _nodes[_currentNode] out of an empty document and the + // device hard-crashes. Reload the parent macro / sub here + // so the engine can continue past the broken sub-routine + // node. (Common case: a sub-routine whose GUI graph has + // zero connections flattens to an empty file, which is + // stored as the invalid JSON "[" by beginSubWrite — also + // fixed in macro_storage.h.) + _callDepth--; + bool reloaded = parentInSub + ? _storage->loadSubNodes(parentSubSlot, _nodesDoc) + : _storage->loadNodes(parentSlot, _nodesDoc); + if (!reloaded) { + Serial.println("[ENGINE] Parent reload after sub failure also failed — stopping"); + stop(); + return; + } + _nodes = _nodesDoc.as(); + _nodeCount = _nodes.size(); + _currentNode++; + return; + } + + _nodes = _nodesDoc.as(); + _nodeCount = _nodes.size(); + _currentNode = 0; + _inSubroutine = true; + _currentSubSlot = subSlot; + + Serial.printf("[ENGINE] Entering sub-routine '%s' (slot=%d depth=%d, nodes=%d)\n", + subName, subSlot, _callDepth, _nodeCount); + if (_nodeCount == 0) { + // An "empty" sub-routine is technically valid — the engine + // will immediately hit the _currentNode >= _nodeCount branch + // on the next tick() and return to the caller. Log it so + // the user can tell from the serial monitor that the sub + // ran but did nothing (usually means the GUI graph is + // missing connections or content). + Serial.printf("[ENGINE] (sub-routine '%s' is empty — will skip back to caller)\n", + subName); + } + + } else if (strcmp(type, "rs232") == 0) { + if (!_rs232Serial) { + Serial.println("[ENGINE] RS232: no serial port configured"); + saveExecutionState(); + _currentNode++; + return; + } + + int baud = data["baud"] | 9600; + int dataBits = data["data_bits"] | 8; + const char* stopBitsStr = data["stop_bits"] | "1"; + const char* parityStr = data["parity"] | "none"; + const char* rawMsg = data["message"] | ""; + const char* lineEnding = data["line_ending"] | "none"; + bool waitResponse = data["wait_response"] | false; + const char* expectedResp = data["expected_response"] | ""; + int timeoutMs = data["timeout_ms"] | 5000; + int postSendDelayMs = data["post_send_delay_ms"] | 0; + + bool willWait = (waitResponse && expectedResp[0] != '\0'); + + // === Pre-commit the advance BEFORE sending === + // Rationale: this RS232 payload may cause an imminent power cut + // (e.g. a KVM switch that also cuts the M5Stack's USB power). + // If we saved "node=current" and then lost power mid-send, the + // resume flow would re-enter the RS232 node, re-send, and cut + // power again — an infinite reboot loop. + // + // Instead we save "node=next" now. On power loss, resume picks + // up at the node AFTER this send. Side effect: if power is cut + // BEFORE the payload reaches the wire, the macro still advances + // as if it had — acceptable for the KVM-switching use case. + // + // When waiting for a response, we save "node=current" so a + // power loss during the wait simply re-enters and re-waits. + if (willWait) { + saveExecutionState(); + } else { + _currentNode++; + saveExecutionState(); + _currentNode--; + } + + // Build UART config and re-init the port only when the settings change + uint32_t config = computeRS232Config(dataBits, parityStr, stopBitsStr); + if (baud != _rs232LastBaud || config != _rs232LastConfig) { + _rs232Serial->end(); + _rs232Serial->begin(baud, config, RS232_RX_PIN, RS232_TX_PIN); + _rs232LastBaud = baud; + _rs232LastConfig = config; + delay(50); + } + + // Expand BLE variables in the message + char expanded[512]; + expandVariables(rawMsg, expanded, sizeof(expanded)); + + _display->showRS232Sending(expanded, baud); + + // Send message (+ line ending) + _rs232Serial->print(expanded); + if (strcmp(lineEnding, "cr") == 0) _rs232Serial->print('\r'); + else if (strcmp(lineEnding, "lf") == 0) _rs232Serial->print('\n'); + else if (strcmp(lineEnding, "crlf") == 0) _rs232Serial->print("\r\n"); + + _rs232Serial->flush(); + Serial.printf("[ENGINE] RS232: sent '%s' at %dbps\n", expanded, baud); + + // Post-send delay: once the bytes are on the wire, the resume + // state has already been pre-committed, so the device is now + // safe to lose power. Sit idle for the configured duration so + // any power-cutting side effect (e.g. a KVM cutting USB power) + // has a guaranteed window, and flash has plenty of time to + // fully settle the save that just happened. + if (!willWait && postSendDelayMs > 0) { + _display->showRS232SafeIdle(expanded, postSendDelayMs); + Serial.printf("[ENGINE] RS232: safe-idle %dms (ready to lose power)\n", + postSendDelayMs); + delay((uint32_t)postSendDelayMs); + } + + if (willWait) { + _waitUntil = millis() + (uint32_t)timeoutMs; + strlcpy(_rs232Expected, expectedResp, sizeof(_rs232Expected)); + _rs232BufPos = 0; + _rs232Buf[0] = '\0'; + while (_rs232Serial->available()) _rs232Serial->read(); + state = ENGINE_RS232_WAITING; + } else { + _currentNode++; + } + + } else if (strcmp(type, "macro") == 0) { + // Replay a recorded key sequence verbatim. + // + // Event format (from Python flatten): each entry is an array + // [t_ms, action, hid_code] + // t_ms — ms since the start of the recording + // action — 0 = press (key down), 1 = release (key up) + // hid_code — raw USB HID usage code + // + // We use pressRaw / releaseRaw to send each key independently — + // never the high-level press(char) path, which would silently + // toggle the shift modifier as a side-effect of mapping capital + // ASCII letters. With pressRaw, chords replay exactly the way + // they were recorded (modifiers stay held across the key-down + // and key-up of other keys). + // + // Timing: we anchor every event to the WALL-CLOCK offset from + // the start of playback (target = startMs + t). The previous + // implementation kept an idealised ``elapsed`` counter and + // delayed for ``t - elapsed`` between events, which silently + // drifted slower as USB report acks accumulated (~1-5 ms per + // pressRaw/releaseRaw call). On a long recording that drift + // adds up and the replay no longer matches the original + // cadence. Anchoring to millis() absorbs the ack time into + // the gap instead of pushing every later event back by it. + // Two event formats are supported, auto-detected per event: + // * legacy keyboard-only: [t_ms, action, hid] + // * library (keys+mouse): ["k", t_ms, action, hid] + // ["m", t_ms, buttons, x, y, wheel] + // (x/y are absolute 0..32767.) A leading string element marks the + // tagged form. data["mode"] is "recorded" (default) or "library" + // but we detect per-event so a missing/old mode field still works. + const char* mname = data["name"] | ""; + JsonArray events = data["events"].as(); + int nEvents = events.size(); + uint32_t totalMs = 0; + if (nEvents > 0) { + JsonArray last = events[nEvents - 1].as(); + if (last.size() >= 1) { + int ti = last[0].is() ? 1 : 0; + if ((int)last.size() > ti) + totalMs = (uint32_t)(last[ti].as()); + } + } + _display->showMacroPlayback(mname, nEvents, totalMs); + Serial.printf("[ENGINE] macro '%s' playback start: %d events, %ums\n", + mname, nEvents, totalMs); + + _hid->beginCritical(); + uint32_t startMs = millis(); + bool sawMouse = false; + uint16_t lastX = 0, lastY = 0; + for (JsonVariant evVar : events) { + JsonArray ev = evVar.as(); + if (ev.size() < 3) continue; + + bool tagged = ev[0].is(); + uint32_t t = (uint32_t)(ev[tagged ? 1 : 0].as()); + + // Sleep until the wall-clock target for this event (anchored + // to playback start so USB-ack time doesn't accumulate drift). + uint32_t target = startMs + t; + uint32_t now = millis(); + if ((int32_t)(target - now) > 0) { + delay(target - now); + } + + if (!tagged) { + // Legacy keyboard event. + int action = ev[1].as(); + uint8_t code = (uint8_t)(ev[2].as()); + if (action == 0) _hid->keyboard.pressRaw(code); + else _hid->keyboard.releaseRaw(code); + continue; + } + + const char* tag = ev[0].as(); + if (tag && tag[0] == 'k' && ev.size() >= 4) { + int action = ev[2].as(); + uint8_t code = (uint8_t)(ev[3].as()); + if (action == 0) _hid->keyboard.pressRaw(code); + else _hid->keyboard.releaseRaw(code); + } else if (tag && tag[0] == 'm' && ev.size() >= 6) { + uint8_t buttons = (uint8_t)(ev[2].as()); + uint16_t x = (uint16_t)(ev[3].as()); + uint16_t y = (uint16_t)(ev[4].as()); + int8_t wheel = (int8_t)(ev[5].as()); + // Call absMouse.report() directly (NOT the absMouseReport + // wrapper) — we're already inside beginCritical/endCritical + // and the wrapper would toggle the critical flag off. + _hid->absMouse.report(buttons, x, y, wheel); + sawMouse = true; + lastX = x; lastY = y; + } + } + + // Safety release: flush keyboard, and drop any held mouse buttons + // at the last position so nothing's left stuck after a hand-edit + // or a mid-chord stop. + _hid->keyboard.releaseAll(); + if (sawMouse) _hid->absMouse.report(0, lastX, lastY, 0); + _hid->endCritical(); + Serial.println("[ENGINE] macro playback done"); + _currentNode++; + + } else if (strcmp(type, "aggregator") == 0) { + // Passthrough — branch paths merge here, just advance to next node. + // No display update: the aggregator is a visual-only merge marker, + // and showing it would flicker between the preceding node and the + // post-aggregator node. + _currentNode++; + + } else if (strcmp(type, "_jump") == 0) { + int target = data["target"] | (_currentNode + 1); + _currentNode = target; + + } else { + // Unknown or internal node type - show generic display + if (type[0] != '_') { // Don't show display for internal nodes like _jump + _display->showGenericNode(type); + } + Serial.printf("[ENGINE] Unknown node type: '%s'\n", type); + _currentNode++; + } + } + + // Variables -> Get Variables mode. Types the configured script into the + // currently-focused window, presses Enter, then watches the host's Num + // Lock LED for a sequence of toggles. The number of OFF->ON transitions + // observed within the listen window is matched against the configured + // outcomes; on a match the named variable is set and the Pass output is + // taken. On no match (or timeout) the variable is left alone and the + // Fail output is taken. + // One end-to-end attempt of the get_local flow: optionally launch an + // elevated terminal, type the script, listen for Scroll Lock toggles, + // match against the outcomes table. Returns the matched value (pointer + // into the JSON doc — valid until the doc is mutated), or nullptr if + // no outcome matched. Does NOT mutate _currentNode. + const char* attemptGetLocal(JsonObject data) { + const char* script = data["script"] | ""; + uint32_t preMs = data["pre_listen_ms"] | 500; + uint32_t winMs = data["listen_window_ms"] | 5000; + + uint8_t typeDelay = _settingsMgr ? + _settingsMgr->settings.typeDelay : DEFAULT_TYPE_DELAY; + uint16_t typeShiftExtra = _settingsMgr ? + _settingsMgr->settings.typeShiftExtraMs : DEFAULT_TYPE_SHIFT_EXTRA_MS; + uint16_t typeSettle = _settingsMgr ? + _settingsMgr->settings.typeSettleMs : DEFAULT_TYPE_SETTLE_MS; + uint16_t typeHoldMin = _settingsMgr ? + _settingsMgr->settings.typeHoldMinMs : DEFAULT_TYPE_HOLD_MIN_MS; + uint16_t typeInterChar = _settingsMgr ? + _settingsMgr->settings.typeInterCharMs : DEFAULT_TYPE_INTER_CHAR_MS; + + // Optional Win+R launcher — brings up an elevated terminal so the + // script lands somewhere with admin rights. Re-run on every attempt + // so retries get a fresh terminal (the previous one may have died + // or be in an unknown state). + JsonObject launch = data["elevated_launch"].as(); + if (!launch.isNull() && (launch["enabled"] | false)) { + const char* launchCmd = launch["command"] | + "powershell -Command \"Start-Process wt -Verb RunAs\""; + uint32_t winRMs = launch["win_r_wait_ms"] | 5000; + uint32_t postMs = launch["post_type_wait_ms"] | 15000; + bool uacAccept = launch["uac_accept"] | false; + uint32_t uacMs = launch["uac_wait_ms"] | 10000; + + _display->showGenericNode("Get Var: Win+R"); + _hid->keyboard.press(KEY_LEFT_GUI); + _hid->keyboard.press('r'); + delay(60); + _hid->keyboard.releaseAll(); + delay(winRMs); + + _display->showGenericNode("Get Var: launching"); + _hid->typeText(launchCmd, typeDelay, nullptr, nullptr, typeShiftExtra, typeSettle, typeHoldMin, typeInterChar); + _hid->keyboard.press(KEY_RETURN); + delay(20); + _hid->keyboard.releaseAll(); + + // Optional UAC auto-accept. Order matters: this runs BEFORE the + // post-launch wait, because the UAC dialog appears almost + // immediately after the Run-box Enter while the launched app + // (e.g. Windows Terminal) only shows up AFTER UAC is accepted. + if (uacAccept) { + _display->showGenericNode("Get Var: UAC wait"); + delay(uacMs); + _display->showGenericNode("Get Var: UAC accept"); + _hid->keyboard.press(KEY_LEFT_ARROW); + delay(60); + _hid->keyboard.releaseAll(); + delay(150); + _hid->keyboard.press(KEY_RETURN); + delay(60); + _hid->keyboard.releaseAll(); + } + + delay(postMs); + } + + _display->showGenericNode("Get Var: typing"); + if (script[0]) { + _hid->typeText(script, typeDelay, nullptr, nullptr, typeShiftExtra, typeSettle, typeHoldMin, typeInterChar); + _hid->keyboard.press(KEY_RETURN); + delay(20); + _hid->keyboard.releaseAll(); + } + + delay(preMs); + + _display->showGenericNode("Get Var: listening"); + Serial.printf("[ENGINE] get_local: listening for %u ms (initial scroll=%d)\n", + (unsigned)winMs, (int)_hid->scrollLockOn); + int toggles = _hid->probeScrollLockSequence(winMs); + Serial.printf("[ENGINE] get_local: observed %d Scroll Lock toggles (final scroll=%d)\n", + toggles, (int)_hid->scrollLockOn); + + JsonArray outs = data["outcomes"].as(); + for (JsonVariant o : outs) { + int wanted = o["toggle_count"] | -1; + if (wanted == toggles) { + return o["value"] | ""; + } + } + return nullptr; + } + + void runGetLocal(JsonObject data) { + if (!_bleManager) { + int t = data["fail_target"] | (_currentNode + 1); + _currentNode = t; + return; + } + const char* varName = data["var_name"] | ""; + const char* scope = data["scope"] | "universal"; + int passTarget = data["pass_target"] | (_currentNode + 1); + int failTarget = data["fail_target"] | (_currentNode + 1); + + // First attempt, then silent retries on no match. retry_attempts + // counts ADDITIONAL attempts after the initial one, so retry=0 means + // "one attempt total", retry=1 means "two attempts" (the historical + // hard-coded behavior), etc. Retries cover the case where the script + // never ran (USB not enumerated yet, terminal died, UAC dismissed, + // etc.) AND the case where it ran but produced an unconfigured toggle + // count. Each retry re-runs the elevated_launch block so the user + // gets a fresh terminal. We exit the loop early on first match so a + // successful first attempt never triggers a redundant rerun. + int retryAttempts = data["retry_attempts"] | 1; + if (retryAttempts < 0) retryAttempts = 0; + const char* matchedValue = attemptGetLocal(data); + for (int i = 0; i < retryAttempts && !matchedValue; i++) { + Serial.printf("[ENGINE] get_local: no matching outcome -> retry %d/%d\n", + i + 1, retryAttempts); + matchedValue = attemptGetLocal(data); + } + + if (matchedValue && varName[0]) { + _bleManager->setLocal(scope, varName, matchedValue); + Serial.printf("[ENGINE] get_local: %s=%s -> Pass\n", varName, matchedValue); + _currentNode = passTarget; + return; + } + + // All attempts failed — enter the FAIL screen state. The tick() + // handler will count down for 10 s and then take the Fail wire. + // A main-button click pauses the countdown; a second click retries + // the whole flow from scratch. + Serial.println("[ENGINE] get_local: all attempts failed -> FAIL screen"); + _failNodeIdx = _currentNode; + _failContinueTarget = failTarget; + _failStartMs = millis(); + _failTotalMs = 10000; + _waitUntil = _failStartMs + _failTotalMs; + _failLastDisplayUpdate = 0; + _display->showFailScreen(_failTotalMs, _failTotalMs, false); + state = ENGINE_GET_LOCAL_FAIL; + } + + // Expand (VAR{name}) placeholders in input using the variable store. + // Lookup is case-insensitive (matches user-typed names like + // (VAR{password}) against stored "Password"). If a name is not found, + // the placeholder is left verbatim so text still reads correctly. + // + // Legacy: (BLE{name}) is still recognized for backwards compatibility + // with old text nodes; new content should use (VAR{name}). + void expandVariables(const char* input, char* output, size_t maxLen) { + static const char* OPEN_VAR = "(VAR{"; + static const char* OPEN_BLE = "(BLE{"; + static const char* CLOSE = "})"; + static const int OPEN_LEN = 5; // both prefixes are 5 chars + static const int CLOSE_LEN = 2; + + size_t out = 0; + size_t in = 0; + size_t len = strlen(input); + + while (in < len && out < maxLen - 1) { + bool matchesOpen = in + OPEN_LEN <= len && + (strncmp(input + in, OPEN_VAR, OPEN_LEN) == 0 || + strncmp(input + in, OPEN_BLE, OPEN_LEN) == 0); + if (matchesOpen) { + // Find closing "})" + size_t nameStart = in + OPEN_LEN; + size_t nameEnd = nameStart; + bool closed = false; + while (nameEnd + CLOSE_LEN <= len) { + if (strncmp(input + nameEnd, CLOSE, CLOSE_LEN) == 0) { + closed = true; + break; + } + nameEnd++; + } + + if (closed && nameEnd > nameStart) { + char varName[BLE_VAR_NAME_LEN]; + size_t nameLen = nameEnd - nameStart; + if (nameLen < BLE_VAR_NAME_LEN) { + strncpy(varName, input + nameStart, nameLen); + varName[nameLen] = '\0'; + + const char* val = _bleManager ? _bleManager->getVariable(varName) : nullptr; + if (val && val[0] != '\0') { + size_t valLen = strlen(val); + size_t copy = (out + valLen < maxLen - 1) ? valLen : (maxLen - 1 - out); + memcpy(output + out, val, copy); + out += copy; + in = nameEnd + CLOSE_LEN; + continue; + } + } + // Unknown variable — copy placeholder verbatim + size_t placeholderEnd = nameEnd + CLOSE_LEN; + while (in < placeholderEnd && out < maxLen - 1) { + output[out++] = input[in++]; + } + continue; + } + } + output[out++] = input[in++]; + } + output[out] = '\0'; + } + + void updateLoopSelectorDisplay() { + _display->showLoopSelector(_loopSelectorPrompt, _loopSelectorCurrent, + _loopSelectorMin, _loopSelectorMax); + } + + void updateLoopStartSelectorDisplay() { + _display->showLoopStartSelector(_loopSelectorPrompt, _loopSelectorCurrent, + _loopSelectorMin, _loopSelectorMax, + _loopSelectorValue); + } + + void updateBranchDisplay() { + _display->showBranchSelector(_branchLabels, _branchColors, + _branchCount, _branchSelected, _branchScroll); + } + + void updatePauseDisplay() { + if (_pauseTimed) { + uint32_t elapsed = millis() - _pauseStartMs; + uint32_t remain = (_pauseTotalMs > elapsed) ? (_pauseTotalMs - elapsed) : 0; + _display->showPauseScreen(_pauseText, _pauseFontSize, true, remain, _pauseTotalMs, + _pauseTextColor, + _pauseMarginL(), _pauseMarginR(), + _pauseMarginT(), _pauseMarginB()); + } + } + + // Margin getters fall back to the compiled defaults when no settings + // manager is wired (e.g., during startup before settings.begin()). + uint8_t _pauseMarginL() const { + return _settingsMgr ? _settingsMgr->settings.pauseMarginLeft : DEFAULT_PAUSE_MARGIN_LEFT; + } + uint8_t _pauseMarginR() const { + return _settingsMgr ? _settingsMgr->settings.pauseMarginRight : DEFAULT_PAUSE_MARGIN_RIGHT; + } + uint8_t _pauseMarginT() const { + return _settingsMgr ? _settingsMgr->settings.pauseMarginTop : DEFAULT_PAUSE_MARGIN_TOP; + } + uint8_t _pauseMarginB() const { + return _settingsMgr ? _settingsMgr->settings.pauseMarginBottom : DEFAULT_PAUSE_MARGIN_BOTTOM; + } + +}; diff --git a/firmware/MacroPad/macro_storage.h b/firmware/MacroPad/macro_storage.h new file mode 100644 index 0000000..c6d6dd5 --- /dev/null +++ b/firmware/MacroPad/macro_storage.h @@ -0,0 +1,435 @@ +#pragma once + +#include +#include +#include "config.h" + +struct MacroInfo { + char name[64]; + char labelColor[12]; + int nodeCount; + bool hasImage; +}; + +struct SubInfo { + char name[64]; + int nodeCount; +}; + +class MacroStorage { +public: + int macroCount = 0; + int order[MAX_MACROS]; + MacroInfo macros[MAX_MACROS]; + + int subCount = 0; + SubInfo subs[MAX_SUBROUTINES]; + + bool begin() { + if (!LittleFS.begin(true)) { + return false; + } + memset(subs, 0, sizeof(subs)); + loadIndex(); + return true; + } + + void loadIndex() { + macroCount = 0; + memset(order, 0, sizeof(order)); + + if (!LittleFS.exists(CONFIG_PATH)) { + saveIndex(); + return; + } + + File f = LittleFS.open(CONFIG_PATH, "r"); + if (!f) return; + + JsonDocument doc; + if (deserializeJson(doc, f) != DeserializationError::Ok) { + f.close(); + return; + } + f.close(); + + macroCount = doc["count"] | 0; + JsonArray orderArr = doc["order"].as(); + for (int i = 0; i < macroCount && i < MAX_MACROS; i++) { + order[i] = orderArr[i] | i; + } + + for (int i = 0; i < macroCount; i++) { + loadMacroMeta(order[i]); + } + } + + void saveIndex() { + JsonDocument doc; + doc["count"] = macroCount; + JsonArray orderArr = doc["order"].to(); + for (int i = 0; i < macroCount; i++) { + orderArr.add(order[i]); + } + + File f = LittleFS.open(CONFIG_PATH, "w"); + if (f) { + serializeJson(doc, f); + f.close(); + } + } + + void loadMacroMeta(int slot) { + if (slot < 0 || slot >= MAX_MACROS) return; + + char path[48]; + snprintf(path, sizeof(path), "/m%d/meta.json", slot); + + MacroInfo& info = macros[slot]; + memset(&info, 0, sizeof(MacroInfo)); + strcpy(info.name, "Unnamed"); + strcpy(info.labelColor, "white"); + + if (!LittleFS.exists(path)) return; + + File f = LittleFS.open(path, "r"); + if (!f) return; + + JsonDocument doc; + if (deserializeJson(doc, f) == DeserializationError::Ok) { + strlcpy(info.name, doc["name"] | "Unnamed", sizeof(info.name)); + strlcpy(info.labelColor, doc["label_color"] | "white", sizeof(info.labelColor)); + info.nodeCount = doc["nodes"] | 0; + } + f.close(); + + snprintf(path, sizeof(path), "/m%d/icon.raw", slot); + info.hasImage = LittleFS.exists(path); + } + + bool beginMacroWrite(int slot, const char* name, int nodeCount, const char* labelColor = "white") { + if (slot < 0 || slot >= MAX_MACROS) return false; + + char dir[16]; + snprintf(dir, sizeof(dir), "/m%d", slot); + LittleFS.mkdir(dir); + + char path[48]; + snprintf(path, sizeof(path), "/m%d/meta.json", slot); + File f = LittleFS.open(path, "w"); + if (!f) return false; + + JsonDocument doc; + doc["name"] = name; + doc["label_color"] = labelColor; + doc["nodes"] = nodeCount; + serializeJson(doc, f); + f.close(); + + // Clear existing nodes file + snprintf(path, sizeof(path), "/m%d/nodes.json", slot); + File nf = LittleFS.open(path, "w"); + if (nf) { + nf.print("["); + nf.close(); + } + + strlcpy(macros[slot].name, name, sizeof(macros[slot].name)); + strlcpy(macros[slot].labelColor, labelColor, sizeof(macros[slot].labelColor)); + macros[slot].nodeCount = nodeCount; + + return true; + } + + bool writeImageData(int slot, uint8_t* data, size_t len) { + char path[32]; + snprintf(path, sizeof(path), "/m%d/icon.raw", slot); + File f = LittleFS.open(path, "w"); + if (!f) return false; + size_t written = f.write(data, len); + f.close(); + macros[slot].hasImage = (written == len); + return macros[slot].hasImage; + } + + bool writeImageChunk(int slot, uint8_t* data, size_t len, bool first) { + char path[32]; + snprintf(path, sizeof(path), "/m%d/icon.raw", slot); + File f = LittleFS.open(path, first ? "w" : "a"); + if (!f) return false; + f.write(data, len); + f.close(); + return true; + } + + bool appendNode(int slot, const char* nodeJson, bool last) { + char path[48]; + snprintf(path, sizeof(path), "/m%d/nodes.json", slot); + File f = LittleFS.open(path, "a"); + if (!f) return false; + f.print(nodeJson); + if (!last) f.print(","); + else f.print("]"); + f.close(); + return true; + } + + bool finalizeMacro(int slot) { + // Add to index if not already present + bool found = false; + for (int i = 0; i < macroCount; i++) { + if (order[i] == slot) { found = true; break; } + } + if (!found && macroCount < MAX_MACROS) { + order[macroCount] = slot; + macroCount++; + } + loadMacroMeta(slot); + saveIndex(); + return true; + } + + bool deleteMacro(int slot) { + char path[48]; + snprintf(path, sizeof(path), "/m%d/meta.json", slot); + LittleFS.remove(path); + snprintf(path, sizeof(path), "/m%d/nodes.json", slot); + LittleFS.remove(path); + snprintf(path, sizeof(path), "/m%d/icon.raw", slot); + LittleFS.remove(path); + snprintf(path, sizeof(path), "/m%d", slot); + LittleFS.rmdir(path); + + // Remove from order + int idx = -1; + for (int i = 0; i < macroCount; i++) { + if (order[i] == slot) { idx = i; break; } + } + if (idx >= 0) { + for (int i = idx; i < macroCount - 1; i++) { + order[i] = order[i + 1]; + } + macroCount--; + } + saveIndex(); + return true; + } + + bool loadNodes(int slot, JsonDocument& doc) { + char path[48]; + snprintf(path, sizeof(path), "/m%d/nodes.json", slot); + File f = LittleFS.open(path, "r"); + if (!f) return false; + DeserializationError err = deserializeJson(doc, f); + f.close(); + return err == DeserializationError::Ok; + } + + void reorder(int* newOrder, int count) { + macroCount = count; + for (int i = 0; i < count && i < MAX_MACROS; i++) { + order[i] = newOrder[i]; + } + saveIndex(); + } + + size_t getFreeSpace() { + return LittleFS.totalBytes() - LittleFS.usedBytes(); + } + + // --- Sub-routine storage --- + // + // Upload protocol on the wire is "sub_begin → 0..N sub_node → sub_end". + // Storage writes go to ``/sub/s{N}/nodes.tmp`` during the upload and only + // get renamed to the live ``/sub/s{N}/nodes.json`` once sub_end fires, + // confirming we have all the expected nodes AND that the assembled text + // parses as valid JSON. Three failure modes are now handled atomically: + // + // 1. Upload aborts mid-stream (USB unplug, host crash): tmp file exists + // but nodes.json is untouched, so loadSubNodes continues to see the + // LAST GOOD version (or returns false if it never existed). + // 2. nodeCount=0 — sub-routine that flattens to nothing. We skip tmp + // entirely and write "[]" straight to nodes.json so the file is + // immediately valid. + // 3. Malformed JSON (corrupt host send): finalizeSubWrite re-parses + // the tmp before promoting it. If parse fails, the bad tmp is + // removed and the live file is left as-is. + // + // Engine-side, loadSubNodes is unchanged (just reads nodes.json), so + // the engine never sees a partial / malformed file on this path. + + bool beginSubWrite(int slot, const char* name, int nodeCount) { + if (slot < 0 || slot >= MAX_SUBROUTINES) return false; + + char dir[32]; + snprintf(dir, sizeof(dir), "/sub/s%d", slot); + LittleFS.mkdir("/sub"); + LittleFS.mkdir(dir); + + char path[48]; + snprintf(path, sizeof(path), "/sub/s%d/meta.json", slot); + File f = LittleFS.open(path, "w"); + if (!f) return false; + JsonDocument doc; + doc["name"] = name; + doc["nodes"] = nodeCount; + serializeJson(doc, f); + f.close(); + + // Sweep any leftover tmp from a previous interrupted upload so the + // append path starts from a known-empty state. + snprintf(path, sizeof(path), "/sub/s%d/nodes.tmp", slot); + if (LittleFS.exists(path)) LittleFS.remove(path); + + if (nodeCount <= 0) { + // Empty sub-routine — no append phase will follow, so commit + // the valid empty array straight to the live file. No tmp dance + // needed. + snprintf(path, sizeof(path), "/sub/s%d/nodes.json", slot); + File nf = LittleFS.open(path, "w"); + if (nf) { nf.print("[]"); nf.close(); } + } else { + // Open tmp with the opening bracket. appendSubNode will fill + // it in; finalizeSubWrite will rename it to nodes.json on + // success. + snprintf(path, sizeof(path), "/sub/s%d/nodes.tmp", slot); + File nf = LittleFS.open(path, "w"); + if (nf) { nf.print("["); nf.close(); } + } + + strlcpy(subs[slot].name, name, sizeof(subs[slot].name)); + subs[slot].nodeCount = nodeCount; + + if (slot >= subCount) subCount = slot + 1; + return true; + } + + bool appendSubNode(int slot, const char* nodeJson, bool last) { + char path[48]; + snprintf(path, sizeof(path), "/sub/s%d/nodes.tmp", slot); + File f = LittleFS.open(path, "a"); + if (!f) return false; + f.print(nodeJson); + if (!last) f.print(","); + else f.print("]"); + f.close(); + return true; + } + + // Promote the just-written tmp to the live nodes.json — only if it + // parses as valid JSON. Returns true on successful swap. If the tmp + // file doesn't exist (because beginSubWrite already committed the + // empty-sub case directly to nodes.json), this is a no-op success. + // If the tmp file is malformed, the live nodes.json is left as-is + // (preserving the previous good version) and the bad tmp is removed. + bool finalizeSubWrite(int slot) { + if (slot < 0 || slot >= MAX_SUBROUTINES) return false; + + char tmpPath[48], livePath[48]; + snprintf(tmpPath, sizeof(tmpPath), "/sub/s%d/nodes.tmp", slot); + snprintf(livePath, sizeof(livePath), "/sub/s%d/nodes.json", slot); + + if (!LittleFS.exists(tmpPath)) { + // beginSubWrite handled the empty-sub case directly. Nothing + // to promote, but make sure nodes.json exists with at least + // an empty array so loadSubNodes never returns false here. + if (!LittleFS.exists(livePath)) { + File nf = LittleFS.open(livePath, "w"); + if (nf) { nf.print("[]"); nf.close(); } + } + return true; + } + + // Validate the tmp before promoting. If it doesn't parse, the + // previous live file (if any) is left untouched — the device will + // keep using the last known-good version of this sub. + // + // NOTE: no Serial.printf in this function. It's called from + // cmdSubEnd during profile upload, which shares the USB CDC pipe + // with the JSON command/response stream. Any text emitted here + // would corrupt the host's readline() on the next response and + // tear down the serial connection. Failure is communicated up + // through the bool return value; the caller turns that into a + // sendError(...) JSON payload. + { + File vf = LittleFS.open(tmpPath, "r"); + if (!vf) { LittleFS.remove(tmpPath); return false; } + JsonDocument vdoc; + DeserializationError err = deserializeJson(vdoc, vf); + vf.close(); + if (err != DeserializationError::Ok) { + LittleFS.remove(tmpPath); + return false; + } + } + + // Atomically swap tmp -> live. Some LittleFS versions don't + // overwrite on rename, so remove the live file first; the window + // between remove and rename is tiny (microseconds) compared to + // the full upload, so accepting it here is fine. + if (LittleFS.exists(livePath)) LittleFS.remove(livePath); + if (!LittleFS.rename(tmpPath, livePath)) { + LittleFS.remove(tmpPath); + return false; + } + return true; + } + + bool loadSubNodes(int slot, JsonDocument& doc) { + // Stays silent (no Serial.printf) on failure — the storage layer + // can be exercised from inside the protocol handler in edge + // cases (e.g. a re-upload while the engine just finished using + // the sub), and any text emitted on the USB CDC pipe corrupts + // the host's JSON response stream. The engine's caller handles + // the failure case with its own diagnostic line. + char path[48]; + snprintf(path, sizeof(path), "/sub/s%d/nodes.json", slot); + File f = LittleFS.open(path, "r"); + if (!f) return false; + DeserializationError err = deserializeJson(doc, f); + f.close(); + return err == DeserializationError::Ok; + } + + int findSubByName(const char* name) { + for (int i = 0; i < subCount; i++) { + if (strcmp(subs[i].name, name) == 0) return i; + } + return -1; + } + + void clearAllSubs() { + for (int i = 0; i < subCount; i++) { + char path[48]; + snprintf(path, sizeof(path), "/sub/s%d/meta.json", i); + LittleFS.remove(path); + snprintf(path, sizeof(path), "/sub/s%d/nodes.json", i); + LittleFS.remove(path); + // Sweep any stray tmp left over from an interrupted upload. + snprintf(path, sizeof(path), "/sub/s%d/nodes.tmp", i); + if (LittleFS.exists(path)) LittleFS.remove(path); + snprintf(path, sizeof(path), "/sub/s%d", i); + LittleFS.rmdir(path); + } + subCount = 0; + } + + void loadSubIndex() { + subCount = 0; + for (int i = 0; i < MAX_SUBROUTINES; i++) { + char path[48]; + snprintf(path, sizeof(path), "/sub/s%d/meta.json", i); + if (!LittleFS.exists(path)) break; + + File f = LittleFS.open(path, "r"); + if (!f) break; + JsonDocument doc; + if (deserializeJson(doc, f) == DeserializationError::Ok) { + strlcpy(subs[i].name, doc["name"] | "Unnamed", sizeof(subs[i].name)); + subs[i].nodeCount = doc["nodes"] | 0; + subCount = i + 1; + } + f.close(); + } + } +}; diff --git a/firmware/MacroPad/rs232_util.h b/firmware/MacroPad/rs232_util.h new file mode 100644 index 0000000..5086ca0 --- /dev/null +++ b/firmware/MacroPad/rs232_util.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +// Build an ESP32 UART config constant from individual parameters. +// Shared by MacroEngine (for rs232 node execution) and SerialProtocol +// (for the host-side RS232 terminal pass-through). +inline uint32_t computeRS232Config(int dataBits, const char* parity, const char* stopBits) { + bool twoStop = (strcmp(stopBits, "2") == 0 || strcmp(stopBits, "1.5") == 0); + + if (dataBits == 5) { + if (strcmp(parity, "even") == 0) return twoStop ? SERIAL_5E2 : SERIAL_5E1; + if (strcmp(parity, "odd") == 0) return twoStop ? SERIAL_5O2 : SERIAL_5O1; + return twoStop ? SERIAL_5N2 : SERIAL_5N1; + } else if (dataBits == 6) { + if (strcmp(parity, "even") == 0) return twoStop ? SERIAL_6E2 : SERIAL_6E1; + if (strcmp(parity, "odd") == 0) return twoStop ? SERIAL_6O2 : SERIAL_6O1; + return twoStop ? SERIAL_6N2 : SERIAL_6N1; + } else if (dataBits == 7) { + if (strcmp(parity, "even") == 0) return twoStop ? SERIAL_7E2 : SERIAL_7E1; + if (strcmp(parity, "odd") == 0) return twoStop ? SERIAL_7O2 : SERIAL_7O1; + return twoStop ? SERIAL_7N2 : SERIAL_7N1; + } else { // default to 8 data bits + if (strcmp(parity, "even") == 0) return twoStop ? SERIAL_8E2 : SERIAL_8E1; + if (strcmp(parity, "odd") == 0) return twoStop ? SERIAL_8O2 : SERIAL_8O1; + return twoStop ? SERIAL_8N2 : SERIAL_8N1; + } +} diff --git a/firmware/MacroPad/serial_protocol.h b/firmware/MacroPad/serial_protocol.h new file mode 100644 index 0000000..37a1995 --- /dev/null +++ b/firmware/MacroPad/serial_protocol.h @@ -0,0 +1,861 @@ +#pragma once + +#include +#include +#include "esp32-hal-tinyusb.h" +#include "config.h" +#include "settings.h" +#include "macro_storage.h" +#include "display_ui.h" +#include "debug_log.h" +#include "rs232_util.h" +#include "ble_keystore.h" +#include "ble_manager.h" +#include "espnow_manager.h" + +// CRC16-CCITT (poly 0x1021, init 0xFFFF) over the hub binary framing — +// must match mesh_link.py on the host. +inline uint16_t hubCrc16(const uint8_t* data, size_t len, + uint16_t crc = 0xFFFF) { + for (size_t i = 0; i < len; i++) { + crc ^= (uint16_t)data[i] << 8; + for (int b = 0; b < 8; b++) { + crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) + : (uint16_t)(crc << 1); + } + } + return crc; +} + +class SerialProtocol { +public: + typedef void (*VoidCallback)(); + + void begin(SettingsManager* settings, MacroStorage* storage, DisplayUI* display, + DebugLog* dlog = nullptr, HardwareSerial* rs232 = nullptr, + VoidCallback onRS232Reconfig = nullptr, + BLEKeyStore* keystore = nullptr, + BLEManager* bleManager = nullptr, + EspNowManager* mesh = nullptr) { + _settings = settings; + _storage = storage; + _display = display; + _dlog = dlog; + _rs232Serial = rs232; + _onRS232Reconfig = onRS232Reconfig; + _keystore = keystore; + _bleManager = bleManager; + _mesh = mesh; + } + + // Call periodically from the main loop. When the host-side RS232 terminal + // is open, drains incoming bytes into a buffer that rs232_poll returns. + void pollRS232() { + if (!_rs232TerminalOpen || !_rs232Serial) return; + while (_rs232Serial->available()) { + if (_rs232TerminalBufPos >= (int)sizeof(_rs232TerminalBuf)) { + // Buffer full — drop oldest half so we don't lose forever-recent data + int keep = sizeof(_rs232TerminalBuf) / 2; + memmove(_rs232TerminalBuf, _rs232TerminalBuf + (sizeof(_rs232TerminalBuf) - keep), keep); + _rs232TerminalBufPos = keep; + } + _rs232TerminalBuf[_rs232TerminalBufPos++] = _rs232Serial->read(); + } + } + + // Returns true if settings changed (display needs refresh) + bool handleSerial() { + if (!Serial.available()) return false; + + if (_receivingImage) { + // Keep the upload-active window fresh across the whole image + // transfer so BLE stays suspended until it finishes. + _lastUploadCmdMs = millis(); + return receiveImageData(); + } + + // Hub binary bridge: a frame in progress, or a new one starting. + // JSON lines keep working in parallel — we dispatch on the first + // byte (0xC8 = binary frame, '{' = JSON line). + if (_binState != BIN_IDLE) return _pumpBinary(); + if (Serial.peek() == HUB_MAGIC0) { + Serial.read(); + _binState = BIN_MAGIC1; + return _pumpBinary(); + } + + String line = Serial.readStringUntil('\n'); + line.trim(); + if (line.length() == 0) return false; + + JsonDocument doc; + if (deserializeJson(doc, line) != DeserializationError::Ok) { + sendError("invalid json"); + return false; + } + + const char* cmd = doc["cmd"] | ""; + return processCommand(cmd, doc); + } + + bool isBusy() const { return _receivingImage || _receivingNodes; } + + // True while a profile upload (or key/bootloader op) is in flight or + // just finished. The main loop uses this to keep live-BLE advertising + // OFF during USB transfers — NimBLE advertising concurrent with a + // sustained USB-CDC upload is the radio/CDC contention we must avoid. + bool isUploadActive() const { + if (_receivingImage || _receivingNodes) return true; + return _lastUploadCmdMs != 0 && + (millis() - _lastUploadCmdMs) < UPLOAD_QUIET_MS; + } + + // True once the host app has talked to us over USB this boot (it pings + // on connect). It means we're plugged into the configuring computer, + // so BLE stays off for the rest of the boot (see MacroPad.ino) to keep + // NimBLE from contending with the USB-CDC pipe during uploads. Latched + // for the whole boot; cleared only by a power cycle. + bool isHostConnected() const { return _hostSeen; } + + bool needsRefresh() { + bool r = _refreshNeeded; + _refreshNeeded = false; + return r; + } + +private: + SettingsManager* _settings; + MacroStorage* _storage; + DisplayUI* _display; + DebugLog* _dlog = nullptr; + HardwareSerial* _rs232Serial = nullptr; + VoidCallback _onRS232Reconfig = nullptr; + BLEKeyStore* _keystore = nullptr; + BLEManager* _bleManager = nullptr; + EspNowManager* _mesh = nullptr; + + // RS232 pass-through terminal state + bool _rs232TerminalOpen = false; + uint8_t _rs232TerminalBuf[1024]; + int _rs232TerminalBufPos = 0; + + // Image receive state + bool _receivingImage = false; + int _imgSlot = 0; + size_t _imgSize = 0; + size_t _imgReceived = 0; + bool _imgFirst = true; + + // Chunk protocol state + bool _imgChunkActive = false; + size_t _imgChunkSize = 0; + size_t _imgChunkRead = 0; + uint8_t _imgChunkBuf[512]; + + // Node receive state + bool _receivingNodes = false; + int _nodeSlot = 0; + int _nodeCount = 0; + int _nodesReceived = 0; + + bool _refreshNeeded = false; + + // millis() of the last profile-mutating serial command. Drives + // isUploadActive() so the main loop suspends live-BLE advertising + // for a short window around USB uploads. + uint32_t _lastUploadCmdMs = 0; + static constexpr uint32_t UPLOAD_QUIET_MS = 2000; + + // Latched true the first time we process any valid command from the + // host app over USB. Drives isHostConnected(). + bool _hostSeen = false; + + // Commands that imply the host is actively uploading a profile (or + // syncing the BLE key / entering the bootloader). During these we + // want BLE off the radio. Lightweight status pings (ping, get_*, + // rs232_poll) are intentionally excluded so the toolbar can keep + // polling without flapping the live link. + static bool _isUploadCmd(const char* cmd) { + return strcmp(cmd, "macro_begin") == 0 || + strcmp(cmd, "node") == 0 || + strcmp(cmd, "macro_end") == 0 || + strcmp(cmd, "macro_delete") == 0 || + strcmp(cmd, "macro_reorder") == 0 || + strcmp(cmd, "sub_begin") == 0 || + strcmp(cmd, "sub_node") == 0 || + strcmp(cmd, "sub_end") == 0 || + strcmp(cmd, "sub_clear") == 0 || + strcmp(cmd, "get_ble_key") == 0 || + strcmp(cmd, "bootloader") == 0; + } + + bool processCommand(const char* cmd, JsonDocument& doc) { + // Any valid command means the host app is connected over USB — keep + // BLE off for the rest of this boot. + _hostSeen = true; + // Note any profile-mutating / bulk-transfer command so the main + // loop suspends live-BLE advertising during USB uploads. + if (_isUploadCmd(cmd)) _lastUploadCmdMs = millis(); + + if (strcmp(cmd, "ping") == 0) { + return cmdPing(); + } else if (strcmp(cmd, "set") == 0) { + return cmdSet(doc); + } else if (strcmp(cmd, "get_settings") == 0) { + return cmdGetSettings(); + } else if (strcmp(cmd, "macro_begin") == 0) { + return cmdMacroBegin(doc); + } else if (strcmp(cmd, "node") == 0) { + return cmdNode(doc); + } else if (strcmp(cmd, "macro_end") == 0) { + return cmdMacroEnd(doc); + } else if (strcmp(cmd, "macro_delete") == 0) { + return cmdMacroDelete(doc); + } else if (strcmp(cmd, "macro_reorder") == 0) { + return cmdMacroReorder(doc); + } else if (strcmp(cmd, "bootloader") == 0) { + return cmdBootloader(); + } else if (strcmp(cmd, "get_log") == 0) { + return cmdGetLog(); + } else if (strcmp(cmd, "clear_log") == 0) { + return cmdClearLog(); + } else if (strcmp(cmd, "get_ble_log") == 0) { + return cmdGetBleLog(); + } else if (strcmp(cmd, "clear_ble_log") == 0) { + return cmdClearBleLog(); + } else if (strcmp(cmd, "sub_begin") == 0) { + return cmdSubBegin(doc); + } else if (strcmp(cmd, "sub_node") == 0) { + return cmdSubNode(doc); + } else if (strcmp(cmd, "sub_end") == 0) { + return cmdSubEnd(doc); + } else if (strcmp(cmd, "sub_clear") == 0) { + return cmdSubClear(); + } else if (strcmp(cmd, "rs232_open") == 0) { + return cmdRs232Open(doc); + } else if (strcmp(cmd, "rs232_close") == 0) { + return cmdRs232Close(doc); + } else if (strcmp(cmd, "rs232_send") == 0) { + return cmdRs232Send(doc); + } else if (strcmp(cmd, "rs232_poll") == 0) { + return cmdRs232Poll(doc); + } else if (strcmp(cmd, "get_ble_key") == 0) { + return cmdGetBleKey(); + } else if (strcmp(cmd, "espnow_hub") == 0) { + return cmdEspnowHub(doc); + } else if (strcmp(cmd, "mesh_poll") == 0) { + return cmdMeshPoll(doc); + } else if (strcmp(cmd, "hub_ping") == 0) { + return cmdHubPing(); + } else { + sendError("unknown command"); + return false; + } + } + + // ===================================================================== + // ESP-NOW mesh hub bridge + // ===================================================================== + + // Binary frame from the host (H2D): 0xC8 0x35 | htype | len u16LE | + // payload | crc16(htype, len, payload). Stateful so a frame split + // across loop iterations resumes where it left off. + enum BinState : uint8_t { BIN_IDLE = 0, BIN_MAGIC1, BIN_HDR, BIN_BODY }; + + BinState _binState = BIN_IDLE; + uint8_t _binHdr[3] = {0}; + int _binHdrPos = 0; + uint16_t _binLen = 0; + uint16_t _binPos = 0; + uint8_t _binBuf[HUB_MAX_FRAME + 2]; + + bool _pumpBinary() { + uint32_t start = millis(); + while ((millis() - start) < 50) { + if (!Serial.available()) return false; // resume next loop + switch (_binState) { + case BIN_MAGIC1: { + int c = Serial.read(); + if (c != HUB_MAGIC1) { _binState = BIN_IDLE; return false; } + _binState = BIN_HDR; + _binHdrPos = 0; + break; + } + case BIN_HDR: { + _binHdr[_binHdrPos++] = (uint8_t)Serial.read(); + if (_binHdrPos == 3) { + _binLen = (uint16_t)_binHdr[1] | ((uint16_t)_binHdr[2] << 8); + if (_binLen > HUB_MAX_FRAME) { + _binState = BIN_IDLE; // garbage; resync on next magic + return false; + } + _binPos = 0; + _binState = BIN_BODY; + } + break; + } + case BIN_BODY: { + _binBuf[_binPos++] = (uint8_t)Serial.read(); + if (_binPos == (uint16_t)(_binLen + 2)) { // payload + crc16 + _binState = BIN_IDLE; + uint16_t want = (uint16_t)_binBuf[_binLen] | + ((uint16_t)_binBuf[_binLen + 1] << 8); + uint16_t got = hubCrc16(_binHdr, 3); + got = hubCrc16(_binBuf, _binLen, got); + if (want != got) return false; // corrupt; drop + _dispatchBinary(_binHdr[0], _binBuf, _binLen); + return false; + } + break; + } + default: + _binState = BIN_IDLE; + return false; + } + } + return false; + } + + void _dispatchBinary(uint8_t htype, const uint8_t* payload, size_t len) { + if (htype == HUB_H2D_SEND && _mesh) { + _mesh->hubSendFromHost(payload, len); + } + // Unknown htypes are ignored (forward compatibility). + } + + // Device-to-host sink used by EspNowManager (main task only). Wraps + // the payload in the same framing the host parser expects. + static void hostSinkStatic(uint8_t htype, const uint8_t* payload, + size_t len) { + uint8_t hdr[5] = { HUB_MAGIC0, HUB_MAGIC1, htype, + (uint8_t)len, (uint8_t)(len >> 8) }; + uint16_t crc = hubCrc16(hdr + 2, 3); + crc = hubCrc16(payload, len, crc); + uint8_t tail[2] = { (uint8_t)crc, (uint8_t)(crc >> 8) }; + Serial.write(hdr, sizeof(hdr)); + Serial.write(payload, len); + Serial.write(tail, 2); + Serial.flush(); + } + + bool cmdEspnowHub(JsonDocument& doc) { + if (!_mesh) { sendError("no mesh"); return false; } + bool on = doc["on"] | true; + if (on) { + // The hub owns the radio: live BLE (if any) must be torn down + // first. The BLE variables path is unaffected — it only runs + // inside routines, which a hub never executes. + if (_bleManager) { + _bleManager->stopLive(); + _bleManager->shutdown(); + } + if (!_mesh->hubStart(&SerialProtocol::hostSinkStatic)) { + sendError("hub start failed"); + return false; + } + JsonDocument rsp; + rsp["rsp"] = "hub"; + rsp["on"] = true; + rsp["ch"] = _settings->settings.meshChannel; + { + uint8_t mac[6] = {0}; + if (esp_efuse_mac_get_default(mac) != ESP_OK) { + esp_read_mac(mac, ESP_MAC_WIFI_STA); + } + char macStr[18]; + snprintf(macStr, sizeof(macStr), + "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + rsp["sta_mac"] = macStr; + } + sendJson(rsp); + _refreshNeeded = true; // repaint: hub screen + } else { + _mesh->hubStop(); + sendOk(); + _refreshNeeded = true; // repaint: back to selector + } + return false; + } + + bool cmdMeshPoll(JsonDocument& doc) { + if (!_mesh) { sendError("no mesh"); return false; } + bool on = doc["on"] | true; + _mesh->hubSetPollActive(on); + _mesh->notifyHostActivity(); + sendOk(); + return false; + } + + bool cmdHubPing() { + if (_mesh) _mesh->notifyHostActivity(); + JsonDocument rsp; + rsp["rsp"] = "hub_pong"; + rsp["hub"] = _mesh ? _mesh->isHub() : false; + rsp["nodes"] = _mesh ? _mesh->hubNodeCount() : 0; + sendJson(rsp); + return false; + } + + bool cmdPing() { + JsonDocument rsp; + rsp["rsp"] = "pong"; + rsp["ver"] = FW_VERSION; + rsp["id"] = DEVICE_ID; + rsp["macros"] = _storage->macroCount; + rsp["free"] = _storage->getFreeSpace(); + // Universal binary: tell the host which board this is so the GUI + // can adapt (the Lite has no screen) and so flash tooling can + // print accurate instructions. + rsp["board"] = (_display && !_display->present()) + ? BOARD_NAME_ATOMS3_LITE : BOARD_NAME_ATOMS3; + // WiFi STA MAC (eFuse base MAC) — the mesh identity. Same bytes as + // the AES device tag, surfaced directly so the host never has to + // parse the tag string. + { + uint8_t mac[6] = {0}; + if (esp_efuse_mac_get_default(mac) != ESP_OK) { + esp_read_mac(mac, ESP_MAC_WIFI_STA); + } + char macStr[18]; + snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + rsp["sta_mac"] = macStr; + } + rsp["mesh_ch"] = _settings->settings.meshChannel; + // Persisted live transport (0 = ESP-NOW mesh, 1 = BLE) so the host + // can show each device's mode as it's plugged in over USB. + rsp["live_tx"] = _settings->settings.liveTransport; + sendJson(rsp); + return false; + } + + bool cmdSet(JsonDocument& doc) { + const char* key = doc["key"] | ""; + int val = doc["val"] | 0; + _settings->set(key, val); + + if (strcmp(key, "orientation") == 0) { + _display->setOrientation(val); + } + + sendOk(); + return true; + } + + bool cmdGetSettings() { + JsonDocument rsp; + rsp["rsp"] = "settings"; + rsp["hold_ms"] = _settings->settings.holdMs; + rsp["orientation"] = _settings->settings.orientation; + rsp["type_delay"] = _settings->settings.typeDelay; + rsp["resume_delay"] = _settings->settings.resumeDelay; + rsp["combo_pre_ms"] = _settings->settings.comboPreMs; + rsp["combo_post_ms"] = _settings->settings.comboPostMs; + rsp["probe_timeout_ms"] = _settings->settings.probeTimeoutMs; + rsp["media_hold_ms"] = _settings->settings.mediaHoldMs; + rsp["type_shift_extra_ms"] = _settings->settings.typeShiftExtraMs; + rsp["type_settle_ms"] = _settings->settings.typeSettleMs; + rsp["type_hold_min_ms"] = _settings->settings.typeHoldMinMs; + rsp["type_inter_char_ms"] = _settings->settings.typeInterCharMs; + rsp["pause_margin_left"] = _settings->settings.pauseMarginLeft; + rsp["pause_margin_right"] = _settings->settings.pauseMarginRight; + rsp["pause_margin_top"] = _settings->settings.pauseMarginTop; + rsp["pause_margin_bottom"] = _settings->settings.pauseMarginBottom; + sendJson(rsp); + return false; + } + + bool cmdMacroBegin(JsonDocument& doc) { + int slot = doc["slot"] | 0; + const char* name = doc["name"] | "Unnamed"; + const char* labelColor = doc["label_color"] | "white"; + int nodeCount = doc["node_count"] | 0; + size_t imgSize = doc["img_size"] | 0; + + if (!_storage->beginMacroWrite(slot, name, nodeCount, labelColor)) { + sendError("write failed"); + return false; + } + + _nodeSlot = slot; + _nodeCount = nodeCount; + _nodesReceived = 0; + + if (imgSize > 0) { + _receivingImage = true; + _imgSlot = slot; + _imgSize = imgSize; + _imgReceived = 0; + _imgFirst = true; + _imgChunkActive = false; + _imgChunkSize = 0; + _imgChunkRead = 0; + } + + sendReady(); + return false; + } + + bool receiveImageData() { + // Chunk+ACK protocol: host sends chunk size as text line first, + // then binary data, we ACK after writing each chunk. + if (!_imgChunkActive) { + // Read the chunk header line (e.g. "CHUNK:128\n") + if (!Serial.available()) return false; + String line = Serial.readStringUntil('\n'); + line.trim(); + if (line.startsWith("CHUNK:")) { + _imgChunkSize = line.substring(6).toInt(); + if (_imgChunkSize <= 0 || _imgChunkSize > 512) { + sendError("bad chunk size"); + _receivingImage = false; + return false; + } + _imgChunkRead = 0; + _imgChunkActive = true; + } else if (line == "IMG_DONE") { + // Transfer complete + _receivingImage = false; + _storage->macros[_imgSlot].hasImage = true; + Serial.println("{\"rsp\":\"img_ok\"}"); + Serial.flush(); + } + return false; + } + + // Read binary chunk data byte-by-byte in a tight loop + unsigned long start = millis(); + while (_imgChunkRead < _imgChunkSize && (millis() - start) < 2000) { + if (Serial.available()) { + _imgChunkBuf[_imgChunkRead++] = Serial.read(); + } + } + + if (_imgChunkRead >= _imgChunkSize) { + _storage->writeImageChunk(_imgSlot, _imgChunkBuf, _imgChunkSize, _imgFirst); + _imgFirst = false; + _imgReceived += _imgChunkSize; + _imgChunkActive = false; + + Serial.println("OK"); + Serial.flush(); + } + // On timeout we stay in chunk-active mode and resume next loop + return false; + } + + bool cmdNode(JsonDocument& doc) { + int idx = doc["idx"] | _nodesReceived; + + // Strip down to just the node fields we want to persist + JsonDocument nodeDoc; + nodeDoc["type"] = doc["type"]; + nodeDoc["data"] = doc["data"]; + + String nodeStr; + serializeJson(nodeDoc, nodeStr); + + bool last = (idx >= _nodeCount - 1); + _storage->appendNode(_nodeSlot, nodeStr.c_str(), last); + _nodesReceived++; + + sendOk(); + return false; + } + + bool cmdMacroEnd(JsonDocument& doc) { + int slot = doc["slot"] | _nodeSlot; + _storage->finalizeMacro(slot); + _refreshNeeded = true; + sendOk(); + return false; + } + + bool cmdMacroDelete(JsonDocument& doc) { + int slot = doc["slot"] | 0; + _storage->deleteMacro(slot); + _refreshNeeded = true; + sendOk(); + return false; + } + + bool cmdBootloader() { + // Acknowledge before disappearing so the host knows the command landed + sendOk(); + delay(100); + // Tear down TinyUSB, route USB PHY back to USB-Serial/JTAG, + // set FORCE_DOWNLOAD_BOOT flag, then restart into ROM download mode. + // esptool must use --before no-reset to connect after this. + usb_persist_restart(RESTART_BOOTLOADER); + return false; // unreachable + } + + bool cmdMacroReorder(JsonDocument& doc) { + JsonArray orderArr = doc["order"].as(); + int newOrder[MAX_MACROS]; + int count = 0; + for (JsonVariant v : orderArr) { + if (count < MAX_MACROS) { + newOrder[count++] = v.as(); + } + } + _storage->reorder(newOrder, count); + _refreshNeeded = true; + sendOk(); + return false; + } + + bool cmdGetLog() { + if (_dlog) _dlog->sendOverSerial(); + else Serial.println("{\"rsp\":\"log\",\"entries\":[]}"); + Serial.flush(); + return false; + } + + bool cmdClearLog() { + if (_dlog) _dlog->clear(); + sendOk(); + return false; + } + + bool cmdGetBleLog() { + if (_bleManager) _bleManager->dbg.dumpJson(); + else Serial.println("{\"rsp\":\"ble_log\",\"entries\":[]}"); + Serial.flush(); + return false; + } + + bool cmdClearBleLog() { + if (_bleManager) _bleManager->dbg.clear(); + sendOk(); + return false; + } + + bool cmdGetBleKey() { + if (!_keystore || !_keystore->hasKey()) { + sendError("no ble key"); + return false; + } + char hex[BLEKeyStore::KEY_LEN * 2 + 1]; + const uint8_t* k = _keystore->key(); + for (size_t i = 0; i < BLEKeyStore::KEY_LEN; i++) { + sprintf(hex + i * 2, "%02x", k[i]); + } + hex[BLEKeyStore::KEY_LEN * 2] = '\0'; + JsonDocument rsp; + rsp["rsp"] = "ble_key"; + rsp["key"] = hex; + // Also expose the device tag so the host can store keys + // per-device. Without this, uploading a profile to a second + // M5Stack overwrites the first device's key on the host and + // the user has to re-upload to switch between them. + if (_bleManager) { + rsp["tag"] = _bleManager->deviceTag(); + } + sendJson(rsp); + return false; + } + + // --- Sub-routine commands --- + int _subSlot = 0; + int _subNodeCount = 0; + int _subNodesReceived = 0; + + bool cmdSubBegin(JsonDocument& doc) { + int slot = doc["slot"] | 0; + const char* name = doc["name"] | "Unnamed"; + int nodeCount = doc["node_count"] | 0; + if (!_storage->beginSubWrite(slot, name, nodeCount)) { + sendError("sub write failed"); + return false; + } + _subSlot = slot; + _subNodeCount = nodeCount; + _subNodesReceived = 0; + sendReady(); + return false; + } + + bool cmdSubNode(JsonDocument& doc) { + if (_subNodesReceived >= _subNodeCount) { + sendError("too many sub nodes"); + return false; + } + JsonDocument nodeDoc; + nodeDoc["type"] = doc["type"]; + nodeDoc["data"] = doc["data"]; + String nodeStr; + serializeJson(nodeDoc, nodeStr); + bool last = (_subNodesReceived >= _subNodeCount - 1); + _storage->appendSubNode(_subSlot, nodeStr.c_str(), last); + _subNodesReceived++; + sendOk(); + return false; + } + + bool cmdSubEnd(JsonDocument& doc) { + // Verify we got all the nodes the host promised. Missing nodes + // would leave the tmp file with a trailing comma instead of a + // closing bracket, which finalizeSubWrite's JSON validation + // catches anyway — but failing early gives a cleaner error. + // + // IMPORTANT: do NOT Serial.printf debug text here. The USB CDC + // pipe is shared with the JSON response stream, and any non-JSON + // line on this pipe gets fed to the host's readline() instead of + // the {"rsp":...} response, which trips json.JSONDecodeError on + // the host and tears down the serial connection. Diagnostics for + // upload failures must travel back to the host via the sendError + // payload, not via Serial. + int slot = doc["slot"] | _subSlot; + if (_subNodesReceived != _subNodeCount) { + // Drop the tmp so the next loadSubNodes still finds the last + // good live file instead of a stale partial. + char tmpPath[48]; + snprintf(tmpPath, sizeof(tmpPath), "/sub/s%d/nodes.tmp", slot); + if (LittleFS.exists(tmpPath)) LittleFS.remove(tmpPath); + sendError("sub_end node-count mismatch"); + return false; + } + if (!_storage->finalizeSubWrite(slot)) { + sendError("sub_end finalize failed"); + return false; + } + sendOk(); + return false; + } + + bool cmdSubClear() { + _storage->clearAllSubs(); + sendOk(); + return false; + } + + // ===================================================================== + // RS232 pass-through (for the host-side terminal) + // ===================================================================== + + bool cmdRs232Open(JsonDocument& doc) { + if (!_rs232Serial) { + sendError("no rs232 configured"); + return false; + } + int baud = doc["baud"] | 9600; + int dataBits = doc["data_bits"] | 8; + const char* stopBits = doc["stop_bits"] | "1"; + const char* parity = doc["parity"] | "none"; + + uint32_t config = computeRS232Config(dataBits, parity, stopBits); + + _rs232Serial->end(); + _rs232Serial->begin((unsigned long)baud, config, RS232_RX_PIN, RS232_TX_PIN); + delay(30); + + _rs232TerminalOpen = true; + _rs232TerminalBufPos = 0; + + // Invalidate any RS232 node's cached config so a later macro run + // re-initializes the port with its own settings. + if (_onRS232Reconfig) _onRS232Reconfig(); + + sendOk(); + return false; + } + + bool cmdRs232Close(JsonDocument& doc) { + _rs232TerminalOpen = false; + _rs232TerminalBufPos = 0; + // Don't end() the port — the engine may want to use it next. + if (_onRS232Reconfig) _onRS232Reconfig(); + sendOk(); + return false; + } + + bool cmdRs232Send(JsonDocument& doc) { + if (!_rs232Serial || !_rs232TerminalOpen) { + sendError("not open"); + return false; + } + + // Support either a hex-encoded payload (safe for any byte value) + // or a plain ASCII string in "data". Hex wins if both are present. + const char* hex = doc["hex"] | ""; + if (hex[0] != '\0') { + // Parse pairs of hex digits, tolerating whitespace + int n = 0; + char pair[3] = {0, 0, 0}; + int pairIdx = 0; + while (*hex && n < 512) { + char c = *hex++; + if (c == ' ' || c == '\t' || c == ',' || c == '\n' || c == '\r') continue; + pair[pairIdx++] = c; + if (pairIdx == 2) { + pair[2] = 0; + uint8_t b = (uint8_t)strtol(pair, nullptr, 16); + _rs232Serial->write(b); + pairIdx = 0; + n++; + } + } + } else { + const char* data = doc["data"] | ""; + _rs232Serial->print(data); + } + _rs232Serial->flush(); + sendOk(); + return false; + } + + bool cmdRs232Poll(JsonDocument& doc) { + JsonDocument rsp; + rsp["rsp"] = "rx"; + rsp["n"] = _rs232TerminalBufPos; + if (_rs232TerminalBufPos > 0) { + // Encode buffer as hex (2 chars per byte + null terminator) + static char hexBuf[sizeof(_rs232TerminalBuf) * 2 + 1]; + int n = _rs232TerminalBufPos; + if (n > (int)(sizeof(hexBuf) - 1) / 2) n = (sizeof(hexBuf) - 1) / 2; + static const char* HEX_DIGITS = "0123456789abcdef"; + for (int i = 0; i < n; i++) { + uint8_t v = _rs232TerminalBuf[i]; + hexBuf[i * 2] = HEX_DIGITS[v >> 4]; + hexBuf[i * 2 + 1] = HEX_DIGITS[v & 0x0F]; + } + hexBuf[n * 2] = 0; + rsp["hex"] = hexBuf; + } else { + rsp["hex"] = ""; + } + // Clear the buffer now that we've reported it + _rs232TerminalBufPos = 0; + sendJson(rsp); + return false; + } + + void sendJson(JsonDocument& doc) { + String out; + serializeJson(doc, out); + Serial.println(out); + Serial.flush(); + } + + void sendOk() { + Serial.println("{\"rsp\":\"ok\"}"); + Serial.flush(); + } + + void sendReady() { + Serial.println("{\"rsp\":\"ready\"}"); + Serial.flush(); + } + + void sendError(const char* msg) { + JsonDocument doc; + doc["rsp"] = "error"; + doc["msg"] = msg; + String out; + serializeJson(doc, out); + Serial.println(out); + Serial.flush(); + } +}; diff --git a/firmware/MacroPad/settings.h b/firmware/MacroPad/settings.h new file mode 100644 index 0000000..0d5577c --- /dev/null +++ b/firmware/MacroPad/settings.h @@ -0,0 +1,151 @@ +#pragma once + +#include +#include "config.h" + +struct Settings { + uint16_t holdMs = DEFAULT_HOLD_MS; + uint8_t orientation = DEFAULT_ORIENTATION; + uint8_t typeDelay = DEFAULT_TYPE_DELAY; + uint16_t resumeDelay = DEFAULT_RESUME_DELAY; // seconds, 0 = disabled + uint16_t comboPreMs = DEFAULT_COMBO_PRE_MS; + uint16_t comboPostMs = DEFAULT_COMBO_POST_MS; + uint16_t probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS; + uint16_t mediaHoldMs = DEFAULT_MEDIA_HOLD_MS; + uint16_t typeShiftExtraMs = DEFAULT_TYPE_SHIFT_EXTRA_MS; + uint16_t typeSettleMs = DEFAULT_TYPE_SETTLE_MS; + uint16_t typeHoldMinMs = DEFAULT_TYPE_HOLD_MIN_MS; + uint16_t typeInterCharMs = DEFAULT_TYPE_INTER_CHAR_MS; + // Pause-screen text-box padding. drawWrapped() uses these to compute + // the text width and vertical center on every Pause node. + uint8_t pauseMarginLeft = DEFAULT_PAUSE_MARGIN_LEFT; + uint8_t pauseMarginRight = DEFAULT_PAUSE_MARGIN_RIGHT; + uint8_t pauseMarginTop = DEFAULT_PAUSE_MARGIN_TOP; + uint8_t pauseMarginBottom = DEFAULT_PAUSE_MARGIN_BOTTOM; + // ESP-NOW mesh channel (1-13). Every device in a mesh must share it; + // it's applied the next time the radio starts (node listen / hub on). + uint8_t meshChannel = DEFAULT_MESH_CHANNEL; + // Live-keyboard transport used when idle: LIVE_TX_MESH or LIVE_TX_BLE. + // Toggled on-device by a long screen-button hold; persisted in NVS. + uint8_t liveTransport = DEFAULT_LIVE_TRANSPORT; +}; + +class SettingsManager { +public: + Settings settings; + + void begin() { + _prefs.begin("macropad", false); + settings.holdMs = _prefs.getUShort("hold_ms", DEFAULT_HOLD_MS); + settings.orientation = _prefs.getUChar("orient", DEFAULT_ORIENTATION); + settings.typeDelay = _prefs.getUChar("type_delay", DEFAULT_TYPE_DELAY); + settings.resumeDelay = _prefs.getUShort("resume_dly", DEFAULT_RESUME_DELAY); + settings.comboPreMs = _prefs.getUShort("combo_pre", DEFAULT_COMBO_PRE_MS); + settings.comboPostMs = _prefs.getUShort("combo_post", DEFAULT_COMBO_POST_MS); + settings.probeTimeoutMs = _prefs.getUShort("probe_to", DEFAULT_PROBE_TIMEOUT_MS); + settings.mediaHoldMs = _prefs.getUShort("media_hold", DEFAULT_MEDIA_HOLD_MS); + settings.typeShiftExtraMs = _prefs.getUShort("type_sh_ex", DEFAULT_TYPE_SHIFT_EXTRA_MS); + settings.typeSettleMs = _prefs.getUShort("type_settle", DEFAULT_TYPE_SETTLE_MS); + settings.typeHoldMinMs = _prefs.getUShort("type_hold_min", DEFAULT_TYPE_HOLD_MIN_MS); + settings.typeInterCharMs = _prefs.getUShort("type_inter_ch", DEFAULT_TYPE_INTER_CHAR_MS); + settings.pauseMarginLeft = _prefs.getUChar("p_mar_l", DEFAULT_PAUSE_MARGIN_LEFT); + settings.pauseMarginRight = _prefs.getUChar("p_mar_r", DEFAULT_PAUSE_MARGIN_RIGHT); + settings.pauseMarginTop = _prefs.getUChar("p_mar_t", DEFAULT_PAUSE_MARGIN_TOP); + settings.pauseMarginBottom = _prefs.getUChar("p_mar_b", DEFAULT_PAUSE_MARGIN_BOTTOM); + settings.meshChannel = _prefs.getUChar("mesh_ch", DEFAULT_MESH_CHANNEL); + if (settings.meshChannel < 1 || settings.meshChannel > 13) { + settings.meshChannel = DEFAULT_MESH_CHANNEL; + } + settings.liveTransport = _prefs.getUChar("live_tx", DEFAULT_LIVE_TRANSPORT); + if (settings.liveTransport > LIVE_TX_BLE) { + settings.liveTransport = DEFAULT_LIVE_TRANSPORT; + } + } + + void set(const char* key, int value) { + if (strcmp(key, "hold_ms") == 0) { + settings.holdMs = value; + _prefs.putUShort("hold_ms", value); + } else if (strcmp(key, "orientation") == 0) { + settings.orientation = value; + _prefs.putUChar("orient", value); + } else if (strcmp(key, "type_delay") == 0) { + settings.typeDelay = value; + _prefs.putUChar("type_delay", value); + } else if (strcmp(key, "resume_delay") == 0) { + settings.resumeDelay = value; + _prefs.putUShort("resume_dly", value); + } else if (strcmp(key, "combo_pre_ms") == 0) { + settings.comboPreMs = value; + _prefs.putUShort("combo_pre", value); + } else if (strcmp(key, "combo_post_ms") == 0) { + settings.comboPostMs = value; + _prefs.putUShort("combo_post", value); + } else if (strcmp(key, "probe_timeout_ms") == 0) { + settings.probeTimeoutMs = value; + _prefs.putUShort("probe_to", value); + } else if (strcmp(key, "media_hold_ms") == 0) { + settings.mediaHoldMs = value; + _prefs.putUShort("media_hold", value); + } else if (strcmp(key, "type_shift_extra_ms") == 0) { + settings.typeShiftExtraMs = value; + _prefs.putUShort("type_sh_ex", value); + } else if (strcmp(key, "type_settle_ms") == 0) { + settings.typeSettleMs = value; + _prefs.putUShort("type_settle", value); + } else if (strcmp(key, "type_hold_min_ms") == 0) { + settings.typeHoldMinMs = value; + _prefs.putUShort("type_hold_min", value); + } else if (strcmp(key, "type_inter_char_ms") == 0) { + settings.typeInterCharMs = value; + _prefs.putUShort("type_inter_ch", value); + } else if (strcmp(key, "pause_margin_left") == 0) { + settings.pauseMarginLeft = (uint8_t)value; + _prefs.putUChar("p_mar_l", (uint8_t)value); + } else if (strcmp(key, "pause_margin_right") == 0) { + settings.pauseMarginRight = (uint8_t)value; + _prefs.putUChar("p_mar_r", (uint8_t)value); + } else if (strcmp(key, "pause_margin_top") == 0) { + settings.pauseMarginTop = (uint8_t)value; + _prefs.putUChar("p_mar_t", (uint8_t)value); + } else if (strcmp(key, "pause_margin_bottom") == 0) { + settings.pauseMarginBottom = (uint8_t)value; + _prefs.putUChar("p_mar_b", (uint8_t)value); + } else if (strcmp(key, "mesh_ch") == 0) { + if (value >= 1 && value <= 13) { + settings.meshChannel = (uint8_t)value; + _prefs.putUChar("mesh_ch", (uint8_t)value); + } + } else if (strcmp(key, "live_tx") == 0) { + if (value == LIVE_TX_MESH || value == LIVE_TX_BLE) { + settings.liveTransport = (uint8_t)value; + _prefs.putUChar("live_tx", (uint8_t)value); + } + } + } + + int get(const char* key) { + if (strcmp(key, "hold_ms") == 0) return settings.holdMs; + if (strcmp(key, "orientation") == 0) return settings.orientation; + if (strcmp(key, "type_delay") == 0) return settings.typeDelay; + if (strcmp(key, "resume_delay") == 0) return settings.resumeDelay; + if (strcmp(key, "combo_pre_ms") == 0) return settings.comboPreMs; + if (strcmp(key, "combo_post_ms") == 0) return settings.comboPostMs; + if (strcmp(key, "probe_timeout_ms") == 0) return settings.probeTimeoutMs; + if (strcmp(key, "media_hold_ms") == 0) return settings.mediaHoldMs; + if (strcmp(key, "type_shift_extra_ms") == 0) return settings.typeShiftExtraMs; + if (strcmp(key, "type_settle_ms") == 0) return settings.typeSettleMs; + if (strcmp(key, "type_hold_min_ms") == 0) return settings.typeHoldMinMs; + if (strcmp(key, "type_inter_char_ms") == 0) return settings.typeInterCharMs; + if (strcmp(key, "pause_margin_left") == 0) return settings.pauseMarginLeft; + if (strcmp(key, "pause_margin_right") == 0) return settings.pauseMarginRight; + if (strcmp(key, "pause_margin_top") == 0) return settings.pauseMarginTop; + if (strcmp(key, "pause_margin_bottom") == 0) return settings.pauseMarginBottom; + if (strcmp(key, "mesh_ch") == 0) return settings.meshChannel; + if (strcmp(key, "live_tx") == 0) return settings.liveTransport; + return -1; + } + +private: + Preferences _prefs; +}; diff --git a/firmware/MacroPad/usb_hid.h b/firmware/MacroPad/usb_hid.h new file mode 100644 index 0000000..7162704 --- /dev/null +++ b/firmware/MacroPad/usb_hid.h @@ -0,0 +1,542 @@ +#pragma once + +#include "USB.h" +#include "USBHIDKeyboard.h" +#include "USBHIDMouse.h" +#include "USBHIDConsumerControl.h" +#include "abs_mouse.h" +#include "config.h" + +// USB HID timing floors. The underlying SendReport call is blocking — it +// returns only after the host has acknowledged the report — so most "wait +// for the bytes to arrive" timing concerns are already covered by the +// library. These values are the EXTRA delay we hold after a press or +// between events so the *host application* has a chance to observe and +// process each keystroke (BIOS prompts, installer wizards, and PE shells +// can miss reports that flip in too quickly even after the USB stack has +// delivered them). +// +// USB_HID_HOLD_MIN_MS — minimum keydown hold time. Floor for the +// between-press-and-release delay even if the user's typeDelay +// setting is smaller. 8 ms = one boot-keyboard poll interval. +// USB_HID_INTER_CHAR_MS — gap between releasing one char's keys and +// pressing the next char's. Keeps key-repeat detection happy and +// stops fast-typing apps from coalescing two characters into one. +// USB_HID_PROLOGUE_SETTLE_MS — delay after the defensive entry +// releaseAll() before the first character's press(). Must be long +// enough that the releaseAll's xfer-complete callback has fired, +// otherwise the TinyUSB SendReport semaphore can desync on the +// first character (see press/release retry below). +// USB_HID_RETRY_SETTLE_MS — pause before retrying a failed press or +// release report. Gives the endpoint FIFO time to drain. +// USB_HID_COMBO_MOD_SETTLE_MS — extra dwell, on top of the per-mod +// preDelay, between pressing the last modifier of a combo and +// pressing the main key. Some hosts (notably Windows shell hotkey +// handlers and BIOS UIs) need a clear "modifier is steady-state +// held" window before the keycode arrives or they treat the combo +// as a plain keypress without the modifier. 25 ms is empirically +// enough on every host we've tested without being noticeable to a +// human watching the combo fire. +#define USB_HID_HOLD_MIN_MS 8 +#define USB_HID_INTER_CHAR_MS 5 +#define USB_HID_PROLOGUE_SETTLE_MS 10 +#define USB_HID_RETRY_SETTLE_MS 2 +#define USB_HID_COMBO_MOD_SETTLE_MS 25 + +class HIDController { +public: + USBHIDKeyboard keyboard; + USBHIDMouse mouse; + USBHIDConsumerControl consumer; + AbsoluteMouse absMouse; // absolute-position pointer for the BT Keyboard trackpad + + // Keyboard LED state (updated via host reports) + volatile bool numLockOn = false; + volatile bool capsLockOn = false; + volatile bool scrollLockOn = false; + volatile bool ledStateReceived = false; // true once we've received at least one LED report + + void begin() { + USB.productName("ATOMS3 MacroPad"); + USB.manufacturerName("M5Stack"); + + // Register LED event callback BEFORE begin() so we don't miss events + _instance = this; + keyboard.onEvent(_keyboardEventCB); + + // Register all HID interfaces BEFORE USB.begin() + keyboard.begin(); + mouse.begin(); + consumer.begin(); + absMouse.begin(); + + // Leave shiftKeyReports at the library default (false). With it + // ON, a shifted character emits FOUR reports — shift-down alone, + // then shift+key, then shift-only on key-up, then shift-up. + // Theoretically this matches a physical keyboard more closely + // and is "what BIOSes expect," but in practice on a normal + // Windows host the intermediate "shift-alone" report has been + // observed to leave shift latched on across subsequent keys, + // causing "Coconuts4frodo" to come out as "COCONUTS$FRODO". + // The single-report form (shift+key bundled) is what every + // tested host actually wants. If a future BIOS/PE target needs + // the split form, enable per-target rather than globally. + // keyboard.setShiftKeyReports(true); // DO NOT enable globally. + + // Start the TinyUSB stack LAST - this finalizes all descriptors + USB.begin(); + + // Disable DTR/RTS triggered reboot AFTER USB stack is running + USBSerial.enableReboot(false); + } + + // True while the engine is in the middle of a sequence of HID writes + // (typeText / keyCombo / mediaKey / macro playback / probeNumLock). + // The main loop checks this and skips non-USB-HID polling work + // (BLE log flushes, RS232 RX scraping) so the typing path runs as + // uninterrupted as possible. Read-only from outside. + bool isCritical() const { return _critical; } + + // External entry points for callers that emit HID reports directly + // (e.g. ``macro`` node-type playback in the engine, which uses + // pressRaw / releaseRaw against the keyboard object). Wrap the + // sequence in beginCritical() / endCritical() so the main-loop + // priority gate observes it the same as typeText / keyCombo. + void beginCritical() { _critical = true; } + void endCritical() { _critical = false; } + + // Probe whether a host PC is alive by toggling Num Lock and checking + // if the LED state changes. Works regardless of the initial Num Lock + // state. Restores the original state if the host is alive. + // + // Returns true if the host responded (PC is alive), false otherwise. + // + // Algorithm: + // 1. Read current Num Lock LED state (before) + // 2. Send Num Lock keypress (toggle) + // 3. Wait for host to report new LED (after) + // 4. Compare before vs after + // - Changed → host is alive → toggle back to restore → return true + // - Same → host is dead / not connected → return false + // + // stepCallback is called at each phase so the display can show progress. + typedef void (*ProbeStepCB)(const char* phase, void* userData); + + bool probeNumLock(uint16_t waitMs = 250, ProbeStepCB stepCB = nullptr, void* cbData = nullptr) { + _critical = true; + struct CriticalGuard { + HIDController* h; + ~CriticalGuard() { h->_critical = false; } + } guard{this}; + + // Step 1: Record the "before" state + if (stepCB) stepCB("Read state...", cbData); + bool before = numLockOn; + delay(25); + + // Step 2: Toggle Num Lock + if (stepCB) stepCB("Toggling...", cbData); + ledStateReceived = false; + keyboard.press(KEY_NUM_LOCK); + delay(25); + keyboard.releaseAll(); + delay(25); + + // Step 3: Wait for the host to send an LED report + if (stepCB) stepCB("Waiting for host...", cbData); + uint32_t deadline = millis() + waitMs; + while (!ledStateReceived && millis() < deadline) { + delay(5); + } + delay(25); + + bool after = numLockOn; + + // Step 4: Compare — if LED changed, host is alive; restore original state + if (ledStateReceived && after != before) { + if (stepCB) stepCB("Host alive! Restoring...", cbData); + ledStateReceived = false; + keyboard.press(KEY_NUM_LOCK); + delay(25); + keyboard.releaseAll(); + delay(25); + + // Wait for restore to register + deadline = millis() + waitMs; + while (!ledStateReceived && millis() < deadline) { + delay(5); + } + delay(25); + return true; + } + + if (stepCB) stepCB("No response", cbData); + delay(25); + return false; + } + + // Count the number of Scroll Lock LED transitions observed within a + // listening window. The Get Variables node uses this to read a simple + // signal channel from a host-side script (e.g. PowerShell calling + // user32!keybd_event with VK_SCROLL). Scroll Lock is preferred over + // Num Lock because most users have Num Lock toggling externally + // (numeric keypads, BIOS settings) which would corrupt the count, + // whereas Scroll Lock is virtually never touched by other software. + // + // We count BOTH directions (off->on AND on->off) so each press the + // host script issues maps 1:1 to one count, regardless of starting + // state. Earlier versions only counted off->on, which made the second + // press of a "press twice" sequence invisible. + int probeScrollLockSequence(uint32_t windowMs) { + int count = 0; + bool prev = scrollLockOn; + uint32_t deadline = millis() + windowMs; + while (millis() < deadline) { + bool now = scrollLockOn; + if (now != prev) { + count++; + prev = now; + } + delay(5); + } + return count; + } + + // Callback type: called before each character is typed. + // Arguments: (fullText, charIndex, userData) + typedef void (*CharCallback)(const char* fullText, int charIdx, void* userData); + + // Type a string one character at a time over the HID keyboard interface. + // + // Per-char timeline (with setShiftKeyReports(true) — see begin()): + // - keyboard.press(c) + // Unshifted: 1 report (key down). Blocks until host ack (~1-5 ms). + // Shifted: 2 reports (shift down, then key down). Blocks ~5-10 ms. + // - delay(holdMs) + // Host-side keydown processing time. Floored at USB_HID_HOLD_MIN_MS + // so very small typeDelay values don't starve apps that need to + // observe the keydown for at least one poll cycle. + // - keyboard.release(c) + // Unshifted: 1 report. Shifted: 2 reports (key up, then shift up). + // - delay(interCharMs) + // Host-side keyup processing + breathing room before the next + // keydown. Without this gap, fast key-repeat detection in some + // editors can elide every other keystroke. + // - For shifted chars, ``shiftExtraMs`` is added to the inter-char gap + // (NOT the hold) because the cost is on the release/next-press + // boundary: 4 reports must drain before the next char's shift + // state diverges. + // + // The original implementation surrounded each ``write()`` with two + // ``releaseAll()`` calls — that's 4 extra HID reports per character + // (~20 ms wasted) plus 15 ms of explicit delay. Functionally identical + // to plain press/release because press() / release() are paired + // already, so the releases were redundant. Removed here. + // + // The trailing settle is a final ``releaseAll()`` followed by + // ``settleMs`` of quiet so any in-flight report fully drains before + // the next macro node fires. + void typeText(const char* text, uint8_t delayMs, + CharCallback onChar = nullptr, void* userData = nullptr, + uint16_t shiftExtraMs = 25, uint16_t settleMs = 150, + uint16_t holdMinMs = USB_HID_HOLD_MIN_MS, + uint16_t interCharMs = USB_HID_INTER_CHAR_MS) { + _critical = true; + int count = 0; + const char* start = text; // Keep pointer to full string for callback + + // Defensive prologue: clear any modifier state left held by a + // preceding combo / macro-playback / failed earlier typeText. + // Without this, a stuck shift bit from the previous node would + // turn the whole text into shifted equivalents ("Coconuts4frodo" + // → "COCONUTS$FRODO"). The cost is one report (~1-5 ms host + // ack), cheap insurance. + // + // The post-prologue settle is bumped above interCharMs so the + // releaseAll's xfer-complete callback has definitively fired + // before the first character's press(). On TinyUSB / arduino-esp32 + // builds with the known semaphore-desync bug, a too-tight gap + // here can cause the FIRST character of every typeText call to + // drop silently. + keyboard.releaseAll(); + uint16_t prologueSettle = interCharMs; + if (prologueSettle < USB_HID_PROLOGUE_SETTLE_MS) prologueSettle = USB_HID_PROLOGUE_SETTLE_MS; + delay(prologueSettle); + + // Hold time floor: ensure at least one USB poll cycle elapses with + // the key down so the host always observes the press. Below this + // floor, fast-typing apps can drop characters. + uint16_t holdMs = delayMs; + if (holdMs < holdMinMs) holdMs = holdMinMs; + + while (*text) { + if (onChar) { + onChar(start, count, userData); + } + + char c = *text; + bool shifted = _isShiftedChar(c); + + // press()/release() bundle shift+key into a single HID report + // (since shiftKeyReports is left at the default `false`). + // For an unshifted char: 1 report on press, 1 on release. + // For a shifted char ('A', '!', etc.): same 2 reports, with + // the shift modifier bit set in the press and cleared in the + // release. + // + // SendReport blocks on a semaphore that is given by + // tud_hid_report_complete_cb, so the happy path returns only + // after the host has drained the endpoint. The known + // arduino-esp32 / TinyUSB semaphore-desync bug occasionally + // makes that semaphore-take time out, returning 0 from + // press() / release() without the report ever reaching the + // host. Without the retry, that one character drops + // silently — the symptom is rare random misses like + // "Start-Process" → "Start-Proess". + _hidWriteWithRetry(true, (uint8_t)c, start, count); + delay(holdMs); + _hidWriteWithRetry(false, (uint8_t)c, start, count); + + // Inter-char gap. Shifted chars get an extra slice so the + // shift bit has clearly cleared at the host before the next + // unshifted character's keycode arrives — a few hosts have + // been observed to apply a still-cached shift state to the + // very next report. + uint16_t gap = interCharMs; + if (shifted && shiftExtraMs > 0) gap += shiftExtraMs; + delay(gap); + + text++; + count++; + } + + // Final safety release in case something above failed mid-sequence + // and left a key latched, then the configured settle. + keyboard.releaseAll(); + delay(settleMs); + _critical = false; + } + + static bool _isShiftedChar(char c) { + if (c >= 'A' && c <= 'Z') return true; + return c != 0 && strchr("!@#$%^&*()_+{}|:\"<>?~", c) != nullptr; + } + + // Single press or release with one-shot retry. Returns true if the + // report was acknowledged by the host on either the first try or + // the retry. A logged failure means the character was lost — emit + // a Serial line so the user can confirm in the field whether the + // semaphore-desync failure mode actually fires for their hardware. + // + // ``isPress`` selects press vs release. ``c`` is the key. ``textCtx`` + // and ``idx`` are only used for the log line. + bool _hidWriteWithRetry(bool isPress, uint8_t c, const char* textCtx, int idx) { + size_t ok = isPress ? keyboard.press(c) : keyboard.release(c); + if (ok) return true; + + // First attempt failed (semaphore-take timeout / FIFO not ready). + // Brief settle then one retry. + delay(USB_HID_RETRY_SETTLE_MS); + ok = isPress ? keyboard.press(c) : keyboard.release(c); + if (ok) { + Serial.printf("[HID retry] %s '%c' idx=%d ok on retry\n", + isPress ? "press" : "release", (char)c, idx); + return true; + } + + Serial.printf("[HID retry] %s '%c' idx=%d FAILED twice — char dropped\n", + isPress ? "press" : "release", (char)c, idx); + return false; + } + + // Press a key combination (zero or more modifiers + zero or one main key). + // + // Timeline: + // 1. Press each modifier, with ``preDelay`` after each press. The + // ``preDelay`` after the LAST modifier doubles as the + // modifier-to-key spacer — the host has one ack-plus-preDelay + // worth of time to observe each new modifier bit settled before + // anything else happens. + // 2. When ``modCount > 0`` (combo has at least one modifier), + // apply an additional ``USB_HID_COMBO_MOD_SETTLE_MS`` (25 ms) + // dwell so the modifier is steady-state held for clearly more + // than one host poll cycle before the keycode arrives. Without + // this, fast-firing combos like Ctrl+R can race the host's + // modifier-state pipeline and register as a bare 'R' instead. + // Skipped for plain (modifier-less) key presses since there's + // nothing to settle. + // 3. Press the main key. + // 4. Hold the combo for ``postDelay`` so the host registers the + // shortcut as a tap, not a coalesced flicker. + // 5. releaseAll() — single report clears everything. Floor of + // USB_HID_INTER_CHAR_MS afterwards so the next macro node doesn't + // race against the just-issued release. + void keyCombo(const uint8_t* modifiers, uint8_t modCount, uint8_t key, + uint16_t preDelay = COMBO_KEY_PRE_DELAY, + uint16_t postDelay = COMBO_KEY_POST_DELAY) { + _critical = true; + // Caller controls the timings. Engine clamps to a 1ms floor before + // calling us; we trust the inputs here. + for (uint8_t i = 0; i < modCount; i++) { + keyboard.press(modifiers[i]); + delay(preDelay); + } + if (key != 0) { + if (modCount > 0) { + // Extra modifier-settle window so the host clearly sees + // the modifier bits as held BEFORE the keycode flips on. + delay(USB_HID_COMBO_MOD_SETTLE_MS); + } + keyboard.press(key); + } + delay(postDelay); + keyboard.releaseAll(); + // Small trailing gap so the next node (often another combo or a + // text node) doesn't race the just-emitted release report. + delay(USB_HID_INTER_CHAR_MS); + _critical = false; + } + + // Emit one absolute-position pointer report (BT Keyboard trackpad). + // Wrapped in the critical guard like all other live HID emission so the + // main-loop priority gate defers other work while it's in flight. + void absMouseReport(uint8_t buttons, uint16_t x, uint16_t y, int8_t wheel) { + _critical = true; + absMouse.report(buttons, x, y, wheel); + _critical = false; + } + + void mouseClick(uint8_t button) { + mouse.click(button); + } + + void mouseDoubleClick(uint8_t button) { + mouse.click(button); + delay(80); + mouse.click(button); + } + + void mousePress(uint8_t button) { + mouse.press(button); + } + + void mouseRelease(uint8_t button) { + mouse.release(button); + } + + void mediaKey(uint16_t key, uint16_t holdMs = 100) { + _critical = true; + consumer.press(key); + delay(holdMs); + consumer.release(); + _critical = false; + } + + uint8_t resolveModifier(const char* mod) { + if (strcmp(mod, "ctrl") == 0 || strcmp(mod, "control") == 0) return KEY_LEFT_CTRL; + if (strcmp(mod, "shift") == 0) return KEY_LEFT_SHIFT; + if (strcmp(mod, "alt") == 0) return KEY_LEFT_ALT; + if (strcmp(mod, "gui") == 0 || strcmp(mod, "win") == 0 || strcmp(mod, "meta") == 0) return KEY_LEFT_GUI; + if (strcmp(mod, "rctrl") == 0) return KEY_RIGHT_CTRL; + if (strcmp(mod, "rshift") == 0) return KEY_RIGHT_SHIFT; + if (strcmp(mod, "ralt") == 0 || strcmp(mod, "altgr") == 0) return KEY_RIGHT_ALT; + if (strcmp(mod, "rgui") == 0) return KEY_RIGHT_GUI; + return 0; + } + + uint8_t resolveKey(const char* key) { + if (strlen(key) == 1) return (uint8_t)key[0]; + if (strcmp(key, "enter") == 0 || strcmp(key, "return") == 0) return KEY_RETURN; + if (strcmp(key, "esc") == 0 || strcmp(key, "escape") == 0) return KEY_ESC; + if (strcmp(key, "backspace") == 0) return KEY_BACKSPACE; + if (strcmp(key, "tab") == 0) return KEY_TAB; + if (strcmp(key, "space") == 0) return KEY_SPACE; + if (strcmp(key, "delete") == 0) return KEY_DELETE; + if (strcmp(key, "insert") == 0) return KEY_INSERT; + if (strcmp(key, "home") == 0) return KEY_HOME; + if (strcmp(key, "end") == 0) return KEY_END; + if (strcmp(key, "pageup") == 0) return KEY_PAGE_UP; + if (strcmp(key, "pagedown") == 0) return KEY_PAGE_DOWN; + if (strcmp(key, "up") == 0) return KEY_UP_ARROW; + if (strcmp(key, "down") == 0) return KEY_DOWN_ARROW; + if (strcmp(key, "left") == 0) return KEY_LEFT_ARROW; + if (strcmp(key, "right") == 0) return KEY_RIGHT_ARROW; + if (strcmp(key, "capslock") == 0) return KEY_CAPS_LOCK; + if (strcmp(key, "numlock") == 0) return KEY_NUM_LOCK; + if (strcmp(key, "scrolllock") == 0) return KEY_SCROLL_LOCK; + if (strcmp(key, "printscreen") == 0) return KEY_PRINT_SCREEN; + if (strcmp(key, "pause") == 0) return KEY_PAUSE; + if (strcmp(key, "menu") == 0) return KEY_MENU; + if (key[0] == 'f' || key[0] == 'F') { + int num = atoi(key + 1); + if (num >= 1 && num <= 12) return KEY_F1 + (num - 1); + if (num >= 13 && num <= 24) return KEY_F13 + (num - 13); + } + return 0; + } + + uint8_t resolveMouseButton(const char* btn) { + if (strcmp(btn, "left") == 0) return MOUSE_LEFT; + if (strcmp(btn, "right") == 0) return MOUSE_RIGHT; + if (strcmp(btn, "middle") == 0) return MOUSE_MIDDLE; + return MOUSE_LEFT; + } + + uint16_t resolveMediaKey(const char* action) { + if (strcmp(action, "vol_up") == 0) return CONSUMER_CONTROL_VOLUME_INCREMENT; + if (strcmp(action, "vol_down") == 0) return CONSUMER_CONTROL_VOLUME_DECREMENT; + if (strcmp(action, "mute") == 0) return CONSUMER_CONTROL_MUTE; + if (strcmp(action, "play_pause") == 0) return CONSUMER_CONTROL_PLAY_PAUSE; + if (strcmp(action, "next") == 0) return CONSUMER_CONTROL_SCAN_NEXT; + if (strcmp(action, "prev") == 0) return CONSUMER_CONTROL_SCAN_PREVIOUS; + if (strcmp(action, "stop") == 0) return CONSUMER_CONTROL_STOP; + if (strcmp(action, "brightness_up") == 0) return CONSUMER_CONTROL_BRIGHTNESS_INCREMENT; + if (strcmp(action, "brightness_down") == 0) return CONSUMER_CONTROL_BRIGHTNESS_DECREMENT; + return 0; + } + +private: + static HIDController* _instance; + + // Set true while a HID-emitting method (typeText, keyCombo, mediaKey, + // probeNumLock) is in progress. The main loop reads via isCritical() + // and defers non-USB-HID polling work (BLE log flush, RS232 RX + // buffering, M5 button events) for the duration. In practice the + // main loop is already blocked inside engine.tick() while these + // methods run, so the flag mainly serves to document the intent and + // protect any future caller that might pump the main loop from a + // nested context. + volatile bool _critical = false; + + static void _keyboardEventCB(void* arg, esp_event_base_t event_base, + int32_t event_id, void* event_data) { + if (!_instance) return; + if (event_base == ARDUINO_USB_HID_KEYBOARD_EVENTS && + event_id == ARDUINO_USB_HID_KEYBOARD_LED_EVENT) { + arduino_usb_hid_keyboard_event_data_t* data = + (arduino_usb_hid_keyboard_event_data_t*)event_data; + // Log every LED report so we can verify whether a host-side + // toggle actually propagated to this USB HID device. If the + // host's keybd_event(VK_SCROLL,...) updates the OS state but + // doesn't trigger a Set Report to the keyboard, we'd see no + // log lines here and that's the signaling channel's bug. + bool prevN = _instance->numLockOn; + bool prevC = _instance->capsLockOn; + bool prevS = _instance->scrollLockOn; + _instance->numLockOn = data->numlock; + _instance->capsLockOn = data->capslock; + _instance->scrollLockOn = data->scrolllock; + _instance->ledStateReceived = true; + if (prevN != data->numlock || prevC != data->capslock || + prevS != data->scrolllock) { + Serial.printf("[LED] num=%d caps=%d scroll=%d (was %d/%d/%d)\n", + (int)data->numlock, (int)data->capslock, + (int)data->scrolllock, + (int)prevN, (int)prevC, (int)prevS); + } + } + } +}; + +// Static member definition +HIDController* HIDController::_instance = nullptr; diff --git a/live_protocol.py b/live_protocol.py new file mode 100644 index 0000000..74ae363 --- /dev/null +++ b/live_protocol.py @@ -0,0 +1,145 @@ +"""Shared live-keyboard protocol constants and packing helpers. + +Two layers share this module: + + * The legacy BLE live client (ble_live.py) — inner frame layout only. + * The ESP-NOW mesh transport (mesh_link.py / mesh_manager.py) — inner + frame layout PLUS the plaintext mesh transport header and the binary + USB-CDC hub framing. + +Inner (encrypted) frame layout — byte-for-byte the historic BLE live +protocol, so device-side dispatch is identical on both transports: + + byte 0: msg_type + bytes 1..8: session_id (uint64 LE) + bytes 9..16: seq (uint64 LE) + bytes 17..: body per msg_type + +Mesh transport header (plaintext, precedes the encrypted envelope): + + off 0 u8 magic 0xE5 + off 1 u8 type (MESH_T_*) + off 2 u8 flags bit0 = retransmission + off 3 u8 rsvd + off 4 u32LE seq reliable seq (DATA) / move seq (DATA_U) / 0 + off 8 u8[6] dest FF:FF:FF:FF:FF:FF = all nodes, else one STA MAC + off 14 ... payload + +Hub CDC framing (host <-> the USB-attached hub device): + + 0xC8 0x35 | htype u8 | len u16LE | payload | crc16-ccitt u16LE + (CRC over htype, len bytes, payload — must match serial_protocol.h) +""" + +from __future__ import annotations + +import struct + +# ---- Inner message types (host -> device unless noted) ---- +MSG_START = 0x01 +MSG_KEYS = 0x02 +MSG_STOP = 0x03 +MSG_IDENTIFY = 0x04 +MSG_MOUSE = 0x05 +MSG_LABEL = 0x06 +MSG_PAUSE = 0x07 # mesh-only: mute one node (it keeps ACKing the stream) +MSG_RESUME = 0x08 # mesh-only: unmute +MSG_ACK = 0x10 # device -> host (BLE only; mesh ACKs at transport level) +MSG_ERROR = 0x11 # device -> host +MSG_HELLO = 0x12 # device -> host (BLE only) + +ACTION_DOWN = 0 +ACTION_UP = 1 + +ERR_LABELS = { + 1: "BUFFER_FULL", + 2: "NOT_LIVE_MODE", + 3: "HID_FAILURE", + 4: "BAD_MSG", +} + +# ---- Mesh transport ---- +MESH_MAGIC = 0xE5 +MESH_HDR_LEN = 14 +MESH_MAX_FRAME = 250 # ESP-NOW v1 payload cap; keeps node buffers small + +MESH_T_DATA = 0x01 # hub -> all (broadcast, reliable lane) +MESH_T_DATA_U = 0x02 # hub -> all (latest-wins mouse-move lane) +MESH_T_JOIN = 0x03 # hub -> node (per-device key) +MESH_T_POLL = 0x04 # hub -> all (keepalive / discovery) +MESH_T_BEACON = 0x81 # node -> hub (plaintext identity) +MESH_T_ACK = 0x82 # node -> hub (consumed by hub; host sees acktab) +MESH_T_JOIN_ACK = 0x83 # node -> hub (per-device key, forwarded to host) +MESH_T_NACK = 0x84 # node -> hub (consumed by hub) +MESH_T_ERR = 0x85 # node -> hub (forwarded to host) + +MESH_F_RETX = 0x01 + +BCAST_MAC = b"\xFF\xFF\xFF\xFF\xFF\xFF" + +# ---- Hub CDC framing ---- +HUB_MAGIC0 = 0xC8 +HUB_MAGIC1 = 0x35 +HUB_H2D_SEND = 0x01 +HUB_D2H_RX = 0x81 +HUB_D2H_ACKTAB = 0x82 +HUB_MAX_FRAME = 1500 + + +def crc16_ccitt(data: bytes, crc: int = 0xFFFF) -> int: + """CRC16-CCITT, poly 0x1021, init 0xFFFF — matches hubCrc16() in + firmware/MacroPad/serial_protocol.h.""" + for b in data: + crc ^= b << 8 + for _ in range(8): + crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 \ + else (crc << 1) & 0xFFFF + return crc + + +def mac_to_bytes(mac: str) -> bytes: + return bytes(int(p, 16) for p in mac.split(":")) + + +def mac_to_str(b: bytes) -> str: + return ":".join(f"{x:02X}" for x in b[:6]) + + +def pack_header(msg_type: int, session_id: int, seq: int) -> bytes: + """Inner 17-byte header (identical to ble_live._pack_header).""" + return struct.pack(" bytes: + """``batch`` is a list of (action, hid, t_ms) tuples (max 16).""" + return bytes([len(batch)]) + b"".join( + struct.pack(" bytes: + w = max(-127, min(127, int(wheel))) + return struct.pack(" bytes: + return struct.pack(" bytes: + """Wrap a payload in the binary CDC framing for the hub.""" + hdr = bytes([htype, len(payload) & 0xFF, (len(payload) >> 8) & 0xFF]) + crc = crc16_ccitt(payload, crc16_ccitt(hdr)) + return (bytes([HUB_MAGIC0, HUB_MAGIC1]) + hdr + payload + + bytes([crc & 0xFF, (crc >> 8) & 0xFF])) diff --git a/main.py b/main.py new file mode 100644 index 0000000..c215aae --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ +"""ATOMS3 MacroPad - Node-based macro keyboard programmer. + +Launch this script to open the macro editor GUI. +Requires: Python 3.10+, pyserial, Pillow +""" + +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app import MacroPadApp + + +def main(): + app = MacroPadApp() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/mesh_link.py b/mesh_link.py new file mode 100644 index 0000000..3a0212f --- /dev/null +++ b/mesh_link.py @@ -0,0 +1,209 @@ +"""Serial link to the ESP-NOW mesh hub. + +Owns the COM port while the BT Keyboard window is open. The hub device +(the USB-attached M5Stack the app switched into hub mode) speaks a mixed +stream on its CDC port: + + * Binary frames — 0xC8 0x35 | htype | len u16LE | payload | crc16 — + carrying mesh traffic (D2H_RX = forwarded node frames, D2H_ACKTAB = + periodic per-node delivery table). Host-to-device H2D_SEND frames + carry complete, host-encrypted mesh frames for the hub to broadcast. + * JSON lines (``{"rsp": ...}``) — responses to control commands + (hub_ping, mesh_poll, espnow_hub off). + +A reader thread demultiplexes the stream; callbacks fire ON THAT THREAD +(callers marshal to Tk with ``after``). A 1 Hz hub_ping keeps the hub's +host-activity watchdog fed (the hub reverts to a normal node ~5 s after +the host goes quiet, so a crashed app never leaves an orphaned hub). +""" + +from __future__ import annotations + +import json +import threading +import time + +from live_protocol import ( + HUB_MAGIC0, HUB_MAGIC1, HUB_H2D_SEND, HUB_MAX_FRAME, + crc16_ccitt, frame_hub_message, +) + +PING_INTERVAL_S = 1.0 + + +class MeshLink: + """Framed transport over the hub's serial port. + + The caller hands over an OPEN pyserial handle (borrowed from + SerialManager after the espnow_hub handshake) and gets it back + untouched after stop(). + """ + + def __init__(self, ser): + self._ser = ser + self._running = False + self._reader: threading.Thread | None = None + self._pinger: threading.Thread | None = None + self._wlock = threading.Lock() + self.on_rx = None # (src_mac_str, mesh_frame_bytes) + self.on_acktab = None # (list of dict) + self.on_json = None # (dict) + self.bytes_sent = 0 + self.frames_sent = 0 + + def start(self, on_rx=None, on_acktab=None, on_json=None) -> None: + self.on_rx = on_rx + self.on_acktab = on_acktab + self.on_json = on_json + self._running = True + # Short timeout so the reader notices shutdown promptly. + try: + self._ser.timeout = 0.05 + except Exception: + pass + self._reader = threading.Thread(target=self._reader_main, + daemon=True, name="MeshLinkReader") + self._reader.start() + self._pinger = threading.Thread(target=self._pinger_main, + daemon=True, name="MeshLinkPinger") + self._pinger.start() + + def stop(self, timeout: float = 2.0) -> None: + self._running = False + for t in (self._reader, self._pinger): + if t is not None and t.is_alive(): + t.join(timeout=timeout) + self._reader = None + self._pinger = None + + # ---- TX ---- + + def send_mesh_frame(self, frame: bytes) -> bool: + """Ship one complete (transport header + encrypted payload) mesh + frame to the hub for broadcast/caching.""" + return self._write(frame_hub_message(HUB_H2D_SEND, frame)) + + def send_json(self, cmd: dict) -> bool: + """Fire-and-forget JSON control command; any response surfaces + via on_json on the reader thread.""" + try: + data = (json.dumps(cmd) + "\n").encode("utf-8") + except (TypeError, ValueError): + return False + return self._write(data) + + def _write(self, data: bytes) -> bool: + with self._wlock: + try: + self._ser.write(data) + self.bytes_sent += len(data) + self.frames_sent += 1 + return True + except Exception: + return False + + # ---- RX ---- + + def _reader_main(self) -> None: + buf = bytearray() + while self._running: + try: + chunk = self._ser.read(256) + except Exception: + time.sleep(0.2) + continue + if chunk: + buf.extend(chunk) + self._drain_buffer(buf) + + def _drain_buffer(self, buf: bytearray) -> None: + while True: + if not buf: + return + b0 = buf[0] + if b0 == HUB_MAGIC0: + # Binary frame: need full header before length is known. + if len(buf) < 5: + return + if buf[1] != HUB_MAGIC1: + del buf[0] # false magic; resync + continue + length = buf[3] | (buf[4] << 8) + if length > HUB_MAX_FRAME: + del buf[0] + continue + total = 5 + length + 2 + if len(buf) < total: + return + htype = buf[2] + payload = bytes(buf[5:5 + length]) + want = buf[5 + length] | (buf[6 + length] << 8) + got = crc16_ccitt(payload, crc16_ccitt(bytes(buf[2:5]))) + del buf[:total] + if want == got: + self._dispatch_binary(htype, payload) + continue + # JSON / stray text line: consume up to newline. + nl = buf.find(b"\n") + if nl < 0: + # No newline yet. If a binary magic appears later in the + # buffer, drop the leading garbage up to it. + m = buf.find(bytes([HUB_MAGIC0])) + if m > 0: + del buf[:m] + continue + return + line = bytes(buf[:nl]).strip() + del buf[:nl + 1] + if not line: + continue + try: + doc = json.loads(line.decode("utf-8", errors="ignore")) + except (json.JSONDecodeError, ValueError): + continue + if self.on_json: + try: + self.on_json(doc) + except Exception: + pass + + def _dispatch_binary(self, htype: int, payload: bytes) -> None: + from live_protocol import HUB_D2H_RX, HUB_D2H_ACKTAB, mac_to_str + if htype == HUB_D2H_RX and len(payload) > 6: + src = mac_to_str(payload[:6]) + if self.on_rx: + try: + self.on_rx(src, payload[6:]) + except Exception: + pass + elif htype == HUB_D2H_ACKTAB and len(payload) >= 1: + n = payload[0] + entries = [] + off = 1 + for _ in range(n): + if off + 17 > len(payload): + break + mac = mac_to_str(payload[off:off + 6]) + cum = int.from_bytes(payload[off + 6:off + 10], "little") + move = int.from_bytes(payload[off + 10:off + 14], "little") + age = payload[off + 14] | (payload[off + 15] << 8) + flags = payload[off + 16] + entries.append({"mac": mac, "cum": cum, "move": move, + "age_ms": age, "flags": flags}) + off += 17 + if self.on_acktab: + try: + self.on_acktab(entries) + except Exception: + pass + + # ---- Keepalive ---- + + def _pinger_main(self) -> None: + while self._running: + self.send_json({"cmd": "hub_ping"}) + # Sleep in small steps so stop() returns promptly. + for _ in range(int(PING_INTERVAL_S / 0.1)): + if not self._running: + return + time.sleep(0.1) diff --git a/mesh_manager.py b/mesh_manager.py new file mode 100644 index 0000000..9275bd2 --- /dev/null +++ b/mesh_manager.py @@ -0,0 +1,547 @@ +"""ESP-NOW mesh keyboard manager — drop-in for MultiBleKeyboardManager. + +Replaces the per-device BLE fan-out with a single broadcast through the +USB-attached hub device. Exposes the SAME public surface the BT Keyboard +window already calls (slots / add_device / remove_device / set_enabled / +set_label / identify / send_event / send_event_with_t / send_mouse / +reset_session_clocks / stats / shutdown / set_callbacks), so the window's +streaming, recording, and replay paths are unchanged. + +How it differs from the BLE manager internally: + + * One session per window. On open we mint a random 64-bit session_id and + a fresh 32-byte AES group key. Every reliable frame the host emits + carries a monotonically increasing transport seq; the hub caches it + and retransmits on a node's NACK, so no keystroke is ever lost. + * A "slot" is a known device MAC (the WiFi STA MAC == the AES device-tag + MAC == the key in config/.ble_keys.json — no migration needed). We + JOIN it by sending an encrypted invite (its per-device key) carrying + the group key + index + base seq; the node decrypts, adopts the group + key, and starts ACKing. + * Discovery surfaces beacons the hub forwards (board type + label), + independent of Bleak. + * Status is derived from the hub's periodic ack-table: a node whose ack + is fresh and whose cum is near the head is "connected"; lagging / gone + nodes show accordingly. + +Threading: public API is Tk-thread safe. Outgoing keystrokes are batched +on a background flush thread (≤16 events per frame, every few ms) so a +burst pays one broadcast instead of N. Inbound hub callbacks fire on the +MeshLink reader thread; we marshal state changes out via the same +on_status_change / on_stats_change callbacks the window already installs. +""" + +from __future__ import annotations + +import os +import threading +import time +from dataclasses import dataclass, field +from typing import Callable, Optional + +import ble_keystore +from ble_frame import build_frame, DEVICE_TAG_PREFIX +from live_protocol import ( + MSG_STOP, MSG_KEYS, MSG_MOUSE, MSG_IDENTIFY, MSG_LABEL, + MSG_PAUSE, MSG_RESUME, + MESH_T_DATA, MESH_T_DATA_U, MESH_T_JOIN, MESH_T_BEACON, MESH_T_JOIN_ACK, + MESH_T_ERR, BCAST_MAC, + pack_header, pack_keys_body, pack_mouse_body, pack_mesh_header, + parse_mesh_header, mac_to_bytes, ERR_LABELS, +) + +# Status strings (kept identical to ble_live's so the window's color map +# and any existing checks keep working). +ST_DISCONNECTED = "disconnected" +ST_CONNECTING = "connecting" # JOIN sent, no ack yet +ST_CONNECTED = "connected" +ST_LAGGING = "lagging" +ST_ERROR = "error" + +# Soft warning threshold for the ESP-NOW hub. Higher than BLE mode +# (ble_multi.MAX_SLOTS == 4) because the hub broadcasts one frame to the +# whole fleet instead of holding N concurrent BLE links. Not a hard cap — +# the hub roster can track more; the UI just warns past this. +MAX_SLOTS = 12 + +# A node is considered present if its last ack-table entry is younger than +# this; lagging if older but still in the table. +_ACK_FRESH_MS = 1500 +_ACK_LAG_MS = 600 # cum-lag (frames behind head) that flips lagging +_JOIN_RETRY_S = 1.0 # re-JOIN an unacked / offline node this often +_FLUSH_INTERVAL_S = 0.005 # keystroke batch flush cadence +_MAX_BATCH = 16 + + +def _device_tag(mac: str) -> str: + return DEVICE_TAG_PREFIX + mac.upper() + + +@dataclass +class DeviceSlot: + address: str # STA MAC ("AA:BB:CC:DD:EE:FF") + label: str = "" + enabled: bool = True + status: str = ST_DISCONNECTED + board: str = "" # "atoms3" / "atoms3_lite" + last_status_change: float = field(default_factory=time.monotonic) + added_at: float = field(default_factory=time.monotonic) + # Reliability bookkeeping (updated from the hub ack-table) + cum: int = 0 + ack_lag: int = 0 + last_ack_age_ms: int = 0 + events_sent: int = 0 + bytes_sent: int = 0 + joined: bool = False + last_join_s: float = 0.0 + idx: int = 0 + + def display_label(self) -> str: + return self.label or self.address + + +class MeshKeyboardManager: + """Owns the mesh session and every known device slot.""" + + def __init__(self, link, max_slots: int = MAX_SLOTS): + self._link = link + self._max_slots = max_slots + self._slots: dict[str, DeviceSlot] = {} + self._lock = threading.Lock() + self._on_status_change: Optional[Callable[[str, str], None]] = None + self._on_stats_change: Optional[Callable[[], None]] = None + + # Session identity. Random session id + group key; no persistence — + # a fresh window session can never collide with a stale one, and a + # rebooted node re-JOINs into the current generation. + self._session_id = int.from_bytes(os.urandom(8), "little") + self._group_key = os.urandom(32) + self._hub_mac = "" # filled from the espnow_hub response + self._hub_tag = "" + + # Reliable transport seq (DATA lane) and move seq (DATA_U lane). + self._seq = 0 + self._move_seq = 0 + self._seq_lock = threading.Lock() + + # Host session clock (mirrors ble_live: t=0 at first send). + self._session_t0_ns: Optional[int] = None + + # Outgoing keystroke batch queue (flush thread coalesces). + self._pending: list = [] + self._pending_lock = threading.Lock() + self._running = True + self._flush_thread = threading.Thread( + target=self._flush_loop, daemon=True, name="MeshFlush") + + # Discovery: beacons collected between discover() calls. + self._beacons: dict[str, dict] = {} + + self._link.start(on_rx=self._on_hub_rx, + on_acktab=self._on_acktab, + on_json=self._on_hub_json) + self._flush_thread.start() + + # ---- Hub identity ---- + + def set_hub(self, mac: str) -> None: + """Record which device is the hub so the host never tries to JOIN + it as a node, and so the data-lane tag binds to it.""" + self._hub_mac = (mac or "").upper() + self._hub_tag = _device_tag(self._hub_mac) if self._hub_mac else "" + + def hub_mac(self) -> str: + return self._hub_mac + + # ---- Public API (parity with MultiBleKeyboardManager) ---- + + def set_callbacks(self, *, on_status_change=None, on_stats_change=None): + self._on_status_change = on_status_change + self._on_stats_change = on_stats_change + + def slots(self) -> list[DeviceSlot]: + with self._lock: + return sorted(self._slots.values(), key=lambda s: s.added_at) + + def slot_count(self) -> int: + with self._lock: + return len(self._slots) + + def is_full(self) -> bool: + return False + + def over_soft_limit(self) -> bool: + return self.slot_count() > self._max_slots + + def add_device(self, address: str, label: str = "", + board: str = "") -> DeviceSlot | None: + addr = address.upper() + if self._hub_mac and addr == self._hub_mac: + # The hub bridges; it is not a controllable node. + return None + with self._lock: + if addr in self._slots: + return None + idx = len(self._slots) + slot = DeviceSlot(address=addr, label=label or addr, + board=board, idx=idx, + status=ST_CONNECTING) + self._slots[addr] = slot + # Kick off a JOIN immediately; the flush/maintenance loop re-tries. + self._send_join(slot) + self._emit_status(addr, ST_CONNECTING) + return slot + + def remove_device(self, address: str) -> bool: + addr = address.upper() + with self._lock: + slot = self._slots.pop(addr, None) + if slot is None: + return False + # Tell the node to leave so it stops emitting / releases held keys. + self._send_inner(MSG_STOP, b"", dest=addr, reliable=True) + return True + + def set_enabled(self, address: str, enabled: bool) -> None: + addr = address.upper() + with self._lock: + slot = self._slots.get(addr) + if slot is None: + return + slot.enabled = bool(enabled) + # PAUSE/RESUME keeps the node ACKing (instant re-enable, no flood) + # but stops it feeding its USB HID. + self._send_inner(MSG_RESUME if enabled else MSG_PAUSE, b"", + dest=addr, reliable=True) + + def get_slot(self, address: str) -> DeviceSlot | None: + with self._lock: + return self._slots.get(address.upper()) + + def set_label(self, address: str, label: str) -> None: + addr = address.upper() + with self._lock: + slot = self._slots.get(addr) + if slot is None: + return + slot.label = label or slot.address + body = (label or "").encode("utf-8")[:38] + self._send_inner(MSG_LABEL, body, dest=addr, reliable=True) + + def identify(self, address: str, on: bool = True) -> None: + self._send_inner(MSG_IDENTIFY, bytes([1 if on else 0]), + dest=address.upper(), reliable=True) + + # ---- Streaming ---- + + def send_event(self, action: int, hid_code: int) -> int: + now_ns = time.monotonic_ns() + if self._session_t0_ns is None: + self._session_t0_ns = now_ns + t_ms = (now_ns - self._session_t0_ns) // 1_000_000 + if t_ms > 0xFFFFFFFF: + t_ms = 0xFFFFFFFF + return self.send_event_with_t(action, hid_code, int(t_ms)) + + def send_event_with_t(self, action: int, hid_code: int, t_ms: int) -> int: + if not self._enabled_count(): + return 0 + with self._pending_lock: + self._pending.append((int(action) & 0xFF, int(hid_code) & 0xFF, + int(t_ms) & 0xFFFFFFFF)) + return self._enabled_count() + + def send_mouse(self, buttons: int, x: float, y: float, wheel: int = 0) -> int: + n = self._enabled_count() + if not n: + return 0 + xi = int(max(0.0, min(1.0, x)) * 32767) + yi = int(max(0.0, min(1.0, y)) * 32767) + body = pack_mouse_body(int(buttons) & 0xFF, xi, yi, int(wheel)) + # Button changes and wheel ticks must not be lost → reliable DATA; + # pure moves ride the latest-wins DATA_U lane. + reliable = (buttons != self._last_mouse_buttons) or (int(wheel) != 0) + self._last_mouse_buttons = buttons + self._send_inner(MSG_MOUSE, body, dest=BCAST_MAC, reliable=reliable) + return n + + _last_mouse_buttons = 0 + + def reset_session_clocks(self) -> None: + self._session_t0_ns = None + + # ---- Stats ---- + + def stats(self) -> list[dict]: + out = [] + for slot in self.slots(): + out.append({ + "address": slot.address, + "label": slot.display_label(), + "enabled": slot.enabled, + "status": slot.status, + "events_sent": slot.events_sent, + "bytes_sent": slot.bytes_sent, + "ack_lag": slot.ack_lag, + "board": slot.board, + }) + return out + + def take_beacons(self) -> list[dict]: + """Return discovered (non-slot) devices seen since the last call.""" + with self._lock: + known = set(self._slots.keys()) + found = [v for k, v in self._beacons.items() if k not in known] + self._beacons.clear() + return found + + # ---- Shutdown ---- + + def shutdown(self) -> None: + self._running = False + # Best-effort: tell every node to leave the session. + try: + self._send_inner(MSG_STOP, b"", dest=BCAST_MAC, reliable=True) + except Exception: + pass + with self._lock: + self._slots.clear() + if self._flush_thread.is_alive(): + self._flush_thread.join(timeout=1.0) + + # ================================================================== + # Internal — framing & send + # ================================================================== + + def _next_seq(self) -> int: + with self._seq_lock: + self._seq += 1 + return self._seq + + def _next_move_seq(self) -> int: + with self._seq_lock: + self._move_seq += 1 + return self._move_seq + + def _build_data_frame(self, mtype: int, seq: int, dest: bytes, + inner_plain: bytes) -> bytes | None: + if not self._hub_tag: + return None + try: + enc = build_frame(self._group_key, self._hub_tag, inner_plain) + except Exception: + return None + return pack_mesh_header(mtype, 0, seq, dest) + enc + + def _send_inner(self, msg_type: int, body: bytes, dest, reliable: bool) -> None: + """Encrypt one inner live-protocol message under the group key and + ship it to the hub for broadcast. ``dest`` is BCAST_MAC for + everyone or a MAC string for a single node.""" + if isinstance(dest, str): + dest_bytes = mac_to_bytes(dest) + else: + dest_bytes = dest + if reliable: + seq = self._next_seq() + mtype = MESH_T_DATA + else: + seq = self._next_move_seq() + mtype = MESH_T_DATA_U + plain = pack_header(msg_type, self._session_id, seq) + body + frame = self._build_data_frame(mtype, seq, dest_bytes, plain) + if frame is None: + return + self._link.send_mesh_frame(frame) + + def _send_keys_batch(self, batch: list) -> None: + if not batch: + return + seq = self._next_seq() + body = pack_keys_body(batch) + plain = pack_header(MSG_KEYS, self._session_id, seq) + body + frame = self._build_data_frame(MESH_T_DATA, seq, BCAST_MAC, plain) + if frame is None: + return + if self._link.send_mesh_frame(frame): + with self._lock: + for slot in self._slots.values(): + if slot.enabled: + slot.events_sent += len(batch) + slot.bytes_sent += len(frame) + + def _send_join(self, slot: DeviceSlot) -> None: + """Invite one device into the session: an AES frame under ITS + per-device key carrying the group key, index, and current base + seq (so a mid-session join doesn't trigger a historic NACK + storm).""" + key = ble_keystore.load_key_for_mac_or_default(slot.address) + if key is None: + self._emit_status(slot.address, ST_ERROR) + return + import json as _json + base = self._seq + payload = _json.dumps({ + "sid": self._session_id, + "gkey": self._group_key.hex(), + "idx": slot.idx, + "base": base, + "label": slot.label, + }).encode("utf-8") + try: + enc = build_frame(key, _device_tag(slot.address), payload) + except Exception: + self._emit_status(slot.address, ST_ERROR) + return + frame = pack_mesh_header(MESH_T_JOIN, 0, base, + mac_to_bytes(slot.address)) + enc + slot.last_join_s = time.monotonic() + self._link.send_mesh_frame(frame) + # Re-send the latched label so a freshly-joined node shows it. + if slot.label and slot.label != slot.address: + self._send_inner(MSG_LABEL, + slot.label.encode("utf-8")[:38], + dest=slot.address, reliable=True) + + def _enabled_count(self) -> int: + with self._lock: + return sum(1 for s in self._slots.values() if s.enabled) + + # ================================================================== + # Internal — flush loop & maintenance + # ================================================================== + + def _flush_loop(self) -> None: + last_maint = 0.0 + while self._running: + # Drain pending keystrokes into ≤16-event batches. + batch = None + with self._pending_lock: + if self._pending: + batch = self._pending[:_MAX_BATCH] + del self._pending[:_MAX_BATCH] + if batch: + self._send_keys_batch(batch) + # If more remain, loop again immediately (no sleep). + with self._pending_lock: + if self._pending: + continue + + now = time.monotonic() + if now - last_maint >= _JOIN_RETRY_S: + last_maint = now + self._maintain_joins(now) + + time.sleep(_FLUSH_INTERVAL_S) + + def _maintain_joins(self, now: float) -> None: + """Re-JOIN any slot that hasn't acked yet or has gone offline.""" + for slot in self.slots(): + if slot.joined and slot.status == ST_CONNECTED: + continue + if (now - slot.last_join_s) >= _JOIN_RETRY_S: + self._send_join(slot) + + # ================================================================== + # Internal — inbound from hub + # ================================================================== + + def _on_acktab(self, entries: list) -> None: + head = self._seq + changed = False + seen = set() + for e in entries: + mac = e["mac"].upper() + seen.add(mac) + with self._lock: + slot = self._slots.get(mac) + if slot is None: + continue + slot.cum = e["cum"] + slot.ack_lag = max(0, head - e["cum"]) + slot.last_ack_age_ms = e["age_ms"] + fresh = e["age_ms"] < _ACK_FRESH_MS + if fresh: + slot.joined = True + new_status = (ST_CONNECTED if slot.ack_lag <= _ACK_LAG_MS + else ST_LAGGING) + else: + new_status = ST_LAGGING + if new_status != slot.status: + slot.status = new_status + slot.last_status_change = time.monotonic() + changed = True + # Slots not in the table at all → disconnected. + with self._lock: + for mac, slot in self._slots.items(): + if mac not in seen and slot.status not in (ST_CONNECTING, + ST_DISCONNECTED): + if slot.last_ack_age_ms or slot.joined: + slot.status = ST_DISCONNECTED + slot.joined = False + changed = True + if changed and self._on_status_change: + try: + self._on_status_change("", "acktab") + except Exception: + pass + + def _on_hub_rx(self, src_mac: str, frame: bytes) -> None: + parsed = parse_mesh_header(frame) + if parsed is None: + return + mtype, flags, seq, dest, payload = parsed + if mtype == MESH_T_BEACON: + self._handle_beacon(src_mac, payload) + elif mtype == MESH_T_JOIN_ACK: + self._handle_join_ack(src_mac, payload) + elif mtype == MESH_T_ERR: + self._handle_err(src_mac, payload) + + def _handle_beacon(self, src_mac: str, payload: bytes) -> None: + # payload: ver, board(0/1), in_session, paused, label[...] + board = "" + label = "" + if len(payload) >= 4: + board = "atoms3_lite" if payload[1] == 1 else "atoms3" + label = payload[4:].split(b"\x00", 1)[0].decode("utf-8", "ignore") + with self._lock: + self._beacons[src_mac.upper()] = { + "address": src_mac.upper(), + "board": board, + "label": label, + "name": label or None, + } + + def _handle_join_ack(self, src_mac: str, payload: bytes) -> None: + addr = src_mac.upper() + with self._lock: + slot = self._slots.get(addr) + if slot is None: + return + slot.joined = True + if slot.status == ST_CONNECTING: + slot.status = ST_CONNECTED + slot.last_status_change = time.monotonic() + self._emit_status(addr, ST_CONNECTED) + + def _handle_err(self, src_mac: str, payload: bytes) -> None: + if not payload: + return + code = payload[0] + label = ERR_LABELS.get(code, f"ERR_{code}") + if self._on_status_change: + try: + self._on_status_change(src_mac.upper(), f"error:{label}") + except Exception: + pass + + def _on_hub_json(self, doc: dict) -> None: + # hub_pong / ok responses — currently informational only. + pass + + def _emit_status(self, addr: str, status: str) -> None: + if self._on_status_change: + try: + self._on_status_change(addr, status) + except Exception: + pass diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/backup_manager.py b/models/backup_manager.py new file mode 100644 index 0000000..98a9198 --- /dev/null +++ b/models/backup_manager.py @@ -0,0 +1,228 @@ +"""Backup manager — snapshots the entire app state into zip files. + +Each backup captures: + - All profile JSONs (profiles/*.json) + - Profile metadata (profiles_meta.json) + - Macro images (images/*) + +Backups are stored as zip files named `backup_YYYY-MM-DD_HH-MM-SS.zip` +in APPDATA_DIR/backups/. +""" + +import os +import re +import shutil +import zipfile +from datetime import datetime, timedelta +from pathlib import Path + +from utils.constants import ( + APPDATA_DIR, + BACKUPS_DIR, + PROFILES_DIR, + PROFILES_META_FILE, + IMAGES_DIR, +) + + +BACKUP_NAME_RE = re.compile(r"^backup_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$") + + +class BackupInfo: + """Metadata for one backup file.""" + + def __init__(self, path: Path): + self.path = path + self.filename = path.name + try: + self.size = path.stat().st_size + except OSError: + self.size = 0 + + # Extract timestamp from filename; fall back to mtime if the name + # doesn't match the expected pattern. + m = BACKUP_NAME_RE.match(path.name) + if m: + try: + self.timestamp = datetime.strptime(m.group(1), "%Y-%m-%d_%H-%M-%S") + except ValueError: + self.timestamp = datetime.fromtimestamp(path.stat().st_mtime) + else: + self.timestamp = datetime.fromtimestamp(path.stat().st_mtime) + + def human_readable_time(self) -> str: + """Return a friendly description of when this backup was taken.""" + now = datetime.now() + delta = now - self.timestamp + + if delta < timedelta(seconds=45): + return "Just now" + if delta < timedelta(minutes=1): + return "Less than a minute ago" + if delta < timedelta(minutes=60): + mins = int(delta.total_seconds() // 60) + return f"{mins} minute{'s' if mins != 1 else ''} ago" + if delta < timedelta(hours=6): + hrs = int(delta.total_seconds() // 3600) + return f"{hrs} hour{'s' if hrs != 1 else ''} ago" + + today = now.date() + ts_date = self.timestamp.date() + time_part = self.timestamp.strftime("%I:%M %p").lstrip("0") + + if ts_date == today: + return f"Today at {time_part}" + if ts_date == today - timedelta(days=1): + return f"Yesterday at {time_part}" + if today - ts_date < timedelta(days=7): + weekday = self.timestamp.strftime("%A") + return f"{weekday} at {time_part}" + + return self.timestamp.strftime("%b %d, %Y at ") + time_part + + def human_readable_size(self) -> str: + return _format_bytes(self.size) + + +def _format_bytes(n: int) -> str: + if n < 1024: + return f"{n} B" + if n < 1024 * 1024: + return f"{n / 1024:.1f} KB" + if n < 1024 * 1024 * 1024: + return f"{n / (1024 * 1024):.1f} MB" + return f"{n / (1024 * 1024 * 1024):.2f} GB" + + +class BackupManager: + """Creates, lists, restores, and deletes zip-file backups of app state.""" + + def __init__(self, backups_dir: str = BACKUPS_DIR): + self.backups_dir = Path(backups_dir) + self.backups_dir.mkdir(parents=True, exist_ok=True) + + def create_backup(self) -> Path | None: + """Create a new backup zip. Returns the path on success, None on failure.""" + try: + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + # If a backup with this exact second already exists, append a counter + out_path = self.backups_dir / f"backup_{timestamp}.zip" + counter = 1 + while out_path.exists(): + out_path = self.backups_dir / f"backup_{timestamp}_{counter}.zip" + counter += 1 + + with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf: + meta_path = Path(PROFILES_META_FILE) + if meta_path.exists(): + zf.write(meta_path, arcname="profiles_meta.json") + + prof_dir = Path(PROFILES_DIR) + if prof_dir.exists(): + for p in prof_dir.glob("*.json"): + zf.write(p, arcname=f"profiles/{p.name}") + + img_dir = Path(IMAGES_DIR) + if img_dir.exists(): + for p in img_dir.iterdir(): + if p.is_file(): + zf.write(p, arcname=f"images/{p.name}") + + return out_path + except Exception as e: + print(f"[backup] create_backup failed: {e}") + return None + + def list_backups(self) -> list[BackupInfo]: + """Return all backups, newest first.""" + if not self.backups_dir.exists(): + return [] + backups = [BackupInfo(p) for p in self.backups_dir.glob("backup_*.zip")] + backups.sort(key=lambda b: b.timestamp, reverse=True) + return backups + + def total_size(self) -> int: + total = 0 + for info in self.list_backups(): + total += info.size + return total + + def total_size_formatted(self) -> str: + return _format_bytes(self.total_size()) + + def delete_backup(self, path) -> bool: + try: + p = Path(path) + if p.exists() and p.parent == self.backups_dir: + p.unlink() + return True + except OSError as e: + print(f"[backup] delete_backup failed: {e}") + return False + + def restore_backup(self, path) -> bool: + """Restore app state from a backup zip. + + Replaces the profiles directory, profiles_meta.json, and images. + Returns True on success, False on failure. + + The caller is responsible for reloading the app's in-memory state + (Project, ProfileManager, etc.) after this returns. + """ + src = Path(path) + if not src.exists(): + print(f"[backup] restore: source missing: {src}") + return False + + try: + with zipfile.ZipFile(src, "r") as zf: + # Sanity-check: a real backup must contain profiles_meta or at least one profile + names = zf.namelist() + if not any(n == "profiles_meta.json" or n.startswith("profiles/") for n in names): + print(f"[backup] restore: zip doesn't look like a valid backup") + return False + + prof_dir = Path(PROFILES_DIR) + img_dir = Path(IMAGES_DIR) + if prof_dir.exists(): + shutil.rmtree(prof_dir) + if img_dir.exists(): + shutil.rmtree(img_dir) + + prof_dir.mkdir(parents=True, exist_ok=True) + img_dir.mkdir(parents=True, exist_ok=True) + + meta_path = Path(PROFILES_META_FILE) + if meta_path.exists(): + meta_path.unlink() + + appdata = Path(APPDATA_DIR).resolve() + for name in names: + # Block absolute paths and .. traversal (zip-slip protection) + if name.startswith("/") or ".." in name.replace("\\", "/").split("/"): + continue + + if name == "profiles_meta.json": + dest = meta_path + elif name.startswith("profiles/"): + dest = prof_dir / name[len("profiles/"):] + elif name.startswith("images/"): + dest = img_dir / name[len("images/"):] + else: + continue + + # Final safety check — destination must resolve inside appdata + try: + resolved = dest.resolve() + resolved.relative_to(appdata) + except (ValueError, OSError): + continue + + dest.parent.mkdir(parents=True, exist_ok=True) + with zf.open(name) as src_f, open(dest, "wb") as out_f: + shutil.copyfileobj(src_f, out_f) + + return True + except Exception as e: + print(f"[backup] restore_backup failed: {e}") + return False diff --git a/models/macro.py b/models/macro.py new file mode 100644 index 0000000..c9e2582 --- /dev/null +++ b/models/macro.py @@ -0,0 +1,1011 @@ +"""Macro and node data models.""" + +import uuid + + +def new_id(): + return str(uuid.uuid4())[:8] + + +def _ps_quote(s: str) -> str: + """Wrap ``s`` as a PowerShell single-quoted literal. + + Single-quoted strings in PowerShell are literal except the single quote + itself, which doubles as the escape — so we just replace ``'`` with ``''``. + Newlines and other control chars are passed through; the user shouldn't + embed those in a Check's expected/path value, but if they do PowerShell + handles them inside the quotes. + """ + return "'" + (s or "").replace("'", "''") + "'" + + +SETTLE_MS_DEFAULT = 800 # delay inside the block before any toggle fires + + +def _render_semi_auto_sequence(sequence: list, settle_ms: int = SETTLE_MS_DEFAULT): + """Convert a Semi-Auto step list into a PowerShell script + outcomes. + + Each ``check`` step in the sequence becomes one outcome (toggle_count = + 1-based step number, value = step['value']). On match, the script + invokes ``t N`` to fire N Scroll Lock toggles and ``return``s out of + the enclosing ``& { ... }`` block so the shell stays open and any + subsequent checks are skipped. + + Critical timing detail: ``sleep -m {settle_ms}`` runs as the FIRST + statement inside the ``& { ... }`` block. The block evaluates the + instant the device finishes typing its closing ``}``, but the device's + Scroll-Lock listen window doesn't start until ~520 ms later (the + extra-Enter + pre-listen-ms gap). Without this settle, the toggles fire + BEFORE the device begins listening — script "instantly finishes" with + no observed toggle. 800 ms covers the gap with comfortable margin. + + Compact form: a single ``Add-Type`` with ``uint dwExtraInfo`` (so we + can pass literal ``0`` instead of ``[System.UIntPtr]::Zero``), a + one-line ``t($n)`` helper. Output is NOT redirected — stderr stays + visible, captured stdout is echoed via ``$o`` so the user sees what + the command returned. + """ + body: list[str] = [] + outcomes: list[dict] = [] + check_count = 0 + needs_wsh = False # only emit WScript.Shell if a step actually uses it + + for step in sequence: + kind = step.get("kind", "") + if kind == "wait": + ms = int(step.get("ms", 500) or 0) + body.append(f" sleep -m {ms}") + elif kind == "send_keys": + keys = step.get("keys", "") + body.append(f" $wsh.SendKeys({_ps_quote(keys)})") + needs_wsh = True + elif kind == "run": + cmd = (step.get("command", "") or "").rstrip() + if cmd: + body.append(" & {") + for ln in cmd.splitlines(): + body.append(" " + ln) + body.append(" }") + elif kind == "check": + check_count += 1 + n = check_count + outcomes.append({ + "toggle_count": n, + "value": step.get("value", ""), + }) + op = step.get("operator", "equals") + expected = step.get("expected", "") or "" + cmd = (step.get("command", "") or "").rstrip() + body.append(f" # Check {n} ({op}) -> on match: t {n}") + if op == "exists": + # `expected` holds the path; command field unused. + body.append(f" if (Test-Path {_ps_quote(expected)}) {{ t {n}; return }}") + else: + # Capture stdout, leave stderr visible (no 2>&1). + if cmd: + if "\n" in cmd: + body.append(" $o = ((& {") + for ln in cmd.splitlines(): + body.append(" " + ln) + body.append(" }) | Out-String).Trim()") + else: + body.append(f" $o = (({cmd.strip()}) | Out-String).Trim()") + else: + body.append(" $o = ''") + body.append(' "out=[$o]"') # echo so the user sees the captured value + if op == "equals": + body.append(f" if ($o -eq {_ps_quote(expected)}) {{ t {n}; return }}") + elif op == "contains": + pattern = "*" + (expected or "").replace("*", "`*").replace("?", "`?") + "*" + body.append(f" if ($o -like {_ps_quote(pattern)}) {{ t {n}; return }}") + elif op == "regex": + body.append(f" if ($o -match {_ps_quote(expected)}) {{ t {n}; return }}") + elif op == "numeric_gt": + body.append( + f" try {{ if ([double]$o -gt [double]{_ps_quote(expected)}) " + f"{{ t {n}; return }} }} catch {{}}" + ) + elif op == "numeric_lt": + body.append( + f" try {{ if ([double]$o -lt [double]{_ps_quote(expected)}) " + f"{{ t {n}; return }} }} catch {{}}" + ) + else: + body.append(f" # unknown operator {op!r} — skipped") + body.append("") + # Unknown step kinds silently skipped. + + # Compact PInvoke wrapper. `uint e` (instead of System.UIntPtr) lets us + # pass literal 0 from PowerShell — the high bits of dwExtraInfo are + # unused on x64 in practice for keybd_event so the size mismatch is OK. + # 0x91 = VK_SCROLL, flag 0 = keydown, flag 2 = KEYEVENTF_KEYUP. + # GetKeyState is included so the normalization line below can read the + # current Scroll Lock state without loading System.Windows.Forms. + head: list[str] = [ + "Add-Type -Name K -Namespace W -MemberDefinition '" + "[System.Runtime.InteropServices.DllImport(\"user32\")]" + "public static extern void keybd_event(byte v,byte s,uint f,uint e);" + "[System.Runtime.InteropServices.DllImport(\"user32\")]" + "public static extern short GetKeyState(int v);' -EA 0", + "function t($n){1..$n|%{[W.K]::keybd_event(0x91,0,0,0);" + "[W.K]::keybd_event(0x91,0,2,0);sleep -m 200}}", + # Force Scroll Lock to OFF before the main block runs. probeScroll + # LockSequence counts state transitions, so a stray ON state at boot + # would either be a no-op (firmware tracks current state) or, worse, + # be paired with the device's own listening start at an unpredictable + # time. Normalizing here gives the script a known starting point. + "if (([W.K]::GetKeyState(0x91) -band 1) -eq 1) " + "{ [W.K]::keybd_event(0x91,0,0,0); [W.K]::keybd_event(0x91,0,2,0) }", + ] + if needs_wsh: + head.append("$wsh = New-Object -ComObject WScript.Shell") + + lines: list[str] = list(head) + lines.append("& {") + # Settle delay runs INSIDE the block at evaluation time, after the + # device has finished typing and started listening. + lines.append(f" sleep -m {int(settle_ms)}") + lines.extend(body) + lines.append("}") + return ("\n".join(lines) + "\n", outcomes) + + +def _render_run_script_check(run_script: dict, + settle_ms: int = SETTLE_MS_DEFAULT) -> str: + """Render a 'Run Script + Check' invocation to PowerShell. + + The author's .ps1 lives on disk (locally, or on a USB drive identified by + label). The host's job is just to find it and run it — the script itself + is responsible for emitting Scroll Lock toggles, so the outcomes table is + matched against whatever the script produces just like Manual mode. + + The wrapper mirrors `_render_semi_auto_sequence`'s preamble (the same + `Add-Type` + `t($n)` helper and the same settle delay inside `& { ... }`) + so the device's listen-window timing contract is identical across all + three sub-modes. We still emit `t($n)` even though the user's script + won't see it — it's a no-op overhead but keeps the prefix uniform and + cheap, and leaves room for future host-injected checks. + """ + run_script = run_script or {} + location = run_script.get("location", "local") + args = (run_script.get("args", "") or "").strip() + args_tail = (" " + args) if args else "" + + lines: list[str] = [ + "Add-Type -Name K -Namespace W -MemberDefinition '" + "[System.Runtime.InteropServices.DllImport(\"user32\")]" + "public static extern void keybd_event(byte v,byte s,uint f,uint e);" + "[System.Runtime.InteropServices.DllImport(\"user32\")]" + "public static extern short GetKeyState(int v);' -EA 0", + "function t($n){1..$n|%{[W.K]::keybd_event(0x91,0,0,0);" + "[W.K]::keybd_event(0x91,0,2,0);sleep -m 200}}", + # Force Scroll Lock to OFF before the main block runs (see the + # matching comment in _render_semi_auto_sequence). + "if (([W.K]::GetKeyState(0x91) -band 1) -eq 1) " + "{ [W.K]::keybd_event(0x91,0,0,0); [W.K]::keybd_event(0x91,0,2,0) }", + "& {", + f" sleep -m {int(settle_ms)}", + ] + + if location == "usb": + label = run_script.get("drive_label", "") or "" + rel = (run_script.get("relative_path", "") or "").lstrip("\\/") + err_msg = _ps_quote(f"USB drive '{label}' not mounted") + lines.append( + f" $d = (Get-Volume -FileSystemLabel {_ps_quote(label)} -EA 0 | " + f"Where-Object DriveLetter | Select-Object -First 1).DriveLetter" + ) + lines.append(f" if (-not $d) {{ Write-Error {err_msg}; return }}") + # Build the path as ":\" with -f formatting so the + # relative segment (which may contain backslashes) doesn't need any + # double-quote escaping gymnastics. + rel_literal = _ps_quote("{0}:\\" + rel) + lines.append(f" $p = {rel_literal} -f $d") + lines.append(f" & $p{args_tail}") + else: + path = run_script.get("local_path", "") or "" + lines.append(f" & {_ps_quote(path)}{args_tail}") + + lines.append("}") + return "\n".join(lines) + "\n" + + +class NodeData: + """Represents a single node in a macro's node graph.""" + + def __init__(self, node_type: str, node_id: str = None, x: int = 100, y: int = 100, + data: dict = None, flipped: bool = False): + self.id = node_id or new_id() + self.type = node_type + self.x = x + self.y = y + self.data = data or self.default_data() + # Visual flag: when True, the node renders with its input(s) on the + # right side and output(s) on the left. Purely cosmetic — does not + # affect flatten/execution behavior. + self.flipped = flipped + + def default_data(self) -> dict: + defaults = { + "start": {}, + "text": { + "text": "", + # Editor-only syntax-highlight hint ("none", "cmd", "powershell"); + # the device ignores this field. + "language": "none", + }, + "combo": { + "mods": [], "key": "", + # When custom_timings is True, the 4 ms values below override + # the device's global combo timing settings for THIS combo only. + # Defaults are tuned at 3x faster than the device defaults so + # enabling them maps to the legacy "fast" behavior. + "custom_timings": False, + "custom_pre_ms": 167, + "custom_post_ms": 167, + "custom_key_pre_ms": 3, + "custom_key_post_ms": 8, + }, + "pause": {"wait": "click", "text": "Press to continue", "font_size": 12}, + "branch": { + # mode: "manual" — user picks on-device dropdown (legacy default) + # mode: "by_variable" — device reads `var_name` and routes to the + # first choice whose `match_value` equals the variable's value; + # if no match, falls through to the LAST choice (else branch). + "mode": "manual", + "var_name": "", + "var_scope": "auto", # "auto" (device→universal), "device", "universal" + "choices": [ + {"label": "Option A", "next": -1, "match_value": ""}, + {"label": "Option B", "next": -1, "match_value": ""}, + ], + }, + "delay": {"ms": 500}, + "repeat": {"count": 2, "start_idx": 0, "use_selector": False}, + "loop_selector": { + "min": 1, "max": 10, "step": 1, "default": 1, + "prompt": "Loop count?", + "ask_start": False, + }, + "iteration_branch": { + "loop_node_id": "", # ID of the repeat node this is tied to + "choices": [ + {"label": "Path A"}, + {"label": "Path B"}, + ], + # When True, skip the branch on the final iteration of the + # tied loop. Useful when paths represent "transition to the + # next iteration" (e.g. switch KVM device) — on the last pass + # there's no next to transition to, so the branch no-ops. + "skip_final_iteration": False, + }, + "note": { + "text": "Note", + "font_size": 14, + "color": "white", + "width": 220, # canvas width in world units + }, + "aggregator": {"input_count": 2}, + "mouse": {"button": "left", "action": "click"}, + "media": {"action": "play_pause"}, + "bluetooth": { + # Mode dispatcher. The on-device interpreter switches on this. + # pull_ble — host pushes encrypted vars to device (BLE) + # push_ble — device pushes its on-device vars up (BLE; device scope only) + # request_ble — device asks host to prompt for value(s) (BLE) + # set_local — device writes static (name, value) pairs locally + # get_local — device runs a script, decodes Scroll Lock toggles, sets a var + "mode": "pull_ble", + + # Used by pull_ble / set_local: which on-device store to target. + # "device" — per-device store (keyed by eFuse MAC) + # "universal" — shared across all devices + "scope": "universal", + + # request_ble: which variable names to prompt for on host. + "names": [], + # If true, host plays a Windows notification sound when prompt opens. + "play_sound": True, + + # set_local: list of {name, value} pairs to assign on-device. + "assignments": [], + + # get_local fields: + "script": "", + "script_language": "powershell", + "var_name": "", + "pre_listen_ms": 500, # delay between Enter and start of listening + "listen_window_ms": 5000, # total time to wait for Num Lock toggles + "outcomes": [ + # {"toggle_count": 1, "value": "true"}, + # {"toggle_count": 3, "value": "false"}, + ], + + # get_local sub-mode: + # "manual" — author writes the PowerShell script directly + # "semi_auto" — author builds a sequence of steps; the host + # renders them to PowerShell + outcomes at upload + # "run_script_check" — author points at an existing .ps1 file + # (local path or USB-by-label); host emits a + # thin wrapper that invokes it. The .ps1 itself + # is responsible for firing Scroll Lock toggles. + "script_mode": "manual", + + # run_script_check fields: + "run_script": { + "location": "local", # "local" | "usb" + "local_path": "", # absolute path; used when location == "local" + "drive_label": "", # USB volume label; used when location == "usb" + "relative_path": "", # path from drive root, e.g. "scripts\\check.ps1" + "args": "", # optional arguments appended after the path + }, + + # Optional Win+R launcher run BEFORE typing the script. Used + # to bring up an elevated terminal so the script has admin + # rights and a clean focus target. + "elevated_launch": { + "enabled": False, + "command": "powershell -Command \"Start-Process wt -Verb RunAs\"", + "win_r_wait_ms": 5000, + "post_type_wait_ms": 15000, + # When uac_accept is True, after pressing Enter on the + # Run box the device waits uac_wait_ms for the UAC + # dialog to appear, then sends Left+Enter to click Yes. + # Default Yes-button focus on Win10/11 UAC is on the + # left, so Left moves focus to it (or keeps it there + # if already focused) and Enter accepts. + "uac_accept": False, + "uac_wait_ms": 10000, + }, + + # Semi-Auto step list. Each step is a dict with a `kind` field + # plus kind-specific config. See widgets/sequence_editor_dialog.py + # for the schema. + "sequence": [], + }, + "rs232": { + "baud": 9600, "data_bits": 8, "stop_bits": "1", "parity": "none", + "message": "", "line_ending": "none", + "wait_response": False, "expected_response": "", "timeout_ms": 5000, + "post_send_delay_ms": 0, + }, + "subroutine": {"name": ""}, + "pc_alive_check": { + "condition": "pc_response", # "numlock_on", "numlock_off", "pc_response" + "loop": True, + "poll_delay_ms": 500, + }, + "macro": { + # "events" is a list of [t_ms, action, hid_code] triples: + # t_ms — wall-clock ms since recording start + # action — 0 = press, 1 = release + # hid_code — raw USB HID usage code (see TKKEYSYM_TO_HID) + # Raw HID codes (not ASCII) let us reproduce chords exactly — + # modifiers stay held across other keys instead of being + # stripped by the keyboard library's ASCII→shift mapping. + "events": [], + "name": "", # optional human-readable label + }, + } + return defaults.get(self.type, {}).copy() + + def to_dict(self) -> dict: + return { + "id": self.id, + "type": self.type, + "x": self.x, + "y": self.y, + "data": self.data.copy(), + "flipped": self.flipped, + } + + @classmethod + def from_dict(cls, d: dict) -> "NodeData": + node_type = d["type"] + data = d.get("data") + + # Legacy migration: combo nodes used to have a single "fast" boolean. + # Map fast=True to custom_timings=True with the 3x-faster defaults so + # existing macros behave identically after an upgrade. + if node_type == "combo" and isinstance(data, dict) and "fast" in data: + was_fast = bool(data.pop("fast")) + if was_fast and not data.get("custom_timings"): + data["custom_timings"] = True + data.setdefault("custom_pre_ms", 167) + data.setdefault("custom_post_ms", 167) + data.setdefault("custom_key_pre_ms", 3) + data.setdefault("custom_key_post_ms", 8) + + return cls( + node_type=node_type, + node_id=d.get("id"), + x=d.get("x", 100), + y=d.get("y", 100), + data=data, + flipped=d.get("flipped", False), + ) + + +class ConnectionData: + """Represents a connection between two nodes.""" + + def __init__(self, from_id: str, from_port: str, to_id: str, to_port: str): + self.from_id = from_id + self.from_port = from_port + self.to_id = to_id + self.to_port = to_port + + def to_dict(self) -> dict: + return { + "from": self.from_id, + "from_port": self.from_port, + "to": self.to_id, + "to_port": self.to_port, + } + + @classmethod + def from_dict(cls, d: dict) -> "ConnectionData": + return cls(d["from"], d["from_port"], d["to"], d["to_port"]) + + +class Macro: + """Represents a complete macro with its node graph.""" + + def __init__(self, name: str = "New Routine", image_path: str = None, label_color: str = "white"): + self.name = name + self.image_path = image_path + self.label_color = label_color + self.nodes: list[NodeData] = [] + self.connections: list[ConnectionData] = [] + + def add_node(self, node: NodeData): + self.nodes.append(node) + + def remove_node(self, node_id: str): + self.nodes = [n for n in self.nodes if n.id != node_id] + self.connections = [ + c for c in self.connections + if c.from_id != node_id and c.to_id != node_id + ] + + def add_connection(self, conn: ConnectionData): + # An input port can only have one source — drop any existing one first + self.connections = [ + c for c in self.connections + if not (c.to_id == conn.to_id and c.to_port == conn.to_port) + ] + self.connections.append(conn) + + def remove_connection(self, from_id: str, from_port: str, to_id: str, to_port: str): + self.connections = [ + c for c in self.connections + if not (c.from_id == from_id and c.from_port == from_port + and c.to_id == to_id and c.to_port == to_port) + ] + + def get_node(self, node_id: str) -> NodeData | None: + for n in self.nodes: + if n.id == node_id: + return n + return None + + def to_dict(self) -> dict: + return { + "name": self.name, + "label_color": self.label_color, + "image_path": self.image_path, + "nodes": [n.to_dict() for n in self.nodes], + "connections": [c.to_dict() for c in self.connections], + } + + @classmethod + def from_dict(cls, d: dict) -> "Macro": + m = cls(name=d.get("name", "Unnamed"), image_path=d.get("image_path"), + label_color=d.get("label_color", "white")) + m.nodes = [NodeData.from_dict(nd) for nd in d.get("nodes", [])] + m.connections = [ConnectionData.from_dict(cd) for cd in d.get("connections", [])] + return m + + def clone(self, new_name: str = None) -> "Macro": + """Deep-copy this macro with fresh node IDs. + + Node IDs only need to be unique within a single macro's graph, + but regenerating them on duplicate avoids any chance of a stale + cross-macro reference (e.g. a future global node index) and + matches what users expect from a "duplicate" action. + Connections are remapped to point at the new IDs. + """ + id_map: dict[str, str] = {n.id: new_id() for n in self.nodes} + + cloned_nodes: list[NodeData] = [] + for n in self.nodes: + d = n.to_dict() + d["id"] = id_map[n.id] + cloned_nodes.append(NodeData.from_dict(d)) + + cloned_conns: list[ConnectionData] = [ + ConnectionData( + id_map.get(c.from_id, c.from_id), + c.from_port, + id_map.get(c.to_id, c.to_id), + c.to_port, + ) + for c in self.connections + ] + + copy = Macro( + name=new_name if new_name is not None else f"{self.name} (copy)", + image_path=self.image_path, + label_color=self.label_color, + ) + copy.nodes = cloned_nodes + copy.connections = cloned_conns + return copy + + def _auto_linked_connections(self) -> list["ConnectionData"]: + """Synthesize a linear chain of connections when none are defined. + + A common source of "the sub-routine runs but does nothing" bugs is + a macro / sub-routine whose GUI graph has nodes but no edges — + either because the user laid out nodes in the editor and forgot + to wire them, or because an older config-format upgrade dropped + the connections array. With no connections, walk_chain has + nothing to follow from the start node and the flatten returns + empty; the device shows the macro name and immediately exits. + + Here we paper over that case: when ``self.connections`` is empty + but we have at least two non-note nodes, build a sensible linear + chain by sorting nodes left-to-right (and top-to-bottom as a + tiebreaker), putting the explicit ``start`` node first if one + exists, and connecting each via the "out" → "in" port pair + (the convention used by every simple node type). + + This is intentionally conservative: branch / repeat / + iteration_branch / aggregator graphs need named ports + (out_0, loop_body, in_0, etc.) and can't be auto-wired + meaningfully — for those, the chain still ends up linear via + "out", which the firmware ignores for those node types. The net + effect is "do what the user obviously intended for a linear + sequence; gracefully degrade for advanced graphs." + """ + non_note = [n for n in self.nodes if n.type != "note"] + if len(non_note) < 2: + return [] + # Stable sort by (y, x) so the chain matches the visual top-to-bottom, + # left-to-right reading order. Start node is forced to position 0 so + # the flatten's "find start" logic picks it up naturally. + start_node = next((n for n in non_note if n.type == "start"), None) + rest = [n for n in non_note if n is not start_node] + rest.sort(key=lambda n: (round(n.y / 40.0), n.x)) + ordered = ([start_node] if start_node else []) + rest + return [ + ConnectionData(ordered[i].id, "out", ordered[i + 1].id, "in") + for i in range(len(ordered) - 1) + ] + + def flatten_for_device(self) -> list[dict]: + """Convert the visual node graph into a linear node array for the device. + + Performs topological sort starting from the node with no incoming + connections. Branch nodes produce multiple chains laid out sequentially. + """ + if not self.nodes: + return [] + + # Auto-link orphan graphs (see _auto_linked_connections). We use the + # synthesized edges only when the user actually left connections empty + # — never override explicit wiring. + active_connections = ( + self.connections + if self.connections + else self._auto_linked_connections() + ) + + # Build adjacency: node_id -> {port: target_node_id} + outgoing = {} + incoming_ids = set() + for c in active_connections: + if c.from_id not in outgoing: + outgoing[c.from_id] = {} + outgoing[c.from_id][c.from_port] = c.to_id + incoming_ids.add(c.to_id) + + # Find start node (explicit "start" type, or fallback to no incoming connections). + # Note nodes are GUI-only annotations — never treat one as a start candidate. + start_nodes = [n for n in self.nodes if n.type == "start"] + if not start_nodes: + start_nodes = [n for n in self.nodes + if n.id not in incoming_ids and n.type != "note"] + if not start_nodes and self.nodes: + non_note = [n for n in self.nodes if n.type != "note"] + if non_note: + start_nodes = [non_note[0]] + + # Assign a stable loop_id (small integer) to each repeat node so that + # iteration_branch nodes can cross-reference them on the device. + loop_ids = {} + for n in self.nodes: + if n.type == "repeat": + loop_ids[n.id] = len(loop_ids) + + result = [] + visited = set() + # aggregator node_id -> its position in ``result`` once emitted. + # Lets a later branch path that loops BACK to an already-emitted + # aggregator be patched to jump directly to that known position + # (i.e. "retry" the preceding sub-graph), instead of falling through + # to ``end_target`` — which inside a loop body would hit + # ``_loop_back`` and spuriously bump the outer iteration counter + # (e.g. a nested-loop BIOS-setup routine). + agg_positions: dict[str, int] = {} + + def walk_branch_paths(branch_entry, ports, port_name_fn, default_label_fn): + """Shared helper for branch + iteration_branch path walking. + + Walks each choice's chain with aggregator-merge semantics: if a path + reaches an Aggregator node, it stops there and is patched to jump to + the aggregator's position. After all paths are walked, each unique + NEW aggregator is emitted once as a passthrough, and its output + chain is walked once. Paths whose chains loop BACK to an aggregator + that was emitted earlier (e.g. a retry loop) are patched to jump + directly to that earlier position. Paths that don't reach any + aggregator fall back to the classic "jump past all branches" merge + point. + + Also writes ``skip_target`` into ``branch_entry['data']`` pointing + to the "natural next node" if the branch were skipped entirely. + For a branch with an aggregator merge point, that's the aggregator + (so post-merge body nodes still run); otherwise it's end_target. + iteration_branch's runtime uses this when ``skip_final_iteration`` + is set so the last pass can bypass the path logic and merge + straight into the rest of the body. + """ + choices = branch_entry["data"]["choices"] + end_markers = [] # `_jump` indexes that should land on end_target + agg_markers = {} # NEW agg_node_id -> list of `_jump` indexes + back_edges = [] # (jump_idx, existing_agg_pos) — patch directly + new_agg_positions: dict[str, int] = {} # agg_id -> position emitted here + + for i, _ in enumerate(choices): + target = ports.get(port_name_fn(i)) + choices[i]["next"] = len(result) + + stopped_agg = None + if target: + stopped_agg = walk_chain(target, stop_at_aggregator=True) + + jump_idx = len(result) + result.append({"type": "_jump", "data": {"target": -1}}) + + if stopped_agg: + # Aggregator already emitted earlier? Back-edge: patch + # straight to its known position. Otherwise group it so + # we emit the aggregator once below. + if stopped_agg in agg_positions: + back_edges.append((jump_idx, agg_positions[stopped_agg])) + else: + agg_markers.setdefault(stopped_agg, []).append(jump_idx) + else: + end_markers.append(jump_idx) + + # Back-edges point at already-emitted positions; patch directly. + for jump_idx, agg_pos in back_edges: + result[jump_idx]["data"]["target"] = agg_pos + + # Emit each NEW aggregator once and walk its output chain. + for agg_id, jump_indices in agg_markers.items(): + agg_pos = len(result) + for idx in jump_indices: + result[idx]["data"]["target"] = agg_pos + result.append({"type": "aggregator", "data": {}}) + visited.add(agg_id) + agg_positions[agg_id] = agg_pos + new_agg_positions[agg_id] = agg_pos + agg_out = outgoing.get(agg_id, {}).get("out") + if agg_out: + walk_chain(agg_out) + + # Non-aggregator paths collapse to the final position. + end_target = len(result) + for idx in end_markers: + result[idx]["data"]["target"] = end_target + + # Publish where a "skipped" branch rejoins normal flow. + # Prefer the first NEW aggregator we emitted: its position sits + # BEFORE any post-merge body nodes added by the agg chain, so + # those body nodes still run on skip. Back-edge aggregators + # aren't candidates — they'd skip to an earlier-in-time node. + # Fallback: end_target, where non-aggregator paths go anyway. + if new_agg_positions: + branch_entry["data"]["skip_target"] = min(new_agg_positions.values()) + else: + branch_entry["data"]["skip_target"] = end_target + + def walk_chain(node_id, stop_at_aggregator=False): + """Walk a linear chain of nodes, emitting flattened entries. + + If ``stop_at_aggregator`` is True, returns the aggregator's + node_id when the walk reaches one (first-time or already-visited). + The caller decides whether to emit it anew or patch a back-edge + to its existing position. Aggregators encountered this way are + not emitted here and not added to ``visited`` by this function. + Returns None if the walk ends for any other reason. + """ + # If the starting node itself is an already-visited aggregator, + # the while-loop would exit immediately without reporting it. + if stop_at_aggregator and node_id and node_id in visited: + node = self.get_node(node_id) + if node and node.type == "aggregator": + return node_id + + while node_id and node_id not in visited: + node = self.get_node(node_id) + if not node: + break + + # Branch-walking mode: stop at the first aggregator we reach. + # The caller emits it and walks its tail once. + if stop_at_aggregator and node.type == "aggregator": + return node_id + + visited.add(node_id) + + # Start node is a visual marker; don't emit it. + if node.type == "start": + ports = outgoing.get(node_id, {}) + node_id = ports.get("out") + continue + + # Notes are GUI-only annotations; never send to the device. + if node.type == "note": + break + + if node.type == "branch": + branch_entry = {"type": "branch", "data": { + "mode": node.data.get("mode", "manual"), + "var_name": node.data.get("var_name", ""), + "var_scope": node.data.get("var_scope", "auto"), + "choices": [], + }} + result.append(branch_entry) + for i, choice in enumerate(node.data.get("choices", [])): + branch_entry["data"]["choices"].append({ + "label": choice.get("label", f"Option {i+1}"), + "next": 0, + "match_value": choice.get("match_value", ""), + }) + walk_branch_paths( + branch_entry, + outgoing.get(node_id, {}), + lambda i: f"out_{i}", + lambda i: f"Option {i+1}", + ) + return None + + if node.type == "iteration_branch": + tied_loop = node.data.get("loop_node_id", "") + loop_id = loop_ids.get(tied_loop, -1) + it_entry = {"type": "iteration_branch", "data": { + "loop_id": loop_id, + "skip_final_iteration": bool(node.data.get("skip_final_iteration", False)), + "choices": [], + }} + result.append(it_entry) + for i, choice in enumerate(node.data.get("choices", [])): + it_entry["data"]["choices"].append({ + "label": choice.get("label", f"Path {i+1}"), + "next": 0, + }) + walk_branch_paths( + it_entry, + outgoing.get(node_id, {}), + lambda i: f"out_{i}", + lambda i: f"Path {i+1}", + ) + return None + + if node.type == "aggregator": + # Normal encounter (not inside a stop_at_aggregator walk): + # emit as passthrough and continue through "out". Record + # its position so a later back-edge (e.g. a Retry path + # looping back here) can patch directly to it. + agg_positions[node_id] = len(result) + result.append({"type": "aggregator", "data": {}}) + ports = outgoing.get(node_id, {}) + node_id = ports.get("out") + continue + + if node.type == "repeat": + # 2 inputs (in, loop_back), 2 outputs (loop_body, done). + count = node.data.get("count", 2) + loop_start_idx = len(result) + loop_id = loop_ids.get(node_id, 0) + result.append({"type": "_loop_start", "data": { + "count": count, + "body": loop_start_idx + 1, + "done": -1, + "use_selector": node.data.get("use_selector", False), + "loop_id": loop_id, + }}) + + ports = outgoing.get(node_id, {}) + body_target = ports.get("loop_body") + if body_target: + walk_chain(body_target) + + # End of body jumps back to loop_start. + result.append({"type": "_loop_back", "data": {"target": loop_start_idx}}) + + # done target is the position after the loop_back. + result[loop_start_idx]["data"]["done"] = len(result) + + done_target = ports.get("done") + if done_target: + walk_chain(done_target) + + return + + if node.type == "pc_alive_check": + # Two outputs (true / false). Looping is handled internally + # by the firmware when loop=True. + check_entry = {"type": "pc_alive_check", "data": { + "condition": node.data.get("condition", "pc_response"), + "loop": node.data.get("loop", True), + "poll_delay_ms": node.data.get("poll_delay_ms", 500), + "true_target": -1, + "false_target": -1, + }} + check_idx = len(result) + result.append(check_entry) + + ports = outgoing.get(node_id, {}) + end_markers = [] + agg_markers = {} # NEW agg_id -> [_jump indices] + + for port_name in ("true", "false"): + target = ports.get(port_name) + result[check_idx]["data"][f"{port_name}_target"] = len(result) + + stopped_agg = None + if target: + stopped_agg = walk_chain(target, stop_at_aggregator=True) + + jump_idx = len(result) + result.append({"type": "_jump", "data": {"target": -1}}) + + if stopped_agg: + if stopped_agg in agg_positions: + # Back-edge to an aggregator already emitted by + # the other port's chain. Patch directly. + result[jump_idx]["data"]["target"] = agg_positions[stopped_agg] + else: + agg_markers.setdefault(stopped_agg, []).append(jump_idx) + else: + end_markers.append(jump_idx) + + # Emit each NEW aggregator once and walk its output chain. + for agg_id, jump_indices in agg_markers.items(): + agg_pos = len(result) + for idx in jump_indices: + result[idx]["data"]["target"] = agg_pos + result.append({"type": "aggregator", "data": {}}) + visited.add(agg_id) + agg_positions[agg_id] = agg_pos + agg_out = outgoing.get(agg_id, {}).get("out") + if agg_out: + walk_chain(agg_out) + + end_target = len(result) + for idx in end_markers: + result[idx]["data"]["target"] = end_target + + return + + if node.type == "bluetooth" and node.data.get("mode") == "get_local": + # Two outputs (pass / fail). Same shape as pc_alive_check. + bt_entry = {"type": "bluetooth", "data": node.data.copy()} + bt_entry["data"]["pass_target"] = -1 + bt_entry["data"]["fail_target"] = -1 + + # Semi-Auto mode: render the visual sequence into a real + # PowerShell script + auto-derive the outcomes table. The + # device-side path is identical to manual mode after this. + if bt_entry["data"].get("script_mode") == "semi_auto": + rendered_script, rendered_outcomes = _render_semi_auto_sequence( + bt_entry["data"].get("sequence") or []) + bt_entry["data"]["script"] = rendered_script + bt_entry["data"]["outcomes"] = rendered_outcomes + elif bt_entry["data"].get("script_mode") == "run_script_check": + # Outcomes pass through — the author wrote them in the UI + # and the .ps1 owns the toggle emission. + bt_entry["data"]["script"] = _render_run_script_check( + bt_entry["data"].get("run_script") or {}) + + bt_idx = len(result) + result.append(bt_entry) + + ports = outgoing.get(node_id, {}) + end_markers = [] + agg_markers = {} # NEW agg_id -> [_jump indices] + + for port_name in ("pass", "fail"): + target = ports.get(port_name) + result[bt_idx]["data"][f"{port_name}_target"] = len(result) + + stopped_agg = None + if target: + stopped_agg = walk_chain(target, stop_at_aggregator=True) + + jump_idx = len(result) + result.append({"type": "_jump", "data": {"target": -1}}) + + if stopped_agg: + if stopped_agg in agg_positions: + # Back-edge to an aggregator already emitted by + # the other port's chain (e.g. fail wire merging + # back into the pass-chain's KVM-reset path). + result[jump_idx]["data"]["target"] = agg_positions[stopped_agg] + else: + agg_markers.setdefault(stopped_agg, []).append(jump_idx) + else: + end_markers.append(jump_idx) + + # Emit each NEW aggregator once and walk its output chain. + for agg_id, jump_indices in agg_markers.items(): + agg_pos = len(result) + for idx in jump_indices: + result[idx]["data"]["target"] = agg_pos + result.append({"type": "aggregator", "data": {}}) + visited.add(agg_id) + agg_positions[agg_id] = agg_pos + agg_out = outgoing.get(agg_id, {}).get("out") + if agg_out: + walk_chain(agg_out) + + end_target = len(result) + for idx in end_markers: + result[idx]["data"]["target"] = end_target + + return + + # Default linear node: emit and follow "out". + entry = {"type": node.type, "data": node.data.copy()} + result.append(entry) + + ports = outgoing.get(node_id, {}) + node_id = ports.get("out") + + # Loop exited because node_id is None or already visited. In + # branch-walking mode, if we fell through onto a visited + # aggregator (back-edge to an earlier merge point), report it + # so the caller patches a direct jump to its known position + # instead of treating this path as a "fell-off" end-marker. + # That's what makes a "Retry" path looping back to the + # top-of-body aggregator actually retry, instead of falling + # through to the enclosing loop's _loop_back and spuriously + # incrementing the outer iteration counter. + if stop_at_aggregator and node_id: + tail = self.get_node(node_id) + if tail and tail.type == "aggregator": + return node_id + + # Linear walk fell through to an already-emitted aggregator. + # Emit an explicit back-edge so execution actually jumps to + # the known merge point instead of running off the end of the + # current chain into whatever node happens to be emitted next + # (e.g. the enclosing loop's _loop_back, which silently + # consumes an iteration). This is the path a Retry chain + # walked via an aggregator's "out" takes when it loops back + # to the top-of-body aggregator. + if node_id and node_id in agg_positions: + tail = self.get_node(node_id) + if tail and tail.type == "aggregator": + result.append({"type": "_jump", + "data": {"target": agg_positions[node_id]}}) + + return None + + for start in start_nodes: + walk_chain(start.id) + + return result diff --git a/models/node_graph.py b/models/node_graph.py new file mode 100644 index 0000000..d67f0a9 --- /dev/null +++ b/models/node_graph.py @@ -0,0 +1,230 @@ +"""Project serialization - save/load the complete project state.""" + +import os +import json +from .macro import Macro, NodeData +from .settings import Settings +from utils.constants import APPDATA_DIR, PROJECT_FILE + + +class Project: + """The complete project state: all macros and settings.""" + + def __init__(self): + self.settings = Settings() + self.macros: list[Macro] = [] + # Schema: + # {"universal": {name: value, ...}, + # "devices": {"AA:BB:CC:DD:EE:FF": {name: value, ...}, ...}} + self.ble_variables: dict = {"universal": {}, "devices": {}} + # Per-variable comments — purely a host-side annotation that + # mirrors the ble_variables schema by name. Kept in a separate + # field so the wire format (BLE pull/push payloads, device-side + # storage) stays untouched: comments never leave the desktop app. + # Names that appear in ble_variables but not here have no comment. + self.ble_comments: dict = {"universal": {}, "devices": {}} + + @staticmethod + def _normalize_ble_variables(raw) -> dict: + """Coerce any saved shape into the {universal, devices} schema. + + Old projects stored a flat {name: value} dict. Wrap it as universal. + """ + if not isinstance(raw, dict): + return {"universal": {}, "devices": {}} + if "universal" in raw or "devices" in raw: + return { + "universal": dict(raw.get("universal") or {}), + "devices": { + str(mac): dict(vars_ or {}) + for mac, vars_ in (raw.get("devices") or {}).items() + }, + } + # Legacy flat dict — treat as universal + return {"universal": dict(raw), "devices": {}} + + @staticmethod + def _normalize_ble_comments(raw) -> dict: + """Coerce any saved shape into the {universal, devices} schema. + + Pre-comments projects simply omit this field — we just produce an + empty structure. Comments are scoped exactly like variables so a + per-device variable's comment lives next to its sibling. + """ + if not isinstance(raw, dict): + return {"universal": {}, "devices": {}} + if "universal" in raw or "devices" in raw: + return { + "universal": {str(k): str(v) for k, v in (raw.get("universal") or {}).items()}, + "devices": { + str(mac): {str(k): str(v) for k, v in (cs or {}).items()} + for mac, cs in (raw.get("devices") or {}).items() + }, + } + # Legacy flat dict (unlikely for comments but mirrored for safety) + return {"universal": {str(k): str(v) for k, v in raw.items()}, "devices": {}} + + def get_universal(self) -> dict: + return self.ble_variables.setdefault("universal", {}) + + def get_device(self, mac: str) -> dict: + devices = self.ble_variables.setdefault("devices", {}) + return devices.setdefault(mac, {}) + + def set_device(self, mac: str, vars_: dict): + devices = self.ble_variables.setdefault("devices", {}) + devices[mac] = dict(vars_ or {}) + + def get_universal_comments(self) -> dict: + return self.ble_comments.setdefault("universal", {}) + + def get_device_comments(self, mac: str) -> dict: + devices = self.ble_comments.setdefault("devices", {}) + return devices.setdefault(mac, {}) + + def set_device_comments(self, mac: str, comments: dict): + devices = self.ble_comments.setdefault("devices", {}) + devices[mac] = {str(k): str(v) for k, v in (comments or {}).items()} + + def known_devices(self) -> list: + devices = self.ble_variables.get("devices") or {} + return sorted(devices.keys()) + + def add_macro(self, macro: Macro = None) -> Macro: + if macro is None: + macro = Macro(name=f"Routine {len(self.macros) + 1}") + start_node = NodeData("start", x=100, y=100) + macro.add_node(start_node) + self.macros.append(macro) + return macro + + def remove_macro(self, index: int): + if 0 <= index < len(self.macros): + self.macros.pop(index) + + def move_macro(self, from_idx: int, to_idx: int): + if 0 <= from_idx < len(self.macros) and 0 <= to_idx < len(self.macros): + macro = self.macros.pop(from_idx) + self.macros.insert(to_idx, macro) + + def to_dict(self) -> dict: + """Full in-memory dump including BLE variables/comments. + + Kept inclusive so deep-copy round-trips + (``Project.from_dict(p.to_dict())``) preserve everything. The + on-disk split — main JSON vs. ``.vars.json`` sidecar — lives in + ``save`` / ``load`` below, not here. + """ + return { + "version": 1, + "settings": self.settings.to_dict(), + "ble_variables": self.ble_variables, + "ble_comments": self.ble_comments, + "macros": [m.to_dict() for m in self.macros], + } + + def _to_main_dict(self) -> dict: + """The portion that goes into the git-trackable main JSON. + + BLE variables and comments are deliberately EXCLUDED — they + often hold per-machine credentials (passwords, hostnames, asset + tags) and live in the sidecar file instead so they can be + gitignored without losing the rest of the profile. + """ + return { + "version": 1, + "settings": self.settings.to_dict(), + "macros": [m.to_dict() for m in self.macros], + } + + def _to_vars_dict(self) -> dict: + """The portion that goes into the per-machine sidecar.""" + return { + "ble_variables": self.ble_variables, + "ble_comments": self.ble_comments, + } + + @classmethod + def from_dict(cls, d: dict) -> "Project": + p = cls() + p.settings = Settings.from_dict(d.get("settings", {})) + # Legacy fallback: pre-sidecar projects stored BLE state inline + # in the same file. Accept it here so a single load() can handle + # both old and migrated layouts. + p.ble_variables = cls._normalize_ble_variables(d.get("ble_variables", {})) + p.ble_comments = cls._normalize_ble_comments(d.get("ble_comments", {})) + p.macros = [Macro.from_dict(md) for md in d.get("macros", [])] + return p + + @staticmethod + def _vars_path_for(target: str) -> str: + """Return the sidecar path for a given profile JSON path. + + Foo.json -> Foo.vars.json + Anything else (rare — direct calls with non-.json names) gets a + ``.vars.json`` suffix appended verbatim. + """ + if target.endswith(".json"): + return target[:-len(".json")] + ".vars.json" + return target + ".vars.json" + + def save(self, path=None): + target = path or PROJECT_FILE + os.makedirs(os.path.dirname(target) or ".", exist_ok=True) + # Main profile JSON (synced via git for the shipped profiles). + with open(target, "w", encoding="utf-8") as f: + json.dump(self._to_main_dict(), f, indent=2) + # Per-machine BLE sidecar (always written; gitignored). Skip + # writing when there's literally nothing to persist so we don't + # leave empty sidecars cluttering the profiles directory. + vars_dict = self._to_vars_dict() + has_vars = bool( + (vars_dict["ble_variables"].get("universal") or {}) or + (vars_dict["ble_variables"].get("devices") or {}) or + (vars_dict["ble_comments"].get("universal") or {}) or + (vars_dict["ble_comments"].get("devices") or {}) + ) + vars_path = self._vars_path_for(target) + if has_vars: + with open(vars_path, "w", encoding="utf-8") as f: + json.dump(vars_dict, f, indent=2) + elif os.path.exists(vars_path): + # User cleared all variables — remove a now-empty sidecar so + # the working tree stays tidy. + try: + os.remove(vars_path) + except OSError: + pass + + @classmethod + def load(cls, path=None) -> "Project": + target = path or PROJECT_FILE + if not os.path.exists(target): + return cls() + try: + with open(target, "r", encoding="utf-8") as f: + main = json.load(f) + except (json.JSONDecodeError, KeyError, TypeError): + return cls() + + # Merge in the sidecar's BLE data if present. The sidecar always + # wins over any inline copy in the main JSON — important during + # the transition window where a profile may still have leftover + # legacy fields from before the split. + vars_path = cls._vars_path_for(target) + if os.path.exists(vars_path): + try: + with open(vars_path, "r", encoding="utf-8") as f: + sidecar = json.load(f) + if isinstance(sidecar, dict): + if "ble_variables" in sidecar: + main["ble_variables"] = sidecar["ble_variables"] + if "ble_comments" in sidecar: + main["ble_comments"] = sidecar["ble_comments"] + except (json.JSONDecodeError, OSError): + pass + + try: + return cls.from_dict(main) + except (KeyError, TypeError): + return cls() diff --git a/models/profile_manager.py b/models/profile_manager.py new file mode 100644 index 0000000..c14545d --- /dev/null +++ b/models/profile_manager.py @@ -0,0 +1,205 @@ +"""Manages named profiles - each profile is an independent project (settings + macros).""" + +import json +import os +import re +from pathlib import Path + +from models.node_graph import Project +from utils.constants import ( + DEFAULT_PROFILE_NAME, + PROFILES_DIR, + PROFILES_META_FILE, + PROJECT_FILE, + SUBROUTINES_PROFILE_NAME, +) + +_ILLEGAL_CHARS = re.compile(r'[<>:"/\\|?*]') + + +class ProfileManager: + """Handles CRUD for named profiles stored as individual JSON files.""" + + def __init__(self): + self.profiles_dir = Path(PROFILES_DIR) + self.meta_file = Path(PROFILES_META_FILE) + self.active_name: str = DEFAULT_PROFILE_NAME + self.project: Project = None + + def startup_load(self) -> Project: + """Migrate if needed, then load and return the active profile.""" + self._migrate() + self._load_meta() + self.ensure_subroutines_profile() + # Recover if the active profile was deleted on disk outside the app + if not self._profile_path(self.active_name).exists(): + names = self.profile_names() + self.active_name = names[0] if names else DEFAULT_PROFILE_NAME + self._write_meta() + self.project = Project.load(str(self._profile_path(self.active_name))) + return self.project + + def profile_names(self) -> list: + """Return sorted list of all profile names (including Sub-Routines). + + Excludes ``*.vars.json`` sidecars — those are the per-machine BLE + variable stores that live next to each profile JSON. They are + NOT separate profiles. Without this filter, ``Path.stem`` would + only strip the trailing ``.json`` and surface phantom entries + like ``MyProfile.vars`` in the GUI; clicking one would treat the + sidecar AS a profile and the next save would overwrite it, + losing the variables. + """ + if not self.profiles_dir.exists(): + return [] + return sorted( + p.stem for p in self.profiles_dir.glob("*.json") + if not p.name.endswith(".vars.json") + ) + + def save_current(self): + if self.project is not None: + self.project.save(str(self._profile_path(self.active_name))) + + def switch(self, name: str) -> Project: + """Save current, load the named profile, update meta. Returns new project.""" + self.save_current() + self.active_name = name + self._write_meta() + self.project = Project.load(str(self._profile_path(name))) + return self.project + + def new_profile(self, name: str, copy_current: bool = False) -> Project: + """Create a new profile and switch to it. Raises ValueError on bad input.""" + name = name.strip() + if not name: + raise ValueError("Profile name cannot be empty.") + # A profile literally named "Foo.vars" would land at the same path as + # the BLE sidecar for "Foo" and would corrupt it on the next save + if name.lower().endswith(".vars"): + raise ValueError("Profile names cannot end with '.vars'.") + if name in self.profile_names(): + raise ValueError(f"A profile named '{name}' already exists.") + + if copy_current and self.project is not None: + new_project = Project.from_dict(self.project.to_dict()) + else: + new_project = Project() + + new_project.save(str(self._profile_path(name))) + self.save_current() + self.active_name = name + self._write_meta() + self.project = new_project + return self.project + + def delete_profile(self, name: str) -> Project: + """Delete a profile. Raises ValueError if it's the only one. Returns active project.""" + if name == SUBROUTINES_PROFILE_NAME: + raise ValueError("Cannot delete the Sub-Routines profile.") + names = [n for n in self.profile_names() if n != SUBROUTINES_PROFILE_NAME] + if len(names) <= 1: + raise ValueError("Cannot delete the only profile.") + + # Flush any unsaved edits to the active profile before the delete may + # rotate us away from it (mirrors switch()'s save-then-load order) + self.save_current() + + profile_path = self._profile_path(name) + profile_path.unlink(missing_ok=True) + # Remove the matching BLE sidecar; otherwise a future profile reusing + # this name would silently inherit the old variables + vars_path = profile_path.with_name(profile_path.stem + ".vars.json") + vars_path.unlink(missing_ok=True) + + if self.active_name == name: + remaining = self.profile_names() + self.active_name = remaining[0] + self._write_meta() + self.project = Project.load(str(self._profile_path(self.active_name))) + + return self.project + + def duplicate_macro_to_profile(self, macro, target_profile: str): + """Deep-clone ``macro`` into ``target_profile``. + + If the target is the currently active profile, the clone is + appended to the live ``self.project`` (caller refreshes the UI + and triggers autosave). Otherwise the target profile JSON is + loaded from disk, the clone is appended, and the file is saved + back — the active profile is not disturbed. + + Returns ``(cloned_macro, is_active_target)`` so the caller knows + whether a UI refresh is needed. + """ + if target_profile not in self.profile_names(): + raise ValueError(f"Profile '{target_profile}' does not exist.") + + clone = macro.clone() + + if target_profile == self.active_name and self.project is not None: + self.project.add_macro(clone) + return clone, True + + target = Project.load(str(self._profile_path(target_profile))) + target.add_macro(clone) + target.save(str(self._profile_path(target_profile))) + return clone, False + + def get_subroutine_names(self) -> list[str]: + """Return names of macros in the Sub-Routines profile.""" + sub_path = self._profile_path(SUBROUTINES_PROFILE_NAME) + if not sub_path.exists(): + return [] + try: + sub_project = Project.load(str(sub_path)) + return [m.name for m in sub_project.macros] + except Exception: + return [] + + def get_subroutine_project(self) -> "Project": + sub_path = self._profile_path(SUBROUTINES_PROFILE_NAME) + if not sub_path.exists(): + return Project() + return Project.load(str(sub_path)) + + def ensure_subroutines_profile(self): + sub_path = self._profile_path(SUBROUTINES_PROFILE_NAME) + if not sub_path.exists(): + Project().save(str(sub_path)) + + def _profile_path(self, name: str) -> Path: + return self.profiles_dir / f"{self._sanitize(name)}.json" + + def _sanitize(self, name: str) -> str: + return _ILLEGAL_CHARS.sub("_", name) + + def _write_meta(self): + with open(self.meta_file, "w", encoding="utf-8") as f: + json.dump({"active": self.active_name}, f) + + def _load_meta(self): + if self.meta_file.exists(): + try: + with open(self.meta_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.active_name = data.get("active", DEFAULT_PROFILE_NAME) + except (json.JSONDecodeError, KeyError): + self.active_name = DEFAULT_PROFILE_NAME + + def _migrate(self): + """One-time migration: move old project.json into profiles/Default.json.""" + self.profiles_dir.mkdir(parents=True, exist_ok=True) + + default_path = self._profile_path(DEFAULT_PROFILE_NAME) + + if not any(self.profiles_dir.glob("*.json")): + old_project_file = Path(PROJECT_FILE) + if old_project_file.exists(): + import shutil + shutil.copy2(old_project_file, default_path) + else: + Project().save(str(default_path)) + + self.active_name = DEFAULT_PROFILE_NAME + self._write_meta() diff --git a/models/settings.py b/models/settings.py new file mode 100644 index 0000000..1929079 --- /dev/null +++ b/models/settings.py @@ -0,0 +1,65 @@ +"""Settings model for ATOMS3 MacroPad.""" + +from utils.constants import ( + DEFAULT_HOLD_MS, DEFAULT_TYPE_DELAY, DEFAULT_ORIENTATION, DEFAULT_RESUME_DELAY, + DEFAULT_COMBO_PRE_MS, DEFAULT_COMBO_POST_MS, DEFAULT_PROBE_TIMEOUT_MS, + DEFAULT_MEDIA_HOLD_MS, + DEFAULT_TYPE_SHIFT_EXTRA_MS, DEFAULT_TYPE_SETTLE_MS, + DEFAULT_PAUSE_MARGIN_LEFT, DEFAULT_PAUSE_MARGIN_RIGHT, + DEFAULT_PAUSE_MARGIN_TOP, DEFAULT_PAUSE_MARGIN_BOTTOM, +) + + +class Settings: + def __init__(self): + self.hold_ms: int = DEFAULT_HOLD_MS + self.type_delay: int = DEFAULT_TYPE_DELAY + self.orientation: int = DEFAULT_ORIENTATION + self.resume_delay: int = DEFAULT_RESUME_DELAY + self.combo_pre_ms: int = DEFAULT_COMBO_PRE_MS + self.combo_post_ms: int = DEFAULT_COMBO_POST_MS + self.probe_timeout_ms: int = DEFAULT_PROBE_TIMEOUT_MS + self.media_hold_ms: int = DEFAULT_MEDIA_HOLD_MS + self.type_shift_extra_ms: int = DEFAULT_TYPE_SHIFT_EXTRA_MS + self.type_settle_ms: int = DEFAULT_TYPE_SETTLE_MS + self.pause_margin_left: int = DEFAULT_PAUSE_MARGIN_LEFT + self.pause_margin_right: int = DEFAULT_PAUSE_MARGIN_RIGHT + self.pause_margin_top: int = DEFAULT_PAUSE_MARGIN_TOP + self.pause_margin_bottom: int = DEFAULT_PAUSE_MARGIN_BOTTOM + + def to_dict(self) -> dict: + return { + "hold_ms": self.hold_ms, + "type_delay": self.type_delay, + "orientation": self.orientation, + "resume_delay": self.resume_delay, + "combo_pre_ms": self.combo_pre_ms, + "combo_post_ms": self.combo_post_ms, + "probe_timeout_ms": self.probe_timeout_ms, + "media_hold_ms": self.media_hold_ms, + "type_shift_extra_ms": self.type_shift_extra_ms, + "type_settle_ms": self.type_settle_ms, + "pause_margin_left": self.pause_margin_left, + "pause_margin_right": self.pause_margin_right, + "pause_margin_top": self.pause_margin_top, + "pause_margin_bottom": self.pause_margin_bottom, + } + + @classmethod + def from_dict(cls, d: dict) -> "Settings": + s = cls() + s.hold_ms = d.get("hold_ms", DEFAULT_HOLD_MS) + s.type_delay = d.get("type_delay", DEFAULT_TYPE_DELAY) + s.orientation = d.get("orientation", DEFAULT_ORIENTATION) + s.resume_delay = d.get("resume_delay", DEFAULT_RESUME_DELAY) + s.combo_pre_ms = d.get("combo_pre_ms", DEFAULT_COMBO_PRE_MS) + s.combo_post_ms = d.get("combo_post_ms", DEFAULT_COMBO_POST_MS) + s.probe_timeout_ms = d.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS) + s.media_hold_ms = d.get("media_hold_ms", DEFAULT_MEDIA_HOLD_MS) + s.type_shift_extra_ms = d.get("type_shift_extra_ms", DEFAULT_TYPE_SHIFT_EXTRA_MS) + s.type_settle_ms = d.get("type_settle_ms", DEFAULT_TYPE_SETTLE_MS) + s.pause_margin_left = d.get("pause_margin_left", DEFAULT_PAUSE_MARGIN_LEFT) + s.pause_margin_right = d.get("pause_margin_right", DEFAULT_PAUSE_MARGIN_RIGHT) + s.pause_margin_top = d.get("pause_margin_top", DEFAULT_PAUSE_MARGIN_TOP) + s.pause_margin_bottom = d.get("pause_margin_bottom", DEFAULT_PAUSE_MARGIN_BOTTOM) + return s diff --git a/node_editor/__init__.py b/node_editor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/node_editor/canvas.py b/node_editor/canvas.py new file mode 100644 index 0000000..3ad88fc --- /dev/null +++ b/node_editor/canvas.py @@ -0,0 +1,1336 @@ +"""Node editor canvas - the main visual editing area. + +Coordinate system: + - ``NodeData.x``, ``NodeData.y`` are WORLD coordinates (always in zoom=1.0 space). + - The Tkinter canvas renders at CANVAS coordinates = world * _zoom_level. + - Mouse events return canvas coordinates (via canvasx/canvasy); to store new + node positions in the macro, convert: ``world = canvas / zoom``. +""" + +import copy as _copy +import tkinter as tk +from utils.constants import ( + CANVAS_BG, NODE_TYPES, GRID_SIZE, GRID_COLOR, + NODE_PORT_IN_COLOR, NODE_PORT_OUT_COLOR, +) +from models.macro import NodeData, ConnectionData +from .node import NodeWidget +from .connection import Connection + + +# Default world bounds (zoom=1.0). The scrollregion never shrinks below this rectangle. +DEFAULT_WORLD_LEFT = -2000 +DEFAULT_WORLD_TOP = -2000 +DEFAULT_WORLD_RIGHT = 4000 +DEFAULT_WORLD_BOTTOM = 4000 + +# Auto-grow parameters (world units, zoom=1.0): +# - GROW_MARGIN: bbox edge proximity that triggers expansion +# - GROW_CHUNK: quantization step; also the hysteresis band so a node near +# a threshold doesn't oscillate the scrollregion +# - NODE_FOOTPRINT_*: rough node extent used when computing the bbox +GROW_MARGIN = 400 +GROW_CHUNK = 1000 +NODE_FOOTPRINT_W = 200 +NODE_FOOTPRINT_H = 200 + + +def _floor_chunk_to(value: float, anchor: int) -> int: + """Round ``value`` toward -inf to the nearest ``anchor + k * GROW_CHUNK``.""" + delta = value - anchor + k = int(delta // GROW_CHUNK) + return anchor + k * GROW_CHUNK + + +def _ceil_chunk_to(value: float, anchor: int) -> int: + """Round ``value`` toward +inf to the nearest ``anchor + k * GROW_CHUNK``.""" + delta = value - anchor + k = -int(-delta // GROW_CHUNK) + return anchor + k * GROW_CHUNK + + +class NodeCanvas(tk.Frame): + def __init__(self, parent, on_node_select=None, on_change=None): + super().__init__(parent) + self.on_node_select = on_node_select + self.on_change = on_change + + self.nodes: dict[str, NodeWidget] = {} + self.connections: list[Connection] = [] + self.selected_nodes: set[NodeWidget] = set() + self.macro = None + + self._zoom_level = 1.0 + + # Grow outward in GROW_CHUNK steps as nodes approach an edge; snap + # back to defaults when the canvas empties. See _recompute_world_bounds. + self._world_left = DEFAULT_WORLD_LEFT + self._world_top = DEFAULT_WORLD_TOP + self._world_right = DEFAULT_WORLD_RIGHT + self._world_bottom = DEFAULT_WORLD_BOTTOM + + # Grid cache: while the viewport stays inside _grid_drawn_region the + # existing lines remain valid and _draw_grid is a no-op. The pending + # flag coalesces rapid scroll/resize callbacks into one redraw. + self._grid_drawn_region: tuple | None = None + self._grid_redraw_pending = False + + self._drag_node: NodeWidget | None = None + self._drag_start_x = 0 + self._drag_start_y = 0 + self._is_dragging = False + + self._wire_drag = False + self._wire_from_node = None + self._wire_from_port = None + self._wire_temp_line = None + + self._pan_origin = None + self._is_panning = False + + self._rubberband_active = False + self._rubberband_start = None + self._rubberband_rect_id = None + + self._clipboard = None + self._last_paste_anchor = None # world (x, y) of most recent paste top-left + + # "Pick a loop" mode — used by iteration_branch + self._picking_loop_for = None + self._pick_done_callback = None + self._pick_banner_items = [] + + self._setup_canvas() + self._bind_events() + + @property + def selected_node(self) -> NodeWidget | None: + if len(self.selected_nodes) == 1: + return next(iter(self.selected_nodes)) + return None + + @selected_node.setter + def selected_node(self, value): + if value is None: + self._deselect_all() + + def _setup_canvas(self): + self.canvas = tk.Canvas( + self, bg=CANVAS_BG, + highlightthickness=0, + scrollregion=self._scrollregion_for_zoom(), + ) + h_scroll = tk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.canvas.xview) + v_scroll = tk.Scrollbar(self, orient=tk.VERTICAL, command=self.canvas.yview) + # Wrap scroll commands so the grid lazily extends when the user + # scrolls past the currently-drawn region + self.canvas.configure( + xscrollcommand=lambda *a: (h_scroll.set(*a), self._schedule_grid_redraw()), + yscrollcommand=lambda *a: (v_scroll.set(*a), self._schedule_grid_redraw()), + ) + + self.canvas.grid(row=0, column=0, sticky="nsew") + v_scroll.grid(row=0, column=1, sticky="ns") + h_scroll.grid(row=1, column=0, sticky="ew") + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self.canvas.bind("", self._on_canvas_configure, add="+") + + self._draw_grid() + + def _scrollregion_for_zoom(self): + z = self._zoom_level + return (self._world_left * z, self._world_top * z, + self._world_right * z, self._world_bottom * z) + + # ---- Bounds expand / contract ---- + + def _node_bbox_world(self): + """Bounding box of all nodes in world coords, or None if empty.""" + if not self.nodes: + return None + min_x = min_y = float("inf") + max_x = max_y = float("-inf") + for w in self.nodes.values(): + d = w.data + if d.x < min_x: min_x = d.x + if d.y < min_y: min_y = d.y + # Account for the rendered footprint, not just the top-left + # anchor — otherwise a node planted right at the right edge + # would extend past the scrollregion until you drag it once. + ex = d.x + NODE_FOOTPRINT_W + ey = d.y + NODE_FOOTPRINT_H + if ex > max_x: max_x = ex + if ey > max_y: max_y = ey + return (min_x, min_y, max_x, max_y) + + def _recompute_world_bounds(self, can_shrink: bool) -> bool: + """Recompute scrollregion bounds from node positions. + + Quantized to GROW_CHUNK steps relative to the defaults so we don't + thrash the scrollregion every drag pixel: a node has to move a full + chunk past a threshold before bounds change. + + ``can_shrink=False`` is used during an active drag so the + scrollregion never collapses underneath the cursor mid-motion. + Shrinks (and the resulting viewport snap, if any) are deferred to + drag release / add / delete / paste / load. + + Returns True if any edge changed. + """ + bbox = self._node_bbox_world() + + if bbox is None: + new_left = DEFAULT_WORLD_LEFT + new_top = DEFAULT_WORLD_TOP + new_right = DEFAULT_WORLD_RIGHT + new_bottom = DEFAULT_WORLD_BOTTOM + else: + min_x, min_y, max_x, max_y = bbox + + # The "ideal" bounds: node bbox padded by GROW_MARGIN, then + # quantized outward to chunk boundaries anchored at the + # default edges. Floor toward -inf on the negative sides; + # ceil toward +inf on the positive sides. + ideal_left = _floor_chunk_to(min_x - GROW_MARGIN, DEFAULT_WORLD_LEFT) + ideal_top = _floor_chunk_to(min_y - GROW_MARGIN, DEFAULT_WORLD_TOP) + ideal_right = _ceil_chunk_to(max_x + GROW_MARGIN, DEFAULT_WORLD_RIGHT) + ideal_bottom = _ceil_chunk_to(max_y + GROW_MARGIN, DEFAULT_WORLD_BOTTOM) + + # Defaults are the floor/ceiling — never shrink below them. + new_left = min(DEFAULT_WORLD_LEFT, ideal_left) + new_top = min(DEFAULT_WORLD_TOP, ideal_top) + new_right = max(DEFAULT_WORLD_RIGHT, ideal_right) + new_bottom = max(DEFAULT_WORLD_BOTTOM, ideal_bottom) + + if not can_shrink: + # Keep current bounds if they were larger — only ever grow + # during a drag. + if new_left > self._world_left: new_left = self._world_left + if new_top > self._world_top: new_top = self._world_top + if new_right < self._world_right: new_right = self._world_right + if new_bottom < self._world_bottom: new_bottom = self._world_bottom + + if (new_left == self._world_left and new_top == self._world_top and + new_right == self._world_right and new_bottom == self._world_bottom): + return False + + self._world_left = new_left + self._world_top = new_top + self._world_right = new_right + self._world_bottom = new_bottom + return True + + def _ensure_bounds_for_nodes(self, can_shrink: bool = False) -> None: + """Recompute bounds; if changed, push to scrollregion + refresh grid.""" + if not self._recompute_world_bounds(can_shrink): + return + self.canvas.configure(scrollregion=self._scrollregion_for_zoom()) + # Bounds changed → grid clamping changed → invalidate cache so the + # next redraw re-clips to the new scrollregion. + self._invalidate_grid() + self._schedule_grid_redraw() + + # ---- Viewport-based grid ---- + + def _invalidate_grid(self) -> None: + self._grid_drawn_region = None + + def _schedule_grid_redraw(self) -> None: + """Coalesce many scroll/resize ticks into one idle-time redraw.""" + if self._grid_redraw_pending: + return + self._grid_redraw_pending = True + try: + self.after_idle(self._do_grid_redraw) + except Exception: + self._grid_redraw_pending = False + + def _do_grid_redraw(self) -> None: + self._grid_redraw_pending = False + self._draw_grid() + + def _on_canvas_configure(self, _event) -> None: + # Window/canvas resized → existing grid may not cover the new + # viewport. Drop the cache so the next idle redraw re-extends. + self._invalidate_grid() + self._schedule_grid_redraw() + + def _draw_grid(self) -> None: + z = self._zoom_level + spacing = GRID_SIZE * z + if spacing < 6: # Too dense to be useful at very low zoom + self.canvas.delete("grid") + self._grid_drawn_region = None + return + + # Visible viewport in canvas coords. winfo_width/height can be 1 + # before the widget has been mapped — fall back to a safe default + # so the very first draw still produces a usable grid. + try: + vw = self.canvas.winfo_width() + vh = self.canvas.winfo_height() + except Exception: + vw = vh = 0 + if vw < 2: vw = 800 + if vh < 2: vh = 600 + try: + vleft = float(self.canvas.canvasx(0)) + vtop = float(self.canvas.canvasy(0)) + except Exception: + vleft = vtop = 0.0 + vright = vleft + vw + vbottom = vtop + vh + + # If the existing grid still covers the visible viewport, skip + # the redraw entirely — this is the steady state for normal + # mousewheel scrolling within a viewport. + if self._grid_drawn_region is not None: + gl, gt, gr, gb = self._grid_drawn_region + if vleft >= gl and vtop >= gt and vright <= gr and vbottom <= gb: + return + + # Draw with one viewport's worth of overdraw on every side. This + # means a single redraw covers ~3x viewport in each dimension, so + # the user can scroll a long way before triggering another redraw. + left = vleft - vw + top = vtop - vh + right = vright + vw + bottom = vbottom + vh + + # Clamp to scrollregion — no point drawing grid where the user + # can never scroll to. + sr = self._scrollregion_for_zoom() + left = max(left, sr[0]) + top = max(top, sr[1]) + right = min(right, sr[2]) + bottom = min(bottom, sr[3]) + if right <= left or bottom <= top: + self.canvas.delete("grid") + self._grid_drawn_region = None + return + + self.canvas.delete("grid") + + # Align grid lines to world-grid positions (the modulo math also + # works for negative coords because Python's % returns a + # non-negative remainder). + x = left - (left % spacing) + while x <= right: + self.canvas.create_line(x, top, x, bottom, + fill=GRID_COLOR, tags=("grid",)) + x += spacing + y = top - (top % spacing) + while y <= bottom: + self.canvas.create_line(left, y, right, y, + fill=GRID_COLOR, tags=("grid",)) + y += spacing + + self.canvas.tag_lower("grid") + self._grid_drawn_region = (left, top, right, bottom) + + def _bind_events(self): + self.canvas.bind("", self._on_click) + self.canvas.bind("", self._on_drag) + self.canvas.bind("", self._on_release) + self.canvas.bind("", self._on_delete) + self.canvas.bind("", self._on_mousewheel) + self.canvas.bind("", self._on_select_all) + self.canvas.bind("", self._on_copy) + self.canvas.bind("", self._on_paste) + self.canvas.bind("", self._on_escape) + self.canvas.bind("", self._on_flip_selected) + self.canvas.bind("", self._on_flip_selected) + + self.canvas.bind("", self._on_right_press) + self.canvas.bind("", self._on_right_drag) + self.canvas.bind("", self._on_right_release) + + self.canvas.bind("", self._on_pan_start) + self.canvas.bind("", self._on_pan_move) + + self.canvas.focus_set() + + # ===================================================================== + # Selection helpers + # ===================================================================== + + def _deselect_all(self): + for node in self.selected_nodes: + node.set_selected(False) + self.selected_nodes.clear() + self._refresh_connected_highlights() + + def _select_single(self, node_widget: NodeWidget): + self._deselect_all() + self.selected_nodes.add(node_widget) + node_widget.set_selected(True) + self._refresh_connected_highlights() + if self.on_node_select: + self.on_node_select(node_widget) + + def _toggle_selection(self, node_widget: NodeWidget): + if node_widget in self.selected_nodes: + self.selected_nodes.discard(node_widget) + node_widget.set_selected(False) + else: + self.selected_nodes.add(node_widget) + node_widget.set_selected(True) + self._refresh_connected_highlights() + if self.on_node_select: + self.on_node_select(self.selected_node) + + def select_all(self): + for widget in self.nodes.values(): + self.selected_nodes.add(widget) + widget.set_selected(True) + self._refresh_connected_highlights() + if self.on_node_select: + self.on_node_select(self.selected_node) + + def _refresh_connected_highlights(self): + """Apply selection-adjacency highlights to both nodes and wires. + + Node borders: + - green ("upstream") — wires into our input come from this node + - red ("downstream") — wires from our output go to this node + - mixed ("both") — node is both upstream and downstream of + the current selection + - none — everything else + + Wires: + - green ("input") — wire feeds a selected node's input + - red ("output") — wire leaves a selected node's output + - fade ("fade") — wire connects two selected nodes (red at the + from-port end, green at the to-port end, so + each end's color matches the port color it + terminates at) + - none — default faded node-color gradient + + Highlighted wires also get raised above the other wires (but still + below nodes) via ``_reorder_wire_layers`` so they read as on-top. + + The colors mirror the port colors: green input ports, red output + ports. So at a glance you can see which neighbors feed the current + selection vs. which neighbors the selection feeds. + """ + selected_ids = {w.data.id for w in self.selected_nodes} + + upstream_ids: set[str] = set() + downstream_ids: set[str] = set() + for conn in self.connections: + fid = conn.from_node.data.id + tid = conn.to_node.data.id + from_sel = fid in selected_ids + to_sel = tid in selected_ids + + if from_sel and to_sel: + conn.set_highlight("fade") + elif to_sel: + conn.set_highlight("input") + upstream_ids.add(fid) + elif from_sel: + conn.set_highlight("output") + downstream_ids.add(tid) + else: + conn.set_highlight(None) + + for widget in self.nodes.values(): + if widget.selected: + widget.set_highlight_kind(None) + continue + nid = widget.data.id + is_up = nid in upstream_ids + is_down = nid in downstream_ids + if is_up and is_down: + widget.set_highlight_kind("both") + elif is_up: + widget.set_highlight_kind("upstream") + elif is_down: + widget.set_highlight_kind("downstream") + else: + widget.set_highlight_kind(None) + + # Restack: highlighted wires above normal wires, both below nodes. + self._reorder_wire_layers() + + def _reorder_wire_layers(self): + """Enforce stacking order: grid < wire < wire_hl < node. + + Each tag_raise is guarded because tags may be empty (e.g. no wire + currently highlighted) and Tkinter raises TclError in that case on + some platforms. + """ + for args in (("wire", "grid"), + ("wire_hl", "wire"), + ("node", "wire_hl"), + ("node", "wire")): + try: + self.canvas.tag_raise(*args) + except Exception: + pass + + # ===================================================================== + # Macro loading + # ===================================================================== + + def load_macro(self, macro): + self.clear() + self.macro = macro + if not macro: + return + + for node_data in macro.nodes: + self._create_node_widget(node_data) + + for conn_data in macro.connections: + from_widget = self.nodes.get(conn_data.from_id) + to_widget = self.nodes.get(conn_data.to_id) + if from_widget and to_widget: + from_port = from_widget.get_port(conn_data.from_port) + to_port = to_widget.get_port(conn_data.to_port) + if from_port and to_port: + conn = Connection(self.canvas, from_widget, from_port, + to_widget, to_port, conn_data) + self.connections.append(conn) + + # Existing macros may already have nodes outside the default + # bounds (legacy projects, paste-from-elsewhere, hand-edited + # JSON); make sure the scrollregion grows to fit on first show. + self._ensure_bounds_for_nodes(can_shrink=True) + + def clear(self): + for conn in self.connections: + conn.destroy() + self.connections.clear() + for node in self.nodes.values(): + node.destroy() + self.nodes.clear() + self.selected_nodes.clear() + self._drag_node = None + + # Reset zoom AND world bounds so the next macro starts fresh. + # _ensure_bounds_for_nodes() with no nodes collapses to defaults, + # which is also what we want after a clear with zoom unchanged. + zoom_changed = self._zoom_level != 1.0 + if zoom_changed: + self._zoom_level = 1.0 + bounds_changed = self._recompute_world_bounds(can_shrink=True) + if zoom_changed or bounds_changed: + self.canvas.configure(scrollregion=self._scrollregion_for_zoom()) + self._invalidate_grid() + self._schedule_grid_redraw() + + def _create_node_widget(self, node_data: NodeData) -> NodeWidget: + widget = NodeWidget( + self.canvas, node_data, + on_select=self._on_node_click_select, + on_move=self._on_node_moved, + canvas_ref=self, + ) + self.nodes[node_data.id] = widget + return widget + + def add_node(self, node_type: str, x: int = None, y: int = None): + """Add a new node of the given type. + + ``x``, ``y`` are CANVAS (screen) coords from the context menu; + they are converted to world coords by dividing by zoom. + """ + if not self.macro: + return + + if x is None or y is None: + wx = 200 if x is None else x / self._zoom_level + wy = 200 if y is None else y / self._zoom_level + else: + wx = x / self._zoom_level + wy = y / self._zoom_level + + wx = round(wx / GRID_SIZE) * GRID_SIZE + wy = round(wy / GRID_SIZE) * GRID_SIZE + + node_data = NodeData(node_type, x=wx, y=wy) + self.macro.add_node(node_data) + widget = self._create_node_widget(node_data) + self._ensure_bounds_for_nodes(can_shrink=True) + self._notify_change() + + # For iteration_branch: prompt the user to click a Loop to tie to. + if node_type == "iteration_branch": + self._select_single(widget) + self.after(50, lambda w=widget: self.start_picking_loop_for(w)) + + def delete_selected(self): + if not self.selected_nodes or not self.macro: + return + + nodes_to_delete = list(self.selected_nodes) + for node_widget in nodes_to_delete: + node_id = node_widget.data.id + to_remove = [c for c in self.connections + if c.from_node.data.id == node_id or c.to_node.data.id == node_id] + for conn in to_remove: + conn.destroy() + self.connections.remove(conn) + self.macro.remove_node(node_id) + node_widget.destroy() + del self.nodes[node_id] + + self.selected_nodes.clear() + self._refresh_connected_highlights() + if self.on_node_select: + self.on_node_select(None) + self._ensure_bounds_for_nodes(can_shrink=True) + self._notify_change() + + # ===================================================================== + # Pick-a-Loop mode (used by iteration_branch) + # ===================================================================== + + def start_picking_loop_for(self, iteration_branch_widget, on_done=None): + """Enter 'pick a Loop node' mode. The next click on a repeat node + will bind that loop to the given iteration_branch widget. + Press Escape (or right-click) to cancel. + """ + has_loop = any(w.data.type == "repeat" for w in self.nodes.values()) + self._picking_loop_for = iteration_branch_widget + self._pick_done_callback = on_done + if not has_loop: + self._show_pick_banner("No Loop nodes yet — add one first, then " + "use 'Pick Loop on Canvas' in the properties panel.") + else: + self._show_pick_banner("Click a Loop node to tie this Iteration " + "Branch to it (Esc to cancel)") + + def _cancel_picking_loop(self): + self._picking_loop_for = None + self._pick_done_callback = None + self._clear_pick_banner() + + def _show_pick_banner(self, text): + self._clear_pick_banner() + # Place at the top-left of the currently visible viewport. + vw = self.canvas.winfo_width() or 600 + x0 = self.canvas.canvasx(0) + y0 = self.canvas.canvasy(0) + 8 + + bg = self.canvas.create_rectangle( + x0 + 6, y0, x0 + vw - 6, y0 + 34, + fill="#F1C40F", outline="#F39C12", width=2, + tags=("pick_banner",) + ) + txt = self.canvas.create_text( + x0 + vw // 2, y0 + 17, + text=text, fill="#1E1E2E", font=("Segoe UI", 10, "bold"), + tags=("pick_banner",) + ) + self._pick_banner_items = [bg, txt] + self.canvas.tag_raise("pick_banner") + + def _clear_pick_banner(self): + for item in self._pick_banner_items: + try: + self.canvas.delete(item) + except Exception: + pass + self._pick_banner_items.clear() + + def _on_escape(self, event): + if self._picking_loop_for is not None: + self._cancel_picking_loop() + return "break" + + # ===================================================================== + # Copy / Paste + # ===================================================================== + + def copy_selected(self): + """Snapshot the current selection into an internal clipboard.""" + if not self.selected_nodes: + return False + + copied_ids = set() + copied_nodes = [] + for widget in self.selected_nodes: + copied_ids.add(widget.data.id) + copied_nodes.append({ + "orig_id": widget.data.id, + "type": widget.data.type, + "x": widget.data.x, + "y": widget.data.y, + "data": _copy.deepcopy(widget.data.data), + }) + + # Keep only connections where both endpoints are in the selection + copied_conns = [] + if self.macro: + for c in self.macro.connections: + if c.from_id in copied_ids and c.to_id in copied_ids: + copied_conns.append({ + "from": c.from_id, "from_port": c.from_port, + "to": c.to_id, "to_port": c.to_port, + }) + + min_x = min(n["x"] for n in copied_nodes) + min_y = min(n["y"] for n in copied_nodes) + + self._clipboard = { + "nodes": copied_nodes, + "connections": copied_conns, + "anchor_x": min_x, + "anchor_y": min_y, + } + # Reset paste anchor so the next paste lands one-offset from the source + self._last_paste_anchor = (min_x, min_y) + return True + + def paste(self): + """Paste the clipboard contents into the current macro.""" + if not self._clipboard or not self.macro: + return False + + PASTE_OFFSET = 40 # world units, down-and-right from anchor + + clip = self._clipboard + orig_anchor = (clip["anchor_x"], clip["anchor_y"]) + src_anchor = self._last_paste_anchor if self._last_paste_anchor else orig_anchor + + target_x = src_anchor[0] + PASTE_OFFSET + target_y = src_anchor[1] + PASTE_OFFSET + + target_x = round(target_x / GRID_SIZE) * GRID_SIZE + target_y = round(target_y / GRID_SIZE) * GRID_SIZE + + delta_x = target_x - orig_anchor[0] + delta_y = target_y - orig_anchor[1] + + id_map = {} # orig_id -> new_id + pasted_widgets = [] + + for n in clip["nodes"]: + new_x = n["x"] + delta_x + new_y = n["y"] + delta_y + new_x = round(new_x / GRID_SIZE) * GRID_SIZE + new_y = round(new_y / GRID_SIZE) * GRID_SIZE + node_data = NodeData( + n["type"], x=new_x, y=new_y, + data=_copy.deepcopy(n["data"]), + ) + id_map[n["orig_id"]] = node_data.id + self.macro.add_node(node_data) + widget = self._create_node_widget(node_data) + pasted_widgets.append(widget) + + for c in clip["connections"]: + new_from = id_map.get(c["from"]) + new_to = id_map.get(c["to"]) + if not (new_from and new_to): + continue + conn_data = ConnectionData(new_from, c["from_port"], new_to, c["to_port"]) + self.macro.add_connection(conn_data) + from_widget = self.nodes.get(new_from) + to_widget = self.nodes.get(new_to) + if from_widget and to_widget: + from_port = from_widget.get_port(c["from_port"]) + to_port = to_widget.get_port(c["to_port"]) + if from_port and to_port: + conn = Connection(self.canvas, from_widget, from_port, + to_widget, to_port, conn_data) + self.connections.append(conn) + + self._deselect_all() + for w in pasted_widgets: + self.selected_nodes.add(w) + w.set_selected(True) + self._refresh_connected_highlights() + if self.on_node_select: + self.on_node_select(self.selected_node) + + # Cascade further on the next Ctrl+V + self._last_paste_anchor = (target_x, target_y) + + self._ensure_bounds_for_nodes(can_shrink=True) + self._notify_change() + return True + + def _on_copy(self, event): + self.copy_selected() + return "break" + + def _on_paste(self, event): + self.paste() + return "break" + + # ===================================================================== + # Node finding / selection callback + # ===================================================================== + + def _find_node_at(self, cx, cy) -> NodeWidget | None: + items = self.canvas.find_overlapping(cx - 2, cy - 2, cx + 2, cy + 2) + for item in items: + tags = self.canvas.gettags(item) + for tag in tags: + if tag.startswith(NodeWidget.TAG_PREFIX): + node_id = tag[len(NodeWidget.TAG_PREFIX):] + if node_id in self.nodes: + return self.nodes[node_id] + return None + + def _on_node_click_select(self, node_widget: NodeWidget): + self._select_single(node_widget) + + def _on_node_moved(self, node_widget: NodeWidget): + for conn in self.connections: + if conn.from_node == node_widget or conn.to_node == node_widget: + conn.update() + # Grow the scrollregion if a node has been pushed near an edge. + # Shrinking is held off until drag-release (in _on_release) so the + # viewport doesn't snap underneath the cursor mid-drag. + self._ensure_bounds_for_nodes(can_shrink=False) + self._notify_change() + + # ===================================================================== + # Left-click / drag / release + # ===================================================================== + + def _on_click(self, event): + cx = self.canvas.canvasx(event.x) + cy = self.canvas.canvasy(event.y) + ctrl_held = bool(event.state & 0x4) + + # Pick-loop mode: only Loop (repeat) nodes count; ignore other clicks. + if self._picking_loop_for is not None: + node = self._find_node_at(cx, cy) + if node is not None and node.data.type == "repeat": + target_widget = self._picking_loop_for + target_widget.data.data["loop_node_id"] = node.data.id + target_widget.redraw() + cb = self._pick_done_callback + self._cancel_picking_loop() + if cb: + try: + cb() + except Exception: + pass + self._notify_change() + return + + for node_widget in self.nodes.values(): + port = node_widget.get_port_at(cx, cy) + if port: + if port.port_type == "input": + self._delete_connections_at_port(node_widget, port) + else: + self._wire_drag = True + self._wire_from_node = node_widget + self._wire_from_port = port + self._wire_temp_line = None + return + + node = self._find_node_at(cx, cy) + if node: + if ctrl_held: + self._toggle_selection(node) + else: + if node not in self.selected_nodes: + self._select_single(node) + + self._drag_node = node + self._drag_start_x = cx + self._drag_start_y = cy + self._is_dragging = False + return + + # Background — potential pan + self._pan_origin = (event.x, event.y) + self._is_panning = False + self.canvas.scan_mark(event.x, event.y) + self.canvas.focus_set() + + def _on_drag(self, event): + cx = self.canvas.canvasx(event.x) + cy = self.canvas.canvasy(event.y) + + if self._wire_drag: + if self._wire_temp_line: + self.canvas.delete(self._wire_temp_line) + width = max(1, int(round(2 * self._zoom_level))) + self._wire_temp_line = self.canvas.create_line( + self._wire_from_port.x, self._wire_from_port.y, + cx, cy, + fill=NODE_PORT_OUT_COLOR, width=width, dash=(4, 2), + ) + return + + if self._drag_node: + dx = cx - self._drag_start_x + dy = cy - self._drag_start_y + + if not self._is_dragging: + if abs(dx) > 3 or abs(dy) > 3: + self._is_dragging = True + else: + return + + nodes_to_move = self.selected_nodes if self._drag_node in self.selected_nodes else {self._drag_node} + affected_conns = set() + for nw in nodes_to_move: + nw.move_by(dx, dy) + for conn in self.connections: + if conn.from_node == nw or conn.to_node == nw: + affected_conns.add(conn) + for conn in affected_conns: + conn.update() + + self._drag_start_x = cx + self._drag_start_y = cy + return + + if self._pan_origin is not None: + self._is_panning = True + self.canvas.scan_dragto(event.x, event.y, gain=1) + + def _on_release(self, event): + cx = self.canvas.canvasx(event.x) + cy = self.canvas.canvasy(event.y) + + if self._wire_drag: + from_node = self._wire_from_node + from_port = self._wire_from_port + was_dragging = self._wire_temp_line is not None + + if self._wire_temp_line: + self.canvas.delete(self._wire_temp_line) + self._wire_temp_line = None + + self._wire_drag = False + self._wire_from_node = None + self._wire_from_port = None + + if was_dragging: + for node_widget in self.nodes.values(): + if node_widget == from_node: + continue + port = node_widget.get_port_at(cx, cy) + if port and port.port_type == "input": + self._create_connection(from_node, from_port, node_widget, port) + break + else: + self._delete_connections_at_port(from_node, from_port) + return + + if self._drag_node: + node = self._drag_node + was_dragging = self._is_dragging + self._drag_node = None + self._is_dragging = False + + if was_dragging: + if node not in self.selected_nodes: + self._select_single(node) + # Drag finished — safe to let bounds shrink back if the + # node has moved well inside the previous edges. + self._ensure_bounds_for_nodes(can_shrink=True) + self._notify_change() + else: + if len(self.selected_nodes) > 1: + self._select_single(node) + return + + if self._pan_origin is not None: + was_panning = self._is_panning + self._pan_origin = None + self._is_panning = False + + if not was_panning: + self._deselect_all() + if self.on_node_select: + self.on_node_select(None) + + # ===================================================================== + # Right-click: rubber-band + context menu + # ===================================================================== + + def _on_right_press(self, event): + # Cancel pick-loop mode if active + if self._picking_loop_for is not None: + self._cancel_picking_loop() + return + cx = self.canvas.canvasx(event.x) + cy = self.canvas.canvasy(event.y) + self._rubberband_start = (cx, cy) + self._rubberband_active = False + self._rubberband_rect_id = None + + def _on_right_drag(self, event): + if self._rubberband_start is None: + return + + cx = self.canvas.canvasx(event.x) + cy = self.canvas.canvasy(event.y) + sx, sy = self._rubberband_start + + if not self._rubberband_active: + if abs(cx - sx) > 5 or abs(cy - sy) > 5: + self._rubberband_active = True + else: + return + + if self._rubberband_rect_id: + self.canvas.delete(self._rubberband_rect_id) + self._rubberband_rect_id = self.canvas.create_rectangle( + sx, sy, cx, cy, + outline="#4A90D9", width=2, dash=(4, 2), + fill="", + tags=("rubberband",) + ) + + def _on_right_release(self, event): + cx = self.canvas.canvasx(event.x) + cy = self.canvas.canvasy(event.y) + + if self._rubberband_rect_id: + self.canvas.delete(self._rubberband_rect_id) + self._rubberband_rect_id = None + + if self._rubberband_active and self._rubberband_start: + sx, sy = self._rubberband_start + x1, y1 = min(sx, cx), min(sy, cy) + x2, y2 = max(sx, cx), max(sy, cy) + + ctrl_held = bool(event.state & 0x4) + if not ctrl_held: + self._deselect_all() + + z = self._zoom_level + for widget in self.nodes.values(): + nx = widget.data.x * z + ny = widget.data.y * z + nw = getattr(widget, 'width', 160) + nh = getattr(widget, 'height', 54) + if nx + nw >= x1 and nx <= x2 and ny + nh >= y1 and ny <= y2: + self.selected_nodes.add(widget) + widget.set_selected(True) + + self._refresh_connected_highlights() + if self.on_node_select: + self.on_node_select(self.selected_node) + else: + self._show_context_menu(event) + + self._rubberband_start = None + self._rubberband_active = False + + def _show_context_menu(self, event): + menu = tk.Menu(self, tearoff=0) + cx = self.canvas.canvasx(event.x) + cy = self.canvas.canvasy(event.y) + + menu.add_command(label="Select All", command=self.select_all) + if self.selected_nodes: + menu.add_command(label="Copy Selected", command=self.copy_selected) + if self._clipboard: + menu.add_command(label="Paste", command=self.paste) + menu.add_separator() + + for ntype, info in NODE_TYPES.items(): + menu.add_command( + label=f"{info['label']} - {info['desc']}", + command=lambda t=ntype, x=cx, y=cy: self.add_node(t, x, y), + ) + + for conn in self.connections: + if conn.hit_test(cx, cy): + menu.add_separator() + menu.add_command( + label="Delete Wire", + command=lambda c=conn: self._delete_connection(c), + ) + break + + menu.tk_popup(event.x_root, event.y_root) + + # ===================================================================== + # Connections + # ===================================================================== + + def _create_connection(self, from_node, from_port, to_node, to_port): + if not self.macro: + return + + existing = [c for c in self.connections + if c.to_node.data.id == to_node.data.id and c.to_port.name == to_port.name] + for c in existing: + c.destroy() + self.connections.remove(c) + self.macro.remove_connection(c.data.from_id, c.data.from_port, + c.data.to_id, c.data.to_port) + + conn_data = ConnectionData( + from_node.data.id, from_port.name, + to_node.data.id, to_port.name, + ) + self.macro.add_connection(conn_data) + conn = Connection(self.canvas, from_node, from_port, to_node, to_port, conn_data) + self.connections.append(conn) + self._refresh_connected_highlights() + self._notify_change() + + def _delete_connections_at_port(self, node_widget, port): + if port.port_type == "input": + to_remove = [c for c in self.connections + if c.to_node == node_widget and c.to_port == port] + else: + to_remove = [c for c in self.connections + if c.from_node == node_widget and c.from_port == port] + for conn in to_remove: + self._delete_connection(conn) + + def _delete_connection(self, conn: Connection): + if self.macro: + self.macro.remove_connection( + conn.data.from_id, conn.data.from_port, + conn.data.to_id, conn.data.to_port, + ) + conn.destroy() + self.connections.remove(conn) + self._refresh_connected_highlights() + self._notify_change() + + # ===================================================================== + # Keyboard shortcuts + # ===================================================================== + + def _on_delete(self, event): + self.delete_selected() + + def _on_select_all(self, event): + self.select_all() + return "break" + + def _on_flip_selected(self, event): + """Flip the input/output sides of all selected nodes.""" + if not self.selected_nodes: + return "break" + flipped_widgets = list(self.selected_nodes) + for widget in flipped_widgets: + widget.data.flipped = not bool(getattr(widget.data, "flipped", False)) + widget.redraw() + + # Port positions (wire endpoints) changed, so redraw affected wires + flipped_set = set(flipped_widgets) + for conn in self.connections: + if conn.from_node in flipped_set or conn.to_node in flipped_set: + conn.update() + + self._notify_change() + return "break" + + # ===================================================================== + # Zoom and Pan + # ===================================================================== + + def _on_mousewheel(self, event): + """Route the wheel event based on modifier keys: + - Ctrl → zoom (cursor-anchored) + - Shift → horizontal scroll + - (none) → vertical scroll + + Uses fractional scaling derived from ``event.delta`` so precision + touchpads — which send many small delta values instead of the ±120 + per notch that a physical wheel emits — produce smooth, continuous + motion rather than discrete chunks. + """ + if event.state & 0x4: # Ctrl + self._zoom(event) + elif event.state & 0x1: # Shift + self._smooth_scroll("x", event.delta) + else: + self._smooth_scroll("y", event.delta) + + def _smooth_scroll(self, axis: str, delta: int): + """Pixel-accurate scroll that respects touchpad precision deltas. + + On Windows a physical wheel notch emits ``delta`` of ±120, while + touchpad swipes send much smaller values. We translate delta into + pixels (0.5 px per delta-unit feels natural) and move via + ``xview_moveto`` / ``yview_moveto`` (fractional position) to avoid + the jerky "one unit at a time" look of ``yview_scroll``. + """ + # Positive delta = wheel up / finger down-to-up → show content above + pixels = -delta * 0.5 + region = self._scrollregion_for_zoom() + if axis == "y": + span = region[3] - region[1] + if span <= 0: + return + cur = self.canvas.canvasy(0) + new = cur + pixels + self.canvas.yview_moveto(max(0.0, min(1.0, (new - region[1]) / span))) + else: + span = region[2] - region[0] + if span <= 0: + return + cur = self.canvas.canvasx(0) + new = cur + pixels + self.canvas.xview_moveto(max(0.0, min(1.0, (new - region[0]) / span))) + + def _zoom(self, event): + """Cursor-anchored zoom with a fractional scale factor. + + Factor is derived from ``event.delta``: a wheel notch (delta = 120) + produces ~10% step, while touchpad pinches stream many small deltas + for continuous-feeling zoom. + """ + # 1200 tuned so delta=120 → 1.10x (matches legacy feel on a real wheel) + factor = 1.0 + (event.delta / 1200.0) + # Safety clamp against huge deltas from odd devices + factor = max(0.5, min(2.0, factor)) + + new_zoom = self._zoom_level * factor + if new_zoom < 0.3 or new_zoom > 3.0: + return + # Sub-pixel zoom changes aren't worth a full redraw + if abs(new_zoom - self._zoom_level) < 0.001: + return + + old_zoom = self._zoom_level + + # World coords under the mouse (stay the same after zoom) + cx = self.canvas.canvasx(event.x) + cy = self.canvas.canvasy(event.y) + world_x = cx / old_zoom + world_y = cy / old_zoom + + self._zoom_level = new_zoom + + self.canvas.configure(scrollregion=self._scrollregion_for_zoom()) + # Spacing changed → cached grid lines are at the wrong stride. + self._invalidate_grid() + self._draw_grid() + + for widget in self.nodes.values(): + widget.redraw() + + # Connections read zoom from nodes' canvas_ref + for conn in self.connections: + conn.update() + + # Scroll so the cursor stays over the same world point + new_cx = world_x * new_zoom + new_cy = world_y * new_zoom + delta_cx = new_cx - cx + delta_cy = new_cy - cy + x1, y1, x2, y2 = [float(v) for v in self._scrollregion_for_zoom()] + region_w = x2 - x1 + region_h = y2 - y1 + if region_w > 0 and region_h > 0: + cur_left = self.canvas.canvasx(0) + cur_top = self.canvas.canvasy(0) + new_left = cur_left + delta_cx + new_top = cur_top + delta_cy + fx = (new_left - x1) / region_w + fy = (new_top - y1) / region_h + self.canvas.xview_moveto(max(0.0, min(1.0, fx))) + self.canvas.yview_moveto(max(0.0, min(1.0, fy))) + + def _on_pan_start(self, event): + self.canvas.scan_mark(event.x, event.y) + + def _on_pan_move(self, event): + self.canvas.scan_dragto(event.x, event.y, gain=1) + + # ===================================================================== + # Utility + # ===================================================================== + + def _notify_change(self): + if self.on_change: + self.on_change() + + def find_start_node(self) -> str | None: + for nid, widget in self.nodes.items(): + if widget.data.type == "start": + return nid + return None + + def scroll_to_node(self, node_id: str): + widget = self.nodes.get(node_id) + if not widget: + return + + self.canvas.update_idletasks() + z = self._zoom_level + + nx = widget.data.x * z + getattr(widget, 'width', 160) / 2 + ny = widget.data.y * z + getattr(widget, 'height', 54) / 2 + + x1, y1, x2, y2 = self._scrollregion_for_zoom() + region_w = x2 - x1 + region_h = y2 - y1 + + vw = self.canvas.winfo_width() + vh = self.canvas.winfo_height() + + target_left = nx - vw / 2 + target_top = ny - vh / 2 + + fx = (target_left - x1) / region_w if region_w > 0 else 0 + fy = (target_top - y1) / region_h if region_h > 0 else 0 + + self.canvas.xview_moveto(max(0.0, min(1.0, fx))) + self.canvas.yview_moveto(max(0.0, min(1.0, fy))) + + def refresh_node(self, node_id: str): + widget = self.nodes.get(node_id) + if widget: + widget.redraw() + for conn in self.connections: + if conn.from_node == widget or conn.to_node == widget: + conn.update() + + def rebuild_node_ports(self, node_id: str): + """Rebuild ports for a node whose port count or labels may have + changed (branch choices added/removed/renamed, aggregator inputs, + bluetooth get_local pass/fail, etc.). + + Drops any connections whose port no longer exists, re-links the + survivors to the fresh Port objects, redraws the node so the new + ports/labels actually appear on the canvas, and finally re-routes + the connections through the now-positioned ports. + + Callers can rely on this for the FULL refresh — no need to also + call ``refresh_node`` afterward. + """ + widget = self.nodes.get(node_id) + if not widget: + return + widget.update_branch_ports() + + stale = [] + for conn in self.connections: + if conn.from_node == widget: + new_port = widget.get_port(conn.data.from_port) + if new_port is None: + stale.append(conn) + else: + conn.from_port = new_port + elif conn.to_node == widget: + new_port = widget.get_port(conn.data.to_port) + if new_port is None: + stale.append(conn) + else: + conn.to_port = new_port + + for conn in stale: + self._delete_connection(conn) + + # Redraw the node itself so the new ports/labels paint, then route + # wires off the freshly-positioned ports (port.x / port.y are set + # inside redraw()). + widget.redraw() + for conn in self.connections: + if conn.from_node == widget or conn.to_node == widget: + conn.update() diff --git a/node_editor/connection.py b/node_editor/connection.py new file mode 100644 index 0000000..a3b0a0f --- /dev/null +++ b/node_editor/connection.py @@ -0,0 +1,465 @@ +"""Wire/connection drawing and logic for node editor. + +Each Connection renders as a stack of short line segments, each colored with +an interpolation between the two connected nodes' header colors and blended +at 50% opacity over the canvas background (simulated alpha since Tkinter's +Canvas has no native alpha channel). + +Routing (smart — picks a curve shape based on the geometry): + + 1. ``straight`` — ports almost perfectly aligned horizontally with a clear + forward path; a nearly-straight line with a tiny bulge. + 2. ``s_curve`` — ordinary forward connection (target ahead, ports face + each other); cubic S-curve with tangent length scaled by + the dominant axis (handles both horizontal-dominant and + vertical-dominant cases naturally). + 3. ``vertical`` — target is mostly above/below the source with little + horizontal distance; pulls the tangent much further + vertically so the curve doesn't "bow out" awkwardly. + 4. ``detour`` — forward-facing ports but the target is behind the source + exit direction (i.e. wire would cross back through its + own node body). Routes out-then-down-then-back like a + squared-off hook. + 5. ``horseshoe`` — typical loop-back case (ports facing the same direction + or pointing away from each other). Chooses above vs + below the nodes based on which side has more clearance + so the wire doesn't cross node bodies when possible. +""" + +from math import comb + +from utils.constants import ( + WIRE_COLOR, WIRE_SELECTED_COLOR, CANVAS_BG, NODE_TYPES, + NODE_UPSTREAM_BORDER, NODE_DOWNSTREAM_BORDER, +) + + +# Higher = smoother gradient/curve, but more canvas items. 22 keeps redraws +# responsive even on macros with 150+ connections. +_SEGMENT_COUNT = 22 + +# Simulated alpha (Tkinter has no native alpha) for blending wire colors +# with the canvas background. +_WIRE_ALPHA = 0.5 + +# Highlight colors mirror the node-border palette: +# green = incoming (feeds the selected node's input) +# red = outgoing (driven from the selected node's output) +_WIRE_INPUT_COLOR = NODE_UPSTREAM_BORDER # green +_WIRE_OUTPUT_COLOR = NODE_DOWNSTREAM_BORDER # red + +# Separate tag so canvas.py can stack highlighted wires above normal ones +# (but still below nodes). +TAG_WIRE_NORMAL = "wire" +TAG_WIRE_HIGHLIGHT = "wire_hl" + + +class Connection: + """Visual wire connecting two ports.""" + + def __init__(self, canvas, from_node, from_port, to_node, to_port, conn_data): + self.canvas = canvas + self.from_node = from_node + self.from_port = from_port + self.to_node = to_node + self.to_port = to_port + self.data = conn_data + self._line_ids: list[int] = [] + self.selected = False + # Selection-adjacency highlight; one of: + # None — default faded node-color gradient + # "input" — green (fully opaque); wire enters a selected node + # "output" — red (fully opaque); wire leaves a selected node + # "fade" — red→green gradient; wire connects two selected nodes + # Highlighted wires are also raised above normal wires. + self.highlight_kind: str | None = None + self._draw() + + def update(self): + self._draw() + + def set_selected(self, selected: bool): + self.selected = selected + self._draw() + + def set_highlight(self, kind: str | None): + """Set the selection-adjacency highlight. + + No-op if unchanged so bulk selection updates don't thrash the canvas. + """ + if self.highlight_kind == kind: + return + self.highlight_kind = kind + self._draw() + + def destroy(self): + for cid in self._line_ids: + self.canvas.delete(cid) + self._line_ids = [] + + def hit_test(self, x: int, y: int, threshold: int = 8) -> bool: + """Check if (x, y) is within ``threshold`` pixels of the wire.""" + for lid in self._line_ids: + coords = self.canvas.coords(lid) + if len(coords) < 4: + continue + for i in range(0, len(coords) - 2, 2): + x1, y1 = coords[i], coords[i + 1] + x2, y2 = coords[i + 2], coords[i + 3] + if self._point_line_dist(x, y, x1, y1, x2, y2) < threshold: + return True + return False + + + def _draw(self): + for cid in self._line_ids: + self.canvas.delete(cid) + self._line_ids = [] + + x1, y1 = self.from_port.x, self.from_port.y + x2, y2 = self.to_port.x, self.to_port.y + + zoom = self._get_zoom() + points = self._curve_points(x1, y1, x2, y2, zoom, _SEGMENT_COUNT) + + highlighted = self.highlight_kind is not None + + if self.selected: + colors = [WIRE_SELECTED_COLOR] * len(points) + base_w = 5 + elif self.highlight_kind == "input": + colors = [_WIRE_INPUT_COLOR] * len(points) + base_w = 5 + elif self.highlight_kind == "output": + colors = [_WIRE_OUTPUT_COLOR] * len(points) + base_w = 5 + elif self.highlight_kind == "fade": + # Wire between two selected nodes — fade red → green so each + # end matches the port color it terminates at. + colors = [] + n = max(1, len(points) - 1) + for i in range(len(points)): + t = i / n + colors.append(self._lerp_color(_WIRE_OUTPUT_COLOR, _WIRE_INPUT_COLOR, t)) + base_w = 5 + else: + # Faded gradient between the two nodes' header colors. + from_c = self._node_color(self.from_node) + to_c = self._node_color(self.to_node) + colors = [] + n = max(1, len(points) - 1) + for i in range(len(points)): + t = i / n + rgb = self._lerp_color(from_c, to_c, t) + rgb = self._blend_with_bg(rgb, _WIRE_ALPHA) + colors.append(rgb) + base_w = 4 + + width = max(1, int(round(base_w * zoom))) + + tag = TAG_WIRE_HIGHLIGHT if highlighted else TAG_WIRE_NORMAL + + # Each segment uses the color at its starting endpoint, producing + # the visual gradient along the wire. + for i in range(len(points) - 1): + ax, ay = points[i] + bx, by = points[i + 1] + color = colors[i] + lid = self.canvas.create_line( + ax, ay, bx, by, + fill=color, width=width, + capstyle="round", + tags=(tag,), + ) + self._line_ids.append(lid) + + # Both kinds of wires stay below nodes; canvas._reorder_wire_layers + # handles the finer "highlighted on top of normal" layering. + try: + self.canvas.tag_lower(tag, "node") + except Exception: + pass + + def _curve_points(self, x1, y1, x2, y2, zoom, n_samples): + """Sample points along a smart-routed curve between the two ports. + + Picks a curve style based on the relative geometry of the two ports + (see module docstring). Takes into account each port's ``side`` + ('L' or 'R') so wires always exit/enter in the direction away from + the node body, even when a node is flipped. + """ + # Exit direction: +1 = right of node, -1 = left of node + from_side = getattr(self.from_port, "side", "R") + to_side = getattr(self.to_port, "side", "L") + from_dir = 1 if from_side == "R" else -1 + to_dir = 1 if to_side == "R" else -1 + + dx = x2 - x1 + dy = y2 - y1 + adx = abs(dx) + ady = abs(dy) + + facing = (from_dir != to_dir) + # Target lies ahead of the source exit side + in_exit_direction = (dx * from_dir) > 0 if dx != 0 else True + + if not facing: + # Same-side ports → horseshoe loop + return self._bezier_samples( + self._horseshoe_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom), + n_samples, + ) + + if not in_exit_direction: + # Naive cubic would loop through the source node body; use a tall hook + return self._bezier_samples( + self._detour_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom), + n_samples, + ) + + # Perfectly (or almost) aligned: pure straight line + if ady <= 4 * zoom: + # Tiny offset preserves smooth port joins + off = max(10 * zoom, adx * 0.08) + return self._bezier_samples([ + (x1, y1), + (x1 + off * from_dir, y1), + (x2 + off * to_dir, y2), + (x2, y2), + ], n_samples) + + # Vertical-dominant: kick in early (ratio 1.3) so stacked-node layouts + # use this shape instead of the generic S-curve. + if ady > adx * 1.3 and ady > 60 * zoom: + return self._bezier_samples( + self._vertical_dominant_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom, adx, ady), + n_samples, + ) + + # Nearly-aligned horizontal: minimal bulge + if ady < 40 * zoom and adx > 40 * zoom: + return self._bezier_samples( + self._straight_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom), + n_samples, + ) + + # Very short hop: tight tangents so the wire doesn't overshoot + if adx + ady < 80 * zoom: + off = max(12 * zoom, (adx + ady) * 0.25) + return self._bezier_samples([ + (x1, y1), + (x1 + off * from_dir, y1), + (x2 + off * to_dir, y2), + (x2, y2), + ], n_samples) + + # Default horizontal-dominant S-curve. Tangent length grows with the + # horizontal gap but is capped so huge horizontal separations still + # produce a tidy curve rather than a sagging one. + offset = max(40 * zoom, 0.55 * adx + 0.15 * ady) + offset = min(offset, 300 * zoom + 0.25 * adx) + ctrl = [ + (x1, y1), + (x1 + offset * from_dir, y1), + (x2 + offset * to_dir, y2), + (x2, y2), + ] + return self._bezier_samples(ctrl, n_samples) + + # ---------- Individual routing styles ---------- + + @staticmethod + def _straight_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom): + """Nearly-aligned horizontal pair — very small tangent so the line + reads as essentially straight with gentle port blending.""" + off = max(14 * zoom, abs(x2 - x1) * 0.10) + return [ + (x1, y1), + (x1 + off * from_dir, y1), + (x2 + off * to_dir, y2), + (x2, y2), + ] + + @staticmethod + def _vertical_dominant_ctrl(x1, y1, x2, y2, from_dir, to_dir, zoom, adx, ady): + """Target mostly above/below. Quintic Bezier with middle control points + along the vertical line between the two ports — produces a vertical + "S on its side" instead of the horizontal bow a plain cubic would draw. + """ + # Short horizontal escape off each port; then the curve runs vertical + escape = max(18 * zoom, 20 * zoom + adx * 0.15) + # Strong vertical stretch so the S leans vertical rather than diagonal + vstretch = max(50 * zoom, ady * 0.55) + vdir = 1 if y2 > y1 else -1 + return [ + (x1, y1), + (x1 + escape * from_dir, y1), + (x1 + escape * from_dir, y1 + vstretch * vdir), + (x2 + escape * to_dir, y2 - vstretch * vdir), + (x2 + escape * to_dir, y2), + (x2, y2), + ] + + # Vertical gap (canvas px at zoom=1) below which two vertically-offset + # node bodies overlap, so we route around instead of through. Node body + # is roughly 54 px + padding. + _CORRIDOR_MIN_CLEARANCE = 90 + + @classmethod + def _detour_ctrl(cls, x1, y1, x2, y2, from_dir, to_dir, zoom): + """Facing ports but target is behind source's exit side. + + Picks the shortest viable route: + + 1. **Corridor** — when the two ports are vertically separated by + more than a node's height, there's a clear horizontal strip + between them. Route through that corridor (mid_y between the + two ports). This is dramatically shorter than arcing under or + over both nodes and is the common case for "output up-right of + input" wiring. + + 2. **Arc above / below** — when ports are too close vertically to + have a corridor, loop around the side that matches the natural + direction of travel. Target below source → arc under target. + Target above → arc over source. Arc size is just big enough + to clear one node, not both. + """ + ady = abs(y2 - y1) + h_off = max(55 * zoom, abs(x2 - x1) * 0.18 + 50 * zoom) + + # Corridor routing (preferred when there's room) + if ady > cls._CORRIDOR_MIN_CLEARANCE * zoom: + mid_y = (y1 + y2) / 2 + return [ + (x1, y1), + (x1 + h_off * from_dir, y1), + (x1 + h_off * from_dir, mid_y), + (x2 + h_off * to_dir, mid_y), + (x2 + h_off * to_dir, y2), + (x2, y2), + ] + + # No corridor — arc around one side. v_off only needs to clear one + # node body's height, not two. + v_off = max(60 * zoom, ady * 0.5 + 45 * zoom) + if y2 < y1: + mid_y = min(y1, y2) - v_off + else: + mid_y = max(y1, y2) + v_off + return [ + (x1, y1), + (x1 + h_off * from_dir, y1), + (x1 + h_off * from_dir, mid_y), + (x2 + h_off * to_dir, mid_y), + (x2 + h_off * to_dir, y2), + (x2, y2), + ] + + @classmethod + def _horseshoe_ctrl(cls, x1, y1, x2, y2, from_dir, to_dir, zoom): + """Same-side ports (both exiting right, or both left). + + Prefers the horizontal corridor between the two ports when there's + vertical clearance, falls back to arcing around one side. Arc + direction follows natural vertical travel to avoid bouncing backwards. + """ + ady = abs(y2 - y1) + h_off = max(55 * zoom, abs(x2 - x1) * 0.18 + 50 * zoom) + + if ady > cls._CORRIDOR_MIN_CLEARANCE * zoom: + mid_y = (y1 + y2) / 2 + return [ + (x1, y1), + (x1 + h_off * from_dir, y1), + (x1 + h_off * from_dir, mid_y), + (x2 + h_off * to_dir, mid_y), + (x2 + h_off * to_dir, y2), + (x2, y2), + ] + + v_off = max(55 * zoom, ady * 0.5 + 40 * zoom) + if y2 < y1: + mid_y = min(y1, y2) - v_off + else: + mid_y = max(y1, y2) + v_off + return [ + (x1, y1), + (x1 + h_off * from_dir, y1), + (x1 + h_off * from_dir, mid_y), + (x2 + h_off * to_dir, mid_y), + (x2 + h_off * to_dir, y2), + (x2, y2), + ] + + @staticmethod + def _bezier_samples(ctrl, n): + """Sample a Bezier of any degree at n+1 equally-spaced t values.""" + deg = len(ctrl) - 1 + if deg < 1: + return list(ctrl) + pts = [] + for i in range(n + 1): + t = i / n if n else 0 + u = 1 - t + x = y = 0.0 + for k, (cx, cy) in enumerate(ctrl): + b = comb(deg, k) * (u ** (deg - k)) * (t ** k) + x += cx * b + y += cy * b + pts.append((x, y)) + return pts + + def _get_zoom(self) -> float: + node = self.from_node if self.from_node else self.to_node + if node is not None and getattr(node, "canvas_ref", None) is not None: + return getattr(node.canvas_ref, "_zoom_level", 1.0) + return 1.0 + + @staticmethod + def _node_color(node_widget) -> str: + if node_widget is not None and getattr(node_widget, "data", None): + info = NODE_TYPES.get(node_widget.data.type, {}) + return info.get("color", WIRE_COLOR) + return WIRE_COLOR + + @staticmethod + def _parse_hex(c: str): + return int(c[1:3], 16), int(c[3:5], 16), int(c[5:7], 16) + + @staticmethod + def _to_hex(r, g, b) -> str: + r = max(0, min(255, int(round(r)))) + g = max(0, min(255, int(round(g)))) + b = max(0, min(255, int(round(b)))) + return f"#{r:02X}{g:02X}{b:02X}" + + @classmethod + def _lerp_color(cls, c1, c2, t): + r1, g1, b1 = cls._parse_hex(c1) + r2, g2, b2 = cls._parse_hex(c2) + return cls._to_hex( + r1 + (r2 - r1) * t, + g1 + (g2 - g1) * t, + b1 + (b2 - b1) * t, + ) + + @classmethod + def _blend_with_bg(cls, color, alpha): + """Simulate alpha by blending ``color`` at ``alpha`` over the canvas bg.""" + r1, g1, b1 = cls._parse_hex(color) + r2, g2, b2 = cls._parse_hex(CANVAS_BG) + return cls._to_hex( + r1 * alpha + r2 * (1 - alpha), + g1 * alpha + g2 * (1 - alpha), + b1 * alpha + b2 * (1 - alpha), + ) + + @staticmethod + def _point_line_dist(px, py, x1, y1, x2, y2) -> float: + dx, dy = x2 - x1, y2 - y1 + if dx == 0 and dy == 0: + return ((px - x1) ** 2 + (py - y1) ** 2) ** 0.5 + t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))) + proj_x = x1 + t * dx + proj_y = y1 + t * dy + return ((px - proj_x) ** 2 + (py - proj_y) ** 2) ** 0.5 diff --git a/node_editor/node.py b/node_editor/node.py new file mode 100644 index 0000000..1bc614c --- /dev/null +++ b/node_editor/node.py @@ -0,0 +1,562 @@ +"""Base node widget rendered on the canvas.""" + +from utils.constants import ( + NODE_TYPES, NODE_HEADER_HEIGHT, NODE_MIN_WIDTH, NODE_PORT_RADIUS, + NODE_BODY_COLOR, NODE_TEXT_COLOR, NODE_PORT_IN_COLOR, NODE_PORT_OUT_COLOR, + NODE_SELECTED_BORDER, NODE_UPSTREAM_BORDER, NODE_DOWNSTREAM_BORDER, +) +from .port import Port + + +class NodeWidget: + """Visual representation of a node on the canvas. + + Coordinates: + - ``self.data.x``, ``self.data.y`` are WORLD coordinates (independent of zoom). + - All drawing is done in CANVAS coordinates = world * zoom. + - ``port.x``, ``port.y`` are CANVAS coordinates, kept up to date on redraw/move. + - ``self.width``, ``self.height`` are CANVAS pixel dimensions (scaled by zoom). + """ + + TAG_PREFIX = "node_" + + def __init__(self, canvas, node_data, on_select=None, on_move=None, canvas_ref=None): + self.canvas = canvas + self.canvas_ref = canvas_ref + self.data = node_data + self.on_select = on_select + self.on_move = on_move + self.selected = False + # Directional connectivity highlight. One of: + # None — no highlight + # "upstream" — green: a neighbor wires INTO our input + # "downstream" — red: a neighbor receives FROM our output + # "both" — node is both an upstream and downstream neighbor + # of the current selection (double border) + self.highlight_kind: str | None = None + self.canvas_items = [] + self.ports: list[Port] = [] + self.input_ports: list[Port] = [] + self.output_ports: list[Port] = [] + self.tag = f"{self.TAG_PREFIX}{self.data.id}" + + self._setup_ports() + self._draw() + + def _zoom(self) -> float: + if self.canvas_ref is not None: + return getattr(self.canvas_ref, "_zoom_level", 1.0) + return 1.0 + + def _setup_ports(self): + node_type = self.data.type + + # Note nodes are annotation-only — no ports, no connections + if node_type == "note": + self.input_ports = [] + self.output_ports = [] + self.ports = [] + return + + if node_type == "start": + self.input_ports = [] + elif node_type == "repeat": + self.input_ports = [ + Port("in", "input", "Start"), + Port("loop_back", "input", "Loop Back"), + ] + elif node_type == "pc_alive_check": + self.input_ports = [ + Port("in", "input", "In"), + ] + elif node_type == "aggregator": + count = max(1, int(self.data.data.get("input_count", 2))) + self.input_ports = [ + Port(f"in_{i}", "input", f"In {i+1}") for i in range(count) + ] + else: + self.input_ports = [Port("in", "input", "In")] + + if node_type == "branch" or node_type == "iteration_branch": + choices = self.data.data.get("choices", []) + self.output_ports = [ + Port(f"out_{i}", "output", choice.get("label", f"Out {i+1}")) + for i, choice in enumerate(choices) + ] + elif node_type == "repeat": + self.output_ports = [ + Port("loop_body", "output", "Loop Body"), + Port("done", "output", "Done"), + ] + elif node_type == "pc_alive_check": + self.output_ports = [ + Port("true", "output", "True"), + Port("false", "output", "False"), + ] + elif node_type == "bluetooth" and self.data.data.get("mode") == "get_local": + # Get Variables uses Num Lock probing; routes to Pass on a matched + # outcome and Fail on no match / timeout. + self.output_ports = [ + Port("pass", "output", "Pass"), + Port("fail", "output", "Fail"), + ] + else: + self.output_ports = [Port("out", "output", "Out")] + + self.ports = self.input_ports + self.output_ports + + def _draw(self): + self._clear() + + zoom = self._zoom() + + # World → canvas + x = self.data.x * zoom + y = self.data.y * zoom + + if self.data.type == "note": + self._draw_note(x, y, zoom) + return + + type_info = NODE_TYPES.get(self.data.type, {"label": "Unknown", "color": "#555555"}) + header_color = type_info["color"] + label = type_info["label"] + + w = NODE_MIN_WIDTH * zoom + header_h = NODE_HEADER_HEIGHT * zoom + port_spacing = 24 * zoom + + port_count = max(len(self.input_ports), len(self.output_ports)) + body_h = max(30 * zoom, port_count * port_spacing + 8 * zoom) + total_h = header_h + body_h + port_radius = max(2, NODE_PORT_RADIUS * zoom) + + # Clamp minimum font size for legibility + header_font_size = max(5, int(round(9 * zoom))) + subtitle_font_size = max(5, int(round(8 * zoom))) + port_label_font_size = max(5, int(round(7 * zoom))) + + body = self.canvas.create_rectangle( + x, y, x + w, y + total_h, + fill=NODE_BODY_COLOR, outline="#555555", width=1, + tags=(self.tag, "node") + ) + self.canvas_items.append(body) + + header = self.canvas.create_rectangle( + x, y, x + w, y + header_h, + fill=header_color, outline=header_color, + tags=(self.tag, "node", "header") + ) + self.canvas_items.append(header) + + header_text = self.canvas.create_text( + x + w / 2, y + header_h / 2, + text=label, fill="white", font=("Segoe UI", header_font_size, "bold"), + tags=(self.tag, "node", "header") + ) + self.canvas_items.append(header_text) + + subtitle = self._get_subtitle() + if subtitle: + sub_text = self.canvas.create_text( + x + w / 2, y + header_h + 14 * zoom, + text=subtitle, fill="#AAAAAA", font=("Segoe UI", subtitle_font_size), + width=max(1, int(w - 16 * zoom)), + tags=(self.tag, "node") + ) + self.canvas_items.append(sub_text) + + port_start_y = y + header_h + 8 * zoom + show_port_labels = self.data.type in ( + "branch", "repeat", "pc_alive_check", "iteration_branch", + ) or (self.data.type == "bluetooth" and self.data.data.get("mode") == "get_local") + label_inset = 12 * zoom + + flipped = bool(getattr(self.data, "flipped", False)) + # When flipped: inputs go on the right, outputs on the left + in_on_right = flipped + out_on_right = not flipped + in_x = (x + w) if in_on_right else x + out_x = (x + w) if out_on_right else x + + for i, port in enumerate(self.input_ports): + py = port_start_y + i * port_spacing + 12 * zoom + px = in_x + port.x = px + port.y = py + port.side = "R" if in_on_right else "L" + cid = self.canvas.create_oval( + px - port_radius, py - port_radius, + px + port_radius, py + port_radius, + fill=NODE_PORT_IN_COLOR, outline="#222222", + tags=(self.tag, "port", f"port_{self.data.id}_{port.name}") + ) + port.canvas_id = cid + self.canvas_items.append(cid) + + if show_port_labels and len(self.input_ports) > 1: + # Label goes toward node interior + if in_on_right: + label_x = px - label_inset + anchor = "e" + else: + label_x = px + label_inset + anchor = "w" + plabel = self.canvas.create_text( + label_x, py, + text=port.label, fill="#CCCCCC", + font=("Segoe UI", port_label_font_size), + anchor=anchor, + tags=(self.tag, "node") + ) + self.canvas_items.append(plabel) + + for i, port in enumerate(self.output_ports): + py = port_start_y + i * port_spacing + 12 * zoom + px = out_x + port.x = px + port.y = py + port.side = "R" if out_on_right else "L" + cid = self.canvas.create_oval( + px - port_radius, py - port_radius, + px + port_radius, py + port_radius, + fill=NODE_PORT_OUT_COLOR, outline="#222222", + tags=(self.tag, "port", f"port_{self.data.id}_{port.name}") + ) + port.canvas_id = cid + self.canvas_items.append(cid) + + if show_port_labels: + # Label goes toward node interior + if out_on_right: + label_x = px - label_inset + anchor = "e" + else: + label_x = px + label_inset + anchor = "w" + plabel = self.canvas.create_text( + label_x, py, + text=port.label, fill="#CCCCCC", + font=("Segoe UI", port_label_font_size), + anchor=anchor, + tags=(self.tag, "node") + ) + self.canvas_items.append(plabel) + + self.width = w + self.height = total_h + + if self.selected: + self._draw_selection() + elif self.highlight_kind: + self._draw_highlight() + + def _get_subtitle(self) -> str: + d = self.data.data + t = self.data.type + if t == "text": + text = d.get("text", "") + return f'"{text[:20]}..."' if len(text) > 20 else f'"{text}"' if text else "(empty)" + elif t == "combo": + mods = "+".join(d.get("mods", [])) + key = d.get("key", "") + # Legacy "fast" or new "custom_timings" both surface as a lightning bolt + uses_custom = bool(d.get("custom_timings", False)) or bool(d.get("fast", False)) + prefix = "\u26a1 " if uses_custom else "" + if mods and key: + return f"{prefix}{mods}+{key}" + elif mods: + return f"{prefix}{mods}" + elif key: + return f"{prefix}{key}" + return "(empty)" + elif t == "delay": + return f'{d.get("ms", 0)}ms' + elif t == "pause": + wait = d.get("wait", "click") + return f"Wait: {wait}" + elif t == "mouse": + return f'{d.get("action", "click")} {d.get("button", "left")}' + elif t == "media": + return d.get("action", "?") + elif t == "repeat": + if d.get("use_selector", False): + return "Count from selector" + return f'{d.get("count", 1)}x' + elif t == "loop_selector": + mn = d.get("min", 1) + mx = d.get("max", 10) + step = d.get("step", 1) + if step != 1: + return f"{mn}..{mx} (step {step})" + return f"{mn}..{mx}" + elif t == "iteration_branch": + n_choices = len(d.get("choices", [])) + tied = d.get("loop_node_id", "") + if not tied: + return f"{n_choices} paths (untied)" + return f"{n_choices} paths" + elif t == "aggregator": + n = d.get("input_count", 2) + return f"{n} inputs" + elif t == "subroutine": + name = d.get("name", "") + return f'Call: {name}' if name else "(not set)" + elif t == "rs232": + msg = d.get("message", "") + baud = d.get("baud", 9600) + preview = f'{msg[:15]}...' if len(msg) > 15 else msg + return f'{baud}bps: "{preview}"' if preview else f'{baud}bps' + elif t == "pc_alive_check": + cond = d.get("condition", "pc_response") + labels = {"numlock_on": "NumLock ON", "numlock_off": "NumLock OFF", "pc_response": "PC Response"} + loop = d.get("loop", True) + label = labels.get(cond, cond) + return f"{label}" + (" (loop)" if loop else "") + elif t == "start": + return "Execution begins here" + elif t == "note": + return "" # Notes render their own body; never show subtitle + elif t == "macro": + n = len(d.get("events", [])) + name = d.get("name", "").strip() + if n == 0: + return f"{name} (empty)" if name else "(not recorded)" + last_t = d["events"][-1][0] if d["events"] else 0 + secs = last_t / 1000.0 + dur = f"{secs:.1f}s" if secs < 60 else f"{int(secs // 60)}m{int(secs % 60)}s" + return f'{name} \u25b6 {n} evt, {dur}' if name else f"\u25b6 {n} evt, {dur}" + elif t == "bluetooth": + mode_labels = { + "pull_ble": "Pull BLE Variables", + "push_ble": "Push BLE Variables", + "request_ble": "Request BLE Variable(s)", + "set_local": "Set Variables", + "get_local": "Get Variables", + } + return mode_labels.get(d.get("mode", "pull_ble"), "") + return "" + + def _draw_note(self, x, y, zoom): + """Draw a Note node — GUI-only annotation with no header or ports.""" + d = self.data.data + text = d.get("text", "") or "(empty note)" + font_size = int(d.get("font_size", 14)) + color_name = d.get("color", "white") + width_world = int(d.get("width", 220)) + + font_px = max(5, int(round(font_size * zoom))) + w = max(60, width_world * zoom) + pad = max(4, 8 * zoom) + + from utils.constants import DISPLAY_COLORS + color_map = dict(DISPLAY_COLORS) + text_color = color_map.get(color_name, color_name) + + # Muted text if this is actually an empty placeholder + show_placeholder = not d.get("text") + if show_placeholder: + text_color = "#888888" + + # Create the text item first so we can measure its bbox, + # then back-size the card rectangle around it. + text_id = self.canvas.create_text( + x + pad, y + pad, + text=text, + fill=text_color, + font=("Segoe UI", font_px), + anchor="nw", + width=max(1, int(w - 2 * pad)), + tags=(self.tag, "node", "note") + ) + + bbox = self.canvas.bbox(text_id) + if bbox: + min_h = font_px + 2 * pad + total_h = max(min_h, (bbox[3] - y) + pad) + else: + total_h = max(40, font_px + 2 * pad) + + # Subtle dashed border distinguishes notes from regular nodes + body = self.canvas.create_rectangle( + x, y, x + w, y + total_h, + fill="#25252F", outline="#5A5A7A", width=1, dash=(3, 3), + tags=(self.tag, "node", "note") + ) + self.canvas.tag_lower(body, text_id) + + self.canvas_items.append(body) + self.canvas_items.append(text_id) + + self.width = w + self.height = total_h + + if self.selected: + self._draw_selection() + elif self.highlight_kind: + self._draw_highlight() + + def _draw_selection(self): + zoom = self._zoom() + x = self.data.x * zoom + y = self.data.y * zoom + sel = self.canvas.create_rectangle( + x - 2, y - 2, x + self.width + 2, y + self.height + 2, + outline=NODE_SELECTED_BORDER, width=2, dash=(4, 2), + tags=(self.tag, "selection") + ) + self.canvas_items.append(sel) + + def _draw_highlight(self): + """Directional connectivity border. + + - "upstream" → solid green border (matches the green input-port + color on the selected node — this neighbor is what + feeds INTO the selection). + - "downstream" → solid red border (matches the red output-port color + — this neighbor receives from the selection's + output). + - "both" → an alternating green/red dotted border. Tkinter + can't multi-color a single outline, so we draw the + perimeter as a chain of short segments that cycle + through the two colors dash-by-dash. + """ + zoom = self._zoom() + x = self.data.x * zoom + y = self.data.y * zoom + kind = self.highlight_kind + + if kind == "both": + self._draw_alternating_border(x, y, self.width, self.height, zoom) + return + + color = NODE_UPSTREAM_BORDER if kind == "upstream" else NODE_DOWNSTREAM_BORDER + hl = self.canvas.create_rectangle( + x - 2, y - 2, x + self.width + 2, y + self.height + 2, + outline=color, width=2, + tags=(self.tag, "highlight") + ) + self.canvas_items.append(hl) + + def _draw_alternating_border(self, x, y, w, h, zoom): + """Draw the node border as alternating green/red dashes. + + Tkinter can't multi-color a single outline, so we walk the perimeter + clockwise and emit one short line per dash, alternating colors. + """ + # Slight outset so the dashes don't overlap the node body + pad = 2 + x1 = x - pad + y1 = y - pad + x2 = x + w + pad + y2 = y + h + pad + + seg_len = max(5, 8 * zoom) + gap_len = max(3, 4 * zoom) + stride = seg_len + gap_len + width = max(1, int(round(2 * zoom))) + + colors = (NODE_UPSTREAM_BORDER, NODE_DOWNSTREAM_BORDER) + + # Clockwise: top → right → bottom → left + edges = [ + (x1, y1, x2, y1), + (x2, y1, x2, y2), + (x2, y2, x1, y2), + (x1, y2, x1, y1), + ] + + color_idx = 0 + for ax, ay, bx, by in edges: + length = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5 + if length <= 0: + continue + ux = (bx - ax) / length + uy = (by - ay) / length + pos = 0.0 + while pos < length: + end = min(pos + seg_len, length) + sx = ax + ux * pos + sy = ay + uy * pos + ex = ax + ux * end + ey = ay + uy * end + item = self.canvas.create_line( + sx, sy, ex, ey, + fill=colors[color_idx % 2], + width=width, + capstyle="round", + tags=(self.tag, "highlight") + ) + self.canvas_items.append(item) + color_idx += 1 + pos += stride + + def move_by(self, dx_canvas, dy_canvas): + """Move the node. Deltas are in CANVAS coordinates (screen pixels).""" + for item in self.canvas_items: + self.canvas.move(item, dx_canvas, dy_canvas) + + # Convert canvas delta → world delta before updating data + zoom = self._zoom() + if zoom == 0: + zoom = 1.0 + self.data.x += dx_canvas / zoom + self.data.y += dy_canvas / zoom + + # Port coords are in canvas space — update by canvas delta + for port in self.ports: + port.x += dx_canvas + port.y += dy_canvas + + if self.on_move: + self.on_move(self) + + def set_selected(self, selected: bool): + self.selected = selected + self.redraw() + + def set_highlight_kind(self, kind: str | None): + """Set the directional connectivity highlight. + + ``kind`` is one of None / "upstream" / "downstream" / "both". + No-op if unchanged so bulk selection updates don't thrash the canvas. + """ + if self.highlight_kind == kind: + return + self.highlight_kind = kind + self.redraw() + + def redraw(self): + self._draw() + + def _clear(self): + for item in self.canvas_items: + self.canvas.delete(item) + self.canvas_items.clear() + + def destroy(self): + self._clear() + + def get_port(self, port_name: str) -> Port | None: + for p in self.ports: + if p.name == port_name: + return p + return None + + def get_port_at(self, x: int, y: int) -> Port | None: + """Find a port near the given canvas coords.""" + zoom = self._zoom() + port_radius = max(2, NODE_PORT_RADIUS * zoom) + tol2 = (port_radius + 4) ** 2 + for port in self.ports: + dx = x - port.x + dy = y - port.y + if dx * dx + dy * dy <= tol2: + return port + return None + + def update_branch_ports(self): + if self.data.type in ("branch", "iteration_branch", "aggregator", "bluetooth"): + self._setup_ports() + self.redraw() diff --git a/node_editor/nodes.py b/node_editor/nodes.py new file mode 100644 index 0000000..2bf29e1 --- /dev/null +++ b/node_editor/nodes.py @@ -0,0 +1,2933 @@ +"""Property editor widgets for each node type. + +Visual representation lives in NodeWidget; this module provides +get_property_editor(node_type) -> factory for the properties panel frame. +""" + +import tkinter as tk +import tkinter.font as tkfont +from tkinter import ttk +from utils.constants import ( + MODIFIER_KEYS, MODIFIER_LABELS, SPECIAL_KEYS, + MOUSE_BUTTONS, MOUSE_ACTIONS, MEDIA_ACTIONS, + DISPLAY_COLORS, + DEVICE_SCREEN_W, DEVICE_SCREEN_H, + DEFAULT_PAUSE_MARGIN_LEFT, DEFAULT_PAUSE_MARGIN_RIGHT, + DEFAULT_PAUSE_MARGIN_TOP, DEFAULT_PAUSE_MARGIN_BOTTOM, + RS232_BAUD_RATES, RS232_DATA_BITS, RS232_STOP_BITS, RS232_PARITY, RS232_LINE_ENDINGS, +) + + +# ---- Pause-screen preview helpers ---------------------------------------- + +# How much to upscale the 128x128 device LCD for the preview thumbnail. +_PAUSE_PREVIEW_SCALE = 2 + + +def _pause_preview_font(font_size: int, scale: float) -> tkfont.Font: + """Pick a Tk font that approximates the device's drawWrapped() output. + + The firmware uses M5Unified's bundled font ladder + (Font2 / FreeSansBold9pt / 12pt / 18pt) keyed off the same `fontSize` + integer. We mirror those breakpoints to a Tk font of similar pixel + height so wrapping/truncation in the preview matches the device fairly + closely. Exact match is impossible without the bitmap fonts; the goal + is "useful, not perfect." Scale may be fractional (panel-fit), so we + round to int after multiplying. + """ + if font_size >= 20: + family, base_size, weight = ("DejaVu Sans", 14, "bold") + elif font_size >= 14: + family, base_size, weight = ("DejaVu Sans", 10, "bold") + elif font_size >= 10: + family, base_size, weight = ("DejaVu Sans", 8, "bold") + else: + family, base_size, weight = ("Courier New", 7, "normal") + pixel_size = max(6, int(round(base_size * scale))) + return tkfont.Font(family=family, size=pixel_size, weight=weight) + + +def _wrap_text_for_preview(text: str, max_w: int, font: tkfont.Font, + max_lines: int): + """Word-wrap text the same way drawWrapped() does on the device. + + Returns ``(lines, truncated)`` where lines is a list of strings already + fitted to ``max_w`` and ``truncated`` indicates that the original text + overflowed and the last entry has been ellipsis-trimmed. + """ + out = [] + truncated = False + + for paragraph in text.split("\n"): + if not paragraph: + out.append("") + continue + cur = "" + # Split keeping spaces so single-word overflows still hard-break. + words = paragraph.split(" ") + for wi, word in enumerate(words): + candidate = (cur + (" " if cur else "") + word) if cur else word + if font.measure(candidate) <= max_w: + cur = candidate + continue + # Try to flush the current line and start a new one with the word. + if cur: + out.append(cur) + cur = word + if font.measure(cur) > max_w: + # Single word too long — hard-break it character by character. + chunk = "" + for ch in word: + if font.measure(chunk + ch) > max_w and chunk: + out.append(chunk) + chunk = ch + else: + chunk += ch + cur = chunk + else: + # Even an empty current can't hold the word — hard-break. + chunk = "" + for ch in word: + if font.measure(chunk + ch) > max_w and chunk: + out.append(chunk) + chunk = ch + else: + chunk += ch + cur = chunk + out.append(cur) + + if len(out) > max_lines: + out = out[:max_lines] + truncated = True + # Trim the last line so "..." fits inside max_w. + last = out[-1] + ellipsis = "..." + ew = font.measure(ellipsis) + while last and font.measure(last) + ew > max_w: + last = last[:-1] + out[-1] = last + ellipsis + + return out, truncated + + +def create_text_editor(parent, data, on_change, project=None): + """Editor for Text node properties. + + The inline widget is a small Text box; clicking Editor… opens a + larger pop-out window with line numbers and optional syntax + highlighting (cmd / powershell / none). + + ``project`` (optional) gives the editor access to the BLE variable + catalog so it can: (1) highlight ``(VAR{name})`` tokens in the text + box, (2) show their stored comment in a hover tooltip, and (3) + surface a quick-insert list of all known variables. + """ + from widgets.text_editor_dialog import TextEditorDialog, LANGUAGE_CHOICES + import re as _re + + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Text to type:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + text_var = tk.Text(frame, height=6, width=30, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 10), + relief="flat", borderwidth=1) + text_var.insert("1.0", data.get("text", "")) + text_var.pack(fill="x", padx=8, pady=4) + + # ---- VAR{...} highlighting + hover tooltip -------------------------- + # The firmware resolves VAR{name} case-insensitively across both the + # device-scoped and universal scopes. We mirror that lookup so the + # GUI shows a "known" highlight (vs. the dim "unknown" highlight) and + # the matching comment regardless of which scope the variable lives + # in. Tags: + # var_known — name matches an entry in either scope + # var_unknown — token shape is right but no such variable exists + text_var.tag_configure("var_known", + background="#1F3D5C", foreground="#7CCBFF", + borderwidth=0) + text_var.tag_configure("var_unknown", + background="#3D2828", foreground="#FF8888", + borderwidth=0) + + _VAR_RE = _re.compile(r"\(VAR\{([^}]+)\}\)") + + def _all_known_vars(): + """Return {lower_name: (display_name, scope_label, comment_str)}. + + Device-scoped names take precedence over universal entries with + the same case-insensitive key (matches firmware lookup order). + Comments — pulled from project.ble_comments — are best-effort: + empty string when the user hasn't written one. + """ + out = {} + if project is None: + return out + ble = getattr(project, "ble_variables", None) or {} + com = getattr(project, "ble_comments", None) or {} + + # Universal first; device entries clobber on collision. + for name in (ble.get("universal") or {}): + comment = (com.get("universal") or {}).get(name, "") + out[name.lower()] = (name, "Universal", comment) + for mac, vars_ in (ble.get("devices") or {}).items(): + mac_comments = (com.get("devices") or {}).get(mac, {}) or {} + for name in vars_: + comment = mac_comments.get(name, "") + out[name.lower()] = (name, f"Device {mac}", comment) + return out + + # Tooltip — a single Toplevel reused for every hover so we don't + # leak windows on rapid mouse moves. + tooltip = {"win": None} + + def _hide_tooltip(_event=None): + if tooltip["win"] is not None: + try: + tooltip["win"].destroy() + except tk.TclError: + pass + tooltip["win"] = None + + def _show_tooltip(text, x, y): + _hide_tooltip() + tw = tk.Toplevel(text_var) + tw.overrideredirect(True) + tw.attributes("-topmost", True) + tw.geometry(f"+{x + 14}+{y + 18}") + tk.Label( + tw, text=text, justify="left", + bg="#FFFFE0", fg="#000000", relief="solid", borderwidth=1, + font=("Segoe UI", 8), padx=6, pady=3, + ).pack() + tooltip["win"] = tw + + def _resolve_var_at(index: str): + """Return (name, scope, comment) for the VAR{} token under ``index``, + or None if the cursor isn't inside one.""" + # Read the surrounding text — VAR{...} is at most ~70 chars long + # in any reasonable usage, so a 256-char window is plenty. + try: + line, col = map(int, index.split(".")) + except (ValueError, AttributeError): + return None + line_text = text_var.get(f"{line}.0", f"{line}.end") + # Find any match on this line that contains the column. + for m in _VAR_RE.finditer(line_text): + if m.start() <= col < m.end(): + name = m.group(1) + catalog = _all_known_vars() + hit = catalog.get(name.lower()) + if hit is None: + return (name, None, "") # token shape is right, name unknown + return hit + return None + + def _on_var_motion(event): + idx = text_var.index(f"@{event.x},{event.y}") + info = _resolve_var_at(idx) + if info is None: + _hide_tooltip() + return + name, scope, comment = info + if scope is None: + body = f"VAR{{{name}}}\n(no variable with this name)" + else: + body = f"VAR{{{name}}}\nScope: {scope}" + if comment: + body += f"\n— {comment}" + else: + body += "\n(no comment set)" + _show_tooltip(body, + text_var.winfo_rootx() + event.x, + text_var.winfo_rooty() + event.y) + + def _refresh_var_highlights(_event=None): + # Wipe and re-tag from scratch — the text box is small and this + # keeps the logic trivial vs. tracking incremental edits. + text_var.tag_remove("var_known", "1.0", "end") + text_var.tag_remove("var_unknown", "1.0", "end") + catalog = _all_known_vars() + # Walk the whole buffer; line/col indexing keeps us off the slow + # Tk regex search engine for what's typically <1 KB of text. + full = text_var.get("1.0", "end-1c") + line = 1 + col = 0 + offset = 0 + # Map character offset → "line.col" once, then convert match spans. + positions = [] # positions[i] = (line, col) of full[i] + for ch in full: + positions.append((line, col)) + if ch == "\n": + line += 1 + col = 0 + else: + col += 1 + positions.append((line, col)) # sentinel for end-of-text + for m in _VAR_RE.finditer(full): + sl, sc = positions[m.start()] + el, ec = positions[m.end()] + tag = "var_known" if m.group(1).lower() in catalog else "var_unknown" + text_var.tag_add(tag, f"{sl}.{sc}", f"{el}.{ec}") + + # Bind tag motion for both highlight tags so the tooltip works + # regardless of resolution status. + for _t in ("var_known", "var_unknown"): + text_var.tag_bind(_t, "", _on_var_motion) + text_var.tag_bind(_t, "", _hide_tooltip) + + def save(*_): + data["text"] = text_var.get("1.0", "end-1c") + _refresh_var_highlights() + on_change() + + text_var.bind("", save) + # Hide the tooltip when the editor loses focus or is destroyed — + # otherwise it can linger after the panel rebuilds. + text_var.bind("", _hide_tooltip) + text_var.bind("", _hide_tooltip) + + # Initial highlight pass after the widget is mapped. + frame.after_idle(_refresh_var_highlights) + + # Language is surfaced here as a small label only; the picker lives in + # the pop-out so the inline panel stays compact. + lang_row = tk.Frame(frame, bg="#2D2D3D") + lang_row.pack(fill="x", padx=8, pady=(6, 2)) + + def _lang_display(lang_id: str) -> str: + for name, lid in LANGUAGE_CHOICES: + if lid == lang_id: + return name + return "None (plain text)" + + lang_hint_var = tk.StringVar(value=f"Language: {_lang_display(data.get('language', 'none'))}") + tk.Label(lang_row, textvariable=lang_hint_var, + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8)).pack(side="left") + + def open_pop_out(): + def on_editor_save(): + # Push the dialog's saved text back into the inline widget so + # both editors stay in sync without a reselect round-trip. + text_var.delete("1.0", "end") + text_var.insert("1.0", data.get("text", "")) + _refresh_var_highlights() + lang_hint_var.set(f"Language: {_lang_display(data.get('language', 'none'))}") + on_change() + TextEditorDialog(parent, data, on_editor_save) + + tk.Button(frame, text="Editor…", + bg="#3B82F6", fg="white", activebackground="#2563EB", + font=("Segoe UI", 9, "bold"), relief="flat", padx=12, pady=4, + command=open_pop_out).pack(fill="x", padx=8, pady=(2, 8)) + + # ---- Variable picker -------------------------------------------------- + # Compact list of every known variable across both scopes, with an + # Insert button that appends ``(VAR{name})`` at the end of the text + # box. Stays empty (and the section hidden) when the project has no + # variables defined yet, so we don't burn vertical space on nothing. + catalog = _all_known_vars() + if catalog: + tk.Frame(frame, bg="#444466", height=1).pack(fill="x", padx=8, pady=(8, 4)) + tk.Label(frame, text="Insert variable:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8) + + list_holder = tk.Frame(frame, bg="#2D2D3D") + list_holder.pack(fill="x", padx=8, pady=(2, 4)) + + # Listbox + scrollbar. height in lines — small so we don't dominate + # the panel; the scrollbar handles overflow. + var_listbox = tk.Listbox( + list_holder, height=5, bg="#1E1E2E", fg="white", + selectbackground="#0077CC", selectforeground="white", + font=("Consolas", 9), relief="flat", highlightthickness=0, + activestyle="none", exportselection=False, + ) + list_scroll = tk.Scrollbar(list_holder, orient="vertical", + command=var_listbox.yview) + var_listbox.configure(yscrollcommand=list_scroll.set) + list_scroll.pack(side="right", fill="y") + var_listbox.pack(side="left", fill="x", expand=True) + + # Stable order: alphabetical by name within (universal first, + # devices second). + sorted_universal = sorted( + (k for k, v in catalog.items() if v[1] == "Universal"), + key=lambda k: catalog[k][0].lower(), + ) + sorted_device = sorted( + (k for k, v in catalog.items() if v[1] != "Universal"), + key=lambda k: (catalog[k][1], catalog[k][0].lower()), + ) + ordered_keys = sorted_universal + sorted_device + for k in ordered_keys: + name, scope, _comment = catalog[k] + scope_tag = "U" if scope == "Universal" else "D" + var_listbox.insert("end", f"[{scope_tag}] {name}") + + def _list_index_to_var(): + sel = var_listbox.curselection() + if not sel: + return None + return catalog[ordered_keys[sel[0]]] + + def insert_selected(): + picked = _list_index_to_var() + if picked is None: + return + name, _scope, _comment = picked + text_var.insert("end-1c", f"(VAR{{{name}}})") + save() + + # Tooltip on hover for list rows showing the comment. + def _on_list_motion(event): + idx = var_listbox.nearest(event.y) + if idx < 0 or idx >= len(ordered_keys): + _hide_tooltip() + return + name, scope, comment = catalog[ordered_keys[idx]] + body = f"VAR{{{name}}}\nScope: {scope}" + body += f"\n— {comment}" if comment else "\n(no comment set)" + _show_tooltip( + body, + var_listbox.winfo_rootx() + event.x, + var_listbox.winfo_rooty() + event.y, + ) + var_listbox.bind("", _on_list_motion) + var_listbox.bind("", _hide_tooltip) + # Double-click also inserts so power users can skip the button. + var_listbox.bind("", lambda _e: insert_selected()) + + tk.Button( + frame, text="Insert at end", + bg="#27AE60", fg="white", activebackground="#1E8449", + font=("Segoe UI", 9, "bold"), relief="flat", padx=10, pady=3, + command=insert_selected, + ).pack(fill="x", padx=8, pady=(0, 8)) + + return frame + + +def create_combo_editor(parent, data, on_change): + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Modifiers:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + mod_vars = {} + current_mods = data.get("mods", []) + mods_frame = tk.Frame(frame, bg="#2D2D3D") + mods_frame.pack(fill="x", padx=8) + + for i, mod in enumerate(MODIFIER_KEYS[:4]): + var = tk.BooleanVar(value=mod in current_mods) + mod_vars[mod] = var + cb = tk.Checkbutton(mods_frame, text=MODIFIER_LABELS[mod], variable=var, + bg="#2D2D3D", fg="white", selectcolor="#1E1E2E", + activebackground="#2D2D3D", activeforeground="white", + font=("Segoe UI", 9)) + cb.grid(row=i // 2, column=i % 2, sticky="w", padx=4, pady=1) + + tk.Label(frame, text="Key:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + # Printable + special keys, with "none" for modifier-only combos + all_keys = ["(none - modifier only)"] + list("abcdefghijklmnopqrstuvwxyz0123456789") + SPECIAL_KEYS + current_key = data.get("key", "") + display_key = "(none - modifier only)" if current_key == "" else current_key + key_var = tk.StringVar(value=display_key) + key_combo = ttk.Combobox(frame, textvariable=key_var, values=all_keys, width=20) + key_combo.pack(fill="x", padx=8, pady=4) + + # Migrate stale legacy "fast" key. Profiles loaded via from_dict are + # already migrated, but the dict might be hand-edited or from an older + # in-memory path. + if "fast" in data: + was_fast = bool(data.pop("fast")) + if was_fast and not data.get("custom_timings"): + data["custom_timings"] = True + data.setdefault("custom_pre_ms", 167) + data.setdefault("custom_post_ms", 167) + data.setdefault("custom_key_pre_ms", 3) + data.setdefault("custom_key_post_ms", 8) + + custom_btn = tk.Button(frame, bg="#3D3D5C", fg="white", + font=("Segoe UI", 9), relief="flat", padx=10, pady=4) + custom_btn.pack(fill="x", padx=8, pady=(6, 2)) + + def refresh_custom_btn(): + if bool(data.get("custom_timings", False)): + custom_btn.config(text="\u26a1 Custom Timings (On)...", bg="#27AE60") + else: + custom_btn.config(text="Custom Timings...", bg="#3D3D5C") + + def open_custom_dialog(): + dlg = tk.Toplevel(frame) + dlg.title("Custom Timings for Key Combo") + dlg.geometry("380x420") + dlg.resizable(False, False) + dlg.configure(bg="#2D2D3D") + dlg.transient(frame.winfo_toplevel()) + dlg.grab_set() + + tk.Label(dlg, text="Custom Timings", + bg="#2D2D3D", fg="white", font=("Segoe UI", 12, "bold")).pack( + anchor="w", padx=14, pady=(12, 2)) + tk.Label(dlg, + text="When enabled, this combo uses its own timings\n" + "instead of the device defaults in Settings.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=14, pady=(0, 8)) + + enable_var = tk.BooleanVar(value=bool(data.get("custom_timings", False))) + tk.Checkbutton(dlg, text="Enable custom timings for this combo", + variable=enable_var, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", font=("Segoe UI", 9)).pack( + anchor="w", padx=14, pady=(0, 6)) + + fields_frame = tk.Frame(dlg, bg="#2D2D3D") + fields_frame.pack(fill="x", padx=14, pady=4) + + pre_var = tk.StringVar(value=str(data.get("custom_pre_ms", 167))) + post_var = tk.StringVar(value=str(data.get("custom_post_ms", 167))) + kpre_var = tk.StringVar(value=str(data.get("custom_key_pre_ms", 3))) + kpost_var = tk.StringVar(value=str(data.get("custom_key_post_ms", 8))) + + def mk_row(parent, label_text, var, row): + tk.Label(parent, text=label_text, bg="#2D2D3D", fg="white", + font=("Segoe UI", 9), anchor="w").grid(row=row, column=0, + sticky="w", pady=3) + e = tk.Entry(parent, textvariable=var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), + relief="flat", width=8) + e.grid(row=row, column=1, sticky="e", pady=3, padx=(10, 0)) + tk.Label(parent, text="ms", bg="#2D2D3D", fg="#888888", + font=("Segoe UI", 8)).grid(row=row, column=2, sticky="w", padx=(4, 0)) + return e + + fields_frame.grid_columnconfigure(0, weight=1) + mk_row(fields_frame, "Delay before combo:", pre_var, 0) + mk_row(fields_frame, "Delay after combo:", post_var, 1) + mk_row(fields_frame, "Inter-modifier delay:", kpre_var, 2) + mk_row(fields_frame, "Modifier hold delay:", kpost_var, 3) + + tk.Label(dlg, + text="Defaults (3x speed): 167 / 167 / 3 / 8\n" + "Device defaults: 500 / 500 / 10 / 25\n\n" + "All values are clamped to a 1ms floor on the device.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=14, pady=(8, 4)) + + btn_row = tk.Frame(dlg, bg="#2D2D3D") + btn_row.pack(fill="x", side="bottom", padx=14, pady=12) + + def reset_defaults(): + pre_var.set("167") + post_var.set("167") + kpre_var.set("3") + kpost_var.set("8") + + def save_dialog(): + def parse_int(v, default): + try: + n = int(v.get()) + return max(1, n) + except ValueError: + return default + data["custom_timings"] = enable_var.get() + data["custom_pre_ms"] = parse_int(pre_var, 167) + data["custom_post_ms"] = parse_int(post_var, 167) + data["custom_key_pre_ms"] = parse_int(kpre_var, 3) + data["custom_key_post_ms"] = parse_int(kpost_var, 8) + refresh_custom_btn() + on_change() + dlg.destroy() + + tk.Button(btn_row, text="Reset to 3x defaults", command=reset_defaults, + bg="#4A4A6A", fg="white", font=("Segoe UI", 9), relief="flat", + padx=10).pack(side="left") + tk.Button(btn_row, text="Cancel", command=dlg.destroy, + bg="#3D3D5C", fg="white", font=("Segoe UI", 9), relief="flat", + padx=14).pack(side="right", padx=(4, 0)) + tk.Button(btn_row, text="Save", command=save_dialog, + bg="#27AE60", fg="white", font=("Segoe UI", 9, "bold"), + relief="flat", padx=14).pack(side="right") + + custom_btn.config(command=open_custom_dialog) + refresh_custom_btn() + + def save(*_): + data["mods"] = [m for m, v in mod_vars.items() if v.get()] + raw_key = key_var.get() + data["key"] = "" if raw_key == "(none - modifier only)" else raw_key + on_change() + + for var in mod_vars.values(): + var.trace_add("write", save) + key_var.trace_add("write", save) + + # tkinter keysym → our modifier names + _KEYSYM_TO_MOD = { + "Control_L": "ctrl", "Control_R": "rctrl", + "Shift_L": "shift", "Shift_R": "rshift", + "Alt_L": "alt", "Alt_R": "ralt", + "Super_L": "gui", "Super_R": "rgui", + } + # tkinter keysym → our key names + _KEYSYM_TO_KEY = { + "Return": "enter", "Escape": "esc", "BackSpace": "backspace", + "Tab": "tab", "space": "space", "Delete": "delete", "Insert": "insert", + "Home": "home", "End": "end", "Prior": "pageup", "Next": "pagedown", + "Up": "up", "Down": "down", "Left": "left", "Right": "right", + "Caps_Lock": "capslock", "Num_Lock": "numlock", + "Scroll_Lock": "scrolllock", "Print": "printscreen", + "Pause": "pause", "Menu": "menu", + } + for _fn in range(1, 25): + _KEYSYM_TO_KEY[f"F{_fn}"] = f"f{_fn}" + + record_btn = tk.Button(frame, text="Record", bg="#3D3D5C", fg="white", + font=("Segoe UI", 9), relief="flat") + record_btn.pack(fill="x", padx=8, pady=(4, 2)) + + tk.Label(frame, text="Click Record, then press\na key combination to capture it.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(0, 4)) + + def start_recording(): + held_mods = set() + detected_key = [None] + finalize_job = [None] + + # Hidden grab window captures all keyboard input + grab_win = tk.Toplevel(frame) + grab_win.geometry("1x1+0+0") + grab_win.overrideredirect(True) + grab_win.attributes("-alpha", 0.01) + grab_win.attributes("-topmost", True) + + record_btn.config(text="Recording...", bg="#FF6600", state="disabled") + + def on_key_down(event): + keysym = event.keysym + if keysym in _KEYSYM_TO_MOD: + held_mods.add(_KEYSYM_TO_MOD[keysym]) + else: + if keysym in _KEYSYM_TO_KEY: + detected_key[0] = _KEYSYM_TO_KEY[keysym] + elif len(keysym) == 1 and keysym.isalnum(): + detected_key[0] = keysym.lower() + else: + detected_key[0] = keysym.lower() + + if finalize_job[0] is not None: + frame.after_cancel(finalize_job[0]) + finalize_job[0] = None + + def on_key_up(event): + # Finalize after 500ms of no key events + if finalize_job[0] is not None: + frame.after_cancel(finalize_job[0]) + finalize_job[0] = frame.after(500, finalize) + + def finalize(): + for mod_name, var in mod_vars.items(): + var.set(mod_name in held_mods) + if detected_key[0]: + key_var.set(detected_key[0]) + elif held_mods: + key_var.set("(none - modifier only)") + save() + + try: + grab_win.destroy() + except tk.TclError: + pass + record_btn.config(text="Record", bg="#3D3D5C", state="normal") + + def on_escape(event): + # Cancel without changing anything + if finalize_job[0] is not None: + frame.after_cancel(finalize_job[0]) + try: + grab_win.destroy() + except tk.TclError: + pass + record_btn.config(text="Record", bg="#3D3D5C", state="normal") + + grab_win.bind("", on_key_down) + grab_win.bind("", on_key_up) + grab_win.protocol("WM_DELETE_WINDOW", on_escape) + + try: + grab_win.grab_set() + grab_win.focus_force() + except tk.TclError: + try: + grab_win.destroy() + except tk.TclError: + pass + record_btn.config(text="Record", bg="#3D3D5C", state="normal") + + record_btn.config(command=start_recording) + + return frame + + +def create_pause_editor(parent, data, on_change, project=None): + """Editor for the Pause node. + + ``project`` (optional) is wired by PropertiesPanel so the on-device + preview can read the current pause-text margins from project.settings. + Falls back to the compiled defaults when no project is provided + (keeps legacy callers working). + """ + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Wait type:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + wait_frame = tk.Frame(frame, bg="#2D2D3D") + wait_frame.pack(fill="x", padx=8) + + wait_type_var = tk.StringVar(value="click" if data.get("wait") == "click" else "timed") + + tk.Radiobutton(wait_frame, text="Wait for click", variable=wait_type_var, + value="click", bg="#2D2D3D", fg="white", selectcolor="#1E1E2E", + activebackground="#2D2D3D", font=("Segoe UI", 9)).pack(anchor="w") + tk.Radiobutton(wait_frame, text="Wait for time", variable=wait_type_var, + value="timed", bg="#2D2D3D", fg="white", selectcolor="#1E1E2E", + activebackground="#2D2D3D", font=("Segoe UI", 9)).pack(anchor="w") + + tk.Label(frame, text="Duration (ms):", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + wait_val = data.get("wait", "click") + dur_var = tk.StringVar(value=str(wait_val) if wait_val != "click" else "3000") + dur_entry = tk.Entry(frame, textvariable=dur_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + dur_entry.pack(fill="x", padx=8, pady=4) + + # Select-all on focus so typing replaces the placeholder cleanly instead + # of being prepended/appended to it. Without this, focusing the field + # and typing "5000" can produce "30005000" or "50003000" depending on + # the click point. + def _select_all_dur(_e=None): + dur_entry.select_range(0, "end") + dur_entry.icursor("end") + dur_entry.bind("", _select_all_dur) + + tk.Label(frame, text="Display text:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + text_widget = tk.Text(frame, height=3, width=30, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), + relief="flat", borderwidth=1, wrap="word") + text_widget.insert("1.0", data.get("text", "Press to continue")) + text_widget.pack(fill="x", padx=8, pady=4) + + tk.Label(frame, text="Font size:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + font_var = tk.IntVar(value=data.get("font_size", 12)) + font_scale = tk.Scale(frame, from_=8, to=24, variable=font_var, orient="horizontal", + bg="#2D2D3D", fg="white", troughcolor="#1E1E2E", + highlightthickness=0, font=("Segoe UI", 8)) + font_scale.pack(fill="x", padx=8, pady=4) + + tk.Label(frame, text="Text color:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + color_frame = tk.Frame(frame, bg="#2D2D3D") + color_frame.pack(fill="x", padx=8, pady=(0, 4)) + + selected_color = tk.StringVar(value=data.get("text_color", "white")) + swatch_buttons = {} + + def select_color(name): + selected_color.set(name) + for n, btn in swatch_buttons.items(): + btn.config(highlightthickness=2 if n == name else 0) + data["text_color"] = name + on_change() + + for col_idx, (name, hex_color) in enumerate(DISPLAY_COLORS): + btn = tk.Button( + color_frame, bg=hex_color, width=2, height=1, + relief="flat", highlightbackground="#FFFFFF", highlightcolor="#FFFFFF", + highlightthickness=2 if name == selected_color.get() else 0, + command=lambda n=name: select_color(n), + ) + btn.grid(row=0, column=col_idx, padx=2, pady=2) + swatch_buttons[name] = btn + + # ---- On-device preview ------------------------------------------------ + # 128x128 LCD scaled up by an integer factor for legibility. The factor + # is chosen at runtime to fit the actual properties-panel width — the + # panel is ~250 px wide, so a fixed 2x scale (256 px) overflows and gets + # clipped on the right. We re-scale on of the holder. + tk.Label(frame, text="On-device preview:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(10, 2)) + preview_holder = tk.Frame(frame, bg="#2D2D3D") + preview_holder.pack(fill="x", padx=8, pady=2) + # Canvas itself is centered inside the holder so it never overflows even + # when the holder is briefly wider than the chosen preview size. + preview_canvas = tk.Canvas( + preview_holder, + width=DEVICE_SCREEN_W, height=DEVICE_SCREEN_H, # initial 1x; replaced on first Configure + bg="#000000", highlightthickness=1, highlightbackground="#444466", + ) + preview_canvas.pack() + preview_hint = tk.Label( + frame, text="(Mirrors the pause margins set in Settings → Pause Display)", + bg="#2D2D3D", fg="#777788", font=("Segoe UI", 7), + ) + preview_hint.pack(anchor="w", padx=8) + + # Mutable holder for the current preview scale — closed over by both the + # resize handler and redraw_preview. + preview_state = {"scale": _PAUSE_PREVIEW_SCALE} + + _pause_color_to_hex = dict(DISPLAY_COLORS) + + def _current_margins(): + """Pull live margins from project settings, falling back to defaults.""" + if project is not None and getattr(project, "settings", None) is not None: + s = project.settings + return ( + int(getattr(s, "pause_margin_left", DEFAULT_PAUSE_MARGIN_LEFT)), + int(getattr(s, "pause_margin_right", DEFAULT_PAUSE_MARGIN_RIGHT)), + int(getattr(s, "pause_margin_top", DEFAULT_PAUSE_MARGIN_TOP)), + int(getattr(s, "pause_margin_bottom", DEFAULT_PAUSE_MARGIN_BOTTOM)), + ) + return (DEFAULT_PAUSE_MARGIN_LEFT, DEFAULT_PAUSE_MARGIN_RIGHT, + DEFAULT_PAUSE_MARGIN_TOP, DEFAULT_PAUSE_MARGIN_BOTTOM) + + def redraw_preview(): + # Any of these widgets may have been destroyed by the time a + # pending after_idle fires (e.g. user selected a different node + # which rebuilt the panel). Catch broadly and bail silently — + # there's nothing to redraw onto. + try: + preview_canvas.delete("all") + scale = preview_state["scale"] + ml, mr, mt, mb = _current_margins() + font_size = int(font_var.get() or 12) + color_name = selected_color.get() or "white" + text_hex = _pause_color_to_hex.get(color_name, "#FFFFFF") + text = text_widget.get("1.0", "end-1c") + except tk.TclError: + return + + # Margin-inset rectangle (dashed, faint). + ix1 = ml * scale + iy1 = mt * scale + ix2 = (DEVICE_SCREEN_W - mr) * scale + iy2 = (DEVICE_SCREEN_H - mb) * scale + if ix2 > ix1 and iy2 > iy1: + preview_canvas.create_rectangle( + ix1, iy1, ix2 - 1, iy2 - 1, + outline="#333355", dash=(3, 2), + ) + + # Wrap + render text inside the inset box. + font = _pause_preview_font(font_size, scale) + line_h = font.metrics("linespace") + box_w = max(8 * scale, ix2 - ix1) + box_h = max(line_h, iy2 - iy1) + max_lines = max(1, box_h // line_h) + if max_lines > 12: + max_lines = 12 + + lines, _truncated = _wrap_text_for_preview(text, box_w, font, max_lines) + + total_h = len(lines) * line_h + cur_y = iy1 + (box_h - total_h) // 2 + for ln in lines: + preview_canvas.create_text( + ix1 + box_w // 2, cur_y + line_h // 2, + text=ln, fill=text_hex, font=font, anchor="center", + ) + cur_y += line_h + + # Timer bar mock when "wait for time" is selected. + if wait_type_var.get() == "timed": + bar_h_dev = max(2, min(8, mb - 2)) + bar_y_dev = DEVICE_SCREEN_H - bar_h_dev - 1 + bar_y = bar_y_dev * scale + bar_h_px = max(1, bar_h_dev * scale) + preview_canvas.create_rectangle( + ml * scale, bar_y, + (DEVICE_SCREEN_W - mr) * scale, bar_y + bar_h_px, + fill="#555566", outline="", + ) + # Filled portion: ~60% so it looks like it's mid-countdown. + fill_w = int((DEVICE_SCREEN_W - ml - mr) * scale * 0.6) + preview_canvas.create_rectangle( + ml * scale, bar_y, + ml * scale + fill_w, bar_y + bar_h_px, + fill="#44FFFF", outline="", + ) + + def save(*_): + if wait_type_var.get() == "click": + data["wait"] = "click" + else: + try: + data["wait"] = int(dur_var.get()) + except ValueError: + data["wait"] = 3000 + data["text"] = text_widget.get("1.0", "end-1c") + data["font_size"] = font_var.get() + data["text_color"] = selected_color.get() + on_change() + redraw_preview() + + def _on_dur_change(*_): + """Trace handler for the duration entry. + + Without this layer, save() would unconditionally overwrite the + typed duration with "click" whenever the radio is still on + "Wait for click" — which is exactly the case when a user opens a + click-mode pause node and immediately starts typing a number into + the duration entry. The natural read is "they want a timed + wait," so we auto-flip the radio. The trace on wait_type_var + will then re-fire save() with the right mode. + """ + val = dur_var.get().strip() + if val.isdigit() and wait_type_var.get() == "click": + wait_type_var.set("timed") + return + save() + + # Refresh the preview whenever the user edits anything that affects it. + wait_type_var.trace_add("write", save) + dur_var.trace_add("write", _on_dur_change) + text_widget.bind("", save) + font_var.trace_add("write", save) + + # Wrap select_color so picking a swatch also refreshes the preview. + _orig_select_color = select_color + def _select_color_with_preview(name): + _orig_select_color(name) + redraw_preview() + for n, btn in swatch_buttons.items(): + btn.config(command=lambda nn=n: _select_color_with_preview(nn)) + + def _fit_preview_to_holder(_event=None): + """Pick the largest scale that fits the holder's width. + + Triggered on the holder's (panel resize) and on first + layout. We allow fractional scales so a 236 px holder doesn't + floor to 1× and waste half its width. Cap at 3× so the preview + can't balloon out of all proportion when the panel is widened, + floor at 1× so it stays usable on the narrowest panels. + + Only reapplies when the change is meaningful (>=0.05) so we + don't churn on every sub-pixel tick during a drag. + """ + try: + avail = preview_holder.winfo_width() + except tk.TclError: + return + if avail <= 4: + return + # 2 px reserved for the canvas border (1 px each side). + usable = max(DEVICE_SCREEN_W, avail - 2) + new_scale = max(1.0, min(3.0, usable / DEVICE_SCREEN_W)) + if abs(new_scale - preview_state["scale"]) > 0.05: + preview_state["scale"] = new_scale + try: + preview_canvas.configure( + width=int(DEVICE_SCREEN_W * preview_state["scale"]), + height=int(DEVICE_SCREEN_H * preview_state["scale"]), + ) + except tk.TclError: + return + redraw_preview() + + preview_holder.bind("", _fit_preview_to_holder) + + # Initial paint, deferred to after the canvas has been mapped so + # winfo_width returns sane values. + frame.after_idle(_fit_preview_to_holder) + + return frame + + +def create_branch_editor(parent, data, on_change, node_canvas=None, widget=None): + """Editor for the Branch node — supports both 'manual' and 'by_variable' modes. + + Manual: user picks a path on-device via a dropdown rendered on the LCD. + By Variable: device reads a named variable and routes to the first choice + whose match_value equals the variable's value; if none match, the LAST + choice is taken (treated as the else / default branch). + """ + frame = tk.Frame(parent, bg="#2D2D3D") + + data.setdefault("mode", "manual") + data.setdefault("var_name", "") + data.setdefault("var_scope", "auto") + choices = data.setdefault("choices", []) + for c in choices: + c.setdefault("match_value", "") + # Per-choice color rendered on the device's branch selector. Default + # white preserves legacy macros. + c.setdefault("color", "white") + + # --- Mode dropdown --- + tk.Label(frame, text="Branch on:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2)) + mode_options = [("User selects on-device", "manual"), + ("Variable value", "by_variable")] + mode_display = [m[0] for m in mode_options] + mode_value = [m[1] for m in mode_options] + cur_idx = mode_value.index(data["mode"]) if data["mode"] in mode_value else 0 + mode_var = tk.StringVar(value=mode_display[cur_idx]) + mode_box = ttk.Combobox(frame, textvariable=mode_var, values=mode_display, + state="readonly") + mode_box.pack(fill="x", padx=8, pady=2) + + # --- Variable selector (visible only in by_variable mode) --- + var_frame = tk.Frame(frame, bg="#2D2D3D") + + tk.Label(var_frame, text="Variable name:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(6, 2)) + name_var = tk.StringVar(value=data.get("var_name", "")) + tk.Entry(var_frame, textvariable=name_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), + relief="flat").pack(fill="x", padx=8, pady=2) + + tk.Label(var_frame, text="Lookup scope:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(6, 2)) + scope_options = [("Auto (device, fall back to universal)", "auto"), + ("Device only", "device"), + ("Universal only", "universal")] + scope_display = [s[0] for s in scope_options] + scope_value = [s[1] for s in scope_options] + sidx = scope_value.index(data["var_scope"]) if data["var_scope"] in scope_value else 0 + scope_var = tk.StringVar(value=scope_display[sidx]) + scope_box = ttk.Combobox(var_frame, textvariable=scope_var, + values=scope_display, state="readonly") + scope_box.pack(fill="x", padx=8, pady=2) + + # --- Choices table --- + choices_label = tk.Label(frame, text="Choices:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")) + choices_label.pack(anchor="w", padx=8, pady=(10, 2)) + entries_frame = tk.Frame(frame, bg="#2D2D3D") + entries_frame.pack(fill="x", padx=8) + + entry_vars = [] # list of (label_var, match_var) + + def rebuild_entries(): + for w in entries_frame.winfo_children(): + w.destroy() + entry_vars.clear() + + for i, choice in enumerate(choices): + row = tk.Frame(entries_frame, bg="#2D2D3D") + row.pack(fill="x", pady=2) + + label_var = tk.StringVar(value=choice.get("label", f"Option {i+1}")) + match_var = tk.StringVar(value=choice.get("match_value", "")) + entry_vars.append((label_var, match_var)) + + tk.Label(row, text=f"{i+1}.", bg="#2D2D3D", fg="#888888", + font=("Segoe UI", 9), width=3).pack(side="left") + + # Color chip on the left, opens a small popup color picker. + # Default to white if missing — keeps legacy macros visible. + current_color = choice.get("color", "white") + color_hex = dict(DISPLAY_COLORS).get(current_color, "#FFFFFF") + color_btn = tk.Button( + row, bg=color_hex, width=2, relief="flat", + highlightbackground="#555555", highlightthickness=1, + command=lambda idx=i, btn_ref=[None]: _open_color_picker(idx, btn_ref[0]), + ) + color_btn.pack(side="left", padx=(0, 4)) + # Bind the actual button into the lambda so the picker can update + # its background after the user selects a color. + color_btn.config( + command=lambda idx=i, b=color_btn: _open_color_picker(idx, b) + ) + + tk.Entry(row, textvariable=label_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), + relief="flat").pack(side="left", fill="x", expand=True, padx=2) + + if data.get("mode") == "by_variable": + # Last choice acts as the else/default branch — don't ask for + # a match value (it's never compared), and label it as such. + if i == len(choices) - 1: + tk.Label(row, text="(else)", bg="#2D2D3D", fg="#88AACC", + font=("Segoe UI", 8, "italic"), width=8).pack( + side="left", padx=2) + else: + tk.Label(row, text="=", bg="#2D2D3D", fg="#888888", + font=("Segoe UI", 9)).pack(side="left", padx=2) + tk.Entry(row, textvariable=match_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + width=12, relief="flat").pack( + side="left", fill="x", expand=True, padx=2) + + del_btn = tk.Button(row, text="X", bg="#D94A4A", fg="white", + font=("Segoe UI", 7, "bold"), width=2, relief="flat", + command=lambda idx=i: remove_choice(idx)) + del_btn.pack(side="right", padx=2) + + label_var.trace_add("write", lambda *_, idx=i: commit_choice(idx)) + match_var.trace_add("write", lambda *_, idx=i: commit_choice(idx)) + + def _open_color_picker(idx, swatch_btn): + """Pop a tiny grid of DISPLAY_COLORS swatches near ``swatch_btn``. + + Avoids inflating each row with 8 buttons (the row would no longer + fit on screen for users with many branch choices). Click anywhere + else to dismiss. + """ + if idx < 0 or idx >= len(choices): + return + popup = tk.Toplevel(swatch_btn) + popup.overrideredirect(True) + popup.configure(bg="#1E1E2E", borderwidth=1) + # Anchor under the swatch button. + x = swatch_btn.winfo_rootx() + y = swatch_btn.winfo_rooty() + swatch_btn.winfo_height() + popup.geometry(f"+{x}+{y}") + + def pick(name, hex_color): + choices[idx]["color"] = name + try: + swatch_btn.config(bg=hex_color) + except Exception: + pass + popup.destroy() + on_change() + + for col_idx, (name, hex_color) in enumerate(DISPLAY_COLORS): + btn = tk.Button( + popup, bg=hex_color, width=2, height=1, relief="flat", + highlightbackground="#555555", highlightthickness=1, + command=lambda n=name, h=hex_color: pick(n, h), + ) + btn.grid(row=0, column=col_idx, padx=1, pady=1) + + # Dismiss on focus loss (clicking outside the popup). + popup.bind("", lambda _e: popup.destroy()) + popup.focus_set() + + def commit_choice(idx): + if 0 <= idx < len(choices) and idx < len(entry_vars): + choices[idx]["label"] = entry_vars[idx][0].get() + choices[idx]["match_value"] = entry_vars[idx][1].get() + # Push the new label out to the canvas port. Without this the + # node's port label stays frozen at whatever it was when the + # Port object was first constructed, and users have to reload + # the routine to see the rename. + if node_canvas and widget: + node_canvas.rebuild_node_ports(widget.data.id) + on_change() + + def add_choice(): + choices.append({"label": f"Option {len(choices)+1}", + "next": -1, "match_value": "", "color": "white"}) + rebuild_entries() + if node_canvas and widget: + node_canvas.rebuild_node_ports(widget.data.id) + on_change() + + def remove_choice(idx): + if len(choices) > 1: + choices.pop(idx) + rebuild_entries() + if node_canvas and widget: + node_canvas.rebuild_node_ports(widget.data.id) + on_change() + + rebuild_entries() + + btn_frame = tk.Frame(frame, bg="#2D2D3D") + btn_frame.pack(fill="x", padx=8, pady=8) + tk.Button(btn_frame, text="+ Add Choice", bg="#27AE60", fg="white", + font=("Segoe UI", 9), relief="flat", + command=add_choice).pack(fill="x") + + def refresh_visibility(): + if data.get("mode") == "by_variable": + var_frame.pack(fill="x", before=choices_label) + else: + var_frame.pack_forget() + rebuild_entries() + + def save_mode(*_): + disp = mode_var.get() + idx = mode_display.index(disp) if disp in mode_display else 0 + new_mode = mode_value[idx] + if new_mode != data.get("mode"): + data["mode"] = new_mode + refresh_visibility() + on_change() + + def save_var_fields(*_): + data["var_name"] = name_var.get() + sd = scope_var.get() + sidx = scope_display.index(sd) if sd in scope_display else 0 + data["var_scope"] = scope_value[sidx] + on_change() + + mode_var.trace_add("write", save_mode) + name_var.trace_add("write", save_var_fields) + scope_var.trace_add("write", save_var_fields) + + refresh_visibility() + return frame + + +def create_delay_editor(parent, data, on_change): + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Delay (ms):", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + var = tk.StringVar(value=str(data.get("ms", 500))) + entry = tk.Entry(frame, textvariable=var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + entry.pack(fill="x", padx=8, pady=4) + + def save(*_): + try: + data["ms"] = int(var.get()) + except ValueError: + pass + on_change() + + var.trace_add("write", save) + return frame + + +def create_repeat_editor(parent, data, on_change): + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Loop count:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + var = tk.StringVar(value=str(data.get("count", 2))) + entry = tk.Entry(frame, textvariable=var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + entry.pack(fill="x", padx=8, pady=4) + + selector_var = tk.BooleanVar(value=data.get("use_selector", False)) + selector_cb = tk.Checkbutton(frame, text="Use count from Loop Selector", + variable=selector_var, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", font=("Segoe UI", 9)) + selector_cb.pack(anchor="w", padx=8, pady=(8, 2)) + + tk.Label(frame, + text="When checked, the count above is\n" + "ignored and the loop uses the value\n" + "chosen at the most recent Loop\n" + "Selector node. Falls back to the\n" + "count field if no selector ran yet.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(0, 4)) + + sep = tk.Frame(frame, bg="#444466", height=1) + sep.pack(fill="x", padx=8, pady=6) + + tk.Label(frame, text="How it works:\n" + "1. 'Start' input begins the loop\n" + "2. 'Loop Body' output goes to\n" + " the nodes to repeat\n" + "3. Connect last body node back\n" + " to 'Loop Back' input\n" + "4. 'Done' output continues after\n" + " all iterations complete", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(4, 4)) + + def save(*_): + try: + data["count"] = int(var.get()) + except ValueError: + pass + data["use_selector"] = selector_var.get() + on_change() + + var.trace_add("write", save) + selector_var.trace_add("write", save) + return frame + + +def create_aggregator_editor(parent, data, on_change, node_canvas=None, widget=None): + """Editor for Aggregator node (merges multiple inputs into one output).""" + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Number of inputs:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2)) + + count_var = tk.StringVar(value=str(data.get("input_count", 2))) + + def _rebuild_ports(): + if node_canvas and widget: + node_canvas.rebuild_node_ports(widget.data.id) + + row = tk.Frame(frame, bg="#2D2D3D") + row.pack(fill="x", padx=8, pady=4) + + def dec(): + cur = max(1, int(data.get("input_count", 2))) + if cur > 1: + new_val = cur - 1 + data["input_count"] = new_val + count_var.set(str(new_val)) + _rebuild_ports() + on_change() + + def inc(): + cur = max(1, int(data.get("input_count", 2))) + if cur < 16: + new_val = cur + 1 + data["input_count"] = new_val + count_var.set(str(new_val)) + _rebuild_ports() + on_change() + + tk.Button(row, text="-", bg="#D94A4A", fg="white", relief="flat", + font=("Segoe UI", 10, "bold"), width=2, command=dec).pack(side="left", padx=(0, 4)) + entry = tk.Entry(row, textvariable=count_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat", + justify="center") + entry.pack(side="left", fill="x", expand=True, padx=2) + tk.Button(row, text="+", bg="#27AE60", fg="white", relief="flat", + font=("Segoe UI", 10, "bold"), width=2, command=inc).pack(side="left", padx=(4, 0)) + + def save(*_): + try: + n = int(count_var.get()) + except ValueError: + return + n = max(1, min(16, n)) + old = data.get("input_count", 2) + if n != old: + data["input_count"] = n + _rebuild_ports() + on_change() + + count_var.trace_add("write", save) + + tk.Label(frame, + text="Merges multiple paths back into\n" + "a single output. Typical use: place\n" + "after the branches of an Iteration\n" + "Branch so they all continue to the\n" + "same next node (e.g. the loop_back\n" + "input of the enclosing Loop).", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(8, 4)) + + return frame + + +def create_iteration_branch_editor(parent, data, on_change, macro=None, node_canvas=None, widget=None): + """Editor for Iteration Branch node properties. + + Shows the tied loop (with a button to re-pick it on the canvas) and the + list of path labels. + """ + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Tied Loop:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2)) + + tied_label = tk.Label(frame, text="(none)", bg="#1E1E2E", fg="#AAAAAA", + font=("Segoe UI", 9), anchor="w", padx=6, pady=6, + relief="flat") + tied_label.pack(fill="x", padx=8, pady=(0, 4)) + + def refresh_tied_label(): + tied_id = data.get("loop_node_id", "") + if not tied_id: + tied_label.config(text="(not tied — click Pick Loop)", fg="#E67E22") + return + if macro: + for n in macro.nodes: + if n.id == tied_id and n.type == "repeat": + if n.data.get("use_selector", False): + desc = "Loop (uses selector)" + else: + desc = f"Loop x{n.data.get('count', 1)}" + tied_label.config(text=desc, fg="#7BED9F") + return + tied_label.config(text="(tied loop not found)", fg="#E74C3C") + + refresh_tied_label() + + def pick_on_canvas(): + if node_canvas and widget: + node_canvas.start_picking_loop_for(widget, on_done=lambda: (refresh_tied_label(), on_change())) + + tk.Button(frame, text="Pick Loop on Canvas", command=pick_on_canvas, + bg="#3D3D5C", fg="white", font=("Segoe UI", 9), relief="flat", + padx=10, pady=4).pack(fill="x", padx=8, pady=(0, 4)) + + sep = tk.Frame(frame, bg="#444466", height=1) + sep.pack(fill="x", padx=8, pady=6) + + tk.Label(frame, text="Paths:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(4, 2)) + + choices = data.setdefault("choices", []) + entries_frame = tk.Frame(frame, bg="#2D2D3D") + entries_frame.pack(fill="x", padx=8) + + entry_vars = [] + + def rebuild_entries(): + for w in entries_frame.winfo_children(): + w.destroy() + entry_vars.clear() + + for i, choice in enumerate(choices): + row = tk.Frame(entries_frame, bg="#2D2D3D") + row.pack(fill="x", pady=2) + + var = tk.StringVar(value=choice.get("label", f"Path {i+1}")) + entry_vars.append(var) + + tk.Label(row, text=f"{i+1}.", bg="#2D2D3D", fg="#888888", + font=("Segoe UI", 9), width=3).pack(side="left") + entry = tk.Entry(row, textvariable=var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), relief="flat") + entry.pack(side="left", fill="x", expand=True, padx=2) + + del_btn = tk.Button(row, text="X", bg="#D94A4A", fg="white", + font=("Segoe UI", 7, "bold"), width=2, relief="flat", + command=lambda idx=i: remove_choice(idx)) + del_btn.pack(side="right", padx=2) + + var.trace_add("write", lambda *_, idx=i: update_choice_label(idx)) + + def update_choice_label(idx): + if idx < len(choices) and idx < len(entry_vars): + choices[idx]["label"] = entry_vars[idx].get() + on_change() + + def _rebuild_ports(): + if node_canvas and widget: + node_canvas.rebuild_node_ports(widget.data.id) + + def add_choice(): + choices.append({"label": f"Path {len(choices)+1}"}) + rebuild_entries() + _rebuild_ports() + on_change() + + def remove_choice(idx): + if len(choices) > 1: + choices.pop(idx) + rebuild_entries() + _rebuild_ports() + on_change() + + rebuild_entries() + + btn_frame = tk.Frame(frame, bg="#2D2D3D") + btn_frame.pack(fill="x", padx=8, pady=8) + tk.Button(btn_frame, text="+ Add Path", bg="#27AE60", fg="white", + font=("Segoe UI", 9), relief="flat", command=add_choice).pack(fill="x") + + # Useful when each path is a "transition to next iteration" action + # (e.g. RS232 X-command to switch to the next device). On the final + # iteration there's no next to transition to, so the branch should + # no-op and let the loop exit cleanly. + sep2 = tk.Frame(frame, bg="#444466", height=1) + sep2.pack(fill="x", padx=8, pady=6) + + skip_var = tk.BooleanVar(value=bool(data.get("skip_final_iteration", False))) + + def save_skip(*_): + data["skip_final_iteration"] = bool(skip_var.get()) + on_change() + + tk.Checkbutton(frame, text="Skip branch on final iteration", + variable=skip_var, command=save_skip, + bg="#2D2D3D", fg="white", selectcolor="#1E1E2E", + activebackground="#2D2D3D", activeforeground="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(2, 0)) + tk.Label(frame, + text="When on, the last pass through the tied loop\n" + "falls through this branch without picking a path.\n" + "Good for \"do X to advance to next iteration\"\n" + "branches where the last iteration has nowhere\n" + "to advance to.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(2, 6)) + + tk.Label(frame, + text="Each iteration of the tied loop picks a\n" + "path in order, wrapping if iterations\n" + "exceed the number of paths.\n\n" + "Example with 3 paths and 7 iterations:\n" + "1>A 2>B 3>C 4>A 5>B 6>C 7>A", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(0, 4)) + + return frame + + +def create_note_editor(parent, data, on_change): + """Editor for Note node properties. + + Notes are purely annotations on the canvas. They have no execution + behavior and are never sent to the device. + """ + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Note:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2)) + + text_widget = tk.Text(frame, height=8, width=30, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), + relief="flat", borderwidth=1, wrap="word") + text_widget.insert("1.0", data.get("text", "")) + text_widget.pack(fill="x", padx=8, pady=4) + + tk.Label(frame, text="Font size:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + font_var = tk.IntVar(value=int(data.get("font_size", 14))) + font_scale = tk.Scale(frame, from_=8, to=32, variable=font_var, + orient="horizontal", bg="#2D2D3D", fg="white", + troughcolor="#1E1E2E", highlightthickness=0, + font=("Segoe UI", 8)) + font_scale.pack(fill="x", padx=8, pady=4) + + # Width of the note card in world units + tk.Label(frame, text="Width:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + width_var = tk.IntVar(value=int(data.get("width", 220))) + width_scale = tk.Scale(frame, from_=100, to=600, variable=width_var, + orient="horizontal", bg="#2D2D3D", fg="white", + troughcolor="#1E1E2E", highlightthickness=0, + font=("Segoe UI", 8), resolution=20) + width_scale.pack(fill="x", padx=8, pady=4) + + tk.Label(frame, text="Text color:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + color_frame = tk.Frame(frame, bg="#2D2D3D") + color_frame.pack(fill="x", padx=8, pady=(0, 4)) + + selected_color = tk.StringVar(value=data.get("color", "white")) + swatch_buttons = {} + + def select_color(name): + selected_color.set(name) + for n, btn in swatch_buttons.items(): + btn.config(highlightthickness=2 if n == name else 0) + data["color"] = name + on_change() + + for col_idx, (name, hex_color) in enumerate(DISPLAY_COLORS): + btn = tk.Button( + color_frame, bg=hex_color, width=2, height=1, + relief="flat", highlightbackground="#FFFFFF", highlightcolor="#FFFFFF", + highlightthickness=2 if name == selected_color.get() else 0, + command=lambda n=name: select_color(n), + ) + btn.grid(row=0, column=col_idx, padx=2, pady=2) + swatch_buttons[name] = btn + + tk.Label(frame, + text="Notes are GUI-only. They are never\n" + "sent to the device and have no effect\n" + "on how routines run.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(8, 4)) + + def save(*_): + data["text"] = text_widget.get("1.0", "end-1c") + data["font_size"] = font_var.get() + data["width"] = width_var.get() + data["color"] = selected_color.get() + on_change() + + text_widget.bind("", save) + font_var.trace_add("write", save) + width_var.trace_add("write", save) + return frame + + +def create_loop_selector_editor(parent, data, on_change): + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Prompt text:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + prompt_var = tk.StringVar(value=data.get("prompt", "Loop count?")) + prompt_entry = tk.Entry(frame, textvariable=prompt_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + prompt_entry.pack(fill="x", padx=8, pady=4) + + def make_int_field(label_text, data_key, default_val): + tk.Label(frame, text=label_text, bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + v = tk.StringVar(value=str(data.get(data_key, default_val))) + e = tk.Entry(frame, textvariable=v, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + e.pack(fill="x", padx=8, pady=4) + return v + + min_var = make_int_field("Min:", "min", 1) + max_var = make_int_field("Max:", "max", 10) + step_var = make_int_field("Step:", "step", 1) + default_var = make_int_field("Default (initial selection):", "default", 1) + + ask_start_var = tk.BooleanVar(value=bool(data.get("ask_start", False))) + ask_start_cb = tk.Checkbutton(frame, text="Also ask which iteration to start at", + variable=ask_start_var, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", font=("Segoe UI", 9)) + ask_start_cb.pack(anchor="w", padx=8, pady=(8, 2)) + tk.Label(frame, + text="When enabled, after you confirm the\n" + "loop count the device prompts again\n" + "for the starting iteration (1..count).\n" + "Useful for resuming partway through.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(0, 4)) + + tk.Label(frame, + text="On the device, short-press cycles\n" + "through values. Long-press confirms.\n" + "The selected value is stored for any\n" + "Loop node that has 'Use count from\n" + "Loop Selector' checked.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(8, 4)) + + def save(*_): + data["prompt"] = prompt_var.get() + try: + data["min"] = int(min_var.get()) + except ValueError: + pass + try: + data["max"] = int(max_var.get()) + except ValueError: + pass + try: + s = int(step_var.get()) + data["step"] = s if s > 0 else 1 + except ValueError: + pass + try: + data["default"] = int(default_var.get()) + except ValueError: + pass + data["ask_start"] = ask_start_var.get() + on_change() + + prompt_var.trace_add("write", save) + min_var.trace_add("write", save) + max_var.trace_add("write", save) + step_var.trace_add("write", save) + default_var.trace_add("write", save) + ask_start_var.trace_add("write", save) + return frame + + +def create_mouse_editor(parent, data, on_change): + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Button:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + btn_var = tk.StringVar(value=data.get("button", "left")) + btn_combo = ttk.Combobox(frame, textvariable=btn_var, values=MOUSE_BUTTONS, + state="readonly", width=15) + btn_combo.pack(fill="x", padx=8, pady=4) + + tk.Label(frame, text="Action:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + action_var = tk.StringVar(value=data.get("action", "click")) + action_combo = ttk.Combobox(frame, textvariable=action_var, values=MOUSE_ACTIONS, + state="readonly", width=15) + action_combo.pack(fill="x", padx=8, pady=4) + + def save(*_): + data["button"] = btn_var.get() + data["action"] = action_var.get() + on_change() + + btn_var.trace_add("write", save) + action_var.trace_add("write", save) + return frame + + +def create_media_editor(parent, data, on_change): + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Media action:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + action_values = [a[0] for a in MEDIA_ACTIONS] + action_labels = [a[1] for a in MEDIA_ACTIONS] + + var = tk.StringVar(value=data.get("action", "play_pause")) + combo = ttk.Combobox(frame, textvariable=var, values=action_values, + state="readonly", width=20) + combo.pack(fill="x", padx=8, pady=4) + + label = tk.Label(frame, text="", bg="#2D2D3D", fg="#888888", + font=("Segoe UI", 8)) + label.pack(anchor="w", padx=8) + + def update_label(*_): + val = var.get() + for code, friendly in MEDIA_ACTIONS: + if code == val: + label.config(text=friendly) + break + + def save(*_): + data["action"] = var.get() + update_label() + on_change() + + var.trace_add("write", save) + update_label() + return frame + + +def create_start_editor(parent, data, on_change, macro=None, on_rename=None): + """Editor for Start node - macro name + informational.""" + frame = tk.Frame(parent, bg="#2D2D3D") + + if macro: + tk.Label(frame, text="Routine Name:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2)) + + name_text = tk.Text(frame, height=3, width=30, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), + relief="flat", borderwidth=1, wrap="word") + name_text.insert("1.0", macro.name) + name_text.pack(fill="x", padx=8, pady=4) + + def save_name(*_): + new_name = name_text.get("1.0", "end-1c") + if new_name != macro.name: + macro.name = new_name + on_change() + if on_rename: + on_rename() + + name_text.bind("", save_name) + + tk.Label(frame, text="Label color:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + color_frame = tk.Frame(frame, bg="#2D2D3D") + color_frame.pack(fill="x", padx=8, pady=(0, 4)) + + current_color = getattr(macro, "label_color", "white") + swatch_buttons = {} + + def select_color(name): + macro.label_color = name + for n, btn in swatch_buttons.items(): + btn.config(highlightthickness=2 if n == name else 0) + on_change() + + for col_idx, (name, hex_color) in enumerate(DISPLAY_COLORS): + btn = tk.Button( + color_frame, bg=hex_color, width=2, height=1, + relief="flat", highlightbackground="#FFFFFF", highlightcolor="#FFFFFF", + highlightthickness=2 if name == current_color else 0, + command=lambda n=name: select_color(n), + ) + btn.grid(row=0, column=col_idx, padx=2, pady=2) + swatch_buttons[name] = btn + + separator = tk.Frame(frame, bg="#444466", height=1) + separator.pack(fill="x", padx=8, pady=8) + + tk.Label(frame, text="This is the entry point\nfor this routine.\n\n" + "Connect the output port\nto the first action node.", + bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 9), + justify="left").pack(anchor="w", padx=8, pady=8) + return frame + + +def create_pc_alive_check_editor(parent, data, on_change): + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Condition:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + conditions = [ + ("PC Response", "pc_response"), + ("Num Lock ON", "numlock_on"), + ("Num Lock OFF", "numlock_off"), + ] + cond_display = [c[0] for c in conditions] + cond_values = [c[1] for c in conditions] + + current_cond = data.get("condition", "pc_response") + cond_idx = cond_values.index(current_cond) if current_cond in cond_values else 0 + cond_var = tk.StringVar(value=cond_display[cond_idx]) + cond_combo = ttk.Combobox(frame, textvariable=cond_var, values=cond_display, + state="readonly", width=20) + cond_combo.pack(fill="x", padx=8, pady=4) + + loop_var = tk.BooleanVar(value=data.get("loop", True)) + loop_cb = tk.Checkbutton(frame, text="Loop until condition is met", + variable=loop_var, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", font=("Segoe UI", 9)) + loop_cb.pack(anchor="w", padx=8, pady=(8, 2)) + + tk.Label(frame, text="Loop polling speed (ms):", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(4, 2)) + + delay_var = tk.StringVar(value=str(data.get("poll_delay_ms", 500))) + delay_entry = tk.Entry(frame, textvariable=delay_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + delay_entry.pack(fill="x", padx=8, pady=4) + + sep = tk.Frame(frame, bg="#444466", height=1) + sep.pack(fill="x", padx=8, pady=8) + + tk.Label(frame, text="How it works:\n" + "Toggles Num Lock and checks if\n" + "the host PC updates the LED.\n\n" + "PC Response = state changed\n" + " (host is alive, any LED state)\n" + "Num Lock ON = LED is on after\n" + "Num Lock OFF = LED is off after\n\n" + "True = condition met\n" + "False = condition not met\n\n" + "With Loop enabled, retries until\n" + "the condition is met, then takes\n" + "the True output.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(0, 4)) + + def save(*_): + disp = cond_var.get() + idx = cond_display.index(disp) if disp in cond_display else 0 + data["condition"] = cond_values[idx] + data["loop"] = loop_var.get() + try: + data["poll_delay_ms"] = int(delay_var.get()) + except ValueError: + pass + on_change() + + cond_var.trace_add("write", save) + loop_var.trace_add("write", save) + delay_var.trace_add("write", save) + return frame + + +def create_subroutine_editor(parent, data, on_change, subroutine_names=None): + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Sub-Routine to call:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + + names = subroutine_names or [] + name_var = tk.StringVar(value=data.get("name", "")) + name_combo = ttk.Combobox(frame, textvariable=name_var, values=names, width=20) + name_combo.pack(fill="x", padx=8, pady=4) + + if not names: + tk.Label(frame, text="No sub-routines available.\n" + "Switch to the 'Sub-Routines' profile\n" + "to create reusable routines.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(4, 0)) + else: + tk.Label(frame, text="Select a sub-routine from the\n" + "'Sub-Routines' profile.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(4, 0)) + + tk.Label(frame, text="When this node is reached during\n" + "execution, the sub-routine will run\n" + "then resume from here.", + bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(8, 4)) + + def save(*_): + data["name"] = name_var.get() + on_change() + + name_var.trace_add("write", save) + return frame + + +def create_rs232_editor(parent, data, on_change): + frame = tk.Frame(parent, bg="#2D2D3D") + + tk.Label(frame, text="Baud rate:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + baud_var = tk.StringVar(value=str(data.get("baud", 9600))) + baud_combo = ttk.Combobox(frame, textvariable=baud_var, + values=[str(b) for b in RS232_BAUD_RATES], width=15) + baud_combo.pack(fill="x", padx=8, pady=2) + + # Data bits, Stop bits, Parity in a row + config_frame = tk.Frame(frame, bg="#2D2D3D") + config_frame.pack(fill="x", padx=8, pady=(4, 0)) + + db_frame = tk.Frame(config_frame, bg="#2D2D3D") + db_frame.pack(side="left", fill="x", expand=True, padx=(0, 4)) + tk.Label(db_frame, text="Data bits:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 8)).pack(anchor="w") + data_bits_var = tk.StringVar(value=str(data.get("data_bits", 8))) + ttk.Combobox(db_frame, textvariable=data_bits_var, + values=[str(d) for d in RS232_DATA_BITS], + state="readonly", width=4).pack(fill="x") + + sb_frame = tk.Frame(config_frame, bg="#2D2D3D") + sb_frame.pack(side="left", fill="x", expand=True, padx=2) + tk.Label(sb_frame, text="Stop bits:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 8)).pack(anchor="w") + stop_bits_var = tk.StringVar(value=data.get("stop_bits", "1")) + ttk.Combobox(sb_frame, textvariable=stop_bits_var, + values=RS232_STOP_BITS, state="readonly", width=4).pack(fill="x") + + p_frame = tk.Frame(config_frame, bg="#2D2D3D") + p_frame.pack(side="left", fill="x", expand=True, padx=(4, 0)) + tk.Label(p_frame, text="Parity:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 8)).pack(anchor="w") + parity_var = tk.StringVar(value=data.get("parity", "none")) + ttk.Combobox(p_frame, textvariable=parity_var, + values=RS232_PARITY, state="readonly", width=5).pack(fill="x") + + tk.Label(frame, text="Message to send:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + msg_text = tk.Text(frame, height=4, width=30, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 10), + relief="flat", borderwidth=1) + msg_text.insert("1.0", data.get("message", "")) + msg_text.pack(fill="x", padx=8, pady=2) + + tk.Label(frame, text="Supports (VAR{name}) expansion — case-insensitive", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 7)).pack(anchor="w", padx=8) + + tk.Label(frame, text="Line ending:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + le_display = [label for label, val in RS232_LINE_ENDINGS] + le_values = [val for label, val in RS232_LINE_ENDINGS] + current_le = data.get("line_ending", "none") + le_idx = le_values.index(current_le) if current_le in le_values else 0 + le_var = tk.StringVar(value=le_display[le_idx]) + le_combo = ttk.Combobox(frame, textvariable=le_var, values=le_display, + state="readonly", width=15) + le_combo.pack(fill="x", padx=8, pady=2) + + sep = tk.Frame(frame, bg="#444466", height=1) + sep.pack(fill="x", padx=8, pady=8) + + wait_var = tk.BooleanVar(value=data.get("wait_response", False)) + wait_cb = tk.Checkbutton(frame, text="Wait for response before continuing", + variable=wait_var, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", font=("Segoe UI", 9)) + wait_cb.pack(anchor="w", padx=8) + + # Response fields are shown/hidden based on the wait checkbox + resp_frame = tk.Frame(frame, bg="#2D2D3D") + + tk.Label(resp_frame, text="Expected response:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(4, 2)) + resp_var = tk.StringVar(value=data.get("expected_response", "")) + resp_entry = tk.Entry(resp_frame, textvariable=resp_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + resp_entry.pack(fill="x", padx=8, pady=2) + + tk.Label(resp_frame, text="Timeout (ms):", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(4, 2)) + timeout_var = tk.StringVar(value=str(data.get("timeout_ms", 5000))) + timeout_entry = tk.Entry(resp_frame, textvariable=timeout_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + timeout_entry.pack(fill="x", padx=8, pady=2) + + def toggle_resp_frame(*_): + if wait_var.get(): + resp_frame.pack(fill="x", padx=0, pady=0) + else: + resp_frame.pack_forget() + + wait_var.trace_add("write", toggle_resp_frame) + toggle_resp_frame() + + # --- Post-send delay (power-loss-safe idle) --- + sep2 = tk.Frame(frame, bg="#444466", height=1) + sep2.pack(fill="x", padx=8, pady=8) + + tk.Label(frame, text="Post-send delay (ms):", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(4, 2)) + post_delay_var = tk.StringVar(value=str(data.get("post_send_delay_ms", 0))) + post_delay_entry = tk.Entry(frame, textvariable=post_delay_var, bg="#1E1E2E", + fg="white", insertbackground="white", + font=("Segoe UI", 10), relief="flat") + post_delay_entry.pack(fill="x", padx=8, pady=2) + tk.Label(frame, + text="After sending, idle the M5Stack for this\n" + "long. During the delay the device has\n" + "already persisted its resume state and is\n" + "safe to lose power at any moment.\n" + "Useful when the RS232 command (e.g. a\n" + "KVM switch) will cut USB power to the\n" + "M5Stack — give flash ample time to settle.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=8, pady=(0, 4)) + + def save(*_): + try: + data["baud"] = int(baud_var.get()) + except ValueError: + data["baud"] = 9600 + try: + data["data_bits"] = int(data_bits_var.get()) + except ValueError: + data["data_bits"] = 8 + data["stop_bits"] = stop_bits_var.get() + data["parity"] = parity_var.get() + data["message"] = msg_text.get("1.0", "end-1c") + le_display_val = le_var.get() + idx = le_display.index(le_display_val) if le_display_val in le_display else 0 + data["line_ending"] = le_values[idx] + data["wait_response"] = wait_var.get() + data["expected_response"] = resp_var.get() + try: + data["timeout_ms"] = int(timeout_var.get()) + except ValueError: + data["timeout_ms"] = 5000 + try: + v = int(post_delay_var.get()) + data["post_send_delay_ms"] = max(0, v) + except ValueError: + data["post_send_delay_ms"] = 0 + on_change() + + baud_var.trace_add("write", save) + data_bits_var.trace_add("write", save) + stop_bits_var.trace_add("write", save) + parity_var.trace_add("write", save) + le_var.trace_add("write", save) + wait_var.trace_add("write", save) + resp_var.trace_add("write", save) + timeout_var.trace_add("write", save) + post_delay_var.trace_add("write", save) + msg_text.bind("", save) + + return frame + + +_BLUETOOTH_MODES = [ + ("Pull BLE Variables", "pull_ble"), + ("Push BLE Variables", "push_ble"), + ("Request BLE Variable(s)", "request_ble"), + ("Set Variables", "set_local"), + ("Get Variables", "get_local"), +] + +_GET_EXAMPLE_SCRIPT = ( + "Add-Type -Name K -Namespace W -MemberDefinition " + "'[System.Runtime.InteropServices.DllImport(\"user32\")]" + "public static extern void keybd_event(byte v,byte s,uint f,uint e);' -EA 0\n" + "function t($n){1..$n|%{[W.K]::keybd_event(0x91,0,0,0);" + "[W.K]::keybd_event(0x91,0,2,0);sleep -m 200}}\n" + "& {\n" + " sleep -m 800 # let the device begin its listen window first\n" + " $desktop = [Environment]::GetFolderPath('Desktop')\n" + " if (Test-Path (Join-Path $desktop 'test.txt')) { t 1; return }\n" + " t 3 # not present -> 3 toggles\n" + "}\n" +) + + +def create_bluetooth_editor(parent, data, on_change, node_canvas=None, widget=None): + """Property editor for the unified Variables node (5 modes).""" + from widgets.text_editor_dialog import TextEditorDialog + + frame = tk.Frame(parent, bg="#2D2D3D") + + data.setdefault("mode", "pull_ble") + data.setdefault("scope", "universal") + data.setdefault("names", []) + data.setdefault("play_sound", True) + data.setdefault("assignments", []) + data.setdefault("script", "") + data.setdefault("script_language", "powershell") + data.setdefault("var_name", "") + data.setdefault("pre_listen_ms", 500) + data.setdefault("listen_window_ms", 5000) + data.setdefault("retry_attempts", 1) + data.setdefault("outcomes", []) + + tk.Label(frame, text="Variables Node", bg="#2D2D3D", fg="#0AACFF", + font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=10, pady=(12, 4)) + + tk.Label(frame, text="Mode:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=10, pady=(4, 2)) + + mode_display = [m[0] for m in _BLUETOOTH_MODES] + mode_value = [m[1] for m in _BLUETOOTH_MODES] + cur = data["mode"] + cur_idx = mode_value.index(cur) if cur in mode_value else 0 + mode_var = tk.StringVar(value=mode_display[cur_idx]) + mode_box = ttk.Combobox(frame, textvariable=mode_var, values=mode_display, + state="readonly") + mode_box.pack(fill="x", padx=10, pady=2) + + # Container for the per-mode sub-panel. + body = tk.Frame(frame, bg="#2D2D3D") + body.pack(fill="x", padx=4, pady=(8, 4)) + + def render_pull(): + tk.Label(body, text="Scope:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2)) + scope_display = ["Universal", "This device"] + scope_value = ["universal", "device"] + sidx = scope_value.index(data["scope"]) if data["scope"] in scope_value else 0 + sv = tk.StringVar(value=scope_display[sidx]) + ttk.Combobox(body, textvariable=sv, values=scope_display, + state="readonly").pack(fill="x", padx=6, pady=2) + tk.Label(body, text="Device pulls the chosen scope from the host.\n" + "BLE comes up briefly, then shuts down.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=6, pady=(6, 2)) + + def save(*_): + disp = sv.get() + i = scope_display.index(disp) if disp in scope_display else 0 + data["scope"] = scope_value[i] + on_change() + sv.trace_add("write", save) + + def render_push(): + tk.Label(body, text="Push BLE Variables", + bg="#2D2D3D", fg="white", font=("Segoe UI", 9, "bold")).pack( + anchor="w", padx=6, pady=(4, 2)) + tk.Label(body, + text="Sends the device's full on-device variable map up to " + "the host. The host stores it under THIS DEVICE's profile " + "(keyed by eFuse MAC). Universal is never overwritten.\n\n" + "Use this to sync values that were set locally (Set / Get " + "modes) back to the desktop app.", + bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 8), + justify="left", wraplength=220).pack(anchor="w", padx=6, pady=2) + + def render_request(): + tk.Label(body, text="Variables to prompt for (one per line):", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2)) + names = data.get("names") or [] + text_w = tk.Text(body, height=5, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 10), + relief="flat", borderwidth=1) + text_w.insert("1.0", "\n".join(names)) + text_w.pack(fill="x", padx=6, pady=2) + + sound_var = tk.BooleanVar(value=bool(data.get("play_sound", True))) + tk.Checkbutton(body, text="Play notification sound when prompt opens", + variable=sound_var, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2)) + + tk.Label(body, + text="The desktop app will pop up a dialog listing these " + "names with current device-profile values pre-filled. " + "Saving sends the user's edits back to the device " + "(device scope) and updates the device profile.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left", wraplength=220).pack(anchor="w", padx=6, pady=4) + + def save(*_): + raw = text_w.get("1.0", "end-1c") + data["names"] = [ln.strip() for ln in raw.splitlines() if ln.strip()] + data["play_sound"] = sound_var.get() + on_change() + text_w.bind("", save) + sound_var.trace_add("write", save) + + def render_set(): + tk.Label(body, text="Scope:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2)) + scope_display = ["Universal", "This device"] + scope_value = ["universal", "device"] + sidx = scope_value.index(data["scope"]) if data["scope"] in scope_value else 0 + sv = tk.StringVar(value=scope_display[sidx]) + ttk.Combobox(body, textvariable=sv, values=scope_display, + state="readonly").pack(fill="x", padx=6, pady=2) + + tk.Label(body, text="Assignments:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=6, pady=(8, 2)) + + rows_frame = tk.Frame(body, bg="#2D2D3D") + rows_frame.pack(fill="x", padx=6) + row_vars = [] # (name_var, value_var) + + def commit_assignments(): + data["assignments"] = [ + {"name": n.get().strip(), "value": v.get()} + for n, v in row_vars if n.get().strip() + ] + on_change() + + def rebuild_rows(): + for w in rows_frame.winfo_children(): + w.destroy() + row_vars.clear() + assigns = data.get("assignments") or [] + for i, a in enumerate(assigns): + row = tk.Frame(rows_frame, bg="#2D2D3D") + row.pack(fill="x", pady=2) + nv = tk.StringVar(value=a.get("name", "")) + vv = tk.StringVar(value=str(a.get("value", ""))) + row_vars.append((nv, vv)) + tk.Entry(row, textvariable=nv, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), + relief="flat", width=12).pack( + side="left", padx=(0, 4)) + tk.Entry(row, textvariable=vv, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), + relief="flat").pack( + side="left", fill="x", expand=True, padx=2) + tk.Button(row, text="\u00d7", bg="#D94A4A", fg="white", + font=("Segoe UI", 7, "bold"), width=2, relief="flat", + command=lambda idx=i: drop(idx)).pack(side="right") + nv.trace_add("write", lambda *_a: commit_assignments()) + vv.trace_add("write", lambda *_a: commit_assignments()) + + def drop(idx): + assigns = data.get("assignments") or [] + if 0 <= idx < len(assigns): + assigns.pop(idx) + data["assignments"] = assigns + rebuild_rows() + on_change() + + def add(): + assigns = data.get("assignments") or [] + assigns.append({"name": "", "value": ""}) + data["assignments"] = assigns + rebuild_rows() + on_change() + + rebuild_rows() + tk.Button(body, text="+ Add", bg="#27AE60", fg="white", + font=("Segoe UI", 9), relief="flat", + command=add).pack(fill="x", padx=6, pady=(6, 4)) + + tk.Label(body, + text="Writes name=value pairs into the on-device store.\n" + "No BLE \u2014 completely local to the device.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left").pack(anchor="w", padx=6, pady=2) + + def save_scope(*_): + disp = sv.get() + i = scope_display.index(disp) if disp in scope_display else 0 + data["scope"] = scope_value[i] + on_change() + sv.trace_add("write", save_scope) + + def render_get(): + from widgets.sequence_editor_dialog import SequenceEditorDialog + + # --- Var name + scope --- + tk.Label(body, text="Variable to set:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2)) + name_v = tk.StringVar(value=data.get("var_name", "")) + tk.Entry(body, textvariable=name_v, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), + relief="flat").pack(fill="x", padx=6, pady=2) + + tk.Label(body, text="Scope:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2)) + scope_display = ["Universal", "This device"] + scope_value = ["universal", "device"] + sidx = scope_value.index(data["scope"]) if data["scope"] in scope_value else 0 + sv = tk.StringVar(value=scope_display[sidx]) + ttk.Combobox(body, textvariable=sv, values=scope_display, + state="readonly").pack(fill="x", padx=6, pady=2) + + # --- Script mode picker (Manual / Semi-Auto / Run Script + Check) --- + tk.Label(body, text="Script mode:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack( + anchor="w", padx=6, pady=(10, 2)) + sm_display = ["Manual (PowerShell)", "Semi-Auto (sequence)", + "Run Script + Check"] + sm_value = ["manual", "semi_auto", "run_script_check"] + cur_sm = data.get("script_mode", "manual") + smi = sm_value.index(cur_sm) if cur_sm in sm_value else 0 + sm_var = tk.StringVar(value=sm_display[smi]) + ttk.Combobox(body, textvariable=sm_var, values=sm_display, + state="readonly").pack(fill="x", padx=6, pady=2) + + # Mode-specific sub-panel rebuilt on each change. + sub = tk.Frame(body, bg="#2D2D3D") + sub.pack(fill="x", padx=0, pady=(6, 0)) + + # Shared helper: outcomes table (used by Manual and Run Script + Check). + def render_outcomes(parent): + tk.Label(parent, text="Outcomes (up to 5):", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack( + anchor="w", padx=6, pady=(8, 2)) + tk.Label(parent, + text="Toggles observed → value to assign. No match → Fail.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left", wraplength=220).pack(anchor="w", padx=6, pady=2) + + outc_frame = tk.Frame(parent, bg="#2D2D3D") + outc_frame.pack(fill="x", padx=6) + outc_vars = [] + + def commit_outcomes(): + new = [] + for cv, vv in outc_vars: + try: + cnt = int(cv.get()) + except ValueError: + continue + new.append({"toggle_count": cnt, "value": vv.get()}) + data["outcomes"] = new + on_change() + + def drop_outcome(idx): + outs = data.get("outcomes") or [] + if 0 <= idx < len(outs): + outs.pop(idx) + data["outcomes"] = outs + rebuild_outcomes() + on_change() + + def rebuild_outcomes(): + for w in outc_frame.winfo_children(): + w.destroy() + outc_vars.clear() + outs = data.get("outcomes") or [] + for i, o in enumerate(outs): + row = tk.Frame(outc_frame, bg="#2D2D3D") + row.pack(fill="x", pady=2) + tk.Label(row, text="toggles=", bg="#2D2D3D", fg="#888888", + font=("Segoe UI", 8)).pack(side="left") + cv = tk.StringVar(value=str(o.get("toggle_count", 1))) + vv = tk.StringVar(value=str(o.get("value", ""))) + outc_vars.append((cv, vv)) + tk.Entry(row, textvariable=cv, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + width=4, relief="flat").pack(side="left", padx=2) + tk.Label(row, text="→", bg="#2D2D3D", fg="#888888", + font=("Segoe UI", 9)).pack(side="left", padx=2) + tk.Entry(row, textvariable=vv, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), + relief="flat").pack( + side="left", fill="x", expand=True, padx=2) + tk.Button(row, text="×", bg="#D94A4A", fg="white", + font=("Segoe UI", 7, "bold"), width=2, relief="flat", + command=lambda idx=i: drop_outcome(idx)).pack( + side="right") + cv.trace_add("write", lambda *_a: commit_outcomes()) + vv.trace_add("write", lambda *_a: commit_outcomes()) + + def add_outcome(): + outs = data.get("outcomes") or [] + if len(outs) >= 5: + return + outs.append({"toggle_count": len(outs) + 1, "value": ""}) + data["outcomes"] = outs + rebuild_outcomes() + on_change() + + rebuild_outcomes() + tk.Button(parent, text="+ Add Outcome", bg="#27AE60", fg="white", + font=("Segoe UI", 9), relief="flat", + command=add_outcome).pack(fill="x", padx=6, pady=(4, 4)) + + # Shared helper: Win+R elevated terminal launcher (used by Semi-Auto + # and Run Script + Check). Run Script + Check almost always needs it + # because the script lives off-disk and benefits from elevation. + def render_elevated_launch(parent): + launch = data.setdefault("elevated_launch", {}) + launch.setdefault("enabled", False) + launch.setdefault("command", + "powershell -Command \"Start-Process wt -Verb RunAs\"") + launch.setdefault("win_r_wait_ms", 5000) + launch.setdefault("post_type_wait_ms", 15000) + launch.setdefault("uac_accept", False) + launch.setdefault("uac_wait_ms", 10000) + + tk.Label(parent, text="Elevated terminal launcher:", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack( + anchor="w", padx=6, pady=(4, 2)) + enable_var = tk.BooleanVar(value=bool(launch.get("enabled", False))) + tk.Checkbutton(parent, + text="Open elevated terminal first (Win+R)", + variable=enable_var, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6) + + launch_box = tk.Frame(parent, bg="#2D2D3D") + launch_box.pack(fill="x", padx=6, pady=(4, 4)) + + tk.Label(launch_box, text="Run-dialog command:", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w") + cmd_var = tk.StringVar(value=launch.get("command", "")) + tk.Entry(launch_box, textvariable=cmd_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + relief="flat").pack(fill="x", pady=2, ipady=2) + + tk.Label(launch_box, text="Wait after Win+R (ms):", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", pady=(4, 0)) + wr_var = tk.StringVar(value=str(launch.get("win_r_wait_ms", 5000))) + tk.Entry(launch_box, textvariable=wr_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + relief="flat").pack(fill="x", pady=2, ipady=2) + + tk.Label(launch_box, text="Wait after launch & Enter (ms):", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", pady=(4, 0)) + pt_var = tk.StringVar(value=str(launch.get("post_type_wait_ms", 15000))) + tk.Entry(launch_box, textvariable=pt_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + relief="flat").pack(fill="x", pady=2, ipady=2) + + uac_var = tk.BooleanVar(value=bool(launch.get("uac_accept", False))) + tk.Checkbutton(launch_box, + text="Auto-accept UAC prompt (Left + Enter)", + variable=uac_var, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", + font=("Segoe UI", 9)).pack(anchor="w", pady=(6, 0)) + + tk.Label(launch_box, text="Wait before UAC accept (ms):", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", pady=(4, 0)) + uac_ms_var = tk.StringVar(value=str(launch.get("uac_wait_ms", 10000))) + tk.Entry(launch_box, textvariable=uac_ms_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + relief="flat").pack(fill="x", pady=2, ipady=2) + + tk.Label(launch_box, + text="Sequence: Win+R → type cmd → Enter → " + "wait UAC → Left → Enter → wait launch.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left", wraplength=240).pack( + anchor="w", pady=(2, 0)) + + def save_launch(*_): + launch["enabled"] = enable_var.get() + launch["command"] = cmd_var.get() + launch["uac_accept"] = uac_var.get() + try: + launch["win_r_wait_ms"] = max(0, int(wr_var.get())) + except ValueError: + pass + try: + launch["post_type_wait_ms"] = max(0, int(pt_var.get())) + except ValueError: + pass + try: + launch["uac_wait_ms"] = max(0, int(uac_ms_var.get())) + except ValueError: + pass + on_change() + + enable_var.trace_add("write", save_launch) + cmd_var.trace_add("write", save_launch) + wr_var.trace_add("write", save_launch) + pt_var.trace_add("write", save_launch) + uac_var.trace_add("write", save_launch) + uac_ms_var.trace_add("write", save_launch) + + def render_manual(): + tk.Label(sub, text="Script (typed via HID into focused window):", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2)) + script_w = tk.Text(sub, height=6, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + relief="flat", borderwidth=1) + script_w.insert("1.0", data.get("script", "")) + script_w.pack(fill="x", padx=6, pady=2) + + def save_script(*_): + data["script"] = script_w.get("1.0", "end-1c") + on_change() + script_w.bind("", save_script) + + btn_row = tk.Frame(sub, bg="#2D2D3D") + btn_row.pack(fill="x", padx=6, pady=(2, 0)) + + def open_pop_out(): + shim = { + "text": data.get("script", ""), + "language": data.get("script_language", "powershell"), + } + + def on_editor_save(): + data["script"] = shim.get("text", "") + data["script_language"] = shim.get("language", "powershell") + script_w.delete("1.0", "end") + script_w.insert("1.0", data["script"]) + on_change() + + TextEditorDialog(parent, shim, on_editor_save) + + def fill_example(): + data["script"] = _GET_EXAMPLE_SCRIPT + data["script_language"] = "powershell" + script_w.delete("1.0", "end") + script_w.insert("1.0", _GET_EXAMPLE_SCRIPT) + on_change() + + tk.Button(btn_row, text="Editor\u2026", bg="#3B82F6", fg="white", + relief="flat", padx=10, font=("Segoe UI", 9, "bold"), + command=open_pop_out).pack(side="left", fill="x", expand=True) + tk.Button(btn_row, text="Example", bg="#3D3D5C", fg="white", + relief="flat", padx=10, font=("Segoe UI", 9), + command=fill_example).pack(side="left", padx=(4, 0)) + + # Manual-mode outcomes table (Semi-Auto auto-derives this). + render_outcomes(sub) + + def render_semi_auto(): + # --- Elevated terminal launcher --- + render_elevated_launch(sub) + + # --- Sequence summary + edit button --- + tk.Label(sub, text="Sequence:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack( + anchor="w", padx=6, pady=(8, 2)) + + summary_var = tk.StringVar() + + def refresh_summary(): + seq = data.get("sequence") or [] + if not seq: + summary_var.set("No steps yet.") + else: + checks = sum(1 for s in seq if s.get("kind") == "check") + summary_var.set(f"{len(seq)} step(s), {checks} check(s).") + + tk.Label(sub, textvariable=summary_var, + bg="#2D2D3D", fg="#AAAAAA", + font=("Segoe UI", 9, "italic")).pack( + anchor="w", padx=6) + + def open_sequence_editor(): + def on_editor_save(new_seq): + data["sequence"] = new_seq + refresh_summary() + on_change() + SequenceEditorDialog(parent, data.get("sequence") or [], + on_editor_save) + + tk.Button(sub, text="Edit Sequence\u2026", + bg="#3B82F6", fg="white", + relief="flat", padx=10, font=("Segoe UI", 9, "bold"), + command=open_sequence_editor).pack( + fill="x", padx=6, pady=(4, 4)) + + tk.Label(sub, + text="Each Check step's index becomes its toggle count " + "(1, 2, 3 \u2026). The host renders the sequence to " + "PowerShell at upload time.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left", wraplength=220).pack( + anchor="w", padx=6, pady=(0, 4)) + + refresh_summary() + + def render_run_script_check(): + rs = data.setdefault("run_script", {}) + rs.setdefault("location", "local") + rs.setdefault("local_path", "") + rs.setdefault("drive_label", "") + rs.setdefault("relative_path", "") + rs.setdefault("args", "") + + tk.Label(sub, text="Script location:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack( + anchor="w", padx=6, pady=(4, 2)) + loc_display = ["Script is local", "Script is on USB"] + loc_value = ["local", "usb"] + cur_loc = rs.get("location", "local") + li = loc_value.index(cur_loc) if cur_loc in loc_value else 0 + loc_var = tk.StringVar(value=loc_display[li]) + ttk.Combobox(sub, textvariable=loc_var, values=loc_display, + state="readonly").pack(fill="x", padx=6, pady=2) + + loc_sub = tk.Frame(sub, bg="#2D2D3D") + loc_sub.pack(fill="x", padx=0, pady=(2, 0)) + + def render_local(): + tk.Label(loc_sub, text="Script path (absolute, spaces OK):", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2)) + p_var = tk.StringVar(value=rs.get("local_path", "")) + tk.Entry(loc_sub, textvariable=p_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + relief="flat").pack(fill="x", padx=6, pady=2, ipady=2) + + def save_local(*_): + rs["local_path"] = p_var.get() + on_change() + p_var.trace_add("write", save_local) + + def render_usb(): + tk.Label(loc_sub, text="USB drive label (as shown in File Explorer):", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2)) + d_var = tk.StringVar(value=rs.get("drive_label", "")) + tk.Entry(loc_sub, textvariable=d_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + relief="flat").pack(fill="x", padx=6, pady=2, ipady=2) + + tk.Label(loc_sub, text="Script path on drive:", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2)) + r_var = tk.StringVar(value=rs.get("relative_path", "")) + tk.Entry(loc_sub, textvariable=r_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + relief="flat").pack(fill="x", padx=6, pady=2, ipady=2) + tk.Label(loc_sub, + text="Relative to the drive root — " + "e.g. scripts\\check.ps1 (no leading slash).", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left", wraplength=240).pack( + anchor="w", padx=6, pady=(0, 2)) + + def save_usb(*_): + rs["drive_label"] = d_var.get() + rs["relative_path"] = r_var.get() + on_change() + d_var.trace_add("write", save_usb) + r_var.trace_add("write", save_usb) + + def repaint_loc(): + for w in loc_sub.winfo_children(): + w.destroy() + if rs.get("location") == "usb": + render_usb() + else: + render_local() + + def on_loc_change(*_): + disp = loc_var.get() + i = loc_display.index(disp) if disp in loc_display else 0 + new_loc = loc_value[i] + if new_loc != rs.get("location"): + rs["location"] = new_loc + repaint_loc() + on_change() + loc_var.trace_add("write", on_loc_change) + + repaint_loc() + + # --- Arguments --- + tk.Label(sub, text="Arguments (optional):", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(8, 2)) + a_var = tk.StringVar(value=rs.get("args", "")) + tk.Entry(sub, textvariable=a_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Consolas", 9), + relief="flat").pack(fill="x", padx=6, pady=2, ipady=2) + tk.Label(sub, + text="Appended verbatim after the script path. " + "Quote args containing spaces yourself.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left", wraplength=240).pack( + anchor="w", padx=6, pady=(0, 4)) + + def save_args(*_): + rs["args"] = a_var.get() + on_change() + a_var.trace_add("write", save_args) + + # --- Elevated launcher (same widget as Semi-Auto) --- + render_elevated_launch(sub) + + # --- Outcomes table (same widget as Manual) --- + render_outcomes(sub) + + def repaint_sub(): + for w in sub.winfo_children(): + w.destroy() + mode = data.get("script_mode") + if mode == "semi_auto": + render_semi_auto() + elif mode == "run_script_check": + render_run_script_check() + else: + render_manual() + + def on_mode_change(*_): + disp = sm_var.get() + i = sm_display.index(disp) if disp in sm_display else 0 + new_mode = sm_value[i] + if new_mode != data.get("script_mode"): + data["script_mode"] = new_mode + repaint_sub() + on_change() + sm_var.trace_add("write", on_mode_change) + + repaint_sub() + + # --- Timing (shared by both modes) --- + tk.Label(body, text="Pre-listen delay (ms):", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(8, 2)) + pre_v = tk.StringVar(value=str(data.get("pre_listen_ms", 500))) + tk.Entry(body, textvariable=pre_v, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), + relief="flat").pack(fill="x", padx=6, pady=2) + + tk.Label(body, text="Listen window timeout (ms):", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2)) + win_v = tk.StringVar(value=str(data.get("listen_window_ms", 5000))) + tk.Entry(body, textvariable=win_v, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), + relief="flat").pack(fill="x", padx=6, pady=2) + + tk.Label(body, text="Retry attempts on fail:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2)) + tk.Label(body, + text="Extra attempts after the first one. 0 = no retry.", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8), + justify="left", wraplength=220).pack(anchor="w", padx=6, pady=2) + retry_v = tk.StringVar(value=str(data.get("retry_attempts", 1))) + tk.Entry(body, textvariable=retry_v, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 9), + relief="flat").pack(fill="x", padx=6, pady=2) + + def save_simple(*_): + try: + data["pre_listen_ms"] = int(pre_v.get()) + except ValueError: + pass + try: + data["listen_window_ms"] = int(win_v.get()) + except ValueError: + pass + try: + ra = int(retry_v.get()) + if ra < 0: + ra = 0 + data["retry_attempts"] = ra + except ValueError: + pass + data["var_name"] = name_v.get() + disp = sv.get() + i = scope_display.index(disp) if disp in scope_display else 0 + data["scope"] = scope_value[i] + on_change() + name_v.trace_add("write", save_simple) + sv.trace_add("write", save_simple) + pre_v.trace_add("write", save_simple) + win_v.trace_add("write", save_simple) + retry_v.trace_add("write", save_simple) + + renderers = { + "pull_ble": render_pull, + "push_ble": render_push, + "request_ble": render_request, + "set_local": render_set, + "get_local": render_get, + } + + def repaint(): + for w in body.winfo_children(): + w.destroy() + renderers.get(data["mode"], render_pull)() + + def on_mode_change(*_): + disp = mode_var.get() + idx = mode_display.index(disp) if disp in mode_display else 0 + new_mode = mode_value[idx] + if new_mode == data["mode"]: + return + data["mode"] = new_mode + repaint() + # get_local has 2 outputs (Pass/Fail); the others have 1. + if node_canvas and widget: + node_canvas.rebuild_node_ports(widget.data.id) + on_change() + + mode_var.trace_add("write", on_mode_change) + + repaint() + return frame + + +def create_macro_editor(parent, data, on_change): + """Editor for Macro (key-recording) node properties. + + The heavy lifting lives in the MacroRecorderDialog — this panel just + surfaces a summary plus the Configure button that opens it. Recording + happens inside the dialog so all keystrokes go to the capture handler, + not the main canvas. + """ + from widgets.macro_recorder import MacroRecorderDialog + + frame = tk.Frame(parent, bg="#2D2D3D") + + # Also editable inside the dialog, but handy to rename here + tk.Label(frame, text="Name:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + name_var = tk.StringVar(value=data.get("name", "")) + name_entry = tk.Entry(frame, textvariable=name_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), + relief="flat") + name_entry.pack(fill="x", padx=8, pady=4) + + def save_name(*_): + data["name"] = name_var.get().strip() + _refresh_summary() + on_change() + name_var.trace_add("write", save_name) + + import copy + from widgets.macro_library_picker import MacroLibraryPicker + + # Existing macro nodes have no "mode" key → treat as legacy "recorded". + data.setdefault("mode", "recorded") + + def _is_tagged(evs): + # Library events are tagged arrays (["k",...] / ["m",...]); legacy + # recorded events are bare [t, action, hid]. + return bool(evs) and isinstance(evs[0], (list, tuple)) and \ + len(evs[0]) > 0 and isinstance(evs[0][0], str) + + mode_options = [("Record keys (this node)", "recorded"), + ("BT Keyboard macro (keys + mouse)", "library")] + disp = [m[0] for m in mode_options] + vals = [m[1] for m in mode_options] + cur = data["mode"] if data["mode"] in vals else "recorded" + tk.Label(frame, text="Type:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2)) + mode_var = tk.StringVar(value=disp[vals.index(cur)]) + mode_box = ttk.Combobox(frame, textvariable=mode_var, values=disp, + state="readonly", font=("Segoe UI", 9)) + mode_box.pack(fill="x", padx=8, pady=(0, 4)) + + summary_var = tk.StringVar() + + def _refresh_summary(): + events = data.get("events", []) + n = len(events) + if n == 0: + summary_var.set("Not recorded yet — click Configure to record keys." + if data["mode"] == "recorded" + else "No macro chosen yet — click Choose macro.") + return + last_t = events[-1][1] if _is_tagged(events) else events[-1][0] + secs = (last_t or 0) / 1000.0 + dur = f"{secs:.2f}s" if secs < 60 else f"{int(secs // 60)}m{int(secs % 60)}s" + summary_var.set(f"{n} events over {dur}.") + + tk.Label(frame, textvariable=summary_var, + bg="#2D2D3D", fg="#CCCCCC", font=("Segoe UI", 9), + wraplength=220, justify="left").pack( + anchor="w", padx=8, pady=(6, 0)) + + def open_configure(): + def on_save(): + _refresh_summary() + on_change() + # Live recording is pure BLE. Pull the BLE-client factory and + # the var-sync pause/resume hooks off the top-level MacroPadApp; + # they must run around the live session so the two clients + # don't race for the same SERVICE_UUID advertisement. + top = parent.winfo_toplevel() + factory = getattr(top, "make_ble_live_client", None) + pause_var_sync = getattr(top, "pause_var_sync_ble", None) + resume_var_sync = getattr(top, "resume_var_sync_ble", None) + MacroRecorderDialog(parent, data, on_save, + ble_live_client_factory=factory, + pause_var_sync=pause_var_sync, + resume_var_sync=resume_var_sync) + + def choose_library(): + def on_select(folder, name, events): + # Embed a self-contained copy (re-pick to update later). + data["mode"] = "library" + data["events"] = copy.deepcopy(events) + if not data.get("name"): + data["name"] = name + name_var.set(name) + _refresh_summary() + on_change() + MacroLibraryPicker(parent, select_mode=True, on_select=on_select) + + action_btn = tk.Button(frame, font=("Segoe UI", 10, "bold"), fg="white", + relief="flat", padx=14, pady=6) + action_btn.pack(fill="x", padx=8, pady=(10, 6)) + + info_var = tk.StringVar() + tk.Label(frame, textvariable=info_var, bg="#2D2D3D", fg="#888888", + font=("Segoe UI", 8), justify="left", wraplength=240).pack( + anchor="w", padx=8, pady=(4, 8)) + + def _apply_mode(*_): + sel = vals[disp.index(mode_var.get())] + if sel != data.get("mode"): + # Switching modes: drop events whose shape doesn't match the new + # mode so neither the recorder nor playback sees a mixed format. + evs = data.get("events", []) + tagged = _is_tagged(evs) + if (sel == "recorded" and tagged) or \ + (sel == "library" and evs and not tagged): + data["events"] = [] + data["mode"] = sel + on_change() + if sel == "recorded": + action_btn.config(text="Configure…", bg="#14B8A6", + activebackground="#0D9488", command=open_configure) + info_var.set("Records your exact key presses (timing + chords) on " + "this node. Keyboard only. Replays over USB HID.") + else: + action_btn.config(text="Choose macro…", bg="#7C3AED", + activebackground="#6D28D9", command=choose_library) + info_var.set("Embeds a copy of a saved BT Keyboard macro " + "(keyboard + mouse + Ctrl+Alt+Del). The device " + "replays keys and absolute mouse standalone.") + _refresh_summary() + + mode_var.trace_add("write", _apply_mode) + _apply_mode() + + return frame + + +PROPERTY_EDITORS = { + "start": create_start_editor, + "text": create_text_editor, + "combo": create_combo_editor, + "pause": create_pause_editor, + "branch": create_branch_editor, + "delay": create_delay_editor, + "repeat": create_repeat_editor, + "loop_selector": create_loop_selector_editor, + "iteration_branch": create_iteration_branch_editor, + "note": create_note_editor, + "aggregator": create_aggregator_editor, + "mouse": create_mouse_editor, + "media": create_media_editor, + "bluetooth": create_bluetooth_editor, + "rs232": create_rs232_editor, + "subroutine": create_subroutine_editor, + "pc_alive_check": create_pc_alive_check_editor, + "macro": create_macro_editor, +} + + +def get_property_editor(node_type: str): + return PROPERTY_EDITORS.get(node_type) diff --git a/node_editor/port.py b/node_editor/port.py new file mode 100644 index 0000000..544aed3 --- /dev/null +++ b/node_editor/port.py @@ -0,0 +1,19 @@ +"""Port definitions for node editor.""" + + +class Port: + """Represents an input or output port on a node.""" + + def __init__(self, name: str, port_type: str, label: str = ""): + self.name = name # e.g. "in", "out", "out_0", "out_1" + self.port_type = port_type # "input" or "output" + self.label = label or name + self.canvas_id = None + self.x = 0 + self.y = 0 + # "L" or "R" — set by NodeWidget during draw based on the node's + # flipped state. Wire routing uses it to pick curve control points. + self.side = "L" if port_type == "input" else "R" + + def __repr__(self): + return f"Port({self.name}, {self.port_type})" diff --git a/pull_ble_log.py b/pull_ble_log.py new file mode 100644 index 0000000..162cc51 --- /dev/null +++ b/pull_ble_log.py @@ -0,0 +1,62 @@ +"""Pull the device's in-memory BLE debug ring buffer over USB-CDC. + +Usage: + python pull_ble_log.py # auto-detect port, write to config/.ble_device_log.json + python pull_ble_log.py COM7 # specify port + python pull_ble_log.py --clear # also clear device-side buffer after pulling + +Run this AFTER reproducing the BLE issue so the buffer captures the failure. +The Python desktop app must NOT be running (it holds the serial port). +""" + +import json +import os +import sys +from pathlib import Path + +from serial_manager import SerialManager +from utils.constants import APPDATA_DIR + + +def main(argv: list[str]) -> int: + clear = "--clear" in argv + args = [a for a in argv[1:] if not a.startswith("--")] + port = args[0] if args else None + + sm = SerialManager() + if port: + sm.connect(port) + else: + sm.scan_and_connect() + if not sm.connected: + print("ERROR: device not found on USB. Is the desktop app running and holding the port?") + return 1 + print(f"Connected to {sm.port}") + + entries = sm.get_ble_log() + if entries is None: + print("ERROR: get_ble_log returned no response (firmware too old? reflash needed.)") + sm.disconnect() + return 2 + + out_path = Path(APPDATA_DIR) / ".ble_device_log.json" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(entries, indent=2), encoding="utf-8") + print(f"Wrote {len(entries)} entries to {out_path}") + + # Pretty-print to stdout so you can pipe to a file or grep. + for e in entries: + print(f"[{e.get('t', 0)}ms] {e.get('m', '')}") + + if clear: + if sm.clear_ble_log(): + print("Device buffer cleared.") + else: + print("WARNING: clear_ble_log failed.") + + sm.disconnect() + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3b9f076 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pyserial +bleak +cryptography diff --git a/serial_manager.py b/serial_manager.py new file mode 100644 index 0000000..0090931 --- /dev/null +++ b/serial_manager.py @@ -0,0 +1,444 @@ +"""Serial communication manager for ATOMS3 MacroPad.""" + +import json +import os +import time +import threading +import serial +import serial.tools.list_ports +import ble_keystore +from utils.constants import DEVICE_ID, ESPRESSIF_VID +from utils.image_converter import convert_to_rgb565 + + +class SerialManager: + def __init__(self): + self.ser: serial.Serial | None = None + self.port: str | None = None + self.connected = False + self.is_uploading = False + # Last ping response (board, sta_mac, mesh_ch, live_tx, ...). Captured + # on connect and refreshed on every ping() so the UI can show which + # device is attached and what live transport it's set to. + self.device_info: dict | None = None + self._lock = threading.Lock() + self._on_disconnect = None + self._on_connect = None + + def set_callbacks(self, on_connect=None, on_disconnect=None): + self._on_connect = on_connect + self._on_disconnect = on_disconnect + + def scan_and_connect(self) -> bool: + """Scan COM ports for ATOMS3 MacroPad device.""" + ports = serial.tools.list_ports.comports() + for port in ports: + if port.vid == ESPRESSIF_VID: + try: + # Open without DTR/RTS asserted so the ESP32 doesn't reset + ser = serial.Serial() + ser.port = port.device + ser.baudrate = 115200 + ser.timeout = 2 + ser.dtr = False + ser.rts = False + ser.open() + time.sleep(0.1) + # Assert DTR so TinyUSB CDC sees the host as connected + ser.dtr = True + time.sleep(0.3) + ser.reset_input_buffer() + ser.write(b'{"cmd":"ping"}\n') + line = ser.readline().decode("utf-8", errors="ignore").strip() + if line: + data = json.loads(line) + if data.get("id") == DEVICE_ID: + self.ser = ser + self.port = port.device + self.connected = True + self.device_info = data + if self._on_connect: + self._on_connect(port.device) + return True + ser.close() + except (serial.SerialException, json.JSONDecodeError, OSError): + pass + return False + + def disconnect(self): + if self.ser and self.ser.is_open: + self.ser.close() + self.ser = None + self.port = None + self.connected = False + self.device_info = None + if self._on_disconnect: + self._on_disconnect() + + def send_command(self, cmd: dict) -> dict | None: + """Send a JSON command and wait for response.""" + if not self.connected or not self.ser: + return None + with self._lock: + try: + msg = json.dumps(cmd) + "\n" + self.ser.write(msg.encode("utf-8")) + line = self.ser.readline().decode("utf-8", errors="ignore").strip() + if line: + return json.loads(line) + return None + except (serial.SerialException, json.JSONDecodeError, OSError): + self.connected = False + if self._on_disconnect: + self._on_disconnect() + return None + + def ping(self) -> dict | None: + """Ping the device. Newer firmware also returns 'board' + ("atoms3"/"atoms3_lite"), 'sta_mac' (the WiFi/mesh MAC), 'mesh_ch', + and 'live_tx' (0 = mesh, 1 = BLE) alongside the legacy + id/ver/macros/free fields.""" + rsp = self.send_command({"cmd": "ping"}) + if rsp: + self.device_info = rsp + return rsp + + def live_mode_label(self) -> str | None: + """Human label for the connected device's persisted live transport, + or None if unknown (older firmware doesn't report it).""" + if not self.device_info: + return None + tx = self.device_info.get("live_tx") + if tx is None: + return None + return "BLE" if tx == 1 else "Mesh" + + def get_ble_key_info(self) -> dict | None: + """Return {'key': bytes, 'tag': str|None} from the device. + + Newer firmware includes the device tag (with MAC) so the host + can store keys per-device. Older firmware returns just the key + — tag will be None. + """ + rsp = self.send_command({"cmd": "get_ble_key"}) + if rsp is None or rsp.get("rsp") != "ble_key": + return None + try: + key = bytes.fromhex(rsp["key"]) + except (KeyError, ValueError): + return None + if len(key) != ble_keystore.KEY_LEN: + return None + tag = rsp.get("tag") + if not isinstance(tag, str): + tag = None + return {"key": key, "tag": tag} + + def get_ble_key(self) -> bytes | None: + """Pull the firmware's BLE payload-encryption key (32 bytes).""" + rsp = self.send_command({"cmd": "get_ble_key"}) + if rsp and rsp.get("rsp") == "ble_key": + try: + key = bytes.fromhex(rsp["key"]) + except (KeyError, ValueError): + return None + return key if len(key) == ble_keystore.KEY_LEN else None + return None + + def get_settings(self) -> dict | None: + return self.send_command({"cmd": "get_settings"}) + + def set_setting(self, key: str, value: int) -> bool: + rsp = self.send_command({"cmd": "set", "key": key, "val": value}) + return rsp is not None and rsp.get("rsp") == "ok" + + def upload_settings(self, settings) -> bool: + """Upload all settings to device.""" + ok = True + ok = ok and self.set_setting("hold_ms", settings.hold_ms) + ok = ok and self.set_setting("orientation", settings.orientation) + ok = ok and self.set_setting("type_delay", settings.type_delay) + ok = ok and self.set_setting("resume_delay", settings.resume_delay) + ok = ok and self.set_setting("combo_pre_ms", settings.combo_pre_ms) + ok = ok and self.set_setting("combo_post_ms", settings.combo_post_ms) + ok = ok and self.set_setting("probe_timeout_ms", settings.probe_timeout_ms) + ok = ok and self.set_setting("media_hold_ms", settings.media_hold_ms) + ok = ok and self.set_setting("type_shift_extra_ms", settings.type_shift_extra_ms) + ok = ok and self.set_setting("type_settle_ms", settings.type_settle_ms) + ok = ok and self.set_setting("pause_margin_left", settings.pause_margin_left) + ok = ok and self.set_setting("pause_margin_right", settings.pause_margin_right) + ok = ok and self.set_setting("pause_margin_top", settings.pause_margin_top) + ok = ok and self.set_setting("pause_margin_bottom", settings.pause_margin_bottom) + return ok + + def upload_macro(self, slot: int, macro, progress_cb=None) -> bool: + """Upload a single macro to the device.""" + if not self.connected or not self.ser: + return False + + with self._lock: + try: + nodes = macro.flatten_for_device() + node_count = len(nodes) + + img_data = b"" + if macro.image_path and os.path.exists(macro.image_path): + img_data = convert_to_rgb565(macro.image_path) + + begin_cmd = json.dumps({ + "cmd": "macro_begin", + "slot": slot, + "name": macro.name, + "label_color": getattr(macro, "label_color", "white"), + "node_count": node_count, + "img_size": len(img_data), + }) + "\n" + self.ser.write(begin_cmd.encode("utf-8")) + rsp = self._read_response() + if not rsp or rsp.get("rsp") != "ready": + return False + + if progress_cb: + progress_cb(0.1) + + if img_data: + chunk_size = 128 # Small chunks for reliable TinyUSB CDC transfer + total_chunks = (len(img_data) + chunk_size - 1) // chunk_size + for ci, i in enumerate(range(0, len(img_data), chunk_size)): + chunk = img_data[i:i + chunk_size] + self.ser.write(f"CHUNK:{len(chunk)}\n".encode("utf-8")) + self.ser.flush() + self.ser.write(chunk) + self.ser.flush() + ack = self.ser.readline().decode("utf-8", errors="ignore").strip() + if ack != "OK": + return False + if progress_cb: + progress_cb(0.1 + 0.3 * (ci + 1) / total_chunks) + + self.ser.write(b"IMG_DONE\n") + self.ser.flush() + rsp = self._read_response() + if not rsp or rsp.get("rsp") != "img_ok": + return False + + # Device needs a moment to settle after the image write + time.sleep(0.2) + self.ser.reset_input_buffer() + + if progress_cb: + progress_cb(0.4) + + for i, node in enumerate(nodes): + node_cmd = json.dumps({ + "cmd": "node", + "idx": i, + "type": node["type"], + "data": node["data"], + }) + "\n" + self.ser.write(node_cmd.encode("utf-8")) + rsp = self._read_response() + if not rsp or rsp.get("rsp") != "ok": + return False + + if progress_cb and node_count > 0: + progress_cb(0.4 + 0.4 * (i + 1) / node_count) + + end_cmd = json.dumps({"cmd": "macro_end", "slot": slot}) + "\n" + self.ser.write(end_cmd.encode("utf-8")) + rsp = self._read_response() + if not rsp or rsp.get("rsp") != "ok": + return False + + if progress_cb: + progress_cb(1.0) + + return True + + except (serial.SerialException, OSError): + self.connected = False + if self._on_disconnect: + self._on_disconnect() + return False + + def upload_all(self, project, progress_cb=None, subroutine_macros=None) -> bool: + """Upload entire project (settings + all macros + subroutines) to device. + + Deletes all existing macros on the device first, then re-uploads everything. + """ + self.is_uploading = True + try: + return self._upload_all_impl(project, progress_cb, subroutine_macros) + finally: + self.is_uploading = False + + def _upload_all_impl(self, project, progress_cb=None, subroutine_macros=None) -> bool: + if not self.connected: + return False + + ping_rsp = self.ping() + if not ping_rsp: + return False + + # Sync the BLE payload-encryption key from firmware. Older firmware + # without this command returns None — leave any cached key alone. + # Newer firmware also returns the device tag (with MAC) so the + # host can keep keys for multiple ATOMS3s without clobbering each + # other on every upload. + info = self.get_ble_key_info() + if info is not None: + try: + # Always update the legacy single-key file (back-compat). + ble_keystore.save_key(info["key"]) + # And the per-MAC store, if the device reported its tag. + if info["tag"]: + ble_keystore.save_key_for_mac(info["tag"], info["key"]) + except OSError as exc: + print(f"[BLE] Failed to persist key: {exc}") + + device_macro_count = ping_rsp.get("macros", 0) + for slot in range(device_macro_count): + self.delete_macro(slot) + time.sleep(0.05) + + self.send_command({"cmd": "sub_clear"}) + time.sleep(0.05) + + sub_macros = subroutine_macros or [] + total = len(project.macros) + len(sub_macros) + 2 # +2: delete phase, settings + step = 1 + if progress_cb: + progress_cb(step / total) + + if not self.upload_settings(project.settings): + return False + step += 1 + if progress_cb: + progress_cb(step / total) + + for i, macro in enumerate(project.macros): + def macro_progress(p, _step=step): + if progress_cb: + progress_cb((_step + p) / total) + + if not self.upload_macro(i, macro, macro_progress): + return False + step += 1 + if progress_cb: + progress_cb(step / total) + + order = list(range(len(project.macros))) + self.send_command({"cmd": "macro_reorder", "order": order}) + + for i, sub_macro in enumerate(sub_macros): + nodes = sub_macro.flatten_for_device() + node_count = len(nodes) + + begin_rsp = self.send_command({ + "cmd": "sub_begin", + "slot": i, + "name": sub_macro.name, + "node_count": node_count, + }) + if not begin_rsp or begin_rsp.get("rsp") != "ready": + # Older firmware without sub-routine support replies non-ready + step += 1 + if progress_cb: + progress_cb(step / total) + continue + + node_ok = True + for j, node in enumerate(nodes): + rsp = self.send_command({ + "cmd": "sub_node", + "idx": j, + "type": node["type"], + "data": node["data"], + }) + if not rsp or rsp.get("rsp") != "ok": + node_ok = False + break + + end_rsp = self.send_command({"cmd": "sub_end", "slot": i}) + if not node_ok or not end_rsp or end_rsp.get("rsp") != "ok": + return False + + step += 1 + if progress_cb: + progress_cb(step / total) + + return True + + def delete_macro(self, slot: int) -> bool: + rsp = self.send_command({"cmd": "macro_delete", "slot": slot}) + return rsp is not None and rsp.get("rsp") == "ok" + + def rs232_open(self, baud: int, data_bits: int = 8, + stop_bits: str = "1", parity: str = "none") -> bool: + rsp = self.send_command({ + "cmd": "rs232_open", + "baud": baud, + "data_bits": data_bits, + "stop_bits": stop_bits, + "parity": parity, + }) + return rsp is not None and rsp.get("rsp") == "ok" + + def rs232_close(self) -> bool: + rsp = self.send_command({"cmd": "rs232_close"}) + return rsp is not None and rsp.get("rsp") == "ok" + + def rs232_send(self, data: bytes) -> bool: + """Send raw bytes over the RS232 port (host-side terminal).""" + if not isinstance(data, (bytes, bytearray)): + data = str(data).encode("utf-8", errors="replace") + hex_str = data.hex() + rsp = self.send_command({"cmd": "rs232_send", "hex": hex_str}) + return rsp is not None and rsp.get("rsp") == "ok" + + def rs232_poll(self) -> bytes: + """Return any bytes received since the last poll (may be empty).""" + rsp = self.send_command({"cmd": "rs232_poll"}) + if rsp and rsp.get("rsp") == "rx": + hex_str = rsp.get("hex", "") + if hex_str: + try: + return bytes.fromhex(hex_str) + except ValueError: + return b"" + return b"" + + def get_log(self) -> list | None: + rsp = self.send_command({"cmd": "get_log"}) + if rsp and rsp.get("rsp") == "log": + return rsp.get("entries", []) + return None + + def clear_log(self) -> bool: + rsp = self.send_command({"cmd": "clear_log"}) + return rsp is not None and rsp.get("rsp") == "ok" + + def get_ble_log(self) -> list | None: + """Pull the device's in-memory BLE debug ring buffer.""" + rsp = self.send_command({"cmd": "get_ble_log"}) + if rsp and rsp.get("rsp") == "ble_log": + return rsp.get("entries", []) + return None + + def clear_ble_log(self) -> bool: + rsp = self.send_command({"cmd": "clear_ble_log"}) + return rsp is not None and rsp.get("rsp") == "ok" + + def _read_response(self, timeout: float = 10.0) -> dict | None: + """Read a JSON response line with timeout.""" + old_timeout = self.ser.timeout + self.ser.timeout = timeout + try: + line = self.ser.readline().decode("utf-8", errors="ignore").strip() + if line: + return json.loads(line) + return None + except (json.JSONDecodeError, serial.SerialException): + return None + finally: + self.ser.timeout = old_timeout diff --git a/test_serial.py b/test_serial.py new file mode 100644 index 0000000..614c4f1 --- /dev/null +++ b/test_serial.py @@ -0,0 +1,110 @@ +"""Test serial communication with the ATOMS3 MacroPad device.""" +import sys +import os +import time +import json + +sys.path.insert(0, os.path.dirname(__file__)) + +import serial +import serial.tools.list_ports + +ESPRESSIF_VID = 0x303A +DEVICE_ID = "ATOMS3-MACROPAD" + + +def find_device(): + ports = serial.tools.list_ports.comports() + candidates = [] + for port in ports: + if port.vid == ESPRESSIF_VID: + candidates.append(port) + print(f" Found Espressif device: {port.device} " + f"(VID={hex(port.vid)}, PID={hex(port.pid)}, desc={port.description})") + return candidates + + +def test_connection(port_name, attempt=1): + """Try to connect and ping the device.""" + print(f"\n--- Attempt {attempt} on {port_name} ---") + try: + ser = serial.Serial() + ser.port = port_name + ser.baudrate = 115200 + ser.timeout = 2 + ser.dtr = False + ser.rts = False + ser.open() + print(f" Port opened") + + time.sleep(0.1) + ser.dtr = True + time.sleep(0.5) + ser.reset_input_buffer() + + cmd = b'{"cmd":"ping"}\n' + ser.write(cmd) + print(f" Sent: {cmd.strip()}") + + line = ser.readline().decode("utf-8", errors="ignore").strip() + print(f" Received: {repr(line)}") + + if line: + try: + data = json.loads(line) + if data.get("id") == DEVICE_ID: + print(f" PING OK - Device identified as {DEVICE_ID}") + ser.close() + return True + else: + print(f" Wrong device ID: {data}") + except json.JSONDecodeError: + print(f" Invalid JSON response") + else: + print(f" No response (empty)") + + ser.close() + return False + + except serial.SerialException as e: + print(f" Serial error: {e}") + return False + except OSError as e: + print(f" OS error: {e}") + return False + + +def test_reconnect(port_name): + print(f"\n=== Testing reconnect cycle on {port_name} ===") + for i in range(3): + if not test_connection(port_name, i + 1): + print(f"\nRECONNECT TEST FAILED on attempt {i + 1}") + return False + if i < 2: + print(" Waiting 1s before reconnect...") + time.sleep(1) + print(f"\nRECONNECT TEST PASSED - 3/3 cycles successful") + return True + + +def main(): + print("=== ATOMS3 MacroPad Serial Test ===\n") + print("Scanning for Espressif USB devices...") + candidates = find_device() + + if not candidates: + print("\nNo Espressif USB devices found!") + print("Make sure the ATOMS3 is connected via USB.") + return 1 + + for port in candidates: + if test_reconnect(port.device): + print(f"\nAll tests PASSED on {port.device}") + return 0 + + print("\nAll tests FAILED") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/constants.py b/utils/constants.py new file mode 100644 index 0000000..288e2aa --- /dev/null +++ b/utils/constants.py @@ -0,0 +1,234 @@ +"""Shared constants and key mappings for the ATOMS3 MacroPad app.""" + +import os + +APP_NAME = "ATOMS3 MacroPad" +APP_VERSION = "1.0.0" +DEVICE_ID = "ATOMS3-MACROPAD" +ESPRESSIF_VID = 0x303A + +SCREEN_W = 128 +SCREEN_H = 128 + +# Project-relative storage. utils/constants.py → repo root → config/ +_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +APPDATA_DIR = os.path.join(_PROJECT_ROOT, "config") +PROJECT_FILE = os.path.join(APPDATA_DIR, "project.json") +IMAGES_DIR = os.path.join(APPDATA_DIR, "images") +PROFILES_DIR = os.path.join(APPDATA_DIR, "profiles") +PROFILES_META_FILE = os.path.join(APPDATA_DIR, "profiles_meta.json") +BACKUPS_DIR = os.path.join(APPDATA_DIR, "backups") +DEFAULT_PROFILE_NAME = "Default" + +DEFAULT_HOLD_MS = 500 +DEFAULT_TYPE_DELAY = 15 +DEFAULT_ORIENTATION = 0 +DEFAULT_RESUME_DELAY = 0 # seconds, 0 = disabled +DEFAULT_COMBO_PRE_MS = 500 # ms delay before key combo is sent +DEFAULT_COMBO_POST_MS = 500 # ms delay after key combo is sent +DEFAULT_PROBE_TIMEOUT_MS = 300 # ms to wait for host LED response in PC Alive Check +DEFAULT_MEDIA_HOLD_MS = 100 # ms to hold media key before release +DEFAULT_TYPE_SHIFT_EXTRA_MS = 25 # extra ms per shifted char (uppercase, !@#$ etc.) +DEFAULT_TYPE_SETTLE_MS = 150 # ms after last char to let HID reports drain + +# Pause-screen text-box margins (pixels at the device's 128x128 LCD). +# These control where the pause-text wrapping/truncation box sits inside +# the screen and are mirrored 1:1 into the firmware's drawWrapped() call. +DEFAULT_PAUSE_MARGIN_LEFT = 4 +DEFAULT_PAUSE_MARGIN_RIGHT = 4 +DEFAULT_PAUSE_MARGIN_TOP = 16 +DEFAULT_PAUSE_MARGIN_BOTTOM = 12 + +# Device LCD dimensions — used by the pause preview to render at the +# same proportions as the device. +DEVICE_SCREEN_W = 128 +DEVICE_SCREEN_H = 128 + +NODE_TYPES = { + "start": {"label": "Start", "color": "#2ECC71", "desc": "Routine starts here"}, + "text": {"label": "Type Text", "color": "#4A90D9", "desc": "Type a string of text"}, + "combo": {"label": "Key Combo", "color": "#D94A4A", "desc": "Press a key combination"}, + "pause": {"label": "Pause", "color": "#D9A84A", "desc": "Wait for click or timer"}, + "branch": {"label": "Branch", "color": "#9B59B6", "desc": "Choose between paths"}, + "delay": {"label": "Delay", "color": "#7F8C8D", "desc": "Wait milliseconds"}, + "repeat": {"label": "Loop", "color": "#27AE60", "desc": "Repeat nodes N times"}, + "loop_selector": {"label": "Loop Selector", "color": "#F1C40F", "desc": "Prompt user for loop count"}, + "iteration_branch": {"label": "Iteration Branch", "color": "#8E44AD", "desc": "Branch based on loop iteration number"}, + "note": {"label": "Note", "color": "#555555", "desc": "On-canvas annotation (not sent to device)"}, + "aggregator": {"label": "Aggregator", "color": "#5D6D7E", "desc": "Merge multiple paths into one output"}, + "mouse": {"label": "Mouse Click", "color": "#E67E22", "desc": "Send mouse button"}, + "media": {"label": "Media Key", "color": "#1ABC9C", "desc": "Media control key"}, + "bluetooth": {"label": "Variables", "color": "#0077CC", "desc": "Read/write variables (BLE or local)"}, + "rs232": {"label": "RS232 Send", "color": "#8B5CF6", "desc": "Send data via RS232 serial"}, + "subroutine": {"label": "Sub-Routine", "color": "#FF6B9D", "desc": "Call a reusable sub-routine"}, + "pc_alive_check": {"label": "PC Alive Check", "color": "#F59E0B", "desc": "Check if host PC is responding"}, + "macro": {"label": "Macro", "color": "#14B8A6", "desc": "Replay an exact recorded key-press sequence"}, +} + +SUBROUTINES_PROFILE_NAME = "Sub-Routines" + +RS232_BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200] +RS232_DATA_BITS = [5, 6, 7, 8] +RS232_STOP_BITS = ["1", "1.5", "2"] +RS232_PARITY = ["none", "even", "odd"] +RS232_LINE_ENDINGS = [("None", "none"), ("CR (\\r)", "cr"), ("LF (\\n)", "lf"), ("CRLF (\\r\\n)", "crlf")] + +MODIFIER_KEYS = ["ctrl", "shift", "alt", "gui", "rctrl", "rshift", "ralt", "rgui"] + +MODIFIER_LABELS = { + "ctrl": "Ctrl", "shift": "Shift", "alt": "Alt", "gui": "Win", + "rctrl": "Right Ctrl", "rshift": "Right Shift", "ralt": "AltGr", "rgui": "Right Win", +} + +SPECIAL_KEYS = [ + "enter", "esc", "backspace", "tab", "space", "delete", "insert", + "home", "end", "pageup", "pagedown", + "up", "down", "left", "right", + "capslock", "numlock", "scrolllock", "printscreen", "pause", "menu", + "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", + "f13", "f14", "f15", "f16", "f17", "f18", "f19", "f20", "f21", "f22", "f23", "f24", +] + +MOUSE_BUTTONS = ["left", "right", "middle"] +MOUSE_ACTIONS = ["click", "double", "press", "release"] + +MEDIA_ACTIONS = [ + ("vol_up", "Volume Up"), ("vol_down", "Volume Down"), ("mute", "Mute"), + ("play_pause", "Play/Pause"), ("next", "Next Track"), ("prev", "Previous Track"), + ("stop", "Stop"), ("brightness_up", "Brightness Up"), ("brightness_down", "Brightness Down"), +] + +ORIENTATION_OPTIONS = [ + (0, "Normal (0\u00b0)"), + (1, "90\u00b0"), + (2, "180\u00b0"), + (3, "270\u00b0"), +] + +# (name, GUI hex preview) +DISPLAY_COLORS = [ + ("white", "#FFFFFF"), + ("red", "#FF4444"), + ("green", "#44FF88"), + ("blue", "#4488FF"), + ("yellow", "#FFFF44"), + ("cyan", "#44FFFF"), + ("magenta", "#FF44FF"), + ("orange", "#FFA040"), +] + +# HID USB usage codes for the "macro" recording node. +# Macro events are stored as [t_ms, action, hid_code] tuples. Using raw HID +# usage codes (not ASCII) lets us press/release each physical key independently +# — essential when modifiers and letters overlap or when multiple keys are +# held at once. The firmware calls keyboard.pressRaw() / keyboard.releaseRaw() +# with these codes directly, so modifiers never get retyped out from under a +# chord. +# +# Map from Tkinter keysym → HID USB usage code (standard 104-key layout). +# Keysym ordering reference: https://www.tcl.tk/man/tcl/TkCmd/keysyms.html +TKKEYSYM_TO_HID = { + # --- Letters (both cases map to the same physical key) --- + **{chr(c): 0x04 + (c - ord('a')) for c in range(ord('a'), ord('z') + 1)}, + **{chr(c): 0x04 + (c - ord('A')) for c in range(ord('A'), ord('Z') + 1)}, + # --- Top-row digits (1..0) --- + "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, + "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, "0": 0x27, + # --- Whitespace / edit --- + "Return": 0x28, "KP_Enter": 0x58, + "Escape": 0x29, "BackSpace": 0x2A, "Tab": 0x2B, "space": 0x2C, + # --- Punctuation (US layout shift-pair maps to same key) --- + "minus": 0x2D, "underscore": 0x2D, + "equal": 0x2E, "plus": 0x2E, + "bracketleft": 0x2F, "braceleft": 0x2F, + "bracketright": 0x30, "braceright": 0x30, + "backslash": 0x31, "bar": 0x31, + "semicolon": 0x33, "colon": 0x33, + "apostrophe": 0x34, "quotedbl": 0x34, + "grave": 0x35, "asciitilde": 0x35, + "comma": 0x36, "less": 0x36, + "period": 0x37, "greater": 0x37, + "slash": 0x38, "question": 0x38, + # Shifted digits — map to the underlying digit key + "exclam": 0x1E, "at": 0x1F, "numbersign": 0x20, "dollar": 0x21, + "percent": 0x22, "asciicircum": 0x23, "ampersand": 0x24, + "asterisk": 0x25, "parenleft": 0x26, "parenright": 0x27, + # --- Locks / system --- + "Caps_Lock": 0x39, + "Num_Lock": 0x53, + "Scroll_Lock": 0x47, + "Print": 0x46, + "Pause": 0x48, + "Menu": 0x65, + # --- Navigation --- + "Insert": 0x49, "Home": 0x4A, "Prior": 0x4B, + "Delete": 0x4C, "End": 0x4D, "Next": 0x4E, + "Right": 0x4F, "Left": 0x50, "Down": 0x51, "Up": 0x52, + # --- Function keys --- + **{f"F{i}": 0x3A + (i - 1) for i in range(1, 13)}, # F1..F12 → 0x3A..0x45 + **{f"F{i}": 0x68 + (i - 13) for i in range(13, 25)}, # F13..F24 → 0x68..0x73 + # --- Modifiers (0xE0..0xE7) --- + "Control_L": 0xE0, "Shift_L": 0xE1, "Alt_L": 0xE2, "Super_L": 0xE3, + "Control_R": 0xE4, "Shift_R": 0xE5, "Alt_R": 0xE6, "Super_R": 0xE7, + # Numpad + "KP_Divide": 0x54, "KP_Multiply": 0x55, "KP_Subtract": 0x56, + "KP_Add": 0x57, "KP_Decimal": 0x63, + **{f"KP_{i}": 0x59 + (i - 1) for i in range(1, 10)}, # KP_1..KP_9 → 0x59..0x61 + "KP_0": 0x62, +} + +def hid_code_label(code: int) -> str: + """Friendly short label for a HID usage code (for the macro event list).""" + # Prefer a pretty name over the raw keysym + PRETTY = { + 0x28: "Enter", 0x29: "Esc", 0x2A: "Backspace", 0x2B: "Tab", 0x2C: "Space", + 0x4C: "Delete", 0x49: "Insert", 0x4A: "Home", 0x4D: "End", + 0x4B: "PageUp", 0x4E: "PageDown", + 0x4F: "→", 0x50: "←", 0x51: "↓", 0x52: "↑", + 0x39: "CapsLock", 0x53: "NumLock", 0x47: "ScrollLock", + 0x46: "PrtSc", 0x48: "Pause", 0x65: "Menu", + 0x58: "KP Enter", 0x54: "KP /", 0x55: "KP *", 0x56: "KP -", + 0x57: "KP +", 0x63: "KP .", + 0xE0: "LCtrl", 0xE1: "LShift", 0xE2: "LAlt", 0xE3: "LWin", + 0xE4: "RCtrl", 0xE5: "RShift", 0xE6: "RAlt", 0xE7: "RWin", + } + if code in PRETTY: + return PRETTY[code] + if 0x04 <= code <= 0x1D: + return chr(ord('A') + (code - 0x04)) + if 0x1E <= code <= 0x26: + return str(1 + (code - 0x1E)) + if code == 0x27: + return "0" + if 0x3A <= code <= 0x45: + return f"F{1 + (code - 0x3A)}" + if 0x68 <= code <= 0x73: + return f"F{13 + (code - 0x68)}" + if 0x59 <= code <= 0x61: + return f"KP{1 + (code - 0x59)}" + if code == 0x62: + return "KP0" + PUNCT = { + 0x2D: "-", 0x2E: "=", 0x2F: "[", 0x30: "]", 0x31: "\\", + 0x33: ";", 0x34: "'", 0x35: "`", 0x36: ",", 0x37: ".", 0x38: "/", + } + return PUNCT.get(code, f"0x{code:02X}") + + +CANVAS_BG = "#1E1E2E" +NODE_HEADER_HEIGHT = 24 +NODE_MIN_WIDTH = 160 +NODE_PORT_RADIUS = 6 +NODE_BODY_COLOR = "#2D2D3D" +NODE_SELECTED_BORDER = "#FFFFFF" +NODE_UPSTREAM_BORDER = "#22C55E" # green — matches input-port color (neighbor wires INTO our input) +NODE_DOWNSTREAM_BORDER = "#EF4444" # red — matches output-port color (neighbor is wired FROM our output) +# Backwards-compatible alias; older code may still import this name. +NODE_CONNECTED_BORDER = NODE_UPSTREAM_BORDER +NODE_TEXT_COLOR = "#FFFFFF" +NODE_PORT_IN_COLOR = "#4ADE80" +NODE_PORT_OUT_COLOR = "#F87171" +WIRE_COLOR = "#6B7280" +WIRE_SELECTED_COLOR = "#FBBF24" +GRID_SIZE = 20 +GRID_COLOR = "#2A2A3A" diff --git a/utils/image_converter.py b/utils/image_converter.py new file mode 100644 index 0000000..2245d34 --- /dev/null +++ b/utils/image_converter.py @@ -0,0 +1,89 @@ +"""Image conversion utilities for ATOMS3 MacroPad.""" + +import math +import os +import random +import shutil +from PIL import Image, ImageTk +from .constants import SCREEN_W, SCREEN_H, IMAGES_DIR + + +def ensure_images_dir(): + os.makedirs(IMAGES_DIR, exist_ok=True) + + +def generate_gradient_image() -> str: + """Generate a random linear gradient PNG, save it to images dir, return its path.""" + ensure_images_dir() + + c1 = (random.randint(30, 220), random.randint(30, 220), random.randint(30, 220)) + c2 = (random.randint(30, 220), random.randint(30, 220), random.randint(30, 220)) + angle = math.radians(random.randint(0, 359)) + cos_a = math.cos(angle) + sin_a = math.sin(angle) + + size = SCREEN_W # 128x128 to match device display + img = Image.new("RGB", (size, size)) + pixels = img.load() + for y in range(size): + for x in range(size): + nx = (x / (size - 1)) * 2 - 1 + ny = (y / (size - 1)) * 2 - 1 + t = max(0.0, min(1.0, (nx * cos_a + ny * sin_a + 1) / 2)) + pixels[x, y] = ( + int(c1[0] + (c2[0] - c1[0]) * t), + int(c1[1] + (c2[1] - c1[1]) * t), + int(c1[2] + (c2[2] - c1[2]) * t), + ) + + token = "%08x" % random.getrandbits(32) + path = os.path.join(IMAGES_DIR, f"gradient_{token}.png") + img.save(path) + return path + + +def copy_image_to_appdata(image_path: str) -> str: + """Copy an image to the AppData images folder, return new path.""" + ensure_images_dir() + basename = os.path.basename(image_path) + # Hash-suffix the filename to avoid collisions between same-named imports + name, ext = os.path.splitext(basename) + import hashlib + h = hashlib.md5(open(image_path, "rb").read()).hexdigest()[:8] + dest = os.path.join(IMAGES_DIR, f"{name}_{h}{ext}") + if not os.path.exists(dest): + shutil.copy2(image_path, dest) + return dest + + +def convert_to_rgb565(image_path: str) -> bytes: + """Convert image to 128x128 RGB565 big-endian bytes (32768 bytes).""" + img = Image.open(image_path).convert("RGB") + img = img.resize((SCREEN_W, SCREEN_H), Image.LANCZOS) + + data = bytearray(SCREEN_W * SCREEN_H * 2) + pixels = img.load() + + for y in range(SCREEN_H): + for x in range(SCREEN_W): + r, g, b = pixels[x, y] + rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) + offset = (y * SCREEN_W + x) * 2 + data[offset] = (rgb565 >> 8) & 0xFF # MSB + data[offset + 1] = rgb565 & 0xFF # LSB + + return bytes(data) + + +def create_thumbnail(image_path: str, size: tuple = (64, 64)): + """Create a tkinter-compatible thumbnail from an image file.""" + img = Image.open(image_path).convert("RGB") + img.thumbnail(size, Image.LANCZOS) + return ImageTk.PhotoImage(img) + + +def create_preview(image_path: str, size: tuple = (128, 128)): + """Larger preview image for the properties panel.""" + img = Image.open(image_path).convert("RGB") + img = img.resize(size, Image.LANCZOS) + return ImageTk.PhotoImage(img) diff --git a/utils/win_keyboard_hook.py b/utils/win_keyboard_hook.py new file mode 100644 index 0000000..8c9d797 --- /dev/null +++ b/utils/win_keyboard_hook.py @@ -0,0 +1,356 @@ +"""Low-level Windows keyboard hook for live macro recording. + +Why this exists: + Tkinter on Windows can capture printable keys and most modifiers + in its / bindings, but the Windows key + (VK_LWIN / VK_RWIN) and several other system shortcuts are + intercepted by the OS before any window sees them. To relay + those over BLE to the target machine we need a system-wide + low-level keyboard hook (WH_KEYBOARD_LL) and we need to suppress + the local event so it doesn't *also* fire on the host. + +Critical implementation detail: + LRESULT is a pointer-sized signed integer (4 bytes on x86, + 8 bytes on x64). ctypes.c_long is only 32 bits on Windows x64, + so declaring the hook proc's return type as c_long causes the + "return 1 to suppress" path to silently get sign-extended to a + value Windows reads as "do not suppress" on 64-bit Pythons. + This module uses ctypes.c_ssize_t throughout and EXPLICITLY + declares argtypes/restype on every Win32 function so the + marshalling is right. + +Lifecycle: + hook = WinKeyboardHook(on_event=cb, on_escape=cb2) + hook.start(suppress_local=True) + ... + hook.stop() + +Caveats: + - Ctrl+Alt+Delete is the Windows Secure Attention Sequence and + cannot be hooked by any user-mode code. + - Xbox Game Bar shortcuts (Win+G, Win+R when GB is foregrounded, + etc.) bypass user-mode hooks. They're handled at a lower level. + - The hook runs in its own thread with a Windows message pump; + callbacks fire on that thread. Marshal back to Tk via + Widget.after(0, ...). +""" + +from __future__ import annotations + +import ctypes +import sys +import threading +from ctypes import wintypes +from typing import Callable, Optional + +# Hook ID and message constants +WH_KEYBOARD_LL = 13 +HC_ACTION = 0 +WM_KEYDOWN = 0x0100 +WM_KEYUP = 0x0101 +WM_SYSKEYDOWN = 0x0104 +WM_SYSKEYUP = 0x0105 +WM_QUIT = 0x0012 + +LLKHF_EXTENDED = 0x01 +LLKHF_INJECTED = 0x10 + +# LRESULT is LONG_PTR (signed pointer-sized). c_ssize_t matches that +# on all platforms ctypes runs on, unlike c_long which is 32 bits on +# 64-bit Windows. +LRESULT = ctypes.c_ssize_t + + +# ---- VK → HID translation tables ---- + +# Most non-modifier keys. +_VK_TO_HID: dict[int, int] = { + # Letters (VK_A..VK_Z = 0x41..0x5A) -> HID 0x04..0x1D + **{0x41 + i: 0x04 + i for i in range(26)}, + # Digits (VK_0..VK_9) -> HID 0x1E..0x27 + 0x31: 0x1E, 0x32: 0x1F, 0x33: 0x20, 0x34: 0x21, 0x35: 0x22, + 0x36: 0x23, 0x37: 0x24, 0x38: 0x25, 0x39: 0x26, 0x30: 0x27, + # Whitespace / edit + 0x0D: 0x28, # VK_RETURN + 0x1B: 0x29, # VK_ESCAPE (handled specially) + 0x08: 0x2A, # VK_BACK + 0x09: 0x2B, # VK_TAB + 0x20: 0x2C, # VK_SPACE + # US-layout punctuation + 0xBD: 0x2D, 0xBB: 0x2E, 0xDB: 0x2F, 0xDD: 0x30, 0xDC: 0x31, + 0xBA: 0x33, 0xDE: 0x34, 0xC0: 0x35, 0xBC: 0x36, 0xBE: 0x37, 0xBF: 0x38, + # Locks / system + 0x14: 0x39, 0x90: 0x53, 0x91: 0x47, 0x2C: 0x46, 0x13: 0x48, 0x5D: 0x65, + # Navigation + 0x2D: 0x49, 0x24: 0x4A, 0x21: 0x4B, + 0x2E: 0x4C, 0x23: 0x4D, 0x22: 0x4E, + 0x27: 0x4F, 0x25: 0x50, 0x28: 0x51, 0x26: 0x52, + # F1..F12 + **{0x70 + i: 0x3A + i for i in range(12)}, + # F13..F24 + **{0x7C + i: 0x68 + i for i in range(12)}, + # Numpad + 0x6F: 0x54, 0x6A: 0x55, 0x6D: 0x56, 0x6B: 0x57, 0x6E: 0x63, + 0x61: 0x59, 0x62: 0x5A, 0x63: 0x5B, 0x64: 0x5C, 0x65: 0x5D, + 0x66: 0x5E, 0x67: 0x5F, 0x68: 0x60, 0x69: 0x61, + 0x60: 0x62, +} + +# Modifiers — including the Windows key, which is the whole reason +# this module exists. +_VK_MODIFIER_TO_HID: dict[int, int] = { + 0xA0: 0xE1, # VK_LSHIFT + 0xA1: 0xE5, # VK_RSHIFT + 0xA2: 0xE0, # VK_LCONTROL + 0xA3: 0xE4, # VK_RCONTROL + 0xA4: 0xE2, # VK_LMENU (Left Alt) + 0xA5: 0xE6, # VK_RMENU (Right Alt / AltGr) + 0x5B: 0xE3, # VK_LWIN <-- the Windows key + 0x5C: 0xE7, # VK_RWIN +} + + +def is_supported() -> bool: + return sys.platform == "win32" + + +# ---- ctypes structs ---- + +class KBDLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("vkCode", wintypes.DWORD), + ("scanCode", wintypes.DWORD), + ("flags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", ctypes.c_void_p), + ] + + +# HOOKPROC: LRESULT (*)(int nCode, WPARAM wParam, LPARAM lParam) +LowLevelKeyboardProc = ctypes.WINFUNCTYPE( + LRESULT, # return: LRESULT (NOT c_long!) + ctypes.c_int, # nCode + wintypes.WPARAM, # wParam + wintypes.LPARAM, # lParam +) + + +# ---- Win32 function bindings with explicit argtypes/restype ---- + +if is_supported(): + _user32 = ctypes.WinDLL("user32", use_last_error=True) + _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + _user32.SetWindowsHookExW.argtypes = [ + ctypes.c_int, LowLevelKeyboardProc, + wintypes.HINSTANCE, wintypes.DWORD, + ] + _user32.SetWindowsHookExW.restype = wintypes.HHOOK + + _user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK] + _user32.UnhookWindowsHookEx.restype = wintypes.BOOL + + _user32.CallNextHookEx.argtypes = [ + wintypes.HHOOK, ctypes.c_int, + wintypes.WPARAM, wintypes.LPARAM, + ] + _user32.CallNextHookEx.restype = LRESULT + + _user32.GetMessageW.argtypes = [ + ctypes.POINTER(wintypes.MSG), wintypes.HWND, + wintypes.UINT, wintypes.UINT, + ] + _user32.GetMessageW.restype = ctypes.c_int # signed BOOL + + _user32.TranslateMessage.argtypes = [ctypes.POINTER(wintypes.MSG)] + _user32.TranslateMessage.restype = wintypes.BOOL + + _user32.DispatchMessageW.argtypes = [ctypes.POINTER(wintypes.MSG)] + _user32.DispatchMessageW.restype = LRESULT + + _user32.PostThreadMessageW.argtypes = [ + wintypes.DWORD, wintypes.UINT, + wintypes.WPARAM, wintypes.LPARAM, + ] + _user32.PostThreadMessageW.restype = wintypes.BOOL + + _kernel32.GetCurrentThreadId.argtypes = [] + _kernel32.GetCurrentThreadId.restype = wintypes.DWORD + + _kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR] + _kernel32.GetModuleHandleW.restype = wintypes.HMODULE +else: + _user32 = None + _kernel32 = None + + +class WinKeyboardHook: + """Installs WH_KEYBOARD_LL, translates VKs to HID codes, and + suppresses anything we forward so the host doesn't react to it.""" + + ACTION_DOWN = 0 + ACTION_UP = 1 + + def __init__( + self, + on_event: Callable[[int, int], None], + on_escape: Optional[Callable[[], None]] = None, + ): + if not is_supported(): + raise RuntimeError( + f"WinKeyboardHook only runs on Windows (sys.platform={sys.platform})") + self._on_event = on_event + self._on_escape = on_escape + self._suppress = True + self._thread: threading.Thread | None = None + self._thread_id: int | None = None + self._hook_id = None + self._ready_evt = threading.Event() + self._install_err: int | None = None + # Hold a strong reference so the GC doesn't sweep the callable + # while Windows is still calling into it. + self._proc = LowLevelKeyboardProc(self._hook_proc) + + def start(self, suppress_local: bool = True) -> bool: + """Install the hook. Returns True on success, False if the + Windows API rejected the install (uncommon; usually means + the calling process lacks message-loop privileges).""" + if self._thread is not None: + return True + self._suppress = bool(suppress_local) + self._ready_evt.clear() + self._install_err = None + self._thread = threading.Thread( + target=self._thread_main, + name="WinKeyboardHook", daemon=True) + self._thread.start() + # Wait until the install has resolved one way or the other so + # the caller knows whether it actually took. + self._ready_evt.wait(timeout=2.0) + if self._hook_id in (None, 0): + print(f"[winhook] install failed (err={self._install_err})") + return False + return True + + def stop(self, timeout: float = 2.0) -> None: + """Uninstall the hook. Safe to call multiple times.""" + tid = self._thread_id + if tid is not None and _user32 is not None: + try: + _user32.PostThreadMessageW(tid, WM_QUIT, 0, 0) + except Exception: + pass + if self._thread is not None: + self._thread.join(timeout=timeout) + self._thread = None + self._thread_id = None + self._hook_id = None + + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + # ---- internal ---- + + def _thread_main(self) -> None: + assert _user32 is not None and _kernel32 is not None + self._thread_id = _kernel32.GetCurrentThreadId() + hmod = _kernel32.GetModuleHandleW(None) + self._hook_id = _user32.SetWindowsHookExW( + WH_KEYBOARD_LL, self._proc, hmod, 0) + if not self._hook_id: + self._install_err = ctypes.get_last_error() + self._ready_evt.set() + return + self._ready_evt.set() + try: + msg = wintypes.MSG() + # GetMessageW returns: + # >0 if a message was retrieved + # 0 if WM_QUIT was retrieved (clean exit) + # -1 on error + while True: + ret = _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) + if ret <= 0: + break + _user32.TranslateMessage(ctypes.byref(msg)) + _user32.DispatchMessageW(ctypes.byref(msg)) + finally: + try: + _user32.UnhookWindowsHookEx(self._hook_id) + except Exception: + pass + + def _hook_proc(self, nCode, wParam, lParam): + # Pass-through anything that isn't an action (nCode < 0) or + # that the docs say doesn't apply (nCode != HC_ACTION). + if nCode != HC_ACTION: + return _user32.CallNextHookEx( + self._hook_id, nCode, wParam, lParam) + + try: + kbd = ctypes.cast( + lParam, ctypes.POINTER(KBDLLHOOKSTRUCT))[0] + vk = kbd.vkCode + flags = kbd.flags + is_down = wParam in (WM_KEYDOWN, WM_SYSKEYDOWN) + is_up = wParam in (WM_KEYUP, WM_SYSKEYUP) + + # Defense-in-depth: don't recurse on input we synthesized. + if flags & LLKHF_INJECTED: + return _user32.CallNextHookEx( + self._hook_id, nCode, wParam, lParam) + + # Escape stops the recording locally; never forwarded. + if vk == 0x1B and is_down and self._on_escape is not None: + try: + self._on_escape() + except Exception: + pass + # Let it through — Escape can also dismiss whatever + # modal is open. Suppression isn't needed. + return _user32.CallNextHookEx( + self._hook_id, nCode, wParam, lParam) + + hid = self._vk_to_hid(vk, flags) + if hid is None: + # Unknown key — pass through. We capture the broad set + # of keys mapped above; anything else is rare and + # safer to leak through than to silently swallow. + return _user32.CallNextHookEx( + self._hook_id, nCode, wParam, lParam) + + if is_down: + self._safe_emit(self.ACTION_DOWN, hid) + elif is_up: + self._safe_emit(self.ACTION_UP, hid) + + if self._suppress: + # Returning a non-zero LRESULT tells the OS to drop + # the event before it reaches any window or the + # shell. For VK_LWIN this is what stops the Start + # menu from opening. + return LRESULT(1).value + except Exception: + # NEVER let the hook proc raise — Windows would silently + # disable the hook and the user would see no keys at all. + pass + return _user32.CallNextHookEx( + self._hook_id, nCode, wParam, lParam) + + @staticmethod + def _vk_to_hid(vk: int, flags: int) -> Optional[int]: + if vk in _VK_MODIFIER_TO_HID: + return _VK_MODIFIER_TO_HID[vk] + if vk == 0x10: + return 0xE1 + if vk == 0x11: + return 0xE4 if (flags & LLKHF_EXTENDED) else 0xE0 + if vk == 0x12: + return 0xE6 if (flags & LLKHF_EXTENDED) else 0xE2 + return _VK_TO_HID.get(vk) + + def _safe_emit(self, action: int, hid: int) -> None: + try: + self._on_event(action, hid) + except Exception: + pass diff --git a/widgets/__init__.py b/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/widgets/backup_dialog.py b/widgets/backup_dialog.py new file mode 100644 index 0000000..255ddbd --- /dev/null +++ b/widgets/backup_dialog.py @@ -0,0 +1,220 @@ +"""Backup restore dialog - lists all backups and offers restore/delete actions.""" + +import tkinter as tk +from tkinter import messagebox + + +class BackupDialog(tk.Toplevel): + """Modal dialog for browsing and restoring backups.""" + + PAGE_SIZE = 50 + + def __init__(self, parent, backup_manager, on_restore=None): + super().__init__(parent) + self.backup_manager = backup_manager + self.on_restore = on_restore + self._visible_count = self.PAGE_SIZE + + self.title("Backup Manager") + self.geometry("520x520") + self.minsize(400, 400) + self.configure(bg="#2D2D3D") + self.transient(parent) + self.grab_set() + + self._build_ui() + self._refresh() + + def _build_ui(self): + header = tk.Frame(self, bg="#1E1E2E") + header.pack(fill="x") + tk.Label(header, text="Backups", bg="#1E1E2E", fg="white", + font=("Segoe UI", 12, "bold"), pady=10).pack(side="left", padx=14) + + info = tk.Label(self, text="Automatic backups are taken after changes are made.\n" + "Select a backup to restore or delete it.", + bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 9), + justify="left") + info.pack(anchor="w", padx=14, pady=(8, 4)) + + list_frame = tk.Frame(self, bg="#2D2D3D") + list_frame.pack(fill="both", expand=True, padx=14, pady=(4, 8)) + + header_row = tk.Frame(list_frame, bg="#1E1E2E") + header_row.pack(fill="x") + tk.Label(header_row, text="When", bg="#1E1E2E", fg="#888888", + font=("Segoe UI", 9, "bold"), anchor="w").pack(side="left", fill="x", + expand=True, padx=(8, 4), pady=4) + tk.Label(header_row, text="Size", bg="#1E1E2E", fg="#888888", + font=("Segoe UI", 9, "bold"), anchor="w", + width=10).pack(side="left", padx=4, pady=4) + + canvas_frame = tk.Frame(list_frame, bg="#1E1E2E") + canvas_frame.pack(fill="both", expand=True) + + self._list_canvas = tk.Canvas(canvas_frame, bg="#1E1E2E", highlightthickness=0) + scrollbar = tk.Scrollbar(canvas_frame, orient="vertical", + command=self._list_canvas.yview) + self._list_inner = tk.Frame(self._list_canvas, bg="#1E1E2E") + + self._list_inner.bind("", + lambda e: self._list_canvas.configure( + scrollregion=self._list_canvas.bbox("all"))) + self._list_canvas.create_window((0, 0), window=self._list_inner, anchor="nw", tags="inner") + self._list_canvas.configure(yscrollcommand=scrollbar.set) + self._list_canvas.bind("", + lambda e: self._list_canvas.itemconfig("inner", width=e.width)) + self._list_canvas.bind_all("", + lambda e: self._list_canvas.yview_scroll( + -1 * (e.delta // 120), "units")) + + self._list_canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + footer = tk.Frame(self, bg="#1E1E2E") + footer.pack(fill="x", side="bottom") + + self._status_label = tk.Label(footer, text="", bg="#1E1E2E", fg="#AAAAAA", + font=("Segoe UI", 9)) + self._status_label.pack(side="left", padx=14, pady=10) + + tk.Button(footer, text="Close", command=self.destroy, bg="#3D3D5C", fg="white", + font=("Segoe UI", 9), relief="flat", + padx=14, pady=4).pack(side="right", padx=8, pady=8) + + tk.Button(footer, text="Create Backup Now", command=self._create_now, + bg="#4A4A6A", fg="white", font=("Segoe UI", 9), relief="flat", + padx=10, pady=4).pack(side="right", padx=4, pady=8) + + def _refresh(self): + """Rebuild the backup list.""" + for widget in self._list_inner.winfo_children(): + widget.destroy() + + backups = self.backup_manager.list_backups() + total_count = len(backups) + + if self._visible_count < self.PAGE_SIZE: + self._visible_count = self.PAGE_SIZE + shown = min(self._visible_count, total_count) + + if not backups: + tk.Label(self._list_inner, text="No backups yet.", + bg="#1E1E2E", fg="#666666", font=("Segoe UI", 10, "italic"), + pady=20).pack() + else: + for i, info in enumerate(backups[:shown]): + self._create_row(info, i) + + remaining = total_count - shown + if remaining > 0: + self._create_load_more_row(remaining) + + total = self.backup_manager.total_size_formatted() + if total_count and shown < total_count: + label = (f"Showing {shown} of {total_count} backup" + f"{'s' if total_count != 1 else ''} • {total} on disk") + else: + label = (f"{total_count} backup{'s' if total_count != 1 else ''}" + f" • {total} on disk") + self._status_label.config(text=label) + + def _create_load_more_row(self, remaining): + row = tk.Frame(self._list_inner, bg="#1E1E2E") + row.pack(fill="x", pady=6) + take = min(self.PAGE_SIZE, remaining) + tk.Button(row, text=f"Load {take} more ({remaining} remaining)", + command=self._load_more, + bg="#3D3D5C", fg="white", font=("Segoe UI", 9), + relief="flat", padx=14, pady=4).pack(pady=4) + + def _load_more(self): + self._visible_count += self.PAGE_SIZE + self._refresh() + # Jump to the bottom so the newly-loaded rows are visible + self._list_canvas.update_idletasks() + self._list_canvas.yview_moveto(1.0) + + def _create_row(self, info, index): + bg = "#222230" if index % 2 == 0 else "#1E1E2E" + hover_bg = "#2A2A3A" + + row = tk.Frame(self._list_inner, bg=bg, cursor="hand2") + row.pack(fill="x") + + ts_label = tk.Label(row, text=info.human_readable_time(), + bg=bg, fg="white", font=("Segoe UI", 10), + anchor="w", padx=8, pady=6) + ts_label.pack(side="left", fill="x", expand=True) + + size_label = tk.Label(row, text=info.human_readable_size(), + bg=bg, fg="#AAAAAA", font=("Segoe UI", 9), + anchor="w", width=10, padx=4, pady=6) + size_label.pack(side="left") + + del_btn = tk.Button(row, text="×", bg="#3D3D5C", fg="#FF7777", + font=("Segoe UI", 11, "bold"), relief="flat", + width=2, + command=lambda p=info.path: self._delete_one(p)) + del_btn.pack(side="right", padx=(4, 8), pady=3) + + restore_btn = tk.Button(row, text="Restore", bg="#27AE60", fg="white", + font=("Segoe UI", 9), relief="flat", + padx=10, pady=2, + command=lambda p=info.path: self._restore_one(p)) + restore_btn.pack(side="right", padx=4, pady=3) + + for w in (row, ts_label, size_label): + w.bind("", lambda e, r=row: self._set_bg(r, hover_bg)) + w.bind("", lambda e, r=row, b=bg: self._set_bg(r, b)) + + def _set_bg(self, row, color): + row.config(bg=color) + for c in row.winfo_children(): + if isinstance(c, tk.Label): + c.config(bg=color) + + def _delete_one(self, path): + if not messagebox.askyesno("Delete Backup", + f"Delete this backup?\n\n{path.name}\n\n" + "This cannot be undone.", + parent=self): + return + self.backup_manager.delete_backup(path) + self._refresh() + + def _restore_one(self, path): + if not messagebox.askyesno("Restore Backup", + "Restoring will replace ALL current profiles,\n" + "settings, and images with the backup's contents.\n\n" + f"Restore from:\n{path.name}?\n\n" + "A safety backup of the current state will be\n" + "created automatically before restoring.", + parent=self): + return + + self.backup_manager.create_backup() # safety snapshot so the user can undo + + if self.on_restore: + ok = self.on_restore(path) + if ok: + messagebox.showinfo("Restore Complete", + "Backup restored successfully.", + parent=self) + self.destroy() + else: + messagebox.showerror("Restore Failed", + "Could not restore the backup.\n" + "Check the console for details.", + parent=self) + self._refresh() + + def _create_now(self): + path = self.backup_manager.create_backup() + if path: + self._refresh() + else: + messagebox.showerror("Backup Failed", + "Could not create a backup.\n" + "Check the console for details.", + parent=self) diff --git a/widgets/ble_variables_window.py b/widgets/ble_variables_window.py new file mode 100644 index 0000000..6bf3f26 --- /dev/null +++ b/widgets/ble_variables_window.py @@ -0,0 +1,416 @@ +"""Pop-out window for defining variables (Universal + per-device profiles).""" + +import re +import tkinter as tk +from tkinter import messagebox, ttk + + +UNIVERSAL_LABEL = "Universal" +_MAC_RE = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$") + + +class BLEVariablesWindow(tk.Toplevel): + """Window to create / edit / delete variables across profiles. + + Variables live in two scopes: + - Universal: a shared dict applied to every device that doesn't override. + - Per-device: keyed by the ATOMS3's eFuse base MAC. Each device known to + the host gets its own dict. + + A scope dropdown at the top swaps which dict is being edited. Save commits + only the currently-visible dict back into the project — other scopes are + left untouched. + + Args: + parent: Tk parent widget. + ble_variables: Project-shape dict {"universal": {...}, "devices": {mac: {...}}}. + Old flat dicts are accepted and treated as Universal. + ble_comments: Same shape as ble_variables but values are comment + strings. Optional — defaults to an empty structure + so legacy callers keep working unchanged. Comments + are host-side only (never sent to the device). + on_save: Callback(new_variables, new_comments) invoked with + the FULL updated structures (both scopes). For + backward compatibility, callbacks that accept only + a single positional are invoked with just the + variables dict. + """ + + _BG = "#2D2D3D" + _ROW_BG = "#252535" + _ENTRY_BG = "#1E1E2E" + _FG = "#FFFFFF" + _DIM_FG = "#AAAAAA" + _BTN_BG = "#3D3D5C" + _SAVE_BG = "#0077CC" + _DEL_FG = "#FF7777" + + def __init__(self, parent, ble_variables, on_save, ble_comments=None): + super().__init__(parent) + self.title("Variables") + self.geometry("780x500") + self.minsize(560, 320) + self.configure(bg=self._BG) + self.transient(parent) + self.grab_set() + + self._on_save = on_save + self._rows: list[dict] = [] + # In-memory working copies so users can flip between scopes without losing edits + self._working = self._normalize(ble_variables) + self._working_comments = self._normalize(ble_comments) + self._active_label = UNIVERSAL_LABEL + + try: + import ctypes + self.update_idletasks() + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, 20, ctypes.byref(ctypes.c_int(1)), ctypes.sizeof(ctypes.c_int)) + except Exception: + pass + + self._build_ui() + self._load_active() + self.bind("", lambda _: self._save()) + + @staticmethod + def _normalize(raw) -> dict: + if not isinstance(raw, dict): + return {"universal": {}, "devices": {}} + if "universal" in raw or "devices" in raw: + return { + "universal": dict(raw.get("universal") or {}), + "devices": {str(k): dict(v or {}) for k, v in (raw.get("devices") or {}).items()}, + } + return {"universal": dict(raw), "devices": {}} + + def _build_ui(self): + hdr = tk.Frame(self, bg=self._BG) + hdr.pack(fill="x", padx=14, pady=(12, 6)) + + tk.Label(hdr, text="Variables", bg=self._BG, fg="#0AACFF", + font=("Segoe UI", 11, "bold")).pack(side="left") + + tk.Label( + hdr, + text="Use (VAR{name}) in text blocks to insert a value (case-insensitive)", + bg=self._BG, fg=self._DIM_FG, font=("Segoe UI", 8), + ).pack(side="left", padx=(10, 0)) + + scope_row = tk.Frame(self, bg=self._BG) + scope_row.pack(fill="x", padx=14, pady=(0, 6)) + + tk.Label(scope_row, text="Profile:", bg=self._BG, fg=self._DIM_FG, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 6)) + + self._scope_var = tk.StringVar(value=self._active_label) + self._scope_combo = ttk.Combobox( + scope_row, textvariable=self._scope_var, state="readonly", + width=28, font=("Segoe UI", 9), + ) + self._scope_combo.pack(side="left") + self._scope_combo.bind("<>", self._on_scope_change) + + tk.Button( + scope_row, text="+ Device", bg=self._BTN_BG, fg=self._FG, + font=("Segoe UI", 9), relief="flat", padx=8, + command=self._add_device_dialog, + ).pack(side="left", padx=(6, 0)) + + tk.Button( + scope_row, text="− Device", bg=self._BTN_BG, fg=self._DEL_FG, + font=("Segoe UI", 9), relief="flat", padx=8, + command=self._remove_active_device, + ).pack(side="left", padx=(4, 0)) + + self._refresh_scope_options() + + col_hdr = tk.Frame(self, bg=self._BG) + col_hdr.pack(fill="x", padx=14) + tk.Label(col_hdr, text="Variable Name", bg=self._BG, fg=self._DIM_FG, + font=("Segoe UI", 8), width=20, anchor="w").pack(side="left") + # Value and Comment share the remaining width 50/50; using + # ``expand=True`` on both with explicit fill makes Tk distribute + # leftover space evenly without us having to compute pixels. + tk.Label(col_hdr, text="Value", bg=self._BG, fg=self._DIM_FG, + font=("Segoe UI", 8), anchor="w").pack( + side="left", padx=(4, 0), fill="x", expand=True) + tk.Label(col_hdr, text="Comment", bg=self._BG, fg=self._DIM_FG, + font=("Segoe UI", 8), anchor="w").pack( + side="left", padx=(4, 24), fill="x", expand=True) + + outer = tk.Frame(self, bg=self._BG) + outer.pack(fill="both", expand=True, padx=14, pady=4) + + canvas = tk.Canvas(outer, bg=self._BG, highlightthickness=0) + scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview) + canvas.configure(yscrollcommand=scrollbar.set) + + scrollbar.pack(side="right", fill="y") + canvas.pack(side="left", fill="both", expand=True) + + self._rows_frame = tk.Frame(canvas, bg=self._BG) + self._rows_window = canvas.create_window((0, 0), window=self._rows_frame, + anchor="nw") + + self._rows_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")), + ) + canvas.bind( + "", + lambda e: canvas.itemconfig(self._rows_window, width=e.width), + ) + canvas.bind_all("", lambda e: canvas.yview_scroll( + int(-1 * (e.delta / 120)), "units")) + self._canvas = canvas + + btn_row = tk.Frame(self, bg=self._BG) + btn_row.pack(fill="x", padx=14, pady=(4, 12)) + + tk.Button(btn_row, text="+ Add Variable", bg=self._BTN_BG, fg=self._FG, + font=("Segoe UI", 9), relief="flat", padx=10, + command=self._add_row).pack(side="left") + + tk.Button(btn_row, text="Save", bg=self._SAVE_BG, fg=self._FG, + font=("Segoe UI", 9, "bold"), relief="flat", padx=16, + command=self._save).pack(side="right") + + tk.Button(btn_row, text="Cancel", bg=self._BTN_BG, fg=self._FG, + font=("Segoe UI", 9), relief="flat", padx=10, + command=self.destroy).pack(side="right", padx=(0, 6)) + + def _refresh_scope_options(self): + macs = sorted((self._working.get("devices") or {}).keys()) + opts = [UNIVERSAL_LABEL] + [f"Device {m}" for m in macs] + self._scope_combo["values"] = opts + if self._active_label not in opts: + self._active_label = UNIVERSAL_LABEL + self._scope_var.set(self._active_label) + + def _active_dict(self) -> dict: + if self._active_label == UNIVERSAL_LABEL: + return self._working.setdefault("universal", {}) + mac = self._active_label.replace("Device ", "", 1) + return self._working.setdefault("devices", {}).setdefault(mac, {}) + + def _active_comments(self) -> dict: + if self._active_label == UNIVERSAL_LABEL: + return self._working_comments.setdefault("universal", {}) + mac = self._active_label.replace("Device ", "", 1) + return self._working_comments.setdefault("devices", {}).setdefault(mac, {}) + + def _commit_visible_rows(self): + """Write the currently displayed rows back into the active dicts. + + Called before swapping scopes so unsaved edits don't disappear. + Comments are committed only when both name and a non-empty + comment string exist, but they're always keyed off the variable + name so renaming a variable also re-keys its comment. + """ + new_vars = {} + new_comments = {} + for rd in self._rows: + name = rd["name"].get().strip() + if not name: + continue + new_vars[name] = rd["value"].get() + comment = rd["comment"].get() + if comment: + new_comments[name] = comment + if self._active_label == UNIVERSAL_LABEL: + self._working["universal"] = new_vars + self._working_comments["universal"] = new_comments + else: + mac = self._active_label.replace("Device ", "", 1) + self._working.setdefault("devices", {})[mac] = new_vars + self._working_comments.setdefault("devices", {})[mac] = new_comments + + def _on_scope_change(self, _event=None): + self._commit_visible_rows() + self._active_label = self._scope_var.get() + self._load_active() + + def _load_active(self): + for rd in self._rows: + rd["frame"].destroy() + self._rows.clear() + active_vars = self._active_dict() + active_comments = self._active_comments() + for name, value in active_vars.items(): + self._add_row( + name=str(name), + value=str(value), + comment=str(active_comments.get(name, "")), + ) + + def _add_row(self, name: str = "", value: str = "", comment: str = ""): + row_frame = tk.Frame(self._rows_frame, bg=self._ROW_BG, pady=3) + row_frame.pack(fill="x", pady=2) + + name_var = tk.StringVar(value=name) + value_var = tk.StringVar(value=value) + comment_var = tk.StringVar(value=comment) + + name_entry = tk.Entry( + row_frame, textvariable=name_var, bg=self._ENTRY_BG, + fg=self._FG, insertbackground=self._FG, + font=("Segoe UI", 9), relief="flat", width=20, + ) + name_entry.pack(side="left", padx=(6, 4), ipady=3) + + # Pack the delete button BEFORE the stretchy entries so it stays + # pinned to the right edge regardless of which entry happens to + # win the leftover pixels in any given resize tick. + row_data = { + "name": name_var, + "value": value_var, + "comment": comment_var, + "frame": row_frame, + } + del_btn = tk.Button( + row_frame, text="×", bg=self._ROW_BG, fg=self._DEL_FG, + font=("Segoe UI", 10, "bold"), relief="flat", width=2, + command=lambda rd=row_data: self._delete_row(rd), + ) + del_btn.pack(side="right", padx=(0, 4)) + + value_entry = tk.Entry( + row_frame, textvariable=value_var, bg=self._ENTRY_BG, + fg=self._FG, insertbackground=self._FG, + font=("Segoe UI", 9), relief="flat", + ) + value_entry.pack(side="left", fill="x", expand=True, padx=(0, 4), ipady=3) + + comment_entry = tk.Entry( + row_frame, textvariable=comment_var, bg=self._ENTRY_BG, + fg="#BBBBCC", insertbackground=self._FG, + font=("Segoe UI", 9, "italic"), relief="flat", + ) + comment_entry.pack(side="left", fill="x", expand=True, padx=(0, 4), ipady=3) + + self._rows.append(row_data) + + def _delete_row(self, row_data: dict): + row_data["frame"].destroy() + self._rows.remove(row_data) + + def _add_device_dialog(self): + dialog = tk.Toplevel(self) + dialog.title("Add Device Profile") + dialog.configure(bg=self._BG) + dialog.geometry("320x140") + dialog.resizable(False, False) + dialog.transient(self) + dialog.grab_set() + + tk.Label(dialog, text="MAC (AA:BB:CC:DD:EE:FF):", + bg=self._BG, fg=self._FG, + font=("Segoe UI", 9)).pack(anchor="w", padx=12, pady=(14, 2)) + var = tk.StringVar() + entry = tk.Entry(dialog, textvariable=var, bg=self._ENTRY_BG, + fg=self._FG, insertbackground=self._FG, + font=("Segoe UI", 10), relief="flat") + entry.pack(fill="x", padx=12, pady=(0, 8), ipady=4) + entry.focus_set() + + def commit(): + mac = var.get().strip().upper() + if not _MAC_RE.match(mac): + messagebox.showerror( + "Add Device", + "Enter a MAC in AA:BB:CC:DD:EE:FF form.", + parent=dialog, + ) + return + self._commit_visible_rows() + self._working.setdefault("devices", {}).setdefault(mac, {}) + self._active_label = f"Device {mac}" + self._refresh_scope_options() + self._load_active() + dialog.destroy() + + btns = tk.Frame(dialog, bg=self._BG) + btns.pack(side="bottom", pady=10) + tk.Button(btns, text="Cancel", command=dialog.destroy, + bg=self._BTN_BG, fg=self._FG, relief="flat", + padx=10, font=("Segoe UI", 9)).pack(side="left", padx=4) + tk.Button(btns, text="Add", command=commit, + bg=self._SAVE_BG, fg=self._FG, relief="flat", + padx=14, font=("Segoe UI", 9, "bold")).pack(side="left", padx=4) + dialog.bind("", lambda _e: commit()) + + def _remove_active_device(self): + if self._active_label == UNIVERSAL_LABEL: + messagebox.showinfo( + "Variables", + "Universal can't be removed. Switch to a device profile first.", + parent=self, + ) + return + mac = self._active_label.replace("Device ", "", 1) + if not messagebox.askyesno( + "Remove Device Profile", + f"Remove all variables for device {mac}?", + parent=self, + ): + return + devices = self._working.setdefault("devices", {}) + devices.pop(mac, None) + self._active_label = UNIVERSAL_LABEL + self._refresh_scope_options() + self._load_active() + + def _save(self): + # Validate the visible scope and commit it. Other scopes were already + # committed when the user swapped away from them. + seen = set() + new_vars = {} + new_comments = {} + for rd in self._rows: + name = rd["name"].get().strip() + value = rd["value"].get() + comment = rd["comment"].get() + if not name: + messagebox.showerror( + "Variables", + "Variable names cannot be empty.", + parent=self, + ) + return + if name in seen: + messagebox.showerror( + "Variables", + f"Duplicate variable name: '{name}'", + parent=self, + ) + return + seen.add(name) + new_vars[name] = value + if comment: + new_comments[name] = comment + + if self._active_label == UNIVERSAL_LABEL: + self._working["universal"] = new_vars + self._working_comments["universal"] = new_comments + else: + mac = self._active_label.replace("Device ", "", 1) + self._working.setdefault("devices", {})[mac] = new_vars + self._working_comments.setdefault("devices", {})[mac] = new_comments + + # Backward-compat: older callers pass a 1-arg on_save and don't + # know about comments. Try the 2-arg form first; on TypeError + # (wrong arity) fall back to the legacy single-arg call so the + # variable save still lands. + try: + self._on_save(self._working, self._working_comments) + except TypeError: + self._on_save(self._working) + self.destroy() + + # External API — call this from the BLE thread (via after()) when a new + # device MAC appears so the dropdown refreshes. + def register_device(self, mac: str): + self._working.setdefault("devices", {}).setdefault(mac, {}) + self._refresh_scope_options() diff --git a/widgets/bt_keyboard_window.py b/widgets/bt_keyboard_window.py new file mode 100644 index 0000000..a91fbec --- /dev/null +++ b/widgets/bt_keyboard_window.py @@ -0,0 +1,1839 @@ +"""Keyboard — multi-device live keystroke streamer (BLE or ESP-NOW hub). + +A standalone Toplevel window. Lets the user stream their host keyboard +to many M5Stack ATOMS3 / ATOMS3 Lite devices simultaneously, with per- +device enable/disable, recording, and replay. + +Transport is chosen per session (the Keyboard button pops a mode picker): + + * "ble" — one direct BLE link per device (MultiBleKeyboardManager). + Simple, no USB hub required, but Windows only holds ~3-4 + reliable concurrent links, so it soft-caps at 4 devices. + * "hub" — the window borrows the app's USB serial link and switches + the plugged-in device into ESP-NOW HUB mode. The hub + broadcasts the stream to every node over ESP-NOW (no + Bluetooth, no WiFi AP), sidestepping the BLE ceiling and + soft-capping at 12 devices. On close the hub reverts to a + normal node and the serial port is handed back to the app. + +Both managers expose the same public surface (see mesh_manager's module +docstring), so streaming / recording / replay / profiles are identical +regardless of the transport the user picked. + +UX overview (left panel, sectioned): + CONNECTION: [ Discover ] [ Stream ] + MACRO: [ ● Record ] [ Macros | 📁 ] + Record captures keys + mouse (trackpad) + Ctrl+Alt+Del; Stop & Save + stores it into a folder in the macro library (bt_macros). The split + Macros button runs the loaded macro (toggles to Stop while running); + the 📁 opens the library (Quick Run / Load / Delete + Loop, and + folder Create / Rename / Delete). + PROFILES: [ 💾 Save Profile ] [ 📂 Load Profile ] + DEVICES: one row per BLE slot — [enable] label status ev bytes [Remove] + + Right panel: 16:9 virtual trackpad (absolute mouse to all devices) + + a multi-line "type to all devices" box. + +Security: + Each slot is its own BLELiveKeystrokeClient. Each client looks up + the matching per-MAC key in ble_keystore and AES-GCM encrypts every + frame under that key — same security model as the single-device + macro recorder. Adding a second slot doesn't share a key between + devices; each pair stays end-to-end encrypted with its own. +""" + +from __future__ import annotations + +import time +import tkinter as tk +from tkinter import ttk, messagebox, simpledialog +from typing import Optional + +import bt_macros +import bt_profiles +from mesh_link import MeshLink +from mesh_manager import MeshKeyboardManager, MAX_SLOTS as HUB_MAX_SLOTS +from ble_multi import MultiBleKeyboardManager, MAX_SLOTS as BLE_MAX_SLOTS +from widgets.macro_library_picker import MacroLibraryPicker + +# Per-device JOIN timeout while loading a profile, before the +# Skip/Retry dialog pops up. The mesh JOINs in ~tens of ms, so a node +# that hasn't acked within a few seconds is genuinely out of range. +LOAD_TIMEOUT_S = 8.0 + +try: + from utils.win_keyboard_hook import ( + WinKeyboardHook, is_supported as _winhook_supported, + ) +except ImportError: + WinKeyboardHook = None + def _winhook_supported() -> bool: + return False + + +# Tk mouse button number -> absolute-mouse bitmask (bit0 left, bit1 right, +# bit2 middle). Tk uses 1=left, 2=middle, 3=right. +_TK_BTN_TO_MASK = {1: 0x01, 2: 0x04, 3: 0x02} + +# Modifier / special HID usage codes used by the Ctrl+Alt+Del and type-text +# helpers. +HID_LCTRL = 0xE0 +HID_LSHIFT = 0xE1 +HID_LALT = 0xE2 +HID_DELETE = 0x4C + + +def _build_char_map() -> dict: + """ASCII char -> (HID usage code, needs_shift) for a US keyboard.""" + m: dict = {} + for c in range(ord('a'), ord('z') + 1): + code = 0x04 + (c - ord('a')) + m[chr(c)] = (code, False) + m[chr(c).upper()] = (code, True) + for i, ch in enumerate("1234567890"): + m[ch] = (0x1E + i, False) + for i, ch in enumerate("!@#$%^&*()"): + m[ch] = (0x1E + i, True) + m.update({ + ' ': (0x2C, False), '\n': (0x28, False), '\r': (0x28, False), + '\t': (0x2B, False), + '-': (0x2D, False), '=': (0x2E, False), '[': (0x2F, False), + ']': (0x30, False), '\\': (0x31, False), ';': (0x33, False), + "'": (0x34, False), '`': (0x35, False), ',': (0x36, False), + '.': (0x37, False), '/': (0x38, False), + '_': (0x2D, True), '+': (0x2E, True), '{': (0x2F, True), + '}': (0x30, True), '|': (0x31, True), ':': (0x33, True), + '"': (0x34, True), '~': (0x35, True), '<': (0x36, True), + '>': (0x37, True), '?': (0x38, True), + }) + return m + + +_CHAR_TO_HID = _build_char_map() + + +class _NullManager: + """No-op stand-in used when the mesh hub couldn't be brought up (no + device on USB). Keeps every send/query call site working — they just + reach zero devices — so the window stays interactive and the user can + fix the connection and reopen.""" + + def slots(self): + return [] + + def stats(self): + return [] + + def take_beacons(self): + return [] + + def slot_count(self): + return 0 + + def over_soft_limit(self): + return False + + def set_callbacks(self, **_kw): + pass + + def set_hub(self, _mac): + pass + + def add_device(self, *a, **k): + return None + + def get_slot(self, _addr): + return None + + def remove_device(self, *a, **k): + return False + + def set_enabled(self, *a, **k): + pass + + def set_label(self, *a, **k): + pass + + def identify(self, *a, **k): + pass + + def send_event(self, *a, **k): + return 0 + + def send_event_with_t(self, *a, **k): + return 0 + + def send_mouse(self, *a, **k): + return 0 + + def reset_session_clocks(self): + pass + + def shutdown(self): + pass + + +def choose_keyboard_mode(parent) -> Optional[str]: + """Modal transport picker shown when the Keyboard button is clicked. + + Presents the two streaming modes with their soft device caps and + returns "ble", "hub", or None if the user cancels.""" + dlg = tk.Toplevel(parent) + dlg.title("Keyboard — choose a mode") + dlg.configure(bg="#2D2D3D") + dlg.transient(parent.winfo_toplevel()) + dlg.resizable(False, False) + dlg.grab_set() + + choice = {"mode": None} + + def _pick(mode): + choice["mode"] = mode + dlg.destroy() + + tk.Label(dlg, text="How do you want to reach your devices?", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 12, "bold")).pack(padx=22, pady=(16, 4)) + tk.Label(dlg, + text="Pick one transport for this session. Reopen this window " + "to switch modes.", + bg="#2D2D3D", fg="#94A3B8", font=("Segoe UI", 9), + wraplength=440, justify="left").pack(padx=22, pady=(0, 10)) + + def _card(title, desc, cap, accent, active, btn_text, mode): + card = tk.Frame(dlg, bg="#1E1E2E", highlightbackground=accent, + highlightthickness=1) + card.pack(fill="x", padx=22, pady=6) + tk.Label(card, text=title, bg="#1E1E2E", fg=accent, + font=("Segoe UI", 11, "bold")).pack(anchor="w", padx=14, + pady=(10, 2)) + tk.Label(card, text=desc, bg="#1E1E2E", fg="#D1D5DB", + font=("Segoe UI", 9), wraplength=380, + justify="left").pack(anchor="w", padx=14) + tk.Label(card, text=f"Recommended maximum connections: {cap}", + bg="#1E1E2E", fg="#F59E0B", + font=("Segoe UI", 9, "bold")).pack( + anchor="w", padx=14, pady=(6, 2)) + tk.Button(card, text=btn_text, bg=accent, fg="white", + activebackground=active, font=("Segoe UI", 9, "bold"), + relief="flat", padx=16, pady=4, + command=lambda: _pick(mode)).pack(anchor="e", padx=14, + pady=(0, 12)) + + _card("🔵 Bluetooth (BLE)", + "Your PC connects to each device directly over its own Bluetooth " + "Low Energy link. No hub device — the PC drives every device itself.", + BLE_MAX_SLOTS, "#3B82F6", "#2563EB", "Use Bluetooth", "ble") + _card("📡 ESP-NOW Hub", + "The M5Stack plugged into your PC becomes a hub and broadcasts your " + "keystrokes to every other device over Wi-Fi (ESP-NOW). No pairing.", + HUB_MAX_SLOTS, "#7C3AED", "#6D28D9", "Use Hub", "hub") + + tk.Button(dlg, text="Cancel", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=16, + command=dlg.destroy).pack(pady=(2, 14)) + + # Center over the parent window. + dlg.update_idletasks() + try: + top = parent.winfo_toplevel() + x = top.winfo_rootx() + (top.winfo_width() - dlg.winfo_width()) // 2 + y = top.winfo_rooty() + (top.winfo_height() - dlg.winfo_height()) // 3 + dlg.geometry(f"+{max(0, x)}+{max(0, y)}") + except Exception: + pass + + parent.wait_window(dlg) + return choice["mode"] + + +class BtKeyboardWindow(tk.Toplevel): + """Multi-device live keystroke streaming dialog (BLE or ESP-NOW hub).""" + + ACTION_DOWN = 0 + ACTION_UP = 1 + + def __init__(self, parent, serial_manager, mode="hub", pause_var_sync=None, + resume_var_sync=None, pause_port_watcher=None, + resume_port_watcher=None): + super().__init__(parent) + self.parent = parent + self.serial_manager = serial_manager + # Transport for this session: "ble" (direct per-device BLE) or + # "hub" (USB device switched into an ESP-NOW broadcast hub). + self._mode = "ble" if mode == "ble" else "hub" + self._soft_cap = BLE_MAX_SLOTS if self._mode == "ble" else HUB_MAX_SLOTS + self._pause_var_sync = pause_var_sync + self._resume_var_sync = resume_var_sync + self._pause_port_watcher = pause_port_watcher + self._resume_port_watcher = resume_port_watcher + self._var_sync_paused = False + + # Bring up the chosen transport and wire the manager to it. On + # failure (no device plugged in / handshake error / no BLE adapter) + # the window still opens but in a clearly-disabled state. + self._link: MeshLink | None = None + self.manager = None + self._hub_error: str | None = None + if self._mode == "hub": + self._start_hub() + else: + self._start_ble() + if self.manager is None: + self.manager = _NullManager() + else: + self.manager.set_callbacks( + on_status_change=lambda addr, status: + self.after(0, self._refresh_slots), + ) + + # Capture state + self._streaming = False + self._recording = False + self._record_t0_ns: Optional[int] = None + # Recorded macro buffer — tagged events mixing keys + mouse + CAD: + # ["k", t_ms, action, hid] | ["m", t_ms, buttons, x, y, wheel] + # (x/y absolute 0..32767, so playback is desync-proof.) + self._record_buffer: list = [] + # Dedupes auto-repeat (hooks fire repeatedly on a held key). + self._held_codes: set[int] = set() + + # Macro library: the macro Loaded into the Macros button, and the + # after()-driven playback state machine (Quick Run / loaded run). + self._loaded_macro = None # {"folder","name","events","loop"} | None + self._macro_running = False + self._macro_events: list = [] + self._macro_loop = False + self._macro_i = 0 + self._macro_start = 0.0 + self._macro_held_keys: set[int] = set() + self._macro_held_buttons = 0 + self._macro_last_xy = (0.5, 0.5) + + # Virtual-trackpad state + self._mouse_buttons = 0 # current abs-mouse button bitmask + self._pad_box = None # (x0, y0, w, h) of the 16:9 region + self._last_move_send = 0.0 # monotonic ts of last move frame sent + self._fullscreen = True + + # Low-level Windows hook (None on other platforms or if install + # fails). Installed on demand when streaming OR recording starts. + self._win_hook: Optional[WinKeyboardHook] = None + # True while the global keyboard hook is temporarily paused because + # the user is typing in the multi-line text box. We re-arm it when + # focus leaves the box (if streaming/recording is still active). + self._hook_paused_for_text = False + # True while capture is paused because a modal dialog that needs + # keyboard input (e.g. the macro Save dialog, the library picker's + # folder prompts) is open. Re-armed when the dialog closes. + self._capture_paused_for_dialog = False + + # Profile-load state machine (after()-driven so the UI stays live). + self._load_queue = None # list of {"address","label"} or None + self._load_idx = 0 + self._load_deadline = 0.0 + + self._build_ui() + self._refresh_slots() + + # Poll stats / status periodically so the UI shows live counts. + self._stats_job = self.after(500, self._tick_stats) + + self.protocol("WM_DELETE_WINDOW", self._on_close) + + # Surface a transport-setup failure once the window is up. + if self._hub_error: + title = ("Mesh hub not ready" if self._mode == "hub" + else "Bluetooth not ready") + self.after(150, lambda: messagebox.showwarning( + title, self._hub_error, parent=self)) + + # ---- UI construction ---- + + def _build_ui(self): + mode_name = "Bluetooth" if self._mode == "ble" else "ESP-NOW hub" + self.title(f"Keyboard — multi-device streamer ({mode_name})") + self.configure(bg="#2D2D3D") + self.transient(self.parent.winfo_toplevel()) + # Open full-screen; Exit Fullscreen button (and Escape) restore it. + self._fullscreen = True + try: + self.attributes("-fullscreen", True) + except tk.TclError: + self.state("zoomed") + self.bind("", lambda _e: self._exit_fullscreen()) + + # ---- Top bar (full width) ---- + top = tk.Frame(self, bg="#1E1E2E") + top.pack(fill="x", side="top") + tk.Label(top, text=f"Keyboard — {mode_name}", bg="#1E1E2E", + fg="white", font=("Segoe UI", 13, "bold")).pack( + side="left", padx=14, pady=8) + self.capture_var = tk.StringVar(value="Capture: OFF") + tk.Label(top, textvariable=self.capture_var, bg="#1E1E2E", + fg="#94A3B8", font=("Segoe UI", 10, "bold")).pack( + side="left", padx=12) + + self.fs_btn = tk.Button(top, text="Exit Fullscreen", bg="#4A4A6A", + fg="white", font=("Segoe UI", 9), + relief="flat", padx=12, + command=self._toggle_fullscreen) + self.fs_btn.pack(side="right", padx=(6, 14), pady=6) + tk.Button(top, text="Ctrl+Alt+Del", bg="#B91C1C", fg="white", + activebackground="#991B1B", + font=("Segoe UI", 9, "bold"), relief="flat", padx=12, + command=self._send_cad).pack(side="right", padx=6, pady=6) + tk.Button(top, text="Close", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=12, + command=self._on_close).pack(side="right", padx=6, pady=6) + + # ---- Main split: left 1/3 devices, right 2/3 trackpad + text ---- + main = tk.Frame(self, bg="#2D2D3D") + main.pack(fill="both", expand=True) + main.columnconfigure(0, weight=1, uniform="cols") + main.columnconfigure(1, weight=2, uniform="cols") + main.rowconfigure(0, weight=1) + + left = tk.Frame(main, bg="#2D2D3D") + left.grid(row=0, column=0, sticky="nsew", padx=(10, 6), pady=10) + right = tk.Frame(main, bg="#2D2D3D") + right.grid(row=0, column=1, sticky="nsew", padx=(6, 10), pady=10) + + self._build_left(left) + self._build_right(right) + + def _section(self, left, title): + """A titled, visually-grouped section; returns the inner frame to + pack buttons into.""" + tk.Label(left, text=title, bg="#2D2D3D", fg="#9CA3AF", + font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(8, 2)) + outer = tk.Frame(left, bg="#1E1E2E") + outer.pack(fill="x") + inner = tk.Frame(outer, bg="#1E1E2E") + inner.pack(fill="x", padx=6, pady=6) + return inner + + def _build_left(self, left): + tk.Label(left, + text=("Leave each M5Stack idle on its macro selector, then " + "Discover — they connect automatically."), + bg="#2D2D3D", fg="#94A3B8", font=("Segoe UI", 8), + wraplength=340, justify="left").pack(anchor="w", pady=(0, 2)) + + # --- Section: Connection --- + conn = self._section(left, "CONNECTION") + self.discover_btn = tk.Button(conn, text="Discover", bg="#0EA5E9", + fg="white", activebackground="#0284C7", + font=("Segoe UI", 9, "bold"), + relief="flat", padx=10, pady=3, + command=self._discover) + self.discover_btn.pack(side="left") + self.stream_btn = tk.Button(conn, text="▶ Stream", bg="#22C55E", + fg="white", activebackground="#16A34A", + font=("Segoe UI", 9, "bold"), + relief="flat", padx=10, pady=3, + command=self._toggle_stream) + self.stream_btn.pack(side="left", padx=(6, 0)) + + # --- Section: Macro --- + mac = self._section(left, "MACRO") + self.rec_btn = tk.Button(mac, text="● Record", bg="#DC2626", + fg="white", activebackground="#B91C1C", + font=("Segoe UI", 9, "bold"), relief="flat", + padx=10, pady=3, command=self._toggle_record) + self.rec_btn.pack(side="left") + # Split "Macros | 📁" button: left runs the loaded macro (toggles to + # Stop while running); folder icon opens the library. + split = tk.Frame(mac, bg="#1E1E2E") + split.pack(side="left", padx=(8, 0)) + self.macros_run_btn = tk.Button(split, text="Macros", bg="#A78BFA", + fg="#1E1E2E", activebackground="#8B5CF6", + font=("Segoe UI", 9, "bold"), + relief="flat", padx=10, pady=3, + state="disabled", + command=self._on_macros_run) + self.macros_run_btn.pack(side="left") + self.macros_folder_btn = tk.Button(split, text="📁", bg="#7C3AED", + fg="white", activebackground="#6D28D9", + font=("Segoe UI", 10, "bold"), + relief="flat", padx=8, pady=3, + command=self._open_macro_library) + self.macros_folder_btn.pack(side="left", padx=(1, 0)) + + # --- Section: Profiles --- + prof = self._section(left, "PROFILES") + tk.Button(prof, text="💾 Save Profile", bg="#0F766E", fg="white", + activebackground="#0D5C56", font=("Segoe UI", 9, "bold"), + relief="flat", padx=10, pady=3, + command=self._save_profile).pack(side="left") + tk.Button(prof, text="📂 Load Profile", bg="#1D4ED8", fg="white", + activebackground="#1E40AF", font=("Segoe UI", 9, "bold"), + relief="flat", padx=10, pady=3, + command=self._load_profile).pack(side="left", padx=(6, 0)) + + # --- Device list (header + body) --- + tk.Label(left, text="DEVICES", bg="#2D2D3D", fg="#9CA3AF", + font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(8, 2)) + # Brief, non-blocking warning shown when more than the current + # mode's soft cap of devices is connected (the count is uncapped). + self._warn_var = tk.StringVar(value="") + tk.Label(left, textvariable=self._warn_var, bg="#2D2D3D", + fg="#F59E0B", font=("Segoe UI", 8, "bold"), + wraplength=340, justify="left").pack(anchor="w") + list_frame = tk.Frame(left, bg="#2D2D3D") + list_frame.pack(fill="both", expand=True) + header = tk.Frame(list_frame, bg="#1E1E2E") + header.pack(fill="x") + for text, w in [("On", 4), ("Address / Label", 24), + ("Status", 13), ("Lag", 7), ("Events", 9), + ("Bytes", 9), ("", 8)]: + tk.Label(header, text=text, bg="#1E1E2E", fg="#9CA3AF", + font=("Segoe UI", 9, "bold"), + width=w, anchor="w").pack(side="left", padx=4, pady=4) + body = tk.Frame(list_frame, bg="#1E1E2E") + body.pack(fill="both", expand=True) + self.slots_frame = body + + self.recording_status_var = tk.StringVar( + value="No recording in buffer.") + tk.Label(left, textvariable=self.recording_status_var, bg="#2D2D3D", + fg="#9CA3AF", font=("Segoe UI", 9)).pack(anchor="w", + pady=(6, 0)) + + def _build_right(self, right): + right.rowconfigure(0, weight=1) + right.columnconfigure(0, weight=1) + + pad_wrap = tk.Frame(right, bg="#2D2D3D") + pad_wrap.grid(row=0, column=0, sticky="nsew") + tk.Label(pad_wrap, + text=("Virtual trackpad — controls every connected device " + "(absolute position, no desync)"), + bg="#2D2D3D", fg="#94A3B8", + font=("Segoe UI", 9)).pack(anchor="w", pady=(0, 4)) + self.canvas = tk.Canvas(pad_wrap, bg="#11131A", highlightthickness=1, + highlightbackground="#3B3B52", + cursor="tcross") + self.canvas.pack(fill="both", expand=True) + self.canvas.bind("", self._on_pad_configure) + for seq in ("", "", "", ""): + self.canvas.bind(seq, self._on_pad_motion) + for n in (1, 2, 3): + self.canvas.bind(f"", self._on_pad_press) + self.canvas.bind(f"", self._on_pad_release) + self.canvas.bind("", self._on_pad_wheel) + + # ---- Type-to-all row ---- + # Multi-line text box capped at 5 visible lines with a scrollbar + # (infinite lines via scroll). Focusing it pauses the global key + # capture so the user can type into the box without it being + # streamed; leaving it re-arms capture if streaming/recording. + text_row = tk.Frame(right, bg="#2D2D3D") + text_row.grid(row=1, column=0, sticky="ew", pady=(8, 0)) + text_row.columnconfigure(0, weight=1) + tk.Label(text_row, text="Type to all devices (Ctrl+Enter or Send):", + bg="#2D2D3D", fg="white", font=("Segoe UI", 9)).grid( + row=0, column=0, columnspan=2, sticky="w", pady=(0, 2)) + + self.text_entry = tk.Text(text_row, height=5, wrap="word", + bg="#1E1E2E", fg="white", + insertbackground="white", + font=("Segoe UI", 10), relief="flat", + highlightthickness=1, + highlightbackground="#3B3B52", + highlightcolor="#3B82F6") + self.text_entry.grid(row=1, column=0, sticky="ew") + text_sb = tk.Scrollbar(text_row, orient="vertical", + command=self.text_entry.yview) + text_sb.grid(row=1, column=1, sticky="ns") + self.text_entry.config(yscrollcommand=text_sb.set) + self.text_entry.bind("", self._on_textbox_focus_in) + self.text_entry.bind("", self._on_textbox_focus_out) + # Ctrl+Enter sends; plain Enter inserts a newline. + self.text_entry.bind("", self._on_textbox_send_key) + + tk.Button(text_row, text="Send", bg="#22C55E", fg="white", + activebackground="#16A34A", font=("Segoe UI", 9, "bold"), + relief="flat", padx=16, command=self._send_text).grid( + row=1, column=2, sticky="ns", padx=(8, 0)) + + # ---- Fullscreen control ---- + + def _toggle_fullscreen(self): + self._set_fullscreen(not self._fullscreen) + + def _exit_fullscreen(self): + if self._fullscreen: + self._set_fullscreen(False) + + def _set_fullscreen(self, on: bool): + self._fullscreen = bool(on) + try: + self.attributes("-fullscreen", self._fullscreen) + except tk.TclError: + try: + self.state("zoomed" if self._fullscreen else "normal") + except tk.TclError: + pass + if not self._fullscreen: + self.geometry("1100x720") + try: + self.fs_btn.config( + text="Exit Fullscreen" if self._fullscreen else "Fullscreen") + except tk.TclError: + pass + + # ---- Ctrl+Alt+Del ---- + + def _emit_key_action(self, action, hid): + """Send a key to devices AND record it if recording. Used by the + Ctrl+Alt+Del button (whose chord the OS would otherwise swallow).""" + if self._recording and self._record_t0_ns is not None: + t_ms = (time.monotonic_ns() - self._record_t0_ns) // 1_000_000 + self._record_buffer.append(["k", int(t_ms), int(action), int(hid)]) + self.manager.send_event(action, hid) + + def _send_cad(self): + """Send Ctrl+Alt+Del to every connected device (chord then release).""" + for h in (HID_LCTRL, HID_LALT, HID_DELETE): + self._emit_key_action(self.ACTION_DOWN, h) + self.after(60, self._release_cad) + + def _release_cad(self): + for h in (HID_DELETE, HID_LALT, HID_LCTRL): + self._emit_key_action(self.ACTION_UP, h) + + # ---- Virtual trackpad ---- + + def _on_pad_configure(self, _e=None): + self._recompute_pad_box() + self._draw_pad() + + def _recompute_pad_box(self): + try: + cw = self.canvas.winfo_width() + ch = self.canvas.winfo_height() + except tk.TclError: + return + if cw < 8 or ch < 8: + self._pad_box = None + return + # Largest 16:9 box centered in the canvas. + if cw / ch > 16 / 9: + h = ch + w = int(h * 16 / 9) + else: + w = cw + h = int(w * 9 / 16) + self._pad_box = ((cw - w) // 2, (ch - h) // 2, w, h) + + def _draw_pad(self, cursor=None): + c = self.canvas + c.delete("all") + if not self._pad_box: + return + x0, y0, w, h = self._pad_box + c.create_rectangle(x0, y0, x0 + w, y0 + h, outline="#3B82F6", + width=2, fill="#0B0D14") + c.create_text(x0 + w // 2, y0 + 16, + text="16:9 — move / click / scroll here", + fill="#475569", font=("Segoe UI", 9)) + if cursor is not None: + cxp, cyp = cursor + c.create_line(cxp - 9, cyp, cxp + 9, cyp, fill="#22C55E") + c.create_line(cxp, cyp - 9, cxp, cyp + 9, fill="#22C55E") + + def _pad_norm(self, ev): + if not self._pad_box: + return None + x0, y0, w, h = self._pad_box + xn = (ev.x - x0) / w + yn = (ev.y - y0) / h + if xn < 0 or xn > 1 or yn < 0 or yn > 1: + return None + return xn, yn + + def _clamp_norm(self, ev): + if not self._pad_box: + return None + x0, y0, w, h = self._pad_box + return (min(1.0, max(0.0, (ev.x - x0) / w)), + min(1.0, max(0.0, (ev.y - y0) / h))) + + def _maybe_record_mouse(self, buttons, n, wheel): + """Append a mouse event to the record buffer (absolute 0..32767).""" + if n is None or not (self._recording and self._record_t0_ns is not None): + return + t_ms = (time.monotonic_ns() - self._record_t0_ns) // 1_000_000 + x = int(round(max(0.0, min(1.0, n[0])) * 32767)) + y = int(round(max(0.0, min(1.0, n[1])) * 32767)) + self._record_buffer.append( + ["m", int(t_ms), int(buttons) & 0x7, x, y, int(wheel)]) + + def _on_pad_motion(self, ev): + n = self._pad_norm(ev) + if n is None: + return + now = time.monotonic() + # Rate-limit moves (~33 Hz). Absolute positioning means a skipped + # move is harmless — the next one re-pins every cursor. + if now - self._last_move_send < 0.03: + return + self._last_move_send = now + self.manager.send_mouse(self._mouse_buttons, n[0], n[1], 0) + self._maybe_record_mouse(self._mouse_buttons, n, 0) + self._draw_pad((ev.x, ev.y)) + + def _on_pad_press(self, ev): + self.canvas.focus_set() + mask = _TK_BTN_TO_MASK.get(ev.num, 0) + if not mask: + return + self._mouse_buttons |= mask + n = self._pad_norm(ev) or self._clamp_norm(ev) + if n is not None: + self.manager.send_mouse(self._mouse_buttons, n[0], n[1], 0) + self._maybe_record_mouse(self._mouse_buttons, n, 0) + + def _on_pad_release(self, ev): + mask = _TK_BTN_TO_MASK.get(ev.num, 0) + if not mask: + return + self._mouse_buttons &= ~mask + n = self._pad_norm(ev) or self._clamp_norm(ev) + if n is not None: + self.manager.send_mouse(self._mouse_buttons, n[0], n[1], 0) + self._maybe_record_mouse(self._mouse_buttons, n, 0) + + def _on_pad_wheel(self, ev): + n = self._pad_norm(ev) or self._clamp_norm(ev) + if n is None: + return + ticks = int(ev.delta / 120) if ev.delta else 0 + if ticks == 0: + ticks = 1 if ev.delta > 0 else -1 + self.manager.send_mouse(self._mouse_buttons, n[0], n[1], ticks) + self._maybe_record_mouse(self._mouse_buttons, n, ticks) + + # ---- Type text to all devices ---- + + def _on_textbox_send_key(self, _e=None): + self._send_text() + return "break" # don't also insert a newline + + def _on_textbox_focus_in(self, _e=None): + # Pause global key capture so typing goes into the box, not the + # devices. Remember to re-arm on focus-out. + if self._win_hook is not None: + self._uninstall_hook() + self._hook_paused_for_text = True + + def _on_textbox_focus_out(self, _e=None): + if self._hook_paused_for_text: + self._hook_paused_for_text = False + if self._streaming or self._recording: + self._install_hook() + + def _send_text(self): + txt = self.text_entry.get("1.0", "end-1c") + if not txt: + return + events = [] + for ch in txt: + m = _CHAR_TO_HID.get(ch) + if m is None: + continue + hid, shift = m + if shift: + events.append((self.ACTION_DOWN, HID_LSHIFT)) + events.append((self.ACTION_DOWN, hid)) + events.append((self.ACTION_UP, hid)) + if shift: + events.append((self.ACTION_UP, HID_LSHIFT)) + if not events: + return + # Leave the text in the box after sending so the user can resend or + # edit it without retyping. + self._pump_text_events(events, 0) + + def _pump_text_events(self, events, i): + if i >= len(events) or not self._dialog_alive(): + return + action, hid = events[i] + self.manager.send_event(action, hid) + # Pace so target apps don't coalesce a fast burst into dropped keys. + self.after(6, lambda: self._pump_text_events(events, i + 1)) + + # ---- Slot rendering ---- + + def _refresh_slots(self): + # Wipe and re-render. Cheap (small N), avoids stale callbacks. + for child in self.slots_frame.winfo_children(): + child.destroy() + + slots = self.manager.slots() + # Soft-limit warning (non-blocking) — the count is uncapped; this + # just flags when the current mode's soft cap is exceeded. + if hasattr(self, "_warn_var"): + n = len(slots) + if n > self._soft_cap: + if self._mode == "hub": + self._warn_var.set( + f"⚠ {n} devices (soft cap {self._soft_cap}) — watch the " + f"Lag column; if it climbs, try a clearer Wi-Fi channel " + f"in Settings.") + else: + self._warn_var.set( + f"⚠ {n} devices (soft cap {self._soft_cap}) — BLE " + f"bandwidth and latency degrade past this; expect lag.") + else: + self._warn_var.set("") + if not slots: + tk.Label(self.slots_frame, + text="No devices connected. Click Discover to add one.", + bg="#1E1E2E", fg="#6B7280", + font=("Segoe UI", 9, "italic"), + pady=20).pack(fill="x") + return + + stats_by_addr = {s["address"]: s for s in self.manager.stats()} + for slot in slots: + row = tk.Frame(self.slots_frame, bg="#1E1E2E") + row.pack(fill="x", pady=1) + + stats = stats_by_addr.get(slot.address, {}) + + # Enable toggle + enable_var = tk.BooleanVar(value=slot.enabled) + def make_toggle(addr=slot.address, var=enable_var): + def cb(): + self.manager.set_enabled(addr, var.get()) + return cb + tk.Checkbutton(row, variable=enable_var, + bg="#1E1E2E", activebackground="#1E1E2E", + selectcolor="#1E1E2E", fg="#22C55E", + command=make_toggle(), + width=2).pack(side="left", padx=4) + + # Label / address + label_text = slot.display_label() + tk.Label(row, text=label_text, + bg="#1E1E2E", fg="white", + font=("Consolas", 9), width=26, + anchor="w").pack(side="left", padx=4) + + # Status + status = stats.get("status", slot.status) + status_color = { + "connected": "#22C55E", + "connecting": "#F59E0B", + "lagging": "#F59E0B", + "scanning": "#F59E0B", + "disconnected": "#EF4444", + "error": "#EF4444", + }.get(status, "#94A3B8") + tk.Label(row, text=status, + bg="#1E1E2E", fg=status_color, + font=("Segoe UI", 9, "bold"), width=13, + anchor="w").pack(side="left", padx=4) + + # ACK lag (events the device is behind the head of the stream). + # Only the ESP-NOW hub reports this; BLE mode shows a dash. + lag = stats.get("ack_lag") + lag_text = "—" if lag is None else str(lag) + over = lag is not None and lag > 32 + tk.Label(row, text=lag_text, + bg="#1E1E2E", fg=("#EF4444" if over else "#D1D5DB"), + font=("Consolas", 9), width=7, + anchor="w").pack(side="left", padx=4) + + # Counters + tk.Label(row, text=str(stats.get("events_sent", 0)), + bg="#1E1E2E", fg="#D1D5DB", + font=("Consolas", 9), width=9, + anchor="w").pack(side="left", padx=4) + tk.Label(row, text=str(stats.get("bytes_sent", 0)), + bg="#1E1E2E", fg="#D1D5DB", + font=("Consolas", 9), width=9, + anchor="w").pack(side="left", padx=4) + + tk.Button(row, text="Remove", + bg="#4A4A6A", fg="white", + font=("Segoe UI", 8), relief="flat", padx=8, + command=lambda a=slot.address: self._remove_slot(a) + ).pack(side="left", padx=4) + + def _tick_stats(self): + if not self._dialog_alive(): + return + try: + self._refresh_slots() + except Exception: + pass + self._stats_job = self.after(750, self._tick_stats) + + # ---- Discover ---- + + def _discover(self): + """Discover idle, in-range devices and let the user add one. The + hub polls the ESP-NOW channel; BLE mode scans with Bleak. Both + funnel into the shared _discover_done picker.""" + if self._mode == "hub": + self._discover_mesh() + else: + self._discover_ble() + + def _discover_mesh(self): + """Ask the hub to poll the mesh for idle nodes, collect the + beacons they send back for ~3 s, then present anything not already + a slot. No Bluetooth involved — discovery rides the same ESP-NOW + channel the keystroke stream uses.""" + if isinstance(self.manager, _NullManager) or self._link is None: + messagebox.showwarning( + "Mesh hub not ready", + self._hub_error or "The mesh hub isn't connected.", + parent=self) + return + # Drain any stale beacons, turn on discovery polling, collect. + self.manager.take_beacons() + self._link.send_json({"cmd": "mesh_poll", "on": True}) + self.discover_btn.config(state="disabled", text="Scanning...") + self.after(3000, self._discover_collect) + + def _discover_ble(self): + """One-shot Bleak scan for idle live-mode devices (those advertising + LIVE_SERVICE_UUID). Runs on a background thread so Tk stays live.""" + if isinstance(self.manager, _NullManager): + messagebox.showwarning( + "Bluetooth not ready", + self._hub_error or "BLE streaming isn't available.", + parent=self) + return + # Pause var-sync during the scan so its scanner doesn't fight ours + # for the BLE adapter (idempotent — already paused on open). + self._maybe_pause_var_sync() + import threading + self.discover_btn.config(state="disabled", text="Scanning...") + threading.Thread(target=self._discover_worker, daemon=True).start() + + def _discover_worker(self): + import asyncio + try: + from bleak import BleakScanner + from ble_server import LIVE_SERVICE_UUID + except ImportError as exc: + self.after(0, lambda e=exc: self._discover_done([], str(e))) + return + + async def go(): + seen = {} + + def cb(d, adv): + if LIVE_SERVICE_UUID in (adv.service_uuids or []): + seen[d.address.upper()] = { + "address": d.address, + "name": d.name, + } + scanner = BleakScanner(detection_callback=cb) + await scanner.start() + await asyncio.sleep(4.0) + await scanner.stop() + return list(seen.values()) + + try: + results = asyncio.run(go()) + except Exception as exc: + self.after(0, lambda e=exc: self._discover_done([], repr(e))) + return + self.after(0, lambda: self._discover_done(results, None)) + + def _discover_collect(self): + if not self._dialog_alive(): + return + if self._link is not None: + self._link.send_json({"cmd": "mesh_poll", "on": False}) + results = self.manager.take_beacons() if self.manager else [] + self._discover_done(results, None) + + def _discover_done(self, results, err): + self.discover_btn.config(state="normal", text="Discover") + if err: + messagebox.showerror("Discover failed", err, parent=self) + return + # Filter out devices we're already tracking. + existing = {s.address.upper() for s in self.manager.slots()} + candidates = [r for r in results + if r["address"].upper() not in existing] + if not candidates: + messagebox.showinfo( + "Nothing new found", + "No additional M5Stack devices found in range.\n\n" + "Make sure each device is powered on and idle on its macro " + "selector (not running a routine or mid-USB-upload).", + parent=self) + return + + # Quick picker: a small Toplevel listing addresses; pick adds it. + self._open_picker(candidates) + + def _open_picker(self, candidates): + picker = tk.Toplevel(self) + picker.title("Pick a device to add") + picker.configure(bg="#2D2D3D") + picker.transient(self) + picker.grab_set() + picker.geometry("420x320") + + tk.Label(picker, + text="Select a device to add to the stream.", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 10)).pack(padx=14, pady=(12, 6)) + + listbox = tk.Listbox(picker, + bg="#1E1E2E", fg="white", + selectbackground="#3B82F6", + font=("Consolas", 10), + activestyle="none", relief="flat") + listbox.pack(fill="both", expand=True, padx=14, pady=(0, 8)) + for c in candidates: + label = c["address"] + extra = c.get("name") or c.get("board") + if extra: + label += f" ({extra})" + listbox.insert("end", label) + + def add_selected(): + sel = listbox.curselection() + if not sel: + return + idx = sel[0] + c = candidates[idx] + # Add the device FIRST so its BLE client starts connecting, then + # light up its screen with the Bluetooth logo so the user can + # see which physical M5Stack they're labeling. The label prompt + # is modal but the BLE worker keeps running in the background, so + # the identify request is delivered as soon as the link is up. + slot = self.manager.add_device(c["address"], label="", + board=c.get("board", "")) + if slot is None: + messagebox.showerror( + "Add failed", + "Could not add this device (already tracked, or it's the " + "hub).", + parent=picker) + return + picker.destroy() + self._refresh_slots() + + self.manager.identify(c["address"], True) + try: + label = simpledialog.askstring( + "Label", + "The selected M5Stack is showing a Bluetooth logo on its " + f"screen.\n\nOptional friendly label for {c['address']}:", + parent=self) + finally: + # Always clear the logo, even if the user cancels the prompt. + self.manager.identify(c["address"], False) + if label: + self.manager.set_label(c["address"], label) + self._refresh_slots() + + btn_row = tk.Frame(picker, bg="#2D2D3D") + btn_row.pack(fill="x", padx=14, pady=(0, 12)) + tk.Button(btn_row, text="Cancel", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=14, + command=picker.destroy).pack(side="right") + tk.Button(btn_row, text="Add", bg="#22C55E", fg="white", + font=("Segoe UI", 9, "bold"), relief="flat", padx=14, + command=add_selected).pack(side="right", padx=(0, 6)) + + def _remove_slot(self, address: str): + self.manager.remove_device(address) + self._refresh_slots() + + # ---- Device profiles (save / load) ---- + + def _save_profile(self): + slots = self.manager.slots() + if not slots: + messagebox.showinfo( + "Nothing to save", + "Add at least one device (Discover) before saving a profile.", + parent=self) + return + name = simpledialog.askstring( + "Save profile", "Profile name:", parent=self) + if name is None: + return + name = name.strip() + if not name: + return + if name in bt_profiles.list_profiles() and not messagebox.askyesno( + "Overwrite?", + f"A profile named '{name}' already exists. Overwrite it?", + parent=self): + return + devices = [(s.address, s.display_label()) for s in slots] + try: + bt_profiles.save_profile(name, devices) + except OSError as exc: + messagebox.showerror("Save failed", str(exc), parent=self) + return + messagebox.showinfo( + "Profile saved", + f"Saved {len(devices)} device(s) to '{name}'.", parent=self) + + def _load_profile(self): + if self._load_queue is not None: + messagebox.showinfo("Busy", "A profile is still loading.", + parent=self) + return + names = bt_profiles.list_profiles() + if not names: + messagebox.showinfo( + "No profiles", + "No saved profiles yet. Add devices and click Save Profile " + "first.", parent=self) + return + self._open_profile_picker(names) + + def _open_profile_picker(self, names): + picker = tk.Toplevel(self) + picker.title("Load device profile") + picker.configure(bg="#2D2D3D") + picker.transient(self) + picker.grab_set() + picker.geometry("420x320") + + tk.Label(picker, text="Pick a profile to connect to:", + bg="#2D2D3D", fg="white", + font=("Segoe UI", 10)).pack(padx=14, pady=(12, 6)) + + listbox = tk.Listbox(picker, bg="#1E1E2E", fg="white", + selectbackground="#3B82F6", + font=("Consolas", 10), activestyle="none", + relief="flat") + listbox.pack(fill="both", expand=True, padx=14, pady=(0, 8)) + + def repopulate(sel_names): + listbox.delete(0, "end") + for nm in sel_names: + devs = bt_profiles.load_profile(nm) + listbox.insert("end", f"{nm} ({len(devs)} device(s))") + + current = list(names) + repopulate(current) + + def do_load(): + sel = listbox.curselection() + if not sel: + return + nm = current[sel[0]] + devs = bt_profiles.load_profile(nm) + picker.destroy() + if not devs: + messagebox.showinfo("Empty profile", + f"Profile '{nm}' has no devices.", + parent=self) + return + self._begin_load(devs) + + def do_delete(): + sel = listbox.curselection() + if not sel: + return + nm = current[sel[0]] + if not messagebox.askyesno("Delete profile?", + f"Delete profile '{nm}'?", + parent=picker): + return + bt_profiles.delete_profile(nm) + current.clear() + current.extend(bt_profiles.list_profiles()) + repopulate(current) + + btn_row = tk.Frame(picker, bg="#2D2D3D") + btn_row.pack(fill="x", padx=14, pady=(0, 12)) + tk.Button(btn_row, text="Cancel", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=14, + command=picker.destroy).pack(side="right") + tk.Button(btn_row, text="Load", bg="#22C55E", fg="white", + font=("Segoe UI", 9, "bold"), relief="flat", padx=14, + command=do_load).pack(side="right", padx=(0, 6)) + tk.Button(btn_row, text="Delete", bg="#7F1D1D", fg="white", + font=("Segoe UI", 9), relief="flat", padx=12, + command=do_delete).pack(side="left") + + # ---- Profile load state machine (one device at a time) ---- + + def _mesh_addr(self, addr: str) -> str: + """Map a stored profile address to the mesh (STA) MAC. + + New profiles store the STA MAC directly. Legacy profiles saved + under the BLE transport stored the Bleak BT MAC, which on the + ESP32-S3 default eFuse layout is the STA MAC + 2. We prefer an + exact key-store match, then try the BT->STA (-2) arithmetic, + else fall back to the stored value verbatim. + + In BLE mode there is no STA-MAC remap — the manager connects by the + Bleak BT MAC, so the stored value is used as-is.""" + if self._mode != "hub": + return addr.upper() + import ble_keystore + a = addr.upper() + known = {m.upper() for m in ble_keystore.all_known_macs()} + if a in known: + return a + try: + parts = [int(p, 16) for p in a.split(":")] + val = int.from_bytes(bytes(parts), "big") - 2 + cand = ":".join(f"{b:02X}" for b in val.to_bytes(6, "big")) + if cand in known: + return cand + except (ValueError, OverflowError): + pass + return a + + def _begin_load(self, devices): + # Pause var-sync so its scanner doesn't fight ours during the + # sequential connects. + self._maybe_pause_var_sync() + # Migrate any legacy BLE-MAC profile entries to mesh STA MACs. + migrated = [] + for d in devices: + migrated.append({"address": self._mesh_addr(d["address"]), + "label": d.get("label", "")}) + self._load_queue = list(migrated) + self._load_idx = 0 + self.recording_status_var.set( + f"Loading profile — 0/{len(self._load_queue)} connected...") + self._load_next() + + def _load_next(self): + if not self._dialog_alive(): + self._load_queue = None + return + q = self._load_queue + if q is None: + return + if self._load_idx >= len(q): + self._load_queue = None + self._refresh_slots() + self.recording_status_var.set( + f"Profile loaded ({len(q)} device(s)).") + return + + dev = q[self._load_idx] + addr, label = dev["address"], dev["label"] + slot = self.manager.get_slot(addr) + if slot is None: + slot = self.manager.add_device(addr, label=label) + if slot is None: + # Uncapped now — None means it's already tracked; just move on. + self._load_idx += 1 + self.after(50, self._load_next) + return + else: + # Already tracked — just (re)apply the saved label. + self.manager.set_label(addr, label) + + self._refresh_slots() + self._load_deadline = time.monotonic() + LOAD_TIMEOUT_S + self.after(200, self._load_poll) + + def _load_poll(self): + if not self._dialog_alive(): + self._load_queue = None + return + q = self._load_queue + if q is None: + return + dev = q[self._load_idx] + addr, label = dev["address"], dev["label"] + slot = self.manager.get_slot(addr) + connected = bool(slot and slot.status == "connected") + + if connected: + # Push the saved name to the device so its screen shows it too. + self.manager.set_label(addr, label) + self._refresh_slots() + self._load_idx += 1 + self.recording_status_var.set( + f"Loading profile — {self._load_idx}/{len(q)} connected...") + self.after(150, self._load_next) + return + + if time.monotonic() >= self._load_deadline: + choice = self._ask_retry_skip(label or addr, addr) + if choice == "retry": + self._load_deadline = time.monotonic() + LOAD_TIMEOUT_S + self.after(200, self._load_poll) + elif choice == "skip": + # Leave the slot in place — its worker keeps retrying in the + # background — and move on to the next device. + self._load_idx += 1 + self.after(50, self._load_next) + else: # cancel the whole load + self._load_queue = None + self._refresh_slots() + self.recording_status_var.set("Profile load cancelled.") + return + + self.after(200, self._load_poll) + + def _ask_retry_skip(self, label: str, address: str) -> str: + """Modal shown when a device can't be reached during a load. + Returns 'retry', 'skip', or 'cancel'.""" + dlg = tk.Toplevel(self) + dlg.title("Device not found") + dlg.configure(bg="#2D2D3D") + dlg.transient(self) + dlg.grab_set() + dlg.geometry("440x230") + + tk.Label(dlg, text="Couldn't connect to:", bg="#2D2D3D", + fg="#F59E0B", font=("Segoe UI", 10, "bold")).pack( + padx=16, pady=(16, 2), anchor="w") + tk.Label(dlg, text=label, bg="#2D2D3D", fg="white", + font=("Segoe UI", 11, "bold")).pack(padx=16, anchor="w") + tk.Label(dlg, text=address, bg="#2D2D3D", fg="#94A3B8", + font=("Consolas", 9)).pack(padx=16, anchor="w") + tk.Label(dlg, + text=("Make sure it's powered on and idle on its macro " + "selector (not running a routine or mid-USB-upload), " + "then Retry — or Skip it for now."), + bg="#2D2D3D", fg="#CBD5E1", font=("Segoe UI", 9), + wraplength=400, justify="left").pack(padx=16, pady=(8, 12), + anchor="w") + + result = {"v": "skip"} + + def choose(v): + result["v"] = v + try: + dlg.destroy() + except tk.TclError: + pass + + btns = tk.Frame(dlg, bg="#2D2D3D") + btns.pack(fill="x", padx=16, pady=(0, 14)) + tk.Button(btns, text="Retry", bg="#22C55E", fg="white", + activebackground="#16A34A", font=("Segoe UI", 9, "bold"), + relief="flat", padx=16, command=lambda: choose("retry") + ).pack(side="left") + tk.Button(btns, text="Skip", bg="#475569", fg="white", + font=("Segoe UI", 9, "bold"), relief="flat", padx=16, + command=lambda: choose("skip")).pack(side="left", padx=(8, 0)) + tk.Button(btns, text="Cancel load", bg="#7F1D1D", fg="white", + font=("Segoe UI", 9), relief="flat", padx=12, + command=lambda: choose("cancel")).pack(side="right") + + dlg.protocol("WM_DELETE_WINDOW", lambda: choose("skip")) + self.wait_window(dlg) + return result["v"] + + # ---- Capture state ---- + + def _toggle_stream(self): + if self._streaming and not self._recording: + # Streaming-only -> off + self._streaming = False + if not self._recording: + self._uninstall_hook() + self.stream_btn.config(text="▶ Stream", bg="#22C55E") + self.capture_var.set("Capture: OFF") + else: + self._streaming = True + if self._install_hook(): + self.stream_btn.config(text="■ Stop streaming", bg="#0EA5E9") + self._update_capture_label() + else: + self._streaming = False + messagebox.showerror( + "Hook install failed", + "Couldn't install the system keyboard hook. Streaming " + "needs it to capture the Windows key and suppress " + "local OS shortcuts while typing.", + parent=self) + + def _toggle_record(self): + if self._recording: + self._stop_recording() + else: + self._start_recording() + + def _start_recording(self): + if self._recording: + return + self._record_buffer = [] + self._record_t0_ns = time.monotonic_ns() + self._recording = True + # Recording implies streaming (so the recorded actions also reach + # the devices live as you perform them). + if not self._streaming: + self._streaming = True + self.stream_btn.config(text="■ Stop streaming", bg="#0EA5E9") + if not self._install_hook(): + self._recording = False + self._streaming = False + self.stream_btn.config(text="▶ Stream", bg="#22C55E") + messagebox.showerror( + "Hook install failed", + "Couldn't install the system keyboard hook.", + parent=self) + return + self.rec_btn.config(text="■ Stop & Save", bg="#B91C1C") + self.recording_status_var.set("● Recording (keys + mouse + CAD)...") + self._update_capture_label() + + def _stop_recording(self): + if not self._recording: + return + self._recording = False + # Release anything still held so a replay doesn't leave it latched. + if self._record_t0_ns is not None: + t_ms = (time.monotonic_ns() - self._record_t0_ns) // 1_000_000 + for code in list(self._held_codes): + self._record_buffer.append(["k", int(t_ms), self.ACTION_UP, code]) + if self._streaming: + self.manager.send_event(self.ACTION_UP, code) + self._held_codes.clear() + if self._mouse_buttons: + # Release held mouse buttons at the last known position. + self._record_buffer.append( + ["m", int(t_ms), 0, 0, 0, 0]) + self._mouse_buttons = 0 + self.rec_btn.config(text="● Record", bg="#DC2626") + self._update_capture_label() + + events = self._record_buffer + n = len(events) + dur_ms = events[-1][1] if events else 0 + if not events: + self.recording_status_var.set("Recording stopped — nothing captured.") + return + self.recording_status_var.set( + f"Recording stopped — {n} events, {dur_ms/1000.0:.2f}s. Saving…") + self._open_save_macro_dialog(events, dur_ms) + + # ---- Save dialog (custom; name + folder) ---- + + def _open_save_macro_dialog(self, events, duration_ms): + # Pause global key capture so the user can actually type the name — + # otherwise the still-installed hook swallows every keystroke. + self._pause_capture_for_dialog() + dlg = tk.Toplevel(self) + dlg.title("Save macro") + dlg.configure(bg="#2D2D3D") + dlg.transient(self) + dlg.grab_set() + dlg.geometry("420x220") + + tk.Label(dlg, text="Save recorded macro", bg="#2D2D3D", fg="white", + font=("Segoe UI", 11, "bold")).pack(anchor="w", + padx=16, pady=(14, 8)) + body = tk.Frame(dlg, bg="#2D2D3D") + body.pack(fill="x", padx=16) + body.columnconfigure(1, weight=1) + + tk.Label(body, text="Name:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).grid(row=0, column=0, sticky="w", pady=4) + name_var = tk.StringVar() + name_entry = tk.Entry(body, textvariable=name_var, bg="#1E1E2E", + fg="white", insertbackground="white", + font=("Segoe UI", 10), relief="flat") + name_entry.grid(row=0, column=1, sticky="ew", padx=(8, 0), pady=4) + name_entry.focus_set() + + tk.Label(body, text="Folder:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).grid(row=1, column=0, sticky="w", pady=4) + folder_var = tk.StringVar(value=bt_macros.DEFAULT_FOLDER) + folder_box = ttk.Combobox(body, textvariable=folder_var, + values=bt_macros.list_folders(), + font=("Segoe UI", 9)) # editable: type a new name + folder_box.grid(row=1, column=1, sticky="ew", padx=(8, 0), pady=4) + tk.Label(body, text="(type a new name to create a folder)", + bg="#2D2D3D", fg="#94A3B8", font=("Segoe UI", 8)).grid( + row=2, column=1, sticky="w", padx=(8, 0)) + + def finish(): + try: + dlg.destroy() + except tk.TclError: + pass + self._resume_capture_after_dialog() + + def do_save(): + name = name_var.get().strip() + folder = folder_var.get().strip() or bt_macros.DEFAULT_FOLDER + if not name: + messagebox.showinfo("Name required", + "Enter a macro name.", parent=dlg) + return + if bt_macros.macro_exists(folder, name) and not messagebox.askyesno( + "Overwrite?", + f"'{name}' already exists in '{folder}'. Overwrite?", + parent=dlg): + return + bt_macros.save_macro(folder, name, events, duration_ms) + self.recording_status_var.set( + f"Saved macro '{name}' to '{folder}'.") + finish() + + btns = tk.Frame(dlg, bg="#2D2D3D") + btns.pack(fill="x", padx=16, pady=(14, 14)) + tk.Button(btns, text="Cancel", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=14, + command=finish).pack(side="right") + tk.Button(btns, text="Save", bg="#22C55E", fg="white", + font=("Segoe UI", 9, "bold"), relief="flat", padx=16, + command=do_save).pack(side="right", padx=(0, 6)) + dlg.protocol("WM_DELETE_WINDOW", finish) + name_entry.bind("", lambda _e: do_save()) + + # ---- Macro library button + playback ---- + + def _open_macro_library(self): + # Pause capture while the picker is open so its folder name prompts + # (and Delete confirmations) receive keyboard input normally. + self._pause_capture_for_dialog() + picker = MacroLibraryPicker(self, select_mode=False, + on_quick_run=self._quick_run_macro, + on_load=self._load_macro_into_button) + picker.bind( + "", + lambda e, p=picker: (self._resume_capture_after_dialog() + if e.widget is p else None)) + + def _quick_run_macro(self, folder, name, events, loop): + self._run_macro(events, loop) + self.recording_status_var.set( + f"Running '{name}'{' (loop)' if loop else ''}…") + + def _load_macro_into_button(self, folder, name, events, loop): + self._loaded_macro = {"folder": folder, "name": name, + "events": events, "loop": bool(loop)} + try: + self.macros_run_btn.config(state="normal") + except tk.TclError: + pass + self.recording_status_var.set( + f"Loaded '{name}'{' (loop)' if loop else ''} into Macros button.") + + def _on_macros_run(self): + if self._macro_running: + self._stop_macro() + return + if not self._loaded_macro: + return + self._run_macro(self._loaded_macro["events"], + self._loaded_macro["loop"]) + + def _set_macros_running(self, running: bool): + try: + if running: + self.macros_run_btn.config(text="■ Stop", bg="#DC2626", + fg="white", state="normal") + else: + self.macros_run_btn.config(text="Macros", bg="#A78BFA", + fg="#1E1E2E", + state="normal" if self._loaded_macro + else "disabled") + except tk.TclError: + pass + + def _run_macro(self, events, loop): + if self._macro_running or not events: + return + self._macro_events = sorted(events, key=lambda e: e[1]) + self._macro_loop = bool(loop) + self._macro_running = True + self._macro_i = 0 + self._macro_start = time.monotonic() + self._macro_held_keys.clear() + self._macro_held_buttons = 0 + self.manager.reset_session_clocks() + self._set_macros_running(True) + self._macro_pump() + + def _macro_pump(self): + if not self._macro_running or not self._dialog_alive(): + return + elapsed_ms = (time.monotonic() - self._macro_start) * 1000.0 + evs = self._macro_events + while self._macro_i < len(evs): + ev = evs[self._macro_i] + if ev[1] > elapsed_ms: + break + self._emit_macro_event(ev) + self._macro_i += 1 + if self._macro_i >= len(evs): + if self._macro_loop and self._macro_running: + # Release held state, then restart from the top. + self._release_macro_held() + self._macro_i = 0 + self._macro_start = time.monotonic() + self.manager.reset_session_clocks() + self.after(10, self._macro_pump) + else: + self._stop_macro() + return + self.after(5, self._macro_pump) + + def _emit_macro_event(self, ev): + tag = ev[0] + if tag == "k": + _, _t, action, hid = ev + self.manager.send_event(action, hid) + if action == self.ACTION_DOWN: + self._macro_held_keys.add(hid) + else: + self._macro_held_keys.discard(hid) + elif tag == "m": + _, _t, buttons, x, y, wheel = ev + xn, yn = x / 32767.0, y / 32767.0 + self.manager.send_mouse(buttons, xn, yn, wheel) + self._macro_held_buttons = buttons + self._macro_last_xy = (xn, yn) + + def _release_macro_held(self): + for hid in list(self._macro_held_keys): + self.manager.send_event(self.ACTION_UP, hid) + self._macro_held_keys.clear() + if self._macro_held_buttons: + xn, yn = self._macro_last_xy + self.manager.send_mouse(0, xn, yn, 0) + self._macro_held_buttons = 0 + + def _stop_macro(self): + self._macro_running = False + self._release_macro_held() + self._set_macros_running(False) + self.recording_status_var.set("Macro stopped.") + + def _update_capture_label(self): + flags = [] + if self._streaming: + flags.append("STREAM") + if self._recording: + flags.append("REC") + if flags: + self.capture_var.set("Capture: " + "+".join(flags)) + else: + self.capture_var.set("Capture: OFF") + + # ---- Win hook integration ---- + + def _install_hook(self) -> bool: + if self._win_hook is not None: + return True + if WinKeyboardHook is None or not _winhook_supported(): + # Non-Windows host: there's no platform-native equivalent + # yet. Streaming requires the hook to suppress local OS + # behavior (e.g., Win key opening Start menu) and to see + # keys regardless of which Tk widget has focus. + return False + try: + # No on_escape handler: in the streamer, Escape is just another + # key the user wants forwarded to the M5Stack (HID 0x29), not a + # local "stop" gesture. Stopping is done via the Stream / Stop + # buttons (or the mouse). Leaving on_escape unset lets Escape + # flow through _vk_to_hid → get streamed and suppressed locally + # like any other key. + self._win_hook = WinKeyboardHook( + on_event=self._on_hook_event, + ) + return self._win_hook.start(suppress_local=True) + except Exception as exc: + print(f"[bt_kbd] hook install failed: {exc}") + self._win_hook = None + return False + + def _uninstall_hook(self): + if self._win_hook is not None: + try: + self._win_hook.stop() + except Exception: + pass + self._win_hook = None + self._held_codes.clear() + + def _pause_capture_for_dialog(self): + """Uninstall the global keyboard hook while a modal dialog that + needs typed input is open. Without this, the hook keeps capturing + (and suppressing) keys so the dialog's entries receive nothing.""" + if self._win_hook is not None: + self._uninstall_hook() + self._capture_paused_for_dialog = True + + def _resume_capture_after_dialog(self): + if self._capture_paused_for_dialog: + self._capture_paused_for_dialog = False + if self._streaming or self._recording: + self._install_hook() + + def _on_hook_event(self, action: int, hid: int): + # Hook fires on its own thread — marshal to Tk for the + # bookkeeping + send. + self.after(0, lambda a=action, h=hid: self._dispatch(a, h)) + + def _dispatch(self, action: int, hid: int): + # Dedupe auto-repeat (hook fires WM_KEYDOWN repeatedly on a + # held key) and ensure we never UP a key we didn't see DOWN. + if action == self.ACTION_DOWN: + if hid in self._held_codes: + return + self._held_codes.add(hid) + else: + if hid not in self._held_codes: + return + self._held_codes.discard(hid) + + if self._recording and self._record_t0_ns is not None: + t_ms = (time.monotonic_ns() - self._record_t0_ns) // 1_000_000 + self._record_buffer.append(["k", int(t_ms), action, hid]) + + if self._streaming: + self.manager.send_event(action, hid) + + # ---- Transport lifecycle ---- + + def _start_ble(self): + """BLE mode: no serial/hub takeover. Build the per-device BLE + fan-out manager and pause var-sync so its scanner doesn't fight + ours for the Bluetooth adapter. The USB port watcher keeps running + (we don't touch the serial port in this mode).""" + self._maybe_pause_var_sync() + try: + self.manager = MultiBleKeyboardManager(max_slots=BLE_MAX_SLOTS) + except Exception as exc: + self._hub_error = f"Couldn't start Bluetooth streaming: {exc}" + self.manager = None + + def _start_hub(self): + """Borrow the app's USB serial link and switch the plugged-in + device into ESP-NOW hub mode, then build the mesh manager on top + of it. Pauses the port watcher so it can't reconnect/poll on the + port we're taking over.""" + sm = self.serial_manager + if self._pause_port_watcher is not None: + try: + self._pause_port_watcher() + except Exception: + pass + # Pause var-sync too — it shares the radio with the mesh on nodes, + # and its scanner shouldn't run while we drive the fleet. + self._maybe_pause_var_sync() + + if sm is None: + self._hub_error = "No serial manager available." + return + if not sm.connected: + try: + sm.scan_and_connect() + except Exception: + pass + if not sm.connected or sm.ser is None: + self._hub_error = ( + "No M5Stack found on USB. Plug one in over USB to act as the " + "mesh hub, then reopen this window.") + return + + # Clean JSON round-trip to enter hub mode BEFORE the MeshLink + # reader thread takes over the port. + rsp = sm.send_command({"cmd": "espnow_hub", "on": True}) + if not rsp or rsp.get("rsp") != "hub": + self._hub_error = ( + "The connected device didn't enter hub mode. Re-flash it " + "with the current firmware and try again.") + return + hub_mac = rsp.get("sta_mac", "") + + # Hand the raw serial handle to the MeshLink (exclusive owner now). + self._link = MeshLink(sm.ser) + self.manager = MeshKeyboardManager(self._link, max_slots=HUB_MAX_SLOTS) + self.manager.set_hub(hub_mac) + + def _stop_transport(self): + """Tear the active manager down. In hub mode also stop the mesh + link and return the USB device to normal node mode, handing the + serial port back to the app. In BLE mode there's no serial to + restore — just stop the per-device BLE workers.""" + if self.manager is not None: + try: + self.manager.shutdown() + except Exception: + pass + self.manager = None + + if self._mode != "hub": + return + + if self._link is not None: + try: + self._link.stop() + except Exception: + pass + self._link = None + # Now that the reader thread is stopped, it's safe to use the + # serial manager again to revert hub mode. Restore a normal read + # timeout first (MeshLink had shortened it for snappy shutdown). + sm = self.serial_manager + if sm is not None and sm.connected and sm.ser is not None: + try: + sm.ser.timeout = 2 + except Exception: + pass + try: + sm.send_command({"cmd": "espnow_hub", "on": False}) + except Exception: + pass + if self._resume_port_watcher is not None: + try: + self._resume_port_watcher() + except Exception: + pass + + # ---- Var-sync coordination ---- + + def _maybe_pause_var_sync(self): + if not self._var_sync_paused and self._pause_var_sync is not None: + try: + self._pause_var_sync() + self._var_sync_paused = True + except Exception: + pass + + def _maybe_resume_var_sync(self): + if self._var_sync_paused and self._resume_var_sync is not None: + try: + self._resume_var_sync() + except Exception: + pass + self._var_sync_paused = False + + # ---- Close ---- + + def _on_close(self): + # Abort any in-progress profile load + macro playback so their + # after() chains stop. + self._load_queue = None + self._macro_running = False + try: + if self._stats_job is not None: + self.after_cancel(self._stats_job) + except Exception: + pass + self._uninstall_hook() + self._stop_transport() + self._maybe_resume_var_sync() + self.destroy() + + # ---- Utilities ---- + + def _dialog_alive(self) -> bool: + try: + return bool(self.winfo_exists()) + except tk.TclError: + return False diff --git a/widgets/image_picker.py b/widgets/image_picker.py new file mode 100644 index 0000000..d23d025 --- /dev/null +++ b/widgets/image_picker.py @@ -0,0 +1,66 @@ +"""Image picker widget for macro image selection.""" + +import tkinter as tk +from tkinter import filedialog +from utils.image_converter import copy_image_to_appdata, create_preview + + +class ImagePicker(tk.Frame): + """Widget for selecting and previewing a macro image.""" + + def __init__(self, parent, on_change=None): + super().__init__(parent, bg="#2D2D3D") + self.on_change = on_change + self.image_path = None + self._preview = None + + self._build_ui() + + def _build_ui(self): + tk.Label(self, text="Routine Image:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2)) + + self.preview_label = tk.Label(self, bg="#1E1E2E", width=128, height=128, + relief="sunken", borderwidth=1) + self.preview_label.pack(padx=8, pady=4) + + btn_frame = tk.Frame(self, bg="#2D2D3D") + btn_frame.pack(fill="x", padx=8, pady=4) + + tk.Button(btn_frame, text="Choose Image", bg="#3D3D5C", fg="white", + font=("Segoe UI", 8), relief="flat", + command=self._choose_image).pack(side="left", fill="x", expand=True, padx=2) + tk.Button(btn_frame, text="Clear", bg="#7F8C8D", fg="white", + font=("Segoe UI", 8), relief="flat", + command=self._clear_image).pack(side="left", padx=2) + + def set_image(self, path): + self.image_path = path + if path: + try: + self._preview = create_preview(path) + self.preview_label.config(image=self._preview, text="") + except Exception: + self.preview_label.config(image="", text="Error loading image", + fg="#FF4444") + else: + self._preview = None + self.preview_label.config(image="", text="No image", fg="#666666") + + def _choose_image(self): + path = filedialog.askopenfilename( + title="Select Routine Image", + filetypes=[("Images", "*.png *.jpg *.jpeg *.bmp *.gif"), ("All", "*.*")], + ) + if path: + saved = copy_image_to_appdata(path) + self.image_path = saved + self.set_image(saved) + if self.on_change: + self.on_change(saved) + + def _clear_image(self): + self.image_path = None + self.set_image(None) + if self.on_change: + self.on_change(None) diff --git a/widgets/macro_event_editor.py b/widgets/macro_event_editor.py new file mode 100644 index 0000000..34cfb2d --- /dev/null +++ b/widgets/macro_event_editor.py @@ -0,0 +1,133 @@ +"""Per-event editor dialog for a macro recording. + +Opened by double-clicking a row in the live recorder dialog. Lets the user +tweak the timestamp, action (press/release), and HID code of an existing +event, or seed a new event via "Insert before / Insert after". + +The list of selectable HID codes is derived from utils.constants.TKKEYSYM_TO_HID, +which is the same map the live recorder uses on capture — so anything you +record live can be re-selected here. +""" + +import tkinter as tk +from tkinter import ttk + +from utils.constants import TKKEYSYM_TO_HID, hid_code_label + +ACTION_DOWN = 0 +ACTION_UP = 1 + + +def _key_choices() -> list[tuple[int, str]]: + """Return a sorted (hid_code, label) list, deduped by code. + + `hid_code_label` already produces friendly names for everything in + the macro recorder's vocabulary, so we drive the list off that + rather than the raw Tk keysym map (which has shift-pair duplicates). + """ + codes = sorted({code for code in TKKEYSYM_TO_HID.values()}) + return [(code, f"{hid_code_label(code)} (0x{code:02X})") for code in codes] + + +class MacroEventEditorDialog: + """Modal editor for a single macro event.""" + + def __init__(self, parent, event, on_commit, title="Edit event"): + """event is a 3-element list [t_ms, action, hid_code]. + + on_commit(new_event) is invoked with a freshly-built [t, a, c] + list when the user clicks Save. Cancel makes no callback. + """ + self._event = event + self._on_commit = on_commit + self._build(parent, title) + + def _build(self, parent, title): + self.dlg = tk.Toplevel(parent) + self.dlg.title(title) + self.dlg.configure(bg="#2D2D3D") + self.dlg.transient(parent.winfo_toplevel()) + self.dlg.grab_set() + self.dlg.resizable(False, False) + + body = tk.Frame(self.dlg, bg="#2D2D3D") + body.pack(padx=14, pady=12, fill="both", expand=True) + + tk.Label(body, text="Time (ms)", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).grid(row=0, column=0, sticky="w", + pady=(0, 4)) + self.t_var = tk.IntVar(value=int(self._event[0])) + tk.Spinbox(body, from_=0, to=24 * 60 * 60 * 1000, increment=1, + textvariable=self.t_var, width=12, + bg="#1E1E2E", fg="white", insertbackground="white", + relief="flat", font=("Segoe UI", 10)).grid( + row=0, column=1, sticky="w", padx=(8, 0), pady=(0, 4)) + + tk.Label(body, text="Action", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).grid(row=1, column=0, sticky="w", + pady=(4, 4)) + self.action_var = tk.IntVar(value=int(self._event[1])) + action_frame = tk.Frame(body, bg="#2D2D3D") + action_frame.grid(row=1, column=1, sticky="w", padx=(8, 0)) + tk.Radiobutton(action_frame, text="↓ Press", variable=self.action_var, + value=ACTION_DOWN, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", font=("Segoe UI", 9)).pack( + side="left") + tk.Radiobutton(action_frame, text="↑ Release", variable=self.action_var, + value=ACTION_UP, bg="#2D2D3D", fg="white", + selectcolor="#1E1E2E", activebackground="#2D2D3D", + activeforeground="white", font=("Segoe UI", 9)).pack( + side="left", padx=(10, 0)) + + tk.Label(body, text="Key", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9)).grid(row=2, column=0, sticky="w", + pady=(4, 4)) + self._choices = _key_choices() + choice_labels = [label for (_c, label) in self._choices] + self.key_label_var = tk.StringVar() + # Pre-select the current event's code, or fall back to the closest. + current_code = int(self._event[2]) + try: + idx = next(i for i, (c, _l) in enumerate(self._choices) + if c == current_code) + except StopIteration: + idx = 0 + self.key_label_var.set(choice_labels[idx]) + combo = ttk.Combobox(body, textvariable=self.key_label_var, + values=choice_labels, state="readonly", + width=24, font=("Consolas", 9)) + combo.grid(row=2, column=1, sticky="w", padx=(8, 0)) + + footer = tk.Frame(self.dlg, bg="#2D2D3D") + footer.pack(fill="x", side="bottom", padx=14, pady=(0, 12)) + tk.Button(footer, text="Cancel", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=14, + command=self._cancel).pack(side="right") + tk.Button(footer, text="Save", bg="#22C55E", fg="white", + activebackground="#16A34A", + font=("Segoe UI", 9, "bold"), relief="flat", padx=14, + command=self._save).pack(side="right", padx=(0, 6)) + + self.dlg.protocol("WM_DELETE_WINDOW", self._cancel) + self.dlg.bind("", lambda _e: self._save()) + self.dlg.bind("", lambda _e: self._cancel()) + + def _save(self): + try: + t_ms = max(0, int(self.t_var.get())) + except (tk.TclError, ValueError): + t_ms = 0 + action = int(self.action_var.get()) + # Recover the code from the chosen label. + try: + idx = next(i for i, (_c, l) in enumerate(self._choices) + if l == self.key_label_var.get()) + code = self._choices[idx][0] + except StopIteration: + code = int(self._event[2]) + self._on_commit([t_ms, action, code]) + self.dlg.destroy() + + def _cancel(self): + self.dlg.destroy() diff --git a/widgets/macro_library_picker.py b/widgets/macro_library_picker.py new file mode 100644 index 0000000..468d05d --- /dev/null +++ b/widgets/macro_library_picker.py @@ -0,0 +1,248 @@ +"""Modal browser for the BT-Keyboard macro library (bt_macros). + +Two modes: + * manage (default): used from the BT Keyboard window. Bottom row exposes + Quick Run / Load / Delete and a Loop checkbox. + * select : used from the node editor's macro node to pick a macro to + embed. Bottom row exposes Select / Cancel. + +Folder management (Create / Rename / Delete) is available in both modes, +visually separated from the macro actions. Macros are NOT renameable. + +Callbacks (all optional): + on_quick_run(folder, name, events, loop) — Quick Run clicked (stays open) + on_load(folder, name, events, loop) — Load clicked (closes) + on_select(folder, name, events) — Select clicked (closes) +""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import simpledialog, messagebox + +import bt_macros + +_BG = "#2D2D3D" +_PANEL = "#1E1E2E" +_SEL = "#3B82F6" + + +class MacroLibraryPicker(tk.Toplevel): + def __init__(self, parent, *, select_mode: bool = False, + on_quick_run=None, on_load=None, on_select=None): + super().__init__(parent) + self._select_mode = select_mode + self._on_quick_run = on_quick_run + self._on_load = on_load + self._on_select = on_select + self._cur_folder = None + + self.title("Macro library") + self.configure(bg=_BG) + self.transient(parent) + self.grab_set() + self.geometry("620x440") + + self.loop_var = tk.BooleanVar(value=False) + self._build() + self._refresh_folders() + + # ---- layout ---- + + def _build(self): + tk.Label(self, text="Macro library", bg=_BG, fg="white", + font=("Segoe UI", 12, "bold")).pack(anchor="w", + padx=14, pady=(12, 2)) + + # Folder management toolbar — its own clearly separated group. + fbar = tk.Frame(self, bg=_BG) + fbar.pack(fill="x", padx=14, pady=(2, 6)) + tk.Label(fbar, text="Folders:", bg=_BG, fg="#9CA3AF", + font=("Segoe UI", 9, "bold")).pack(side="left", padx=(0, 8)) + tk.Button(fbar, text="+ New", bg="#0F766E", fg="white", + activebackground="#0D5C56", font=("Segoe UI", 9), + relief="flat", padx=10, command=self._create_folder).pack( + side="left") + tk.Button(fbar, text="Rename", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=10, + command=self._rename_folder).pack(side="left", padx=(6, 0)) + tk.Button(fbar, text="Delete", bg="#7F1D1D", fg="white", + font=("Segoe UI", 9), relief="flat", padx=10, + command=self._delete_folder).pack(side="left", padx=(6, 0)) + + # Two side-by-side lists: folders | macros. + lists = tk.Frame(self, bg=_BG) + lists.pack(fill="both", expand=True, padx=14, pady=(0, 8)) + lists.columnconfigure(0, weight=1, uniform="lb") + lists.columnconfigure(1, weight=2, uniform="lb") + lists.rowconfigure(1, weight=1) + + tk.Label(lists, text="Folder", bg=_BG, fg="#9CA3AF", + font=("Segoe UI", 9)).grid(row=0, column=0, sticky="w") + tk.Label(lists, text="Macro", bg=_BG, fg="#9CA3AF", + font=("Segoe UI", 9)).grid(row=0, column=1, sticky="w") + + self.folder_lb = tk.Listbox(lists, bg=_PANEL, fg="white", + selectbackground=_SEL, + font=("Consolas", 10), activestyle="none", + relief="flat", exportselection=False) + self.folder_lb.grid(row=1, column=0, sticky="nsew", padx=(0, 6)) + self.folder_lb.bind("<>", self._on_folder_select) + + self.macro_lb = tk.Listbox(lists, bg=_PANEL, fg="white", + selectbackground=_SEL, + font=("Consolas", 10), activestyle="none", + relief="flat", exportselection=False) + self.macro_lb.grid(row=1, column=1, sticky="nsew") + + # Bottom action row. + actions = tk.Frame(self, bg=_BG) + actions.pack(fill="x", padx=14, pady=(0, 12)) + tk.Button(actions, text="Close" if not self._select_mode else "Cancel", + bg="#4A4A6A", fg="white", font=("Segoe UI", 9), + relief="flat", padx=14, command=self.destroy).pack( + side="right") + + if self._select_mode: + tk.Button(actions, text="Select", bg="#22C55E", fg="white", + font=("Segoe UI", 9, "bold"), relief="flat", padx=14, + command=self._do_select).pack(side="right", padx=(0, 6)) + else: + tk.Checkbutton(actions, text="Loop", variable=self.loop_var, + bg=_BG, fg="white", selectcolor=_PANEL, + activebackground=_BG, activeforeground="white", + font=("Segoe UI", 9)).pack(side="right", + padx=(0, 12)) + tk.Button(actions, text="Delete", bg="#7F1D1D", fg="white", + font=("Segoe UI", 9), relief="flat", padx=12, + command=self._do_delete).pack(side="left") + tk.Button(actions, text="↻ Quick Run", bg="#7C3AED", fg="white", + activebackground="#6D28D9", font=("Segoe UI", 9, "bold"), + relief="flat", padx=14, command=self._do_quick_run).pack( + side="right", padx=(0, 6)) + tk.Button(actions, text="Load", bg="#1D4ED8", fg="white", + activebackground="#1E40AF", font=("Segoe UI", 9, "bold"), + relief="flat", padx=14, command=self._do_load).pack( + side="right", padx=(0, 6)) + + # ---- folder / macro listing ---- + + def _refresh_folders(self, select: str | None = None): + folders = bt_macros.list_folders() + self.folder_lb.delete(0, "end") + for f in folders: + self.folder_lb.insert("end", f) + target = select if (select in folders) else ( + self._cur_folder if self._cur_folder in folders else + (folders[0] if folders else None)) + if target is not None: + idx = folders.index(target) + self.folder_lb.selection_set(idx) + self.folder_lb.see(idx) + self._cur_folder = target + self._refresh_macros() + + def _selected_folder(self): + sel = self.folder_lb.curselection() + if sel: + return self.folder_lb.get(sel[0]) + return self._cur_folder + + def _refresh_macros(self): + self.macro_lb.delete(0, "end") + folder = self._selected_folder() + self._cur_folder = folder + if not folder: + return + for name in bt_macros.list_macros(folder): + m = bt_macros.load_macro(folder, name) or {} + n = len(m.get("events", [])) + dur = (m.get("duration_ms", 0) or 0) / 1000.0 + self.macro_lb.insert("end", f"{name} ({n} ev, {dur:.1f}s)") + + def _on_folder_select(self, _e=None): + self._refresh_macros() + + def _selected_macro(self): + sel = self.macro_lb.curselection() + folder = self._selected_folder() + if not sel or not folder: + return None, None + names = bt_macros.list_macros(folder) + idx = sel[0] + if idx >= len(names): + return folder, None + return folder, names[idx] + + def _selected_events(self): + folder, name = self._selected_macro() + if not name: + messagebox.showinfo("No macro selected", + "Select a macro first.", parent=self) + return None + m = bt_macros.load_macro(folder, name) + if not m: + return None + return folder, name, m["events"] + + # ---- folder ops ---- + + def _create_folder(self): + name = simpledialog.askstring("New folder", "Folder name:", + parent=self) + if name and bt_macros.create_folder(name): + self._refresh_folders(select=name.strip()) + + def _rename_folder(self): + folder = self._selected_folder() + if not folder: + return + new = simpledialog.askstring("Rename folder", + f"New name for '{folder}':", parent=self) + if new and bt_macros.rename_folder(folder, new): + self._refresh_folders(select=new.strip()) + + def _delete_folder(self): + folder = self._selected_folder() + if not folder: + return + if not messagebox.askyesno( + "Delete folder?", + f"Delete folder '{folder}' and ALL macros inside it?", + parent=self): + return + bt_macros.delete_folder(folder) + self._cur_folder = None + self._refresh_folders() + + # ---- macro actions ---- + + def _do_quick_run(self): + res = self._selected_events() + if res and self._on_quick_run: + folder, name, events = res + self._on_quick_run(folder, name, events, self.loop_var.get()) + + def _do_load(self): + res = self._selected_events() + if res and self._on_load: + folder, name, events = res + self._on_load(folder, name, events, self.loop_var.get()) + self.destroy() + + def _do_select(self): + res = self._selected_events() + if res and self._on_select: + folder, name, events = res + self._on_select(folder, name, events) + self.destroy() + + def _do_delete(self): + folder, name = self._selected_macro() + if not name: + return + if not messagebox.askyesno("Delete macro?", + f"Delete macro '{name}'?", parent=self): + return + bt_macros.delete_macro(folder, name) + self._refresh_macros() diff --git a/widgets/macro_list.py b/widgets/macro_list.py new file mode 100644 index 0000000..925be93 --- /dev/null +++ b/widgets/macro_list.py @@ -0,0 +1,374 @@ +"""Left sidebar - macro list with reorder, add, delete.""" + +import tkinter as tk +from tkinter import filedialog +from utils.image_converter import copy_image_to_appdata, create_thumbnail, generate_gradient_image + + +class MacroListPanel(tk.Frame): + """Sidebar showing list of macros with management controls.""" + + def __init__(self, parent, on_select=None, on_change=None): + super().__init__(parent, bg="#252535", width=200) + self.on_select = on_select + self.on_change = on_change + self.project = None + self.profile_manager = None + self.selected_index = -1 + self._thumbnails = [] # Hold refs so Tk PhotoImages aren't garbage-collected + + self.pack_propagate(False) + self._build_ui() + + def set_profile_manager(self, profile_manager): + """Inject the ProfileManager so the Duplicate button can list and + write to other profiles. Without this, the duplicate dialog falls + back to "current profile only".""" + self.profile_manager = profile_manager + + def _build_ui(self): + header = tk.Frame(self, bg="#1E1E2E") + header.pack(fill="x") + tk.Label(header, text="Routines", bg="#1E1E2E", fg="white", + font=("Segoe UI", 11, "bold"), pady=8).pack(side="left", padx=10) + + list_frame = tk.Frame(self, bg="#252535") + list_frame.pack(fill="both", expand=True) + + self.list_canvas = tk.Canvas(list_frame, bg="#252535", highlightthickness=0) + scrollbar = tk.Scrollbar(list_frame, orient="vertical", command=self.list_canvas.yview) + self.list_inner = tk.Frame(self.list_canvas, bg="#252535") + + self.list_inner.bind("", + lambda e: self.list_canvas.configure(scrollregion=self.list_canvas.bbox("all"))) + self.list_canvas.create_window((0, 0), window=self.list_inner, anchor="nw") + self.list_canvas.configure(yscrollcommand=scrollbar.set) + + self.list_canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + btn_frame = tk.Frame(self, bg="#1E1E2E") + btn_frame.pack(fill="x", side="bottom") + + btn_style = {"bg": "#3D3D5C", "fg": "white", "font": ("Segoe UI", 9), + "relief": "flat", "pady": 4} + + btn_row1 = tk.Frame(btn_frame, bg="#1E1E2E") + btn_row1.pack(fill="x", padx=4, pady=2) + tk.Button(btn_row1, text="+ Add", command=self._add_macro, **btn_style).pack( + side="left", fill="x", expand=True, padx=2) + tk.Button(btn_row1, text="- Delete", command=self._delete_macro, **btn_style).pack( + side="left", fill="x", expand=True, padx=2) + + btn_row2 = tk.Frame(btn_frame, bg="#1E1E2E") + btn_row2.pack(fill="x", padx=4, pady=2) + tk.Button(btn_row2, text="\u25b2 Up", command=self._move_up, **btn_style).pack( + side="left", fill="x", expand=True, padx=2) + tk.Button(btn_row2, text="\u25bc Down", command=self._move_down, **btn_style).pack( + side="left", fill="x", expand=True, padx=2) + + btn_row3 = tk.Frame(btn_frame, bg="#1E1E2E") + btn_row3.pack(fill="x", padx=4, pady=(0, 4)) + tk.Button(btn_row3, text="\u29c9 Duplicate", command=self._duplicate_macro, **btn_style).pack( + side="left", fill="x", expand=True, padx=2) + + def set_project(self, project): + self.project = project + self.selected_index = 0 if project.macros else -1 + self.refresh() + + def refresh(self): + """Rebuild the macro list UI.""" + for widget in self.list_inner.winfo_children(): + widget.destroy() + self._thumbnails.clear() + + if not self.project: + return + + for i, macro in enumerate(self.project.macros): + self._create_item(i, macro) + + def _create_item(self, index, macro): + is_selected = (index == self.selected_index) + bg = "#3D3D5C" if is_selected else "#252535" + hover_bg = "#4A4A6A" + + item = tk.Frame(self.list_inner, bg=bg, cursor="hand2") + item.pack(fill="x", padx=4, pady=1) + + if macro.image_path: + try: + thumb = create_thumbnail(macro.image_path, (32, 32)) + self._thumbnails.append(thumb) + thumb_label = tk.Label(item, image=thumb, bg=bg, cursor="hand2") + thumb_label.pack(side="left", padx=4, pady=4) + thumb_label.bind("", lambda event, idx=index: self._remove_image(idx)) + except Exception: + tk.Label(item, text="\u25a1", bg=bg, fg="#888888", + font=("Segoe UI", 16), width=3).pack(side="left", padx=4, pady=4) + else: + tk.Label(item, text="\u25a1", bg=bg, fg="#888888", + font=("Segoe UI", 16), width=3).pack(side="left", padx=4, pady=4) + + name_frame = tk.Frame(item, bg=bg) + name_frame.pack(side="left", fill="x", expand=True, padx=4, pady=6) + + name_label = tk.Label(name_frame, text=macro.name, bg=bg, fg="white", + font=("Segoe UI", 9), anchor="w") + name_label.pack(fill="x") + + # Left click sets image, middle click removes it + img_btn = tk.Button(item, text="\U0001f4f7" if macro.image_path else "\U0001f5bc", + bg=bg, fg="#AAAAAA", font=("Segoe UI", 10), relief="flat", + command=lambda idx=index: self._set_image(idx)) + img_btn.pack(side="right", padx=4) + img_btn.bind("", lambda event, idx=index: self._remove_image(idx)) + + def select(event, idx=index): + self.selected_index = idx + self.refresh() + if self.on_select: + self.on_select(idx) + + for widget in [item, name_label, name_frame]: + widget.bind("", select) + + def rename(event, idx=index): + self._rename_macro(idx) + + name_label.bind("", rename) + + def on_enter(event, frame=item, is_sel=is_selected): + if not is_sel: + frame.config(bg=hover_bg) + for child in frame.winfo_children(): + try: + child.config(bg=hover_bg) + for grandchild in child.winfo_children(): + try: + grandchild.config(bg=hover_bg) + except tk.TclError: + pass + except tk.TclError: + pass + + def on_leave(event, frame=item, orig_bg=bg, is_sel=is_selected): + if not is_sel: + frame.config(bg=orig_bg) + for child in frame.winfo_children(): + try: + child.config(bg=orig_bg) + for grandchild in child.winfo_children(): + try: + grandchild.config(bg=orig_bg) + except tk.TclError: + pass + except tk.TclError: + pass + + item.bind("", on_enter) + item.bind("", on_leave) + + def _add_macro(self): + if not self.project: + return + macro = self.project.add_macro() + macro.image_path = generate_gradient_image() + self.selected_index = len(self.project.macros) - 1 + self.refresh() + if self.on_select: + self.on_select(self.selected_index) + if self.on_change: + self.on_change() + + def _delete_macro(self): + if not self.project or self.selected_index < 0: + return + self.project.remove_macro(self.selected_index) + if self.selected_index >= len(self.project.macros): + self.selected_index = len(self.project.macros) - 1 + self.refresh() + if self.on_select: + self.on_select(self.selected_index) + if self.on_change: + self.on_change() + + def _duplicate_macro(self): + """Prompt for a target profile and duplicate the selected routine into it.""" + if not self.project or self.selected_index < 0: + return + if self.selected_index >= len(self.project.macros): + return + + source = self.project.macros[self.selected_index] + + # Without a profile_manager (e.g. unit-test wiring) we can still + # duplicate inside the current profile — degrade gracefully. + if self.profile_manager is None: + clone = source.clone() + self.project.add_macro(clone) + self.selected_index = len(self.project.macros) - 1 + self.refresh() + if self.on_select: + self.on_select(self.selected_index) + if self.on_change: + self.on_change() + return + + self._open_duplicate_dialog(source) + + def _open_duplicate_dialog(self, source_macro): + names = self.profile_manager.profile_names() + if not names: + return + + active = self.profile_manager.active_name + + dialog = tk.Toplevel(self) + dialog.title("Duplicate Routine") + dialog.geometry("280x260") + dialog.resizable(False, False) + dialog.configure(bg="#2D2D3D") + dialog.transient(self) + dialog.grab_set() + + tk.Label(dialog, text=f"Duplicate '{source_macro.name}' to:", + bg="#2D2D3D", fg="white", font=("Segoe UI", 10), + anchor="w", wraplength=260, justify="left").pack( + anchor="w", padx=10, pady=(10, 4)) + + list_wrap = tk.Frame(dialog, bg="#1E1E2E") + list_wrap.pack(fill="both", expand=True, padx=10, pady=4) + + listbox = tk.Listbox(list_wrap, bg="#1E1E2E", fg="white", + selectbackground="#3D3D5C", selectforeground="white", + highlightthickness=0, relief="flat", + font=("Segoe UI", 10), activestyle="none", + exportselection=False) + scrollbar = tk.Scrollbar(list_wrap, orient="vertical", command=listbox.yview) + listbox.configure(yscrollcommand=scrollbar.set) + listbox.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + for name in names: + display = f"{name} (current)" if name == active else name + listbox.insert("end", display) + + # Default selection: current profile (most common case) + try: + default_idx = names.index(active) + except ValueError: + default_idx = 0 + listbox.selection_set(default_idx) + listbox.see(default_idx) + + def do_duplicate(event=None): + sel = listbox.curselection() + if not sel: + return + target = names[sel[0]] + try: + clone, is_active = self.profile_manager.duplicate_macro_to_profile( + source_macro, target) + except (ValueError, OSError) as exc: + from tkinter import messagebox + messagebox.showerror("Duplicate failed", str(exc), parent=dialog) + return + + dialog.destroy() + + if is_active: + # Live project mutated — focus the new routine and let + # the host autosave/refresh chain catch up. + self.selected_index = len(self.project.macros) - 1 + self.refresh() + if self.on_select: + self.on_select(self.selected_index) + if self.on_change: + self.on_change() + + listbox.bind("", do_duplicate) + listbox.bind("", do_duplicate) + listbox.focus_set() + + btn_bar = tk.Frame(dialog, bg="#2D2D3D") + btn_bar.pack(fill="x", padx=10, pady=(4, 10)) + tk.Button(btn_bar, text="Cancel", command=dialog.destroy, + bg="#3D3D5C", fg="white", relief="flat", + font=("Segoe UI", 9), padx=10).pack(side="right", padx=(4, 0)) + tk.Button(btn_bar, text="Duplicate", command=do_duplicate, + bg="#3D3D5C", fg="white", relief="flat", + font=("Segoe UI", 9), padx=10).pack(side="right") + + def _move_up(self): + if not self.project or self.selected_index <= 0: + return + self.project.move_macro(self.selected_index, self.selected_index - 1) + self.selected_index -= 1 + self.refresh() + if self.on_change: + self.on_change() + + def _move_down(self): + if not self.project or self.selected_index >= len(self.project.macros) - 1: + return + self.project.move_macro(self.selected_index, self.selected_index + 1) + self.selected_index += 1 + self.refresh() + if self.on_change: + self.on_change() + + def _set_image(self, index): + if not self.project: + return + path = filedialog.askopenfilename( + title="Select Routine Image", + filetypes=[("Images", "*.png *.jpg *.jpeg *.bmp *.gif"), ("All", "*.*")], + ) + if path: + saved_path = copy_image_to_appdata(path) + self.project.macros[index].image_path = saved_path + self.refresh() + if self.on_change: + self.on_change() + + def _remove_image(self, index): + if not self.project or index < 0 or index >= len(self.project.macros): + return + self.project.macros[index].image_path = None + self.refresh() + if self.on_change: + self.on_change() + + def _rename_macro(self, index): + if not self.project or index < 0 or index >= len(self.project.macros): + return + + dialog = tk.Toplevel(self) + dialog.title("Rename Routine") + dialog.geometry("250x100") + dialog.resizable(False, False) + dialog.configure(bg="#2D2D3D") + dialog.transient(self) + dialog.grab_set() + + tk.Label(dialog, text="Name:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 10)).pack(anchor="w", padx=10, pady=(10, 2)) + var = tk.StringVar(value=self.project.macros[index].name) + entry = tk.Entry(dialog, textvariable=var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + entry.pack(fill="x", padx=10, pady=4) + entry.select_range(0, "end") + entry.focus_set() + + def save(event=None): + self.project.macros[index].name = var.get() + dialog.destroy() + self.refresh() + if self.on_change: + self.on_change() + + entry.bind("", save) + tk.Button(dialog, text="OK", command=save, bg="#3D3D5C", fg="white", + relief="flat").pack(pady=6) diff --git a/widgets/macro_recorder.py b/widgets/macro_recorder.py new file mode 100644 index 0000000..95ac2c6 --- /dev/null +++ b/widgets/macro_recorder.py @@ -0,0 +1,736 @@ +"""Live macro recorder dialog. + +User-facing surface of the live-BLE recording feature. + +Lifecycle: + - User opens the dialog. Idle state — no BLE link yet, Record button + is disabled. + - User clicks **Open BLE**. We spawn a BLELiveKeystrokeClient that + scans for an idle M5Stack. The device advertises the live service + automatically while sitting on its macro selector — there is no + on-device gesture to perform. The worker retries until it finds one, + then sends an encrypted START which puts the device into recording. + - Once status flips to ST_CONNECTED, the Record button enables. + - User clicks **● Record**: timestamp begins, KeyPress / KeyRelease + events get captured locally AND streamed over BLE to the M5Stack + which emits them as USB HID to the target machine. + - User clicks **■ Stop**: capture pauses but the BLE link stays up so + the user can immediately ● Record again. + - If the BLE link drops mid-session: the worker auto-retries. The + dialog shows the changing status; key capture pauses while the + link is down (we don't want to silently drop events into a black + hole, and the recorded timeline naturally pauses since host clock + isn't being read). When the worker reconnects, capture resumes. + - User clicks **Close BLE**: stops the worker, resumes the var-sync + client. Record disables again. + - User clicks Save or Cancel: same as Close BLE plus dialog closes. + +Events can be hand-edited via double-click (opens MacroEventEditorDialog) +or right-click for insert before/after / delete. On Save we warn if +events aren't monotonic in time and offer to sort them. +""" + +import sys +import time +import tkinter as tk +from tkinter import ttk, messagebox + +from utils.constants import TKKEYSYM_TO_HID, hid_code_label +from widgets.macro_event_editor import MacroEventEditorDialog + +try: + from utils.win_keyboard_hook import WinKeyboardHook, is_supported as _winhook_supported +except ImportError: + WinKeyboardHook = None + def _winhook_supported() -> bool: + return False + + +class MacroRecorderDialog: + """Modal dialog: live-record + edit the ``data['events']`` payload.""" + + ACTION_DOWN = 0 + ACTION_UP = 1 + + def __init__(self, parent, data, on_save, *, + ble_live_client_factory=None, + pause_var_sync=None, resume_var_sync=None): + """ + ble_live_client_factory — zero-arg callable returning a fresh + BLELiveKeystrokeClient. If None, the live Record path is + disabled and only the offline editor is available. + pause_var_sync / resume_var_sync — callables the dialog invokes + around its live session so the var-sync BLE client doesn't + fight for the same advertisement. Both optional. + """ + self.parent = parent + self.data = data + self.on_save = on_save + self._ble_factory = ble_live_client_factory + self._pause_var_sync = pause_var_sync + self._resume_var_sync = resume_var_sync + + self._recording = False + self._record_start_ms: int = 0 + # Dedupes Tk's auto-repeat KeyPress storms while a key is held + self._held_keys: set = set() + # Working copy; committed back to ``data`` only on Save + self._events: list = [list(e) for e in (data.get("events") or [])] + + # Live-BLE state + self._ble_client = None + self._ble_status = "disconnected" + self._var_sync_paused = False + # Disconnect modal stub (some earlier flows referenced it; kept + # so reconnect UI can be added back without churn). + self._disconnect_modal = None + + # Low-level Windows keyboard hook (None on non-Windows or when + # the import failed). Captures the Windows key and other system + # shortcuts Tkinter can't see, and suppresses them locally so + # they only fire on the target machine over BLE. + self._win_hook = None + # Dedupes auto-repeat from the low-level hook the same way + # _held_keys does for Tk. Keys here are HID codes, not Tk keysyms. + self._hook_held_codes: set = set() + + self._build_dialog() + self._refresh_event_list() + self._refresh_button_states() + + # Auto-start the BLE scanner. The worker retries until it finds + # an M5Stack in Live Mode, so the user can take their time + # putting the device into Live Mode after opening this dialog. + if self._ble_factory is not None: + try: + self.dlg.after(50, self._open_ble) + except tk.TclError: + pass + + # ---- UI construction ---- + + def _build_dialog(self): + self.dlg = tk.Toplevel(self.parent) + self.dlg.title("Configure Macro") + self.dlg.geometry("620x600") + self.dlg.configure(bg="#2D2D3D") + self.dlg.transient(self.parent.winfo_toplevel()) + self.dlg.grab_set() + + tk.Label(self.dlg, text="Macro recording (live over BLE)", + bg="#2D2D3D", fg="white", font=("Segoe UI", 12, "bold")).pack( + anchor="w", padx=14, pady=(12, 2)) + + # Persistent reminder banner — drawn in a high-contrast color so + # the user knows the one external prerequisite. The device connects + # automatically; it just has to be idle (not running a routine). + reminder_frame = tk.Frame(self.dlg, bg="#1E40AF", bd=0) + reminder_frame.pack(fill="x", padx=14, pady=(2, 8)) + tk.Label(reminder_frame, + text="The M5Stack connects automatically — just leave it idle on its " + "macro selector (not running a routine).", + bg="#1E40AF", fg="white", + font=("Segoe UI", 9, "bold"), padx=10, pady=6, + anchor="w", justify="left").pack(fill="x") + + name_row = tk.Frame(self.dlg, bg="#2D2D3D") + name_row.pack(fill="x", padx=14, pady=(0, 8)) + tk.Label(name_row, text="Name:", + bg="#2D2D3D", fg="white", font=("Segoe UI", 9)).pack(side="left") + self.name_var = tk.StringVar(value=self.data.get("name", "")) + tk.Entry(name_row, textvariable=self.name_var, + bg="#1E1E2E", fg="white", insertbackground="white", + font=("Segoe UI", 9), relief="flat").pack( + side="left", fill="x", expand=True, padx=(8, 0)) + + self.status_var = tk.StringVar(value=self._idle_status()) + self.status_label = tk.Label(self.dlg, textvariable=self.status_var, + bg="#2D2D3D", fg="#F59E0B", + font=("Segoe UI", 9, "bold")) + self.status_label.pack(anchor="w", padx=14, pady=(0, 6)) + + # ---- BLE control row ---- + ble_row = tk.Frame(self.dlg, bg="#2D2D3D") + ble_row.pack(fill="x", padx=14, pady=(0, 6)) + self.open_btn = tk.Button(ble_row, text="Open BLE", + bg="#0EA5E9", fg="white", + activebackground="#0284C7", + font=("Segoe UI", 9, "bold"), + relief="flat", padx=12, + command=self._open_ble) + self.open_btn.pack(side="left") + self.close_btn = tk.Button(ble_row, text="Close BLE", + bg="#475569", fg="white", + font=("Segoe UI", 9), relief="flat", + padx=12, command=self._close_ble) + self.close_btn.pack(side="left", padx=(6, 0)) + + self.ble_status_var = tk.StringVar(value="BLE: disconnected") + tk.Label(ble_row, textvariable=self.ble_status_var, + bg="#2D2D3D", fg="#94A3B8", font=("Segoe UI", 9)).pack( + side="left", padx=(12, 0)) + + # ---- Record / housekeeping row ---- + btn_row = tk.Frame(self.dlg, bg="#2D2D3D") + btn_row.pack(fill="x", padx=14, pady=(0, 8)) + + self.rec_btn = tk.Button(btn_row, text="● Record", + bg="#DC2626", fg="white", + activebackground="#B91C1C", + font=("Segoe UI", 10, "bold"), relief="flat", + padx=14, command=self._toggle_record) + self.rec_btn.pack(side="left") + + tk.Button(btn_row, text="Clear all", bg="#7F1D1D", fg="white", + activebackground="#991B1B", + font=("Segoe UI", 9), relief="flat", padx=12, + command=self._clear_all).pack(side="left", padx=(8, 0)) + + tk.Button(btn_row, text="Sort by time", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=12, + command=self._sort_events).pack(side="left", padx=(8, 0)) + + list_frame = tk.Frame(self.dlg, bg="#2D2D3D") + list_frame.pack(fill="both", expand=True, padx=14, pady=(4, 8)) + + style = ttk.Style() + try: + style.configure("Macro.Treeview", + background="#1E1E2E", foreground="white", + fieldbackground="#1E1E2E", rowheight=22, + font=("Consolas", 9)) + style.configure("Macro.Treeview.Heading", + background="#2D2D3D", foreground="#CCCCCC", + font=("Segoe UI", 9, "bold")) + style.map("Macro.Treeview", background=[("selected", "#3B82F6")]) + except tk.TclError: + pass + + columns = ("t", "action", "key") + self.tree = ttk.Treeview(list_frame, columns=columns, show="headings", + style="Macro.Treeview", selectmode="extended") + self.tree.heading("t", text="Time (ms)") + self.tree.heading("action", text="Action") + self.tree.heading("key", text="Key") + self.tree.column("t", width=100, anchor="e") + self.tree.column("action", width=80, anchor="center") + self.tree.column("key", width=240, anchor="w") + + vsb = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=vsb.set) + self.tree.grid(row=0, column=0, sticky="nsew") + vsb.grid(row=0, column=1, sticky="ns") + list_frame.grid_rowconfigure(0, weight=1) + list_frame.grid_columnconfigure(0, weight=1) + + self.tree.bind("", self._on_tree_double_click) + self.tree.bind("", self._on_tree_right_click) + + edit_row = tk.Frame(self.dlg, bg="#2D2D3D") + edit_row.pack(fill="x", padx=14, pady=(0, 8)) + tk.Button(edit_row, text="Edit", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=10, + command=self._edit_selected).pack(side="left") + tk.Button(edit_row, text="Insert before", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=10, + command=lambda: self._insert_relative(before=True)).pack( + side="left", padx=(6, 0)) + tk.Button(edit_row, text="Insert after", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=10, + command=lambda: self._insert_relative(before=False)).pack( + side="left", padx=(6, 0)) + tk.Button(edit_row, text="Delete selected", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=10, + command=self._delete_selected).pack(side="left", padx=(6, 0)) + tk.Label(edit_row, text="(double-click a row to edit)", + bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8)).pack( + side="left", padx=(10, 0)) + + footer = tk.Frame(self.dlg, bg="#2D2D3D") + footer.pack(fill="x", side="bottom", padx=14, pady=12) + tk.Button(footer, text="Cancel", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=14, + command=self._cancel).pack(side="right") + tk.Button(footer, text="Save", bg="#22C55E", fg="white", + activebackground="#16A34A", + font=("Segoe UI", 9, "bold"), relief="flat", padx=14, + command=self._save).pack(side="right", padx=(0, 6)) + + self.dlg.focus_set() + self.dlg.protocol("WM_DELETE_WINDOW", self._cancel) + + def _idle_status(self) -> str: + n = len(self._events) + if n == 0: + return "No events recorded." + last_t = self._events[-1][0] + secs = last_t / 1000.0 + return f"{n} events, {secs:.2f}s total." + + # ---- BLE lifecycle ---- + + def _open_ble(self): + if self._ble_client is not None: + return + if self._ble_factory is None: + messagebox.showerror( + "Live record unavailable", + "BLE client factory not configured.", + parent=self.dlg) + return + + # Note: we deliberately do NOT pause the var-sync client here. + # The device advertises a different SERVICE_UUID when in live + # mode, so the two scanners filter to disjoint device sets and + # don't race. Pausing var-sync via stop() would interrupt any + # in-flight WinRT BleakClient cleanup and leave the host BLE + # subsystem in a state that breaks the next connect attempt. + self._ble_client = self._ble_factory() + self._ble_client.start( + on_status=self._on_ble_status, + on_error=self._on_ble_error, + ) + self._set_status("BLE: scanning for idle M5Stack...", "#F59E0B") + self._refresh_button_states() + + def _close_ble(self): + # Stops capture too — pressing Close BLE during a recording is + # equivalent to pressing Stop first. + if self._recording: + self._stop_recording() + if self._ble_client is not None: + try: + self._ble_client.stop(timeout=2.0) + except Exception: + pass + self._ble_client = None + self._ble_status = "disconnected" + self.ble_status_var.set("BLE: disconnected") + self._set_status(self._idle_status(), "#F59E0B") + self._refresh_button_states() + + def _on_ble_status(self, status: str): + self.dlg.after(0, lambda s=status: self._apply_ble_status(s)) + + def _on_ble_error(self, err_code: int, ref_seq, label: str): + # KEY_MISMATCH is host-side: ble_live.py emits it when a few + # consecutive notify frames fail AES-GCM auth. Surface it with + # an actionable remediation instead of an opaque label. + if label == "KEY_MISMATCH": + msg = ("BLE key mismatch — the host and the M5Stack have " + "different keys. Upload the profile over USB to " + "re-sync, then try again.") + self.dlg.after(0, lambda m=msg: self._set_status(m, "#EF4444")) + # Also a modal so the user can't miss it. Done once per + # session: the auth-fail counter only fires the callback + # at exactly count==3. + self.dlg.after(0, self._show_key_mismatch_modal) + return + msg = f"Device reported {label}" + self.dlg.after(0, lambda m=msg: self._set_status(m, "#EF4444")) + + def _show_key_mismatch_modal(self): + if not self._dialog_alive(): + return + try: + messagebox.showerror( + "BLE key mismatch", + "Frames from the M5Stack can't be decrypted because the " + "host's stored key doesn't match the device's.\n\n" + "Fix: connect the M5Stack to this computer over USB and " + "upload your profile. The upload syncs the encryption " + "key automatically.", + parent=self.dlg) + except Exception: + pass + + def _apply_ble_status(self, status: str): + if not self._dialog_alive(): + return + self._ble_status = status + # Surface a friendly label next to the buttons. + labels = { + "idle": "BLE: idle", + "scanning": "BLE: scanning...", + "connecting": "BLE: connecting...", + "connected": "BLE: connected", + "disconnected": "BLE: disconnected — auto-retrying", + "error": "BLE: error", + } + self.ble_status_var.set(labels.get(status, f"BLE: {status}")) + + if status == "connected": + if self._recording: + self._set_status("● Recording", "#EF4444") + else: + self._set_status(self._idle_status(), "#22C55E") + elif status in ("disconnected", "error"): + if self._recording: + # Capture pauses transparently — _on_key_press already + # bails on !is_connected(). Show banner so user knows. + self._set_status("BLE dropped — waiting for reconnect...", + "#F59E0B") + elif status in ("scanning", "connecting"): + if self._recording: + self._set_status(f"● Recording (BLE: {status}...)", + "#F59E0B") + self._refresh_button_states() + + def _refresh_button_states(self): + """Drive button enable/disable from the canonical BLE state.""" + worker_running = self._ble_client is not None + connected = (self._ble_status == "connected") + # Open BLE: only when no worker is up. + self.open_btn.config(state="normal" if not worker_running else "disabled") + # Close BLE: enabled whenever a worker is alive (lets user + # cancel a long scan). + self.close_btn.config(state="normal" if worker_running else "disabled") + # Record: only when actually connected. + if connected or self._recording: + self.rec_btn.config(state="normal") + else: + self.rec_btn.config(state="disabled") + + # ---- Recording lifecycle ---- + + def _toggle_record(self): + if self._recording: + self._stop_recording() + else: + self._start_recording() + + def _start_recording(self): + if not (self._ble_client and self._ble_client.is_connected()): + messagebox.showinfo( + "Not connected", + "Open BLE first and make sure the M5Stack is idle on its " + "macro selector.", + parent=self.dlg) + return + + self._held_keys.clear() + self._hook_held_codes.clear() + # Reset the per-event timestamp anchor on the BLE client so this + # session's events start at t=0 rather than continuing from the + # previous session. + try: + self._ble_client.reset_session_clock() + except AttributeError: + pass + self._recording = True + self._record_start_ms = int(time.monotonic() * 1000) + + self.rec_btn.config(text="■ Stop", bg="#B91C1C") + self._set_status("● Recording", "#EF4444") + self._bind_keys() + + def _stop_recording(self): + if not self._recording: + return + self._recording = False + + # Synthesize releases for anything still held so playback doesn't + # leave a key latched. Drain both held sets — _hook_held_codes + # is the canonical one for hook-captured keys, _held_keys is + # the legacy fallback for the Tk path. + now_ms = int(time.monotonic() * 1000) - self._record_start_ms + for code in list(self._hook_held_codes): + self._events.append([now_ms, self.ACTION_UP, code]) + if self._ble_client and self._ble_client.is_connected(): + self._ble_client.send_event(self.ACTION_UP, code) + self._hook_held_codes.clear() + self._held_keys.clear() + self._unbind_keys() + + self.rec_btn.config(text="● Record", bg="#DC2626") + self._set_status(self._idle_status(), "#F59E0B") + self._refresh_event_list() + + def _clear_all(self): + """Wipe every recorded event after a confirmation. Bound to the + "Clear all" button — explicit, scary-red styling because this + is destructive.""" + if not self._events: + self._set_status("Nothing to clear.", "#94A3B8") + return + if not messagebox.askyesno( + "Clear all events?", + "Remove ALL recorded events? This cannot be undone.", + parent=self.dlg): + return + self._events = [] + self._held_keys.clear() + self._hook_held_codes.clear() + self._refresh_event_list() + self._set_status(self._idle_status(), "#F59E0B") + + # ---- Key capture ---- + # + # Two parallel capture paths, depending on platform: + # + # 1. Tkinter / bindings on the dialog. This + # is the cross-platform fallback. It misses the Windows key on + # Windows because the OS swallows VK_LWIN/VK_RWIN before any + # window sees it. + # + # 2. WinKeyboardHook (Windows only). A low-level keyboard hook + # that sees every keystroke globally, before any window or the + # Start menu handler. We use this to cover the gaps Tk leaves + # AND to suppress the Windows key locally so it relays via BLE + # only. + # + # The Tk path stays installed too because the hook can miss IME + # composition / dead-key sequences that Tk synthesizes from + # WM_CHAR. The two paths feed into the same recording state via + # _record_event, which dedupes by HID code. + + def _bind_keys(self): + self.dlg.bind("", self._on_key_press, add="+") + self.dlg.bind("", self._on_key_release, add="+") + self.dlg.focus_set() + if (WinKeyboardHook is not None and _winhook_supported() + and self._win_hook is None): + try: + self._win_hook = WinKeyboardHook( + on_event=self._on_hook_event, + on_escape=self._on_hook_escape, + ) + self._win_hook.start(suppress_local=True) + except Exception as exc: + print(f"[recorder] win hook install failed: {exc}") + self._win_hook = None + + def _unbind_keys(self): + try: + self.dlg.unbind("") + self.dlg.unbind("") + except tk.TclError: + pass + if self._win_hook is not None: + try: + self._win_hook.stop() + except Exception: + pass + self._win_hook = None + self._hook_held_codes.clear() + + def _on_hook_event(self, action: int, hid: int) -> None: + """Called on the hook thread. Marshal to Tk and dispatch.""" + try: + self.dlg.after(0, lambda a=action, h=hid: self._record_event(a, h)) + except Exception: + pass + + def _on_hook_escape(self) -> None: + """Hook sees Escape — stop recording (marshalled to Tk).""" + if not self._recording: + return + try: + self.dlg.after(0, self._stop_recording) + except Exception: + pass + + def _record_event(self, action: int, hid: int) -> None: + """Single funnel for both Tk and hook paths. Dedupes auto-repeat + and pushes the event to the recorder + BLE.""" + if not self._recording: + return + if not (self._ble_client and self._ble_client.is_connected()): + return + if action == 0: # DOWN + if hid in self._hook_held_codes: + return + self._hook_held_codes.add(hid) + else: # UP + if hid not in self._hook_held_codes: + return + self._hook_held_codes.discard(hid) + t_ms = int(time.monotonic() * 1000) - self._record_start_ms + self._events.append([t_ms, action, hid]) + self._ble_client.send_event(action, hid) + self._append_event_row(len(self._events) - 1) + + def _on_key_press(self, event): + # On Windows the low-level hook with suppress_local=True + # short-circuits Tk before this fires, so this branch is the + # non-Windows fallback (and also catches IME-translated + # WM_CHAR events on Windows that bypass the low-level hook). + if not self._recording: + return + if event.keysym == "Escape": + self._stop_recording() + return "break" + code = TKKEYSYM_TO_HID.get(event.keysym) + if code is None: + return "break" + self._record_event(self.ACTION_DOWN, code) + return "break" + + def _on_key_release(self, event): + if not self._recording: + return + code = TKKEYSYM_TO_HID.get(event.keysym) + if code is None: + return "break" + self._record_event(self.ACTION_UP, code) + return "break" + + # ---- Event list / editor ---- + + def _refresh_event_list(self): + for iid in self.tree.get_children(): + self.tree.delete(iid) + for i in range(len(self._events)): + self._append_event_row(i) + + def _append_event_row(self, idx: int): + t_ms, action, code = self._events[idx] + action_str = "↓ press" if action == self.ACTION_DOWN else "↑ release" + label = hid_code_label(code) + self.tree.insert("", "end", iid=str(idx), + values=(f"{t_ms:>6d}", action_str, f"{label} (0x{code:02X})")) + self.tree.see(str(idx)) + + def _on_tree_double_click(self, _event): + self._edit_selected() + + def _on_tree_right_click(self, event): + row = self.tree.identify_row(event.y) + if row: + self.tree.selection_set(row) + menu = tk.Menu(self.tree, tearoff=0) + menu.add_command(label="Edit...", command=self._edit_selected) + menu.add_command(label="Insert before", + command=lambda: self._insert_relative(before=True)) + menu.add_command(label="Insert after", + command=lambda: self._insert_relative(before=False)) + menu.add_separator() + menu.add_command(label="Delete", command=self._delete_selected) + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _edit_selected(self): + sel = self.tree.selection() + if not sel: + return + try: + idx = int(sel[0]) + except ValueError: + return + if not (0 <= idx < len(self._events)): + return + + def commit(new_ev): + self._events[idx] = new_ev + self._refresh_event_list() + if not self._recording: + self._set_status(self._idle_status(), "#F59E0B") + + MacroEventEditorDialog(self.dlg, list(self._events[idx]), commit, + title=f"Edit event #{idx}") + + def _insert_relative(self, *, before: bool): + if self._recording: + messagebox.showinfo("Stop recording first", + "Stop recording before inserting events manually.", + parent=self.dlg) + return + sel = self.tree.selection() + idx = None + if sel: + try: + idx = int(sel[0]) + except ValueError: + idx = None + if idx is None: + idx = len(self._events) - 1 if before else len(self._events) + + insert_at = idx if before else idx + 1 + if not self._events: + seed = [0, self.ACTION_DOWN, 0x04] + else: + anchor = max(0, min(idx, len(self._events) - 1)) + t_here = self._events[anchor][0] + neighbor_idx = insert_at - 1 if not before else insert_at + neighbor_idx = max(0, min(neighbor_idx, len(self._events) - 1)) + t_neighbor = self._events[neighbor_idx][0] + t_seed = (t_here + t_neighbor) // 2 if t_here != t_neighbor else t_here + seed = [int(t_seed), self.ACTION_DOWN, + int(self._events[anchor][2])] + + def commit(new_ev): + self._events.insert(insert_at, new_ev) + self._refresh_event_list() + + MacroEventEditorDialog(self.dlg, seed, commit, + title="New event") + + def _delete_selected(self): + sel = self.tree.selection() + if not sel: + return + indices = sorted({int(iid) for iid in sel}, reverse=True) + for i in indices: + if 0 <= i < len(self._events): + self._events.pop(i) + self._refresh_event_list() + self._set_status(self._idle_status(), "#F59E0B") + + def _sort_events(self): + if len(self._events) < 2: + return + self._events.sort(key=lambda e: e[0]) + self._refresh_event_list() + + # ---- Save / Cancel ---- + + def _save(self): + if self._recording: + self._stop_recording() + if not self._events_monotonic(): + if messagebox.askyesno( + "Events out of order", + "Some events have timestamps earlier than a preceding event.\n" + "Playback will fire them back-to-back.\n\n" + "Sort events by time now?", + parent=self.dlg): + self._events.sort(key=lambda e: e[0]) + self.data["name"] = self.name_var.get().strip() + self.data["events"] = [list(e) for e in self._events] + self._close_ble() + try: + self.on_save() + finally: + self.dlg.destroy() + + def _cancel(self): + if self._recording: + self._stop_recording() + self._close_ble() + self.dlg.destroy() + + # ---- Utilities ---- + + def _events_monotonic(self) -> bool: + prev = -1 + for ev in self._events: + if ev[0] < prev: + return False + prev = ev[0] + return True + + def _set_status(self, text: str, color): + try: + self.status_var.set(text) + if color: + self.status_label.config(fg=color) + except tk.TclError: + pass + + def _dialog_alive(self) -> bool: + try: + return bool(self.dlg.winfo_exists()) + except tk.TclError: + return False diff --git a/widgets/properties_panel.py b/widgets/properties_panel.py new file mode 100644 index 0000000..f96a031 --- /dev/null +++ b/widgets/properties_panel.py @@ -0,0 +1,194 @@ +"""Right sidebar - node property editor panel.""" + +import tkinter as tk +from utils.constants import NODE_TYPES +from node_editor.nodes import get_property_editor + + +class PropertiesPanel(tk.Frame): + """Dynamic property editor for the selected node.""" + + def __init__(self, parent, on_change=None): + super().__init__(parent, bg="#252535", width=250) + self.on_change = on_change + self.current_node = None + self.current_editor = None + self._node_canvas = None + self._profile_manager = None + self._project = None + + self.pack_propagate(False) + self._build_ui() + + def set_node_canvas(self, canvas): + self._node_canvas = canvas + + def set_macro_list(self, macro_list): + self._macro_list = macro_list + + def set_profile_manager(self, profile_manager): + self._profile_manager = profile_manager + + def set_project(self, project): + """Wire the Project so editors can read live settings (pause-text margins, etc.).""" + self._project = project + + def _build_ui(self): + self.header = tk.Frame(self, bg="#1E1E2E") + self.header.pack(fill="x") + + self.title_label = tk.Label(self.header, text="Properties", bg="#1E1E2E", + fg="white", font=("Segoe UI", 11, "bold"), pady=8) + self.title_label.pack(side="left", padx=10) + + content_frame = tk.Frame(self, bg="#252535") + content_frame.pack(fill="both", expand=True) + + self.content_canvas = tk.Canvas(content_frame, bg="#252535", highlightthickness=0) + scrollbar = tk.Scrollbar(content_frame, orient="vertical", + command=self.content_canvas.yview) + self.content_inner = tk.Frame(self.content_canvas, bg="#252535") + + self.content_inner.bind("", + lambda e: self.content_canvas.configure( + scrollregion=self.content_canvas.bbox("all"))) + self.content_canvas.create_window((0, 0), window=self.content_inner, anchor="nw", + tags="inner") + self.content_canvas.configure(yscrollcommand=scrollbar.set) + self.content_canvas.bind("", + lambda e: self.content_canvas.itemconfig("inner", width=e.width)) + + # Bind wheel scrolling only while the cursor is inside the panel so + # we don't fight other widgets for the global event + def _on_wheel(event): + self.content_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + return "break" + + def _bind_wheel(_): + self.content_canvas.bind_all("", _on_wheel) + + def _unbind_wheel(_): + self.content_canvas.unbind_all("") + + for w in (self.content_canvas, self.content_inner): + w.bind("", _bind_wheel) + w.bind("", _unbind_wheel) + + self.content_canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + self.empty_label = tk.Label(self.content_inner, + text="Select a node to\nedit its properties", + bg="#252535", fg="#666666", + font=("Segoe UI", 10), justify="center") + self.empty_label.pack(expand=True, pady=40) + + def show_node(self, node_widget): + """Display properties for the selected node (or multi-select summary).""" + self._clear_editor() + + if node_widget is None: + self.current_node = None + self.title_label.config(text="Properties") + + canvas = self._node_canvas + if canvas and len(canvas.selected_nodes) > 1: + count = len(canvas.selected_nodes) + tk.Label(self.content_inner, + text=f"{count} nodes selected", + bg="#252535", fg="#AAAAAA", + font=("Segoe UI", 11, "bold"), justify="center").pack(pady=(30, 10)) + tk.Button(self.content_inner, text="Delete All Selected", + bg="#D94A4A", fg="white", font=("Segoe UI", 9), relief="flat", + command=self._delete_current).pack(padx=20, pady=4, fill="x") + return + + self.empty_label = tk.Label(self.content_inner, + text="Select a node to\nedit its properties", + bg="#252535", fg="#666666", + font=("Segoe UI", 10), justify="center") + self.empty_label.pack(expand=True, pady=40) + return + + self.current_node = node_widget + node_data = node_widget.data + type_info = NODE_TYPES.get(node_data.type, {"label": "Unknown"}) + self.title_label.config(text=type_info["label"]) + + type_frame = tk.Frame(self.content_inner, bg="#2D2D3D") + type_frame.pack(fill="x", padx=4, pady=4) + color = type_info.get("color", "#555555") + tk.Frame(type_frame, bg=color, width=4).pack(side="left", fill="y") + tk.Label(type_frame, text=f" {type_info['label']} Node", + bg="#2D2D3D", fg="white", font=("Segoe UI", 10, "bold"), + pady=6).pack(side="left") + + tk.Button(type_frame, text="Delete", bg="#D94A4A", fg="white", + font=("Segoe UI", 8), relief="flat", + command=self._delete_current).pack(side="right", padx=8, pady=4) + + editor_factory = get_property_editor(node_data.type) + if editor_factory: + def on_prop_change(): + if self.current_node and self._node_canvas: + self._node_canvas.refresh_node(self.current_node.data.id) + if self.on_change: + self.on_change() + + if node_data.type == "start" and self._node_canvas and self._node_canvas.macro: + def on_rename(): + if hasattr(self, '_macro_list') and self._macro_list: + self._macro_list.refresh() + + editor = editor_factory( + self.content_inner, node_data.data, on_prop_change, + macro=self._node_canvas.macro, on_rename=on_rename, + ) + elif node_data.type == "subroutine" and self._profile_manager: + sub_names = self._profile_manager.get_subroutine_names() + editor = editor_factory( + self.content_inner, node_data.data, on_prop_change, + subroutine_names=sub_names, + ) + elif node_data.type == "iteration_branch" and self._node_canvas: + editor = editor_factory( + self.content_inner, node_data.data, on_prop_change, + macro=self._node_canvas.macro, + node_canvas=self._node_canvas, + widget=node_widget, + ) + elif node_data.type == "aggregator" and self._node_canvas: + editor = editor_factory( + self.content_inner, node_data.data, on_prop_change, + node_canvas=self._node_canvas, + widget=node_widget, + ) + elif node_data.type in ("branch", "bluetooth") and self._node_canvas: + editor = editor_factory( + self.content_inner, node_data.data, on_prop_change, + node_canvas=self._node_canvas, + widget=node_widget, + ) + elif node_data.type == "pause": + editor = editor_factory( + self.content_inner, node_data.data, on_prop_change, + project=self._project, + ) + elif node_data.type == "text": + editor = editor_factory( + self.content_inner, node_data.data, on_prop_change, + project=self._project, + ) + else: + editor = editor_factory(self.content_inner, node_data.data, on_prop_change) + editor.pack(fill="x", padx=4, pady=4) + self.current_editor = editor + + def _clear_editor(self): + for widget in self.content_inner.winfo_children(): + widget.destroy() + self.current_editor = None + + def _delete_current(self): + if self._node_canvas: + self._node_canvas.delete_selected() diff --git a/widgets/request_variable_dialog.py b/widgets/request_variable_dialog.py new file mode 100644 index 0000000..313d370 --- /dev/null +++ b/widgets/request_variable_dialog.py @@ -0,0 +1,136 @@ +"""Modal popup invoked when a device sends a Request BLE Variable(s) op. + +The dialog lists the variable names the device asked for, pre-fills with the +current device-profile values (if any), and returns the user's edits via the +on_submit callback. Clicking Cancel returns None — the BLE client falls back +to the existing values in that case. +""" + +import tkinter as tk +from tkinter import messagebox + + +class RequestVariableDialog(tk.Toplevel): + _BG = "#2D2D3D" + _ROW_BG = "#252535" + _ENTRY_BG = "#1E1E2E" + _FG = "#FFFFFF" + _DIM_FG = "#AAAAAA" + _BTN_BG = "#3D3D5C" + _SAVE_BG = "#0077CC" + + def __init__(self, parent, mac: str, names: list, current: dict, + on_submit, play_sound: bool = True): + super().__init__(parent) + self.title(f"Variable Request — {mac}") + self.geometry("520x420") + self.minsize(380, 240) + self.configure(bg=self._BG) + self.transient(parent) + self.grab_set() + + self._on_submit = on_submit + self._mac = mac + self._names = list(names) + self._current = dict(current or {}) + self._rows = [] + + try: + import ctypes + self.update_idletasks() + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, 20, ctypes.byref(ctypes.c_int(1)), ctypes.sizeof(ctypes.c_int)) + except Exception: + pass + + self._build_ui() + # Intentionally NOT binding globally on the Toplevel: a stray + # Enter keystroke pending in the OS keyboard queue when this dialog + # focus_force()s itself in would auto-submit before the user types, + # send empty values back to the device, and "end the routine." + # Submission goes through the Send to Device button only. + self.protocol("WM_DELETE_WINDOW", self._cancel) + + if play_sound: + try: + import winsound + winsound.MessageBeep(winsound.MB_ICONEXCLAMATION) + except Exception: + pass + + self.lift() + self.focus_force() + + def _build_ui(self): + hdr = tk.Frame(self, bg=self._BG) + hdr.pack(fill="x", padx=14, pady=(12, 4)) + tk.Label(hdr, text="Variable Request", bg=self._BG, fg="#0AACFF", + font=("Segoe UI", 11, "bold")).pack(side="left") + tk.Label(hdr, text=f"Device {self._mac}", bg=self._BG, fg=self._DIM_FG, + font=("Segoe UI", 8)).pack(side="left", padx=(10, 0)) + + tk.Label(self, bg=self._BG, fg=self._DIM_FG, font=("Segoe UI", 8), + text="Fill in values below; the device will receive them and " + "the device profile will be updated.").pack( + anchor="w", padx=14) + + outer = tk.Frame(self, bg=self._BG) + outer.pack(fill="both", expand=True, padx=14, pady=8) + + canvas = tk.Canvas(outer, bg=self._BG, highlightthickness=0) + scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview) + canvas.configure(yscrollcommand=scrollbar.set) + scrollbar.pack(side="right", fill="y") + canvas.pack(side="left", fill="both", expand=True) + + rows_frame = tk.Frame(canvas, bg=self._BG) + rows_window = canvas.create_window((0, 0), window=rows_frame, anchor="nw") + rows_frame.bind("", + lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.bind("", + lambda e: canvas.itemconfig(rows_window, width=e.width)) + + first_entry = None + for name in self._names: + row = tk.Frame(rows_frame, bg=self._ROW_BG, pady=3) + row.pack(fill="x", pady=2) + tk.Label(row, text=name, bg=self._ROW_BG, fg=self._FG, + font=("Segoe UI", 9, "bold"), width=18, anchor="w").pack( + side="left", padx=(8, 4)) + var = tk.StringVar(value=str(self._current.get(name, ""))) + entry = tk.Entry(row, textvariable=var, bg=self._ENTRY_BG, + fg=self._FG, insertbackground=self._FG, + font=("Segoe UI", 9), relief="flat") + entry.pack(side="left", fill="x", expand=True, padx=(0, 8), ipady=3) + self._rows.append((name, var)) + if first_entry is None: + first_entry = entry + + if first_entry is not None: + first_entry.focus_set() + + btns = tk.Frame(self, bg=self._BG) + btns.pack(fill="x", padx=14, pady=(4, 12)) + tk.Button(btns, text="Cancel", bg=self._BTN_BG, fg=self._FG, + relief="flat", padx=12, font=("Segoe UI", 9), + command=self._cancel).pack(side="right", padx=(6, 0)) + tk.Button(btns, text="Send to Device", bg=self._SAVE_BG, fg=self._FG, + relief="flat", padx=14, font=("Segoe UI", 9, "bold"), + command=self._submit).pack(side="right") + + def _submit(self): + result = {name: var.get() for name, var in self._rows} + try: + self._on_submit(result) + except Exception as e: + messagebox.showerror("Variable Request", str(e), parent=self) + return + self.destroy() + + def _cancel(self): + try: + self._on_submit(None) + except Exception: + pass + self.destroy() diff --git a/widgets/rs232_terminal.py b/widgets/rs232_terminal.py new file mode 100644 index 0000000..d6a07a1 --- /dev/null +++ b/widgets/rs232_terminal.py @@ -0,0 +1,427 @@ +"""RS232 Terminal — talk directly to the RS232 port on the M5Stack over USB. + +The Python app sends JSON commands to the M5Stack (over USB CDC) which +relays them to the Atomic RS232 Base. See the ``rs232_*`` command handlers +in ``firmware/MacroPad/serial_protocol.h`` for the firmware side. +""" + +from __future__ import annotations + +import queue +import threading +import tkinter as tk +from datetime import datetime +from tkinter import filedialog, messagebox, ttk + +from utils.constants import ( + RS232_BAUD_RATES, RS232_DATA_BITS, RS232_STOP_BITS, RS232_PARITY, + RS232_LINE_ENDINGS, +) + + +# KVM quick-send presets: (button label, bytes to send) +KVM_PRESETS = [(f"PC {i}", f"X{i},1$") for i in range(1, 9)] + + +DISPLAY_MODES = [ + ("ASCII", "ascii"), + ("ASCII + Hex", "ascii_hex"), + ("Hex only", "hex"), +] + + +class RS232Terminal(tk.Toplevel): + """Modal window for direct RS232 interaction via the M5Stack.""" + + POLL_INTERVAL_MS = 150 # how often the UI drains the RX queue + BG_POLL_SLEEP = 0.10 # how often the background thread polls the device + + def __init__(self, parent, serial_manager, on_close=None): + super().__init__(parent) + self.serial_manager = serial_manager + self._on_close_cb = on_close + + self.title("RS232 Terminal") + self.geometry("760x620") + self.minsize(640, 480) + self.configure(bg="#2D2D3D") + + self._rx_queue: "queue.Queue[bytes]" = queue.Queue() + self._poll_stop = threading.Event() + self._poll_thread: threading.Thread | None = None + self._is_open = False + + self._baud_var = tk.StringVar(value="115200") + self._data_bits_var = tk.StringVar(value="8") + self._stop_bits_var = tk.StringVar(value="1") + self._parity_var = tk.StringVar(value="none") + self._line_ending_var = tk.StringVar(value="None") + self._display_mode_var = tk.StringVar(value="ASCII + Hex") + self._echo_var = tk.BooleanVar(value=True) + self._timestamps_var = tk.BooleanVar(value=False) + self._auto_scroll_var = tk.BooleanVar(value=True) + + self._build_ui() + self._set_status("Not open", "#E74C3C") + self._ui_poll_job = None + + self.protocol("WM_DELETE_WINDOW", self._on_close) + + def _build_ui(self): + lbl = dict(bg="#2D2D3D", fg="white", font=("Segoe UI", 9)) + hint = dict(bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8)) + entry = dict(bg="#1E1E2E", fg="white", insertbackground="white", + relief="flat", font=("Segoe UI", 10)) + btn = dict(bg="#3D3D5C", fg="white", font=("Segoe UI", 9), relief="flat") + + # ---- Connection settings ---- + conn_frame = tk.LabelFrame(self, text="Connection", bg="#2D2D3D", fg="white", + font=("Segoe UI", 9, "bold"), bd=1, relief="groove") + conn_frame.pack(fill="x", padx=10, pady=(10, 4)) + + row1 = tk.Frame(conn_frame, bg="#2D2D3D") + row1.pack(fill="x", padx=6, pady=4) + + tk.Label(row1, text="Baud:", **lbl).pack(side="left") + ttk.Combobox(row1, textvariable=self._baud_var, + values=[str(b) for b in RS232_BAUD_RATES], + width=8).pack(side="left", padx=(2, 10)) + + tk.Label(row1, text="Data:", **lbl).pack(side="left") + ttk.Combobox(row1, textvariable=self._data_bits_var, + values=[str(d) for d in RS232_DATA_BITS], + state="readonly", width=3).pack(side="left", padx=(2, 10)) + + tk.Label(row1, text="Stop:", **lbl).pack(side="left") + ttk.Combobox(row1, textvariable=self._stop_bits_var, + values=RS232_STOP_BITS, state="readonly", + width=4).pack(side="left", padx=(2, 10)) + + tk.Label(row1, text="Parity:", **lbl).pack(side="left") + ttk.Combobox(row1, textvariable=self._parity_var, + values=RS232_PARITY, state="readonly", + width=6).pack(side="left", padx=(2, 10)) + + self._open_btn = tk.Button(row1, text="Open", bg="#27AE60", fg="white", + font=("Segoe UI", 9, "bold"), relief="flat", + padx=14, command=self._toggle_open) + self._open_btn.pack(side="right", padx=(6, 2)) + + self._status_dot = tk.Canvas(row1, width=12, height=12, bg="#2D2D3D", + highlightthickness=0) + self._dot_id = self._status_dot.create_oval(2, 2, 10, 10, fill="#888", outline="") + self._status_dot.pack(side="right", padx=(4, 0)) + self._status_label = tk.Label(row1, text="Not open", **lbl) + self._status_label.pack(side="right") + + # ---- Display options ---- + opts = tk.Frame(self, bg="#2D2D3D") + opts.pack(fill="x", padx=10, pady=(0, 4)) + + tk.Label(opts, text="Line ending (sent):", **lbl).pack(side="left") + le_display = [label for label, _ in RS232_LINE_ENDINGS] + ttk.Combobox(opts, textvariable=self._line_ending_var, + values=le_display, state="readonly", + width=10).pack(side="left", padx=(2, 10)) + + tk.Label(opts, text="Display:", **lbl).pack(side="left") + ttk.Combobox(opts, textvariable=self._display_mode_var, + values=[label for label, _ in DISPLAY_MODES], + state="readonly", + width=14).pack(side="left", padx=(2, 10)) + + tk.Checkbutton(opts, text="Local echo", variable=self._echo_var, + bg="#2D2D3D", fg="white", selectcolor="#1E1E2E", + activebackground="#2D2D3D", activeforeground="white", + font=("Segoe UI", 9)).pack(side="left", padx=6) + tk.Checkbutton(opts, text="Timestamps", variable=self._timestamps_var, + bg="#2D2D3D", fg="white", selectcolor="#1E1E2E", + activebackground="#2D2D3D", activeforeground="white", + font=("Segoe UI", 9)).pack(side="left", padx=6) + tk.Checkbutton(opts, text="Auto-scroll", variable=self._auto_scroll_var, + bg="#2D2D3D", fg="white", selectcolor="#1E1E2E", + activebackground="#2D2D3D", activeforeground="white", + font=("Segoe UI", 9)).pack(side="left", padx=6) + + # ---- Output area ---- + out_frame = tk.Frame(self, bg="#2D2D3D") + out_frame.pack(fill="both", expand=True, padx=10, pady=(6, 4)) + + self._output = tk.Text(out_frame, wrap="word", bg="#12121A", fg="white", + insertbackground="white", + font=("Consolas", 10), relief="flat", bd=0) + vsb = tk.Scrollbar(out_frame, orient="vertical", command=self._output.yview) + self._output.configure(yscrollcommand=vsb.set, state="disabled") + self._output.pack(side="left", fill="both", expand=True) + vsb.pack(side="right", fill="y") + + self._output.tag_config("tx", foreground="#7BED9F") + self._output.tag_config("rx", foreground="#74B9FF") + self._output.tag_config("meta", foreground="#888888", font=("Consolas", 9, "italic")) + self._output.tag_config("err", foreground="#FF6B6B") + self._output.tag_config("hex", foreground="#DFE4EA") + + # ---- Send row ---- + send_row = tk.Frame(self, bg="#2D2D3D") + send_row.pack(fill="x", padx=10, pady=(2, 6)) + + self._cmd_var = tk.StringVar() + cmd_entry = tk.Entry(send_row, textvariable=self._cmd_var, **entry) + cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 4)) + cmd_entry.bind("", self._send_clicked) + + tk.Button(send_row, text="Send", bg="#27AE60", fg="white", + font=("Segoe UI", 9, "bold"), relief="flat", padx=14, + command=self._send_clicked).pack(side="left", padx=(0, 4)) + + tk.Button(send_row, text="Clear", **btn, padx=10, + command=self._clear).pack(side="left", padx=(0, 2)) + tk.Button(send_row, text="Save Log…", **btn, padx=10, + command=self._save_log).pack(side="left", padx=2) + + # ---- KVM quick-sends ---- + kvm_frame = tk.LabelFrame(self, text="KVM Quick-Send", bg="#2D2D3D", + fg="white", font=("Segoe UI", 9, "bold"), + bd=1, relief="groove") + kvm_frame.pack(fill="x", padx=10, pady=(0, 6)) + + kvm_inner = tk.Frame(kvm_frame, bg="#2D2D3D") + kvm_inner.pack(padx=6, pady=6) + + tk.Label(kvm_inner, text="Select PC:", **lbl).pack(side="left", padx=(0, 6)) + for label, payload in KVM_PRESETS: + tk.Button(kvm_inner, text=label, bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=10, pady=2, + command=lambda p=payload: self._send_preset(p)).pack(side="left", padx=2) + + tk.Label(kvm_frame, + text="Presets send the literal string (no line ending is appended).", + **hint).pack(anchor="w", padx=8, pady=(0, 4)) + + cmd_entry.focus_set() + + def _toggle_open(self): + if self._is_open: + self._close_port() + else: + self._open_port() + + def _open_port(self): + if not self.serial_manager.connected: + messagebox.showerror("RS232 Terminal", + "The MacroPad is not connected over USB.\n\n" + "Connect the device and try again.", + parent=self) + return + try: + baud = int(self._baud_var.get()) + except ValueError: + messagebox.showerror("RS232 Terminal", "Baud must be a number.", parent=self) + return + try: + data_bits = int(self._data_bits_var.get()) + except ValueError: + data_bits = 8 + + ok = self.serial_manager.rs232_open( + baud=baud, + data_bits=data_bits, + stop_bits=self._stop_bits_var.get(), + parity=self._parity_var.get(), + ) + if not ok: + messagebox.showerror("RS232 Terminal", + "Device refused to open the RS232 port.\n" + "Check that firmware is up to date.", + parent=self) + return + + self._is_open = True + self._open_btn.config(text="Close", bg="#D94A4A") + self._set_status(f"Open @ {baud}", "#27AE60") + cfg = f"{baud} {data_bits}{self._parity_var.get()[0].upper()}{self._stop_bits_var.get()}" + self._append_meta(f"--- Opened RS232 @ {cfg} ---") + + self._start_polling() + + def _close_port(self): + self._stop_polling() + try: + self.serial_manager.rs232_close() + except Exception: + pass + self._is_open = False + self._open_btn.config(text="Open", bg="#27AE60") + self._set_status("Not open", "#E74C3C") + self._append_meta("--- Closed ---") + + def _set_status(self, text, color): + self._status_label.config(text=text, fg=color) + try: + self._status_dot.itemconfig(self._dot_id, fill=color) + except tk.TclError: + pass + + def _start_polling(self): + import time + self._poll_stop.clear() + + def loop(): + while not self._poll_stop.is_set(): + try: + data = self.serial_manager.rs232_poll() + if data: + self._rx_queue.put(data) + except Exception: + # Keep the thread alive across transient errors; a real + # disconnect simply yields empty polls on the next tick. + pass + time.sleep(self.BG_POLL_SLEEP) + + self._poll_thread = threading.Thread(target=loop, daemon=True) + self._poll_thread.start() + self._schedule_ui_drain() + + def _stop_polling(self): + self._poll_stop.set() + if self._ui_poll_job: + try: + self.after_cancel(self._ui_poll_job) + except Exception: + pass + self._ui_poll_job = None + + def _schedule_ui_drain(self): + self._drain_rx_queue() + if not self._poll_stop.is_set(): + self._ui_poll_job = self.after(self.POLL_INTERVAL_MS, self._schedule_ui_drain) + + def _drain_rx_queue(self): + while True: + try: + data = self._rx_queue.get_nowait() + except queue.Empty: + break + self._append_rx(data) + + def _line_ending_bytes(self) -> bytes: + disp = self._line_ending_var.get() + for label, code in RS232_LINE_ENDINGS: + if label == disp: + return {"none": b"", "cr": b"\r", "lf": b"\n", "crlf": b"\r\n"}.get(code, b"") + return b"" + + def _send_clicked(self, event=None): + if not self._is_open: + messagebox.showwarning("RS232 Terminal", "Open the port first.", parent=self) + return + text = self._cmd_var.get() + payload = text.encode("utf-8", errors="replace") + self._line_ending_bytes() + if self._send_bytes(payload): + self._cmd_var.set("") + + def _send_preset(self, payload_str: str): + if not self._is_open: + messagebox.showwarning("RS232 Terminal", "Open the port first.", parent=self) + return + self._send_bytes(payload_str.encode("ascii", errors="replace")) + + def _send_bytes(self, data: bytes) -> bool: + ok = self.serial_manager.rs232_send(data) + if ok: + if self._echo_var.get(): + self._append_tx(data) + else: + self._append_err("!! send failed") + return ok + + def _prefix(self) -> str: + if self._timestamps_var.get(): + return datetime.now().strftime("[%H:%M:%S.%f]")[:-3] + " " + return "" + + def _format_payload(self, data: bytes, mode: str) -> str: + if mode == "ascii": + return self._to_printable(data) + if mode == "hex": + return " ".join(f"{b:02x}" for b in data) + ascii_part = self._to_printable(data) + hex_part = " ".join(f"{b:02x}" for b in data) + return f"{ascii_part} [{hex_part}]" + + @staticmethod + def _to_printable(data: bytes) -> str: + out = [] + for b in data: + if b == 0x0D: + out.append("\\r") + elif b == 0x0A: + out.append("\\n") + elif b == 0x09: + out.append("\\t") + elif 0x20 <= b <= 0x7E: + out.append(chr(b)) + else: + out.append(f"\\x{b:02x}") + return "".join(out) + + def _mode_code(self) -> str: + disp = self._display_mode_var.get() + for label, code in DISPLAY_MODES: + if label == disp: + return code + return "ascii_hex" + + def _append_line(self, text: str, tag: str): + self._output.config(state="normal") + self._output.insert("end", text + "\n", (tag,)) + self._output.config(state="disabled") + if self._auto_scroll_var.get(): + self._output.see("end") + + def _append_tx(self, data: bytes): + body = self._format_payload(data, self._mode_code()) + self._append_line(f"{self._prefix()}> {body}", "tx") + + def _append_rx(self, data: bytes): + body = self._format_payload(data, self._mode_code()) + self._append_line(f"{self._prefix()}< {body}", "rx") + + def _append_meta(self, text: str): + self._append_line(text, "meta") + + def _append_err(self, text: str): + self._append_line(text, "err") + + def _clear(self): + self._output.config(state="normal") + self._output.delete("1.0", "end") + self._output.config(state="disabled") + + def _save_log(self): + path = filedialog.asksaveasfilename( + parent=self, + title="Save RS232 log", + defaultextension=".log", + filetypes=[("Log file", "*.log"), ("Text", "*.txt"), ("All files", "*.*")], + ) + if not path: + return + try: + with open(path, "w", encoding="utf-8") as f: + f.write(self._output.get("1.0", "end")) + except OSError as e: + messagebox.showerror("Save Log", f"Could not save:\n{e}", parent=self) + + def _on_close(self): + try: + if self._is_open: + self._close_port() + except Exception: + pass + self.destroy() + if self._on_close_cb: + try: + self._on_close_cb() + except Exception: + pass diff --git a/widgets/sequence_editor_dialog.py b/widgets/sequence_editor_dialog.py new file mode 100644 index 0000000..ed2ef39 --- /dev/null +++ b/widgets/sequence_editor_dialog.py @@ -0,0 +1,388 @@ +"""Pop-out editor for the Get Variables Semi-Auto sequence. + +A sequence is an ordered list of steps that the host renders to PowerShell at +upload time. Each step is one of: + + wait {"kind": "wait", "ms": int} + send_keys {"kind": "send_keys", "keys": str} + run {"kind": "run", "command": str} + check {"kind": "check", "command": str, + "operator": "equals"|"contains"|"regex"|"numeric_gt"|"numeric_lt"|"exists", + "expected": str, # for `exists`, this is the path + "value": str} # value the variable is set to on match + +The 1-based index of each ``check`` step becomes its toggle_count outcome, +so check steps are read top-to-bottom by the device's Scroll Lock decoder. +""" + +import tkinter as tk +from tkinter import ttk, messagebox + + +_BG = "#2D2D3D" +_CARD_BG = "#252535" +_ENTRY_BG = "#1E1E2E" +_FG = "#FFFFFF" +_DIM_FG = "#AAAAAA" +_BTN_BG = "#3D3D5C" +_SAVE_BG = "#0077CC" +_DEL_FG = "#FF7777" + +STEP_KINDS = [ + ("Wait", "wait"), + ("Send Keys", "send_keys"), + ("Run Command", "run"), + ("Check + Signal", "check"), +] + +CHECK_OPERATORS = [ + ("Output equals", "equals"), + ("Output contains", "contains"), + ("Output matches regex", "regex"), + ("Output > number", "numeric_gt"), + ("Output < number", "numeric_lt"), + ("Path exists", "exists"), +] + + +def _kind_label(kind: str) -> str: + for label, k in STEP_KINDS: + if k == kind: + return label + return kind + + +def _op_label(op: str) -> str: + for label, k in CHECK_OPERATORS: + if k == op: + return label + return op + + +class SequenceEditorDialog(tk.Toplevel): + """Modal dialog that edits a sequence in-place via on_save callback.""" + + def __init__(self, parent, sequence: list, on_save): + super().__init__(parent) + self.title("Get Variables — Sequence Editor") + self.geometry("720x600") + self.minsize(560, 420) + self.configure(bg=_BG) + self.transient(parent.winfo_toplevel()) + + self._on_save = on_save + # Per-step copy so Cancel discards edits (steps are flat dicts, no nested mutables) + self._steps = [dict(s) for s in (sequence or [])] + + try: + import ctypes + self.update_idletasks() + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, 20, ctypes.byref(ctypes.c_int(1)), ctypes.sizeof(ctypes.c_int)) + except Exception: + pass + + self._build_ui() + self._refresh() + self.protocol("WM_DELETE_WINDOW", self.destroy) + + def _build_ui(self): + hdr = tk.Frame(self, bg=_BG) + hdr.pack(fill="x", padx=14, pady=(12, 6)) + tk.Label(hdr, text="Sequence", bg=_BG, fg="#0AACFF", + font=("Segoe UI", 12, "bold")).pack(side="left") + tk.Label(hdr, + text="Each Check step's index (1, 2, 3 …) becomes its toggle " + "count. First match wins — no match = Fail output.", + bg=_BG, fg=_DIM_FG, font=("Segoe UI", 8), + justify="left", wraplength=460).pack(side="left", padx=(12, 0)) + + bar = tk.Frame(self, bg=_BG) + bar.pack(fill="x", padx=14, pady=(0, 6)) + tk.Label(bar, text="Add step:", bg=_BG, fg=_FG, + font=("Segoe UI", 9, "bold")).pack(side="left") + for label, kind in STEP_KINDS: + tk.Button(bar, text=f"+ {label}", bg=_BTN_BG, fg=_FG, + relief="flat", font=("Segoe UI", 9), padx=8, + command=lambda k=kind: self._add_step(k)).pack( + side="left", padx=(6, 0)) + + outer = tk.Frame(self, bg=_BG) + outer.pack(fill="both", expand=True, padx=14, pady=4) + canvas = tk.Canvas(outer, bg=_BG, highlightthickness=0) + scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview) + canvas.configure(yscrollcommand=scrollbar.set) + scrollbar.pack(side="right", fill="y") + canvas.pack(side="left", fill="both", expand=True) + self._canvas = canvas + + self._cards_frame = tk.Frame(canvas, bg=_BG) + self._cards_window = canvas.create_window((0, 0), window=self._cards_frame, + anchor="nw") + self._cards_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.bind( + "", + lambda e: canvas.itemconfig(self._cards_window, width=e.width)) + canvas.bind_all("", lambda e: canvas.yview_scroll( + int(-1 * (e.delta / 120)), "units")) + + btns = tk.Frame(self, bg=_BG) + btns.pack(fill="x", padx=14, pady=(4, 12)) + tk.Button(btns, text="Cancel", bg=_BTN_BG, fg=_FG, relief="flat", + padx=12, font=("Segoe UI", 9), + command=self.destroy).pack(side="right", padx=(6, 0)) + tk.Button(btns, text="Save", bg=_SAVE_BG, fg=_FG, relief="flat", + padx=14, font=("Segoe UI", 9, "bold"), + command=self._save).pack(side="right") + + def _refresh(self): + for w in self._cards_frame.winfo_children(): + w.destroy() + check_idx = 0 + for i, step in enumerate(self._steps): + if step.get("kind") == "check": + check_idx += 1 + self._draw_card(i, step, check_idx if step.get("kind") == "check" else None) + if not self._steps: + tk.Label(self._cards_frame, + text="No steps yet — click an + Add button above to begin.", + bg=_BG, fg=_DIM_FG, font=("Segoe UI", 9, "italic")).pack( + pady=30) + + def _draw_card(self, idx: int, step: dict, check_n): + card = tk.Frame(self._cards_frame, bg=_CARD_BG, bd=0) + card.pack(fill="x", pady=4, padx=2) + + head = tk.Frame(card, bg=_CARD_BG) + head.pack(fill="x", padx=8, pady=(6, 2)) + + kind = step.get("kind", "") + title = f"{idx + 1}. {_kind_label(kind)}" + if check_n is not None: + title += f" [toggle = {check_n}]" + tk.Label(head, text=title, bg=_CARD_BG, fg="#0AACFF", + font=("Segoe UI", 10, "bold")).pack(side="left") + + tk.Button(head, text="×", bg=_CARD_BG, fg=_DEL_FG, relief="flat", + font=("Segoe UI", 10, "bold"), width=2, + command=lambda i=idx: self._delete(i)).pack(side="right") + tk.Button(head, text="↓", bg=_CARD_BG, fg=_FG, relief="flat", + font=("Segoe UI", 10), width=2, + command=lambda i=idx: self._move(i, +1)).pack(side="right") + tk.Button(head, text="↑", bg=_CARD_BG, fg=_FG, relief="flat", + font=("Segoe UI", 10), width=2, + command=lambda i=idx: self._move(i, -1)).pack(side="right") + + body = tk.Frame(card, bg=_CARD_BG) + body.pack(fill="x", padx=10, pady=(0, 8)) + + if kind == "wait": + self._draw_wait_fields(body, step) + elif kind == "send_keys": + self._draw_send_keys_fields(body, step) + elif kind == "run": + self._draw_run_fields(body, step) + elif kind == "check": + self._draw_check_fields(body, step) + else: + tk.Label(body, text=f"Unknown step kind: {kind}", + bg=_CARD_BG, fg=_DEL_FG, + font=("Segoe UI", 9, "italic")).pack(anchor="w") + + def _draw_wait_fields(self, parent, step): + row = tk.Frame(parent, bg=_CARD_BG) + row.pack(fill="x") + tk.Label(row, text="Wait (ms):", bg=_CARD_BG, fg=_FG, + font=("Segoe UI", 9), width=12, anchor="w").pack(side="left") + v = tk.StringVar(value=str(step.get("ms", 500))) + e = tk.Entry(row, textvariable=v, bg=_ENTRY_BG, fg=_FG, + insertbackground=_FG, font=("Consolas", 10), + relief="flat", width=10) + e.pack(side="left", padx=4, ipady=2) + + def save(*_): + try: + step["ms"] = max(0, int(v.get())) + except ValueError: + pass + v.trace_add("write", save) + + def _draw_send_keys_fields(self, parent, step): + row = tk.Frame(parent, bg=_CARD_BG) + row.pack(fill="x") + tk.Label(row, text="Keys:", bg=_CARD_BG, fg=_FG, + font=("Segoe UI", 9), width=12, anchor="w").pack(side="left") + v = tk.StringVar(value=str(step.get("keys", ""))) + tk.Entry(row, textvariable=v, bg=_ENTRY_BG, fg=_FG, + insertbackground=_FG, font=("Consolas", 10), + relief="flat").pack(side="left", fill="x", expand=True, + padx=4, ipady=2) + tk.Label(parent, + text="WScript.Shell SendKeys syntax — e.g. {ENTER}, ^c, +{TAB 3}", + bg=_CARD_BG, fg=_DIM_FG, font=("Segoe UI", 8)).pack( + anchor="w", pady=(2, 0)) + + def save(*_): + step["keys"] = v.get() + v.trace_add("write", save) + + def _draw_run_fields(self, parent, step): + tk.Label(parent, text="PowerShell:", bg=_CARD_BG, fg=_FG, + font=("Segoe UI", 9)).pack(anchor="w") + text = tk.Text(parent, height=3, bg=_ENTRY_BG, fg=_FG, + insertbackground=_FG, font=("Consolas", 10), + relief="flat", wrap="none") + text.insert("1.0", step.get("command", "")) + text.pack(fill="x", pady=2) + + def save(*_): + step["command"] = text.get("1.0", "end-1c") + text.bind("", save) + + def _draw_check_fields(self, parent, step): + op_row = tk.Frame(parent, bg=_CARD_BG) + op_row.pack(fill="x") + tk.Label(op_row, text="Operator:", bg=_CARD_BG, fg=_FG, + font=("Segoe UI", 9), width=12, anchor="w").pack(side="left") + + op_display = [d for d, _ in CHECK_OPERATORS] + op_value = [v for _, v in CHECK_OPERATORS] + cur = step.get("operator", "equals") + idx = op_value.index(cur) if cur in op_value else 0 + op_var = tk.StringVar(value=op_display[idx]) + op_box = ttk.Combobox(op_row, textvariable=op_var, values=op_display, + state="readonly") + op_box.pack(side="left", fill="x", expand=True, padx=4) + + # Operator-specific fields; rebuilt on operator change + spec = tk.Frame(parent, bg=_CARD_BG) + spec.pack(fill="x", pady=(4, 0)) + + val_row = tk.Frame(parent, bg=_CARD_BG) + val_row.pack(fill="x", pady=(6, 0)) + tk.Label(val_row, text="On match, set var to:", + bg=_CARD_BG, fg=_FG, + font=("Segoe UI", 9), width=20, anchor="w").pack(side="left") + val_var = tk.StringVar(value=str(step.get("value", ""))) + tk.Entry(val_row, textvariable=val_var, bg=_ENTRY_BG, fg=_FG, + insertbackground=_FG, font=("Consolas", 10), + relief="flat").pack(side="left", fill="x", expand=True, + padx=4, ipady=2) + + def save_value(*_): + step["value"] = val_var.get() + val_var.trace_add("write", save_value) + + def render_spec(): + for w in spec.winfo_children(): + w.destroy() + current_op = step.get("operator", "equals") + if current_op == "exists": + tk.Label(spec, text="Path:", bg=_CARD_BG, fg=_FG, + font=("Segoe UI", 9), width=12, + anchor="w").pack(side="left") + pv = tk.StringVar(value=step.get("expected", "")) + tk.Entry(spec, textvariable=pv, bg=_ENTRY_BG, fg=_FG, + insertbackground=_FG, font=("Consolas", 10), + relief="flat").pack(side="left", fill="x", + expand=True, padx=4, ipady=2) + + def save_path(*_): + step["expected"] = pv.get() + pv.trace_add("write", save_path) + else: + tk.Label(spec, text="Command:", bg=_CARD_BG, fg=_FG, + font=("Segoe UI", 9)).pack(anchor="w") + cmd_text = tk.Text(spec, height=2, bg=_ENTRY_BG, fg=_FG, + insertbackground=_FG, font=("Consolas", 10), + relief="flat", wrap="none") + cmd_text.insert("1.0", step.get("command", "")) + cmd_text.pack(fill="x", pady=2) + + def save_cmd(*_): + step["command"] = cmd_text.get("1.0", "end-1c") + cmd_text.bind("", save_cmd) + + exp_row = tk.Frame(spec, bg=_CARD_BG) + exp_row.pack(fill="x", pady=(2, 0)) + exp_label = "Number:" if current_op in ( + "numeric_gt", "numeric_lt") else ( + "Pattern:" if current_op == "regex" else "Expected:") + tk.Label(exp_row, text=exp_label, bg=_CARD_BG, fg=_FG, + font=("Segoe UI", 9), width=12, + anchor="w").pack(side="left") + ev = tk.StringVar(value=step.get("expected", "")) + tk.Entry(exp_row, textvariable=ev, bg=_ENTRY_BG, fg=_FG, + insertbackground=_FG, font=("Consolas", 10), + relief="flat").pack(side="left", fill="x", + expand=True, padx=4, ipady=2) + + def save_exp(*_): + step["expected"] = ev.get() + ev.trace_add("write", save_exp) + + def on_op_change(*_): + disp = op_var.get() + i = op_display.index(disp) if disp in op_display else 0 + new_op = op_value[i] + if new_op != step.get("operator"): + step["operator"] = new_op + render_spec() + + op_var.trace_add("write", on_op_change) + render_spec() + + def _add_step(self, kind: str): + step = {"kind": kind} + if kind == "wait": + step["ms"] = 500 + elif kind == "send_keys": + step["keys"] = "" + elif kind == "run": + step["command"] = "" + elif kind == "check": + step.update({ + "operator": "equals", + "command": "", + "expected": "", + "value": "", + }) + self._steps.append(step) + self._refresh() + + def _delete(self, i: int): + if 0 <= i < len(self._steps): + self._steps.pop(i) + self._refresh() + + def _move(self, i: int, delta: int): + j = i + delta + if 0 <= j < len(self._steps): + self._steps[i], self._steps[j] = self._steps[j], self._steps[i] + self._refresh() + + def _save(self): + # An empty value would silently set the variable to "", which is rarely intended + check_count = 0 + for i, step in enumerate(self._steps): + if step.get("kind") == "check": + check_count += 1 + if not (step.get("value") or "").strip(): + if not messagebox.askyesno( + "Empty value", + f"Check step {check_count} has no value to set the " + f"variable to. Save anyway? (Variable will be set " + f"to an empty string on match.)", + parent=self, + ): + return + break + try: + self._on_save(self._steps) + except Exception as e: + messagebox.showerror("Sequence Editor", str(e), parent=self) + return + self.destroy() diff --git a/widgets/text_editor_dialog.py b/widgets/text_editor_dialog.py new file mode 100644 index 0000000..0cd69c0 --- /dev/null +++ b/widgets/text_editor_dialog.py @@ -0,0 +1,321 @@ +"""Pop-out editor window for the Text node. + +Features: + * Bigger editing area than the cramped properties-panel widget. + * Gutter with line numbers that tracks scroll + content. + * Language dropdown: ``none`` (plain text, the default), ``cmd`` (Windows + batch), or ``powershell``. When a language is set the dialog re-colors + keywords, comments, strings, variables, and labels in the Text widget + as you type. + +The syntax highlighter is intentionally a simple regex pass — this is an +authoring helper, not a real IDE. It re-tokenizes the full buffer on each +keystroke which is fine for the text sizes a macro node ever holds +(kilobytes at most). +""" + +import re +import tkinter as tk +from tkinter import ttk + + +LANGUAGE_CHOICES = [ + ("None (plain text)", "none"), + ("Command Prompt (cmd / batch)", "cmd"), + ("PowerShell", "powershell"), +] + + +# Tag names are shared across languages; per-language rule lists decide +# which spans get which tag. +_SYNTAX_COLORS = { + "keyword": "#C678DD", + "cmdlet": "#61AFEF", + "string": "#98C379", + "comment": "#5C6370", + "variable": "#E5C07B", + "number": "#D19A66", + "operator": "#56B6C2", + "label": "#E06C75", + "param": "#D19A66", +} + + +# Narrow keyword list — control-flow commands only, not every shipped .exe +_CMD_KEYWORDS = { + "IF", "ELSE", "FOR", "IN", "DO", "GOTO", "CALL", "EXIT", "NOT", + "EXIST", "DEFINED", "ERRORLEVEL", "SET", "SETLOCAL", "ENDLOCAL", + "PAUSE", "REM", "ECHO", "START", "PUSHD", "POPD", "SHIFT", + "TIMEOUT", "CHOICE", "CLS", "COLOR", "TITLE", "VER", "VERIFY", + "ASSOC", "ATTRIB", "BREAK", "DIR", "TYPE", "COPY", "MOVE", + "DEL", "ERASE", "MD", "MKDIR", "RD", "RMDIR", "REN", "RENAME", + "FINDSTR", "FIND", "WHERE", "XCOPY", "ROBOCOPY", +} + +# Rule order matters: earlier rules WIN over later ones (see _rehighlight), +# so comments and strings must come before keywords or variables. +_CMD_RULES = [ + ("comment", re.compile(r"^[ \t]*(?:REM\b|::).*$", re.MULTILINE | re.IGNORECASE)), + ("string", re.compile(r'"[^"\n]*"')), + ("label", re.compile(r"^[ \t]*:[A-Za-z_][A-Za-z0-9_]*", re.MULTILINE)), + ("variable", re.compile(r"%[~A-Za-z0-9_*#$@?!-]+%?")), + ("number", re.compile(r"\b\d+\b")), + ("keyword", re.compile(r"\b(?:" + "|".join(sorted(_CMD_KEYWORDS, key=len, reverse=True)) + + r")\b", re.IGNORECASE)), +] + +_PS_KEYWORDS = { + "if", "else", "elseif", "switch", "while", "for", "foreach", "do", + "until", "break", "continue", "return", "function", "filter", + "param", "begin", "process", "end", "try", "catch", "finally", + "throw", "trap", "class", "enum", "using", "in", "exit", + "true", "false", "null", "global", "script", "local", "private", + "static", "public", "hidden", "data", "dynamicparam", "workflow", + "parallel", "sequence", "inlinescript", "from", "new", +} + +_PS_OPERATORS = { + "eq", "ne", "lt", "gt", "le", "ge", "like", "notlike", "match", "notmatch", + "contains", "notcontains", "in", "notin", "is", "isnot", "as", + "and", "or", "not", "xor", "band", "bor", "bxor", "shl", "shr", + "replace", "split", "join", "f", +} + +# Rule order matters — see _CMD_RULES above. Strings claim their spans first +# so `#` and keywords inside them aren't recolored; variables before keywords +# so $true is a variable rather than the `true` keyword; operators before +# parameters so `-eq` wins over the generic `-Name` rule. +_PS_RULES = [ + ("string", re.compile(r'"(?:[^"`\n]|`.)*"')), + ("string", re.compile(r"'(?:[^'\n]|'')*'")), + ("comment", re.compile(r"<#.*?#>", re.DOTALL)), + ("comment", re.compile(r"#.*$", re.MULTILINE)), + ("variable", re.compile(r"\$(?:\{[^}]*\}|[A-Za-z_][\w:]*|_|\?|\$|\^)")), + ("cmdlet", re.compile(r"\b[A-Z][A-Za-z]+-[A-Z][A-Za-z]+\b")), + ("operator", re.compile(r"(?>", self._on_language_changed) + + body = tk.Frame(self.dlg, bg="#1E1E2E", bd=1, relief="flat") + body.pack(fill="both", expand=True, padx=12, pady=(2, 6)) + + # Gutter uses a Text widget so font metrics and scrolling line up exactly with the editor + self.gutter = tk.Text( + body, width=5, padx=6, bg="#181824", fg="#6B7280", + font=("Consolas", 11), relief="flat", borderwidth=0, + state="disabled", cursor="arrow", + takefocus=0, + ) + self.gutter.pack(side="left", fill="y") + + self.text = tk.Text( + body, bg="#1E1E2E", fg="white", + insertbackground="white", + selectbackground="#3B4F6B", + font=("Consolas", 11), + relief="flat", borderwidth=0, + undo=True, maxundo=200, + wrap="none", + tabs=("4c",), + ) + self.text.pack(side="left", fill="both", expand=True) + + self.vsb = tk.Scrollbar(body, orient="vertical", command=self._on_scrollbar_y) + self.vsb.pack(side="right", fill="y") + self.text.configure(yscrollcommand=self._on_text_yview) + + hsb = tk.Scrollbar(self.dlg, orient="horizontal", command=self.text.xview) + hsb.pack(fill="x", padx=12) + self.text.configure(xscrollcommand=hsb.set) + + footer = tk.Frame(self.dlg, bg="#2D2D3D") + footer.pack(fill="x", padx=12, pady=10) + tk.Button(footer, text="Cancel", bg="#4A4A6A", fg="white", + font=("Segoe UI", 9), relief="flat", padx=14, + command=self._cancel).pack(side="right") + tk.Button(footer, text="Save", bg="#22C55E", fg="white", + activebackground="#16A34A", + font=("Segoe UI", 9, "bold"), relief="flat", padx=14, + command=self._save).pack(side="right", padx=(0, 6)) + + self._setup_tags() + self.text.insert("1.0", self._initial_text) + # <> fires on every change; KeyRelease/ButtonRelease debounce the re-highlight + self.text.bind("<>", self._on_text_modified) + self.text.bind("", self._schedule_rehighlight) + self.text.bind("", self._schedule_rehighlight) + self.text.bind("", self._on_mousewheel) + self.gutter.bind("", self._on_mousewheel) + + self.dlg.protocol("WM_DELETE_WINDOW", self._cancel) + self.dlg.bind("", lambda e: (self._save(), "break")) + + self._refresh_gutter() + self._rehighlight() + self.text.focus_set() + + @staticmethod + def _display_name(lang_id: str) -> str: + for name, lid in LANGUAGE_CHOICES: + if lid == lang_id: + return name + return LANGUAGE_CHOICES[0][0] + + @staticmethod + def _id_from_display(name: str) -> str: + for n, lid in LANGUAGE_CHOICES: + if n == name: + return lid + return "none" + + def _on_language_changed(self, _event=None): + self._current_lang = self._id_from_display(self.lang_var.get()) + self._rehighlight() + + def _setup_tags(self): + """Configure one Text-widget tag per syntax category.""" + for name, color in _SYNTAX_COLORS.items(): + self.text.tag_configure(name, foreground=color) + self.text.tag_configure("comment", foreground=_SYNTAX_COLORS["comment"], + font=("Consolas", 11, "italic")) + + def _clear_tags(self): + for name in _SYNTAX_COLORS.keys(): + self.text.tag_remove(name, "1.0", "end") + + def _schedule_rehighlight(self, _event=None): + # 80 ms feels live but coalesces keystroke bursts into one retokenize + if self._hl_after_id is not None: + try: + self.dlg.after_cancel(self._hl_after_id) + except Exception: + pass + self._hl_after_id = self.dlg.after(80, self._rehighlight) + + def _rehighlight(self): + """Re-tokenize the buffer and apply tags for the current language. + + Rules apply top-down; earlier ones WIN where spans overlap. The + coverage bitmap below stops a late keyword rule from re-tagging + characters already claimed by an earlier comment or string rule + (without this, `REM Set up stuff` would color REM as a keyword). + """ + self._hl_after_id = None + self._clear_tags() + lang = self._current_lang + if lang == "none": + return + rules = _CMD_RULES if lang == "cmd" else _PS_RULES if lang == "powershell" else None + if not rules: + return + content = self.text.get("1.0", "end-1c") + if not content: + return + + covered = bytearray(len(content)) + + for tag, pattern in rules: + for m in pattern.finditer(content): + start, end = m.start(), m.end() + if start == end: + continue + if any(covered[start:end]): + continue + self.text.tag_add(tag, f"1.0+{start}c", f"1.0+{end}c") + for i in range(start, end): + covered[i] = 1 + + def _refresh_gutter(self): + line_count = int(self.text.index("end-1c").split(".")[0]) + digits = max(3, len(str(line_count))) + self.gutter.configure(state="normal", width=digits + 1) + self.gutter.delete("1.0", "end") + lines = "\n".join(f"{i:>{digits}d}" for i in range(1, line_count + 1)) + self.gutter.insert("1.0", lines) + self.gutter.configure(state="disabled") + first, _ = self.text.yview() + self.gutter.yview_moveto(first) + + def _on_text_modified(self, _event=None): + # <> is edge-triggered — clear the flag to re-arm it + if self.text.edit_modified(): + self.text.edit_modified(False) + self._refresh_gutter() + + def _on_text_yview(self, first, last): + """Keep the scrollbar thumb and gutter in sync with the editor.""" + self.vsb.set(first, last) + self.gutter.yview_moveto(float(first)) + + def _on_scrollbar_y(self, *args): + self.text.yview(*args) + first, _ = self.text.yview() + self.gutter.yview_moveto(first) + + def _on_mousewheel(self, event): + # Scroll text and gutter in lock-step so line numbers stay aligned + delta = int(-event.delta / 40) # 120 → -3 lines per notch + if delta == 0: + delta = -1 if event.delta > 0 else 1 + self.text.yview_scroll(delta, "units") + self.gutter.yview_scroll(delta, "units") + return "break" + + def _save(self): + self.data["text"] = self.text.get("1.0", "end-1c") + self.data["language"] = self._current_lang + self.on_save() + self.dlg.destroy() + + def _cancel(self): + self.dlg.destroy() diff --git a/widgets/toolbar.py b/widgets/toolbar.py new file mode 100644 index 0000000..1ec42c2 --- /dev/null +++ b/widgets/toolbar.py @@ -0,0 +1,601 @@ +"""Top toolbar - connection status, upload, settings.""" + +import os +import subprocess +import tkinter as tk +from tkinter import ttk, messagebox +import threading +from utils.constants import APP_NAME, ORIENTATION_OPTIONS + + +class Toolbar(tk.Frame): + """Top toolbar with device connection and upload controls.""" + + def __init__(self, parent, serial_manager=None, on_settings_change=None, + on_profile_switch=None, on_profile_new=None, on_profile_delete=None, + on_ble_variables=None, on_backups=None, on_rs232_terminal=None, + on_bt_keyboard=None, + on_settings_open=None, on_settings_close=None, + profile_manager=None): + super().__init__(parent, bg="#1A1A2E", height=44) + self.serial_manager = serial_manager + self.on_settings_change = on_settings_change + self.on_profile_switch = on_profile_switch + self.on_profile_new = on_profile_new + self.on_profile_delete = on_profile_delete + self.on_ble_variables = on_ble_variables + self.on_backups = on_backups + self.on_rs232_terminal = on_rs232_terminal + self.on_bt_keyboard = on_bt_keyboard + self.on_settings_open = on_settings_open + self.on_settings_close = on_settings_close + self.profile_manager = profile_manager + self.project = None + self._profile_names = [] + + self.pack_propagate(False) + self._build_ui() + + def _build_ui(self): + tk.Label(self, text=APP_NAME, bg="#1A1A2E", fg="#CCCCCC", + font=("Segoe UI", 10, "bold")).pack(side="left", padx=12) + + profile_frame = tk.Frame(self, bg="#1A1A2E") + profile_frame.pack(side="left", padx=(0, 4)) + + tk.Label(profile_frame, text="Profile:", bg="#1A1A2E", fg="#AAAAAA", + font=("Segoe UI", 9)).pack(side="left", padx=(0, 4)) + + self._profile_var = tk.StringVar() + self._profile_combo = ttk.Combobox( + profile_frame, textvariable=self._profile_var, + state="readonly", width=18, font=("Segoe UI", 9), + ) + self._profile_combo.pack(side="left") + self._profile_combo.bind("<>", self._on_profile_selected) + + tk.Button(profile_frame, text="+", bg="#3D3D5C", fg="white", + font=("Segoe UI", 9, "bold"), relief="flat", width=2, + command=self._new_profile_dialog).pack(side="left", padx=(4, 2)) + + tk.Button(profile_frame, text="\u00d7", bg="#3D3D5C", fg="#FF7777", + font=("Segoe UI", 9, "bold"), relief="flat", width=2, + command=self._delete_profile).pack(side="left", padx=(0, 4)) + + tk.Frame(self, bg="#333355", width=1).pack(side="left", fill="y", pady=6) + + self.status_frame = tk.Frame(self, bg="#1A1A2E") + self.status_frame.pack(side="left", padx=12) + + self.status_dot = tk.Canvas(self.status_frame, width=12, height=12, + bg="#1A1A2E", highlightthickness=0) + self.status_dot.pack(side="left", padx=(0, 6)) + self._dot_id = self.status_dot.create_oval(2, 2, 10, 10, fill="#FF4444", outline="") + + self.status_label = tk.Label(self.status_frame, text="Disconnected", + bg="#1A1A2E", fg="#888888", + font=("Segoe UI", 9)) + self.status_label.pack(side="left") + + self.connect_btn = tk.Button(self, text="Connect", bg="#3D3D5C", fg="white", + font=("Segoe UI", 9), relief="flat", padx=12, + command=self._toggle_connection) + self.connect_btn.pack(side="left", padx=4, pady=6) + + tk.Frame(self, bg="#333355", width=1).pack(side="left", fill="y", pady=6) + + self.ble_status_frame = tk.Frame(self, bg="#1A1A2E") + self.ble_status_frame.pack(side="left", padx=8) + + self.ble_dot = tk.Canvas(self.ble_status_frame, width=12, height=12, + bg="#1A1A2E", highlightthickness=0) + self.ble_dot.pack(side="left", padx=(0, 4)) + self._ble_dot_id = self.ble_dot.create_oval(2, 2, 10, 10, fill="#888888", outline="") + + self.ble_label = tk.Label(self.ble_status_frame, text="BLE: Off", + bg="#1A1A2E", fg="#888888", + font=("Segoe UI", 8)) + self.ble_label.pack(side="left") + + self.settings_btn = tk.Button(self, text="\u2699 Settings", bg="#3D3D5C", fg="white", + font=("Segoe UI", 9), relief="flat", padx=12, + command=self._show_settings) + self.settings_btn.pack(side="right", padx=6, pady=6) + + # Live multi-device keyboard streaming. Packed to the right of + # the other action buttons so it sits near the top-right corner + # without crowding Upload Profile, the canonical primary action. + self.bt_kbd_btn = tk.Button(self, text="\u2328 Keyboard", + bg="#7C3AED", fg="white", + activebackground="#6D28D9", + font=("Segoe UI", 9), relief="flat", padx=12, + command=self._on_bt_keyboard_click) + self.bt_kbd_btn.pack(side="right", padx=4, pady=6) + + self.ble_btn = tk.Button(self, text="\u25c6 Variables", bg="#005599", fg="white", + font=("Segoe UI", 9), relief="flat", padx=12, + command=self._on_ble_variables) + self.ble_btn.pack(side="right", padx=4, pady=6) + + self.upload_btn = tk.Button(self, text="\u25b6 Upload Profile", bg="#27AE60", fg="white", + font=("Segoe UI", 9, "bold"), relief="flat", padx=16, + command=self._upload_all) + self.upload_btn.pack(side="right", padx=4, pady=6) + + self.progress_var = tk.DoubleVar(value=0) + self.progress_bar = ttk.Progressbar(self, variable=self.progress_var, + maximum=1.0, length=150) + + def set_project(self, project): + self.project = project + + def set_profiles(self, names: list, active: str): + """Repopulate the profile combobox.""" + self._profile_names = names + self._profile_combo["values"] = names + self._profile_var.set(active) + + def _on_profile_selected(self, event=None): + name = self._profile_var.get() + if name and self.on_profile_switch: + self.on_profile_switch(name) + + def _new_profile_dialog(self): + dialog = tk.Toplevel(self) + dialog.title("New Profile") + dialog.geometry("300x180") + dialog.resizable(False, False) + dialog.configure(bg="#2D2D3D") + dialog.transient(self.winfo_toplevel()) + dialog.grab_set() + + tk.Label(dialog, text="Name:", bg="#2D2D3D", fg="white", + font=("Segoe UI", 10)).pack(anchor="w", padx=16, pady=(16, 2)) + name_var = tk.StringVar(value="New Profile") + name_entry = tk.Entry(dialog, textvariable=name_var, bg="#1E1E2E", fg="white", + insertbackground="white", font=("Segoe UI", 10), relief="flat") + name_entry.pack(fill="x", padx=16, pady=(0, 8)) + name_entry.select_range(0, "end") + name_entry.focus_set() + + copy_var = tk.BooleanVar(value=True) + rb_frame = tk.Frame(dialog, bg="#2D2D3D") + rb_frame.pack(fill="x", padx=16) + for text, val in [("Duplicate current profile", True), ("Empty profile", False)]: + tk.Radiobutton(rb_frame, text=text, variable=copy_var, value=val, + bg="#2D2D3D", fg="white", selectcolor="#1E1E2E", + activebackground="#2D2D3D", activeforeground="white", + font=("Segoe UI", 9)).pack(anchor="w") + + def create(): + name = name_var.get().strip() + if not name: + messagebox.showerror("New Profile", "Name cannot be empty.", parent=dialog) + return + dialog.destroy() + if self.on_profile_new: + self.on_profile_new(name, copy_var.get()) + + btn_frame = tk.Frame(dialog, bg="#2D2D3D") + btn_frame.pack(side="bottom", pady=12) + tk.Button(btn_frame, text="Cancel", command=dialog.destroy, + bg="#3D3D5C", fg="white", relief="flat", padx=12, + font=("Segoe UI", 9)).pack(side="left", padx=6) + tk.Button(btn_frame, text="Create", command=create, + bg="#27AE60", fg="white", relief="flat", padx=12, + font=("Segoe UI", 9, "bold")).pack(side="left", padx=6) + + dialog.bind("", lambda e: create()) + + def _delete_profile(self): + name = self._profile_var.get() + if not name: + return + if not messagebox.askyesno("Delete Profile", + f"Delete profile '{name}'?\nThis cannot be undone.", + parent=self.winfo_toplevel()): + return + if self.on_profile_delete: + self.on_profile_delete(name) + + def set_connected(self, port: str): + self.status_dot.itemconfig(self._dot_id, fill="#44FF44") + # Append the device's live transport (Mesh / BLE) when the firmware + # reports it, so the user can see each device's mode as it's plugged + # in. Older firmware omits it and we just show the port. + mode = None + if self.serial_manager is not None: + try: + mode = self.serial_manager.live_mode_label() + except Exception: + mode = None + text = f"Connected: {port}" + (f" · {mode}" if mode else "") + self.status_label.config(text=text, fg="white") + self.connect_btn.config(text="Disconnect") + + def set_disconnected(self): + self.status_dot.itemconfig(self._dot_id, fill="#FF4444") + self.status_label.config(text="Disconnected", fg="#888888") + self.connect_btn.config(text="Connect") + + def _toggle_connection(self): + if not self.serial_manager: + return + + if self.serial_manager.connected: + self.serial_manager.disconnect() + self.set_disconnected() + else: + self.connect_btn.config(text="Scanning...", state="disabled") + self.update_idletasks() + + def scan(): + found = self.serial_manager.scan_and_connect() + self.after(0, lambda: self._scan_complete(found)) + + threading.Thread(target=scan, daemon=True).start() + + def _scan_complete(self, found): + self.connect_btn.config(state="normal") + if found: + self.set_connected(self.serial_manager.port) + else: + self.set_disconnected() + messagebox.showwarning("Connection", "ATOMS3 MacroPad not found.\n\n" + "Make sure the device is connected via USB\n" + "and the firmware is uploaded.") + + def _upload_all(self): + if not self.serial_manager or not self.serial_manager.connected: + messagebox.showwarning("Upload", "Device not connected.") + return + if not self.project: + messagebox.showwarning("Upload", "No project to upload.") + return + if not self.project.macros: + messagebox.showinfo("Upload", "No routines to upload.") + return + + self.upload_btn.config(state="disabled", text="Uploading...") + self.progress_bar.pack(side="right", padx=4, pady=6) + self.progress_var.set(0) + self.update_idletasks() + + def do_upload(): + def progress(p): + self.after(0, lambda: self.progress_var.set(p)) + + # Flush so the sub-routines profile on disk is current before upload + if self.profile_manager: + self.profile_manager.save_current() + + sub_macros = [] + if self.profile_manager: + sub_project = self.profile_manager.get_subroutine_project() + sub_macros = sub_project.macros + success = self.serial_manager.upload_all(self.project, progress, subroutine_macros=sub_macros) + self.after(0, lambda: self._upload_complete(success)) + + threading.Thread(target=do_upload, daemon=True).start() + + def _upload_complete(self, success): + self.upload_btn.config(state="normal", text="\u25b6 Upload Profile") + self.progress_bar.pack_forget() + self.progress_var.set(0) + + if success: + messagebox.showinfo("Upload", "Upload complete!") + else: + messagebox.showerror("Upload", "Upload failed.\nCheck device connection.") + + def set_ble_status(self, status: str): + """Update the BLE status indicator.""" + dot_colors = { + "idle": "#44FF44", "connecting": "#44AAFF", + "awaiting": "#FFCC44", + "synced": "#44FF44", "not_found": "#FFAA44", + "error": "#FF4444", "off": "#888888", + } + label_texts = { + "idle": "BLE: Ready", "connecting": "BLE: Connecting...", + "awaiting": "BLE: Awaiting input", + "synced": "BLE: Synced", "not_found": "BLE: Not Found", + "error": "BLE: Error", "off": "BLE: Off", + } + dot_color = dot_colors.get(status, "#888888") + label_text = label_texts.get(status, f"BLE: {status}") + self.ble_dot.itemconfig(self._ble_dot_id, fill=dot_color) + self.ble_label.config(text=label_text, fg=dot_color) + + def _on_ble_variables(self): + if self.on_ble_variables: + self.on_ble_variables() + + def _on_backups_click(self): + if self.on_backups: + self.on_backups() + + def _on_rs232_terminal_click(self): + if self.on_rs232_terminal: + self.on_rs232_terminal() + + def _on_bt_keyboard_click(self): + if self.on_bt_keyboard: + self.on_bt_keyboard() + + def _show_settings(self): + if not self.project: + return + + if self.on_settings_open: + self.on_settings_open() + + dialog = tk.Toplevel(self) + dialog.title("Device Settings") + dialog.geometry("380x600") + dialog.resizable(False, True) + dialog.configure(bg="#2D2D3D") + dialog.transient(self.winfo_toplevel()) + dialog.grab_set() + + def _on_dialog_destroy(event, _self=self, _dialog=dialog): + if event.widget is _dialog and _self.on_settings_close: + _self.on_settings_close() + dialog.bind("", _on_dialog_destroy) + + settings = self.project.settings + + outer = tk.Frame(dialog, bg="#2D2D3D") + outer.pack(fill="both", expand=True) + + canvas = tk.Canvas(outer, bg="#2D2D3D", highlightthickness=0) + scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview) + content = tk.Frame(canvas, bg="#2D2D3D") + + content.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=content, anchor="nw", tags="inner") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.bind("", lambda e: canvas.itemconfig("inner", width=e.width)) + canvas.bind_all("", + lambda e: canvas.yview_scroll(-1 * (e.delta // 120), "units")) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + lbl_style = {"bg": "#2D2D3D", "fg": "white", "font": ("Segoe UI", 9)} + hint_style = {"bg": "#2D2D3D", "fg": "#888888", "font": ("Segoe UI", 7), "justify": "left"} + scale_style = {"orient": "horizontal", "bg": "#2D2D3D", "fg": "white", + "troughcolor": "#1E1E2E", "highlightthickness": 0, + "font": ("Segoe UI", 8)} + + def section_label(text): + f = tk.Frame(content, bg="#444466", height=1) + f.pack(fill="x", padx=12, pady=(14, 2)) + tk.Label(content, text=text, bg="#2D2D3D", fg="#AAAAAA", + font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(2, 4)) + + def flash_firmware(variant=""): + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + bat_path = os.path.join(project_root, "CompileAndUpload", "upload.bat") + if not os.path.isfile(bat_path): + messagebox.showerror( + "Flash Firmware", + f"upload.bat not found:\n{bat_path}", + parent=dialog, + ) + return + # Release the serial port BEFORE spawning the flasher. The + # flasher's auto_enter_bootloader.py opens the COM port itself + # to ping the device and send a "bootloader" command. If the + # GUI still holds the port, that step silently fails and the + # flasher falls back to the manual "Unplug → Replug" prompts. + # The port watcher in app.py is already paused while the + # settings dialog is open (on_settings_open) and resumes when + # the dialog closes — it'll re-attach to the M5Stack on its + # next 3-second poll after the flasher releases the port and + # the device reboots into the new firmware. + if self.serial_manager and self.serial_manager.connected: + try: + self.serial_manager.disconnect() + except Exception: + pass + try: + args = ["cmd", "/c", "start", "", bat_path] + if variant: + args.append(variant) + subprocess.Popen( + args, + cwd=os.path.dirname(bat_path), + shell=False, + ) + except Exception as e: + messagebox.showerror( + "Flash Firmware", + f"Failed to launch upload.bat:\n{e}", + parent=dialog, + ) + + # Both buttons flash the SAME universal binary (the AtomS3 and the + # AtomS3 Lite are the identical ESP32-S3 module; the firmware + # detects the board at boot). The Lite button only switches + # upload.bat's manual-recovery instructions to LED-based wording, + # since the Lite has no screen to watch during flashing. + tk.Button(content, text="⚡ Flash Firmware — AtomS3", + command=flash_firmware, + bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"), + relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(12, 4)) + + tk.Button(content, text="⚡ Flash Firmware — AtomS3 Lite (no screen)", + command=lambda: flash_firmware("--lite"), + bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"), + relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 4)) + + tk.Button(content, text="🖧 RS232 Terminal", + command=lambda: (dialog.destroy(), self._on_rs232_terminal_click()), + bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"), + relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 4)) + + tk.Button(content, text="⧉ Backups", + command=lambda: (dialog.destroy(), self._on_backups_click()), + bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"), + relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 8)) + + section_label("General") + + tk.Label(content, text="Long press duration (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0)) + hold_var = tk.IntVar(value=settings.hold_ms) + tk.Scale(content, from_=200, to=2000, variable=hold_var, resolution=50, + **scale_style).pack(fill="x", padx=16, pady=2) + + tk.Label(content, text="Display orientation:", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0)) + orient_var = tk.IntVar(value=settings.orientation) + orient_frame = tk.Frame(content, bg="#2D2D3D") + orient_frame.pack(fill="x", padx=16, pady=2) + for val, label in ORIENTATION_OPTIONS: + tk.Radiobutton(orient_frame, text=label, variable=orient_var, value=val, + bg="#2D2D3D", fg="white", selectcolor="#1E1E2E", + activebackground="#2D2D3D", activeforeground="white", + font=("Segoe UI", 8)).pack(side="left", padx=4) + + tk.Label(content, text="Power-on resume delay (seconds):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0)) + resume_var = tk.IntVar(value=settings.resume_delay) + tk.Scale(content, from_=0, to=60, variable=resume_var, + **scale_style).pack(fill="x", padx=16, pady=2) + tk.Label(content, text="0 = disabled. Auto-resumes routine after power loss.", **hint_style).pack(anchor="w", padx=16) + + section_label("Typing") + + tk.Label(content, text="Per-character delay (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0)) + delay_var = tk.IntVar(value=settings.type_delay) + tk.Scale(content, from_=0, to=200, variable=delay_var, + **scale_style).pack(fill="x", padx=16, pady=2) + tk.Label(content, text="Base delay between each typed character.\n" + "Lower = faster typing. Below ~10ms the host may drop keys.", + **hint_style).pack(anchor="w", padx=16) + + tk.Label(content, text="Extra delay for shifted chars (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0)) + shift_extra_var = tk.IntVar(value=settings.type_shift_extra_ms) + tk.Scale(content, from_=0, to=200, variable=shift_extra_var, + **scale_style).pack(fill="x", padx=16, pady=2) + tk.Label(content, text="Added to the per-character delay only for uppercase\n" + "letters and symbols like !@#$. 0 = uniform rate.", + **hint_style).pack(anchor="w", padx=16) + + tk.Label(content, text="End-of-text settle delay (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0)) + settle_var = tk.IntVar(value=settings.type_settle_ms) + tk.Scale(content, from_=0, to=1000, variable=settle_var, resolution=10, + **scale_style).pack(fill="x", padx=16, pady=2) + tk.Label(content, text="Pause after the last character before moving on,\n" + "so the last HID reports fully drain to the host.", + **hint_style).pack(anchor="w", padx=16) + + section_label("Key Combo Timing") + + tk.Label(content, text="Delay before combo (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0)) + combo_pre_var = tk.IntVar(value=settings.combo_pre_ms) + tk.Scale(content, from_=0, to=2000, variable=combo_pre_var, resolution=25, + **scale_style).pack(fill="x", padx=16, pady=2) + tk.Label(content, text="Wait time before the key combo is sent.", **hint_style).pack(anchor="w", padx=16) + + tk.Label(content, text="Delay after combo (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0)) + combo_post_var = tk.IntVar(value=settings.combo_post_ms) + tk.Scale(content, from_=0, to=2000, variable=combo_post_var, resolution=25, + **scale_style).pack(fill="x", padx=16, pady=2) + tk.Label(content, text="Wait time after the key combo is released.", **hint_style).pack(anchor="w", padx=16) + + section_label("Media Key Timing") + + tk.Label(content, text="Media key hold time (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0)) + media_var = tk.IntVar(value=settings.media_hold_ms) + tk.Scale(content, from_=25, to=500, variable=media_var, resolution=25, + **scale_style).pack(fill="x", padx=16, pady=2) + tk.Label(content, text="How long to hold the media key before releasing.", **hint_style).pack(anchor="w", padx=16) + + section_label("PC Alive Check") + + tk.Label(content, text="Probe timeout (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0)) + probe_var = tk.IntVar(value=settings.probe_timeout_ms) + tk.Scale(content, from_=50, to=1000, variable=probe_var, resolution=25, + **scale_style).pack(fill="x", padx=16, pady=2) + tk.Label(content, text="How long to wait for the host PC to respond\nto a Num Lock toggle.", **hint_style).pack(anchor="w", padx=16) + + section_label("Pause Display Margins") + tk.Label(content, + text="Pixel padding around pause-screen text (128x128 LCD).\n" + "Larger = narrower text box, more aggressive truncation.", + **hint_style).pack(anchor="w", padx=16) + + tk.Label(content, text="Left margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(6, 0)) + pml_var = tk.IntVar(value=settings.pause_margin_left) + tk.Scale(content, from_=0, to=48, variable=pml_var, + **scale_style).pack(fill="x", padx=16, pady=2) + + tk.Label(content, text="Right margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0)) + pmr_var = tk.IntVar(value=settings.pause_margin_right) + tk.Scale(content, from_=0, to=48, variable=pmr_var, + **scale_style).pack(fill="x", padx=16, pady=2) + + tk.Label(content, text="Top margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0)) + pmt_var = tk.IntVar(value=settings.pause_margin_top) + tk.Scale(content, from_=0, to=64, variable=pmt_var, + **scale_style).pack(fill="x", padx=16, pady=2) + + tk.Label(content, text="Bottom margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0)) + pmb_var = tk.IntVar(value=settings.pause_margin_bottom) + tk.Scale(content, from_=0, to=48, variable=pmb_var, + **scale_style).pack(fill="x", padx=16, pady=2) + tk.Label(content, + text="Bottom margin also reserves room for the timer\n" + "progress bar on timed pauses.", + **hint_style).pack(anchor="w", padx=16) + + section_label("Mesh (Keyboard hub)") + tk.Label(content, + text="Wi-Fi channel (1-13) the ESP-NOW mesh uses. Every " + "device in the fleet must match. Applied on the device " + "now; change it on each unit if Lag climbs.", + **hint_style).pack(anchor="w", padx=16) + # Seed from the connected device's current channel (best-effort). + _mesh_ch = 1 + if self.serial_manager and self.serial_manager.connected: + try: + _png = self.serial_manager.ping() + if _png and isinstance(_png.get("mesh_ch"), int): + _mesh_ch = _png["mesh_ch"] + except Exception: + pass + mesh_ch_var = tk.IntVar(value=_mesh_ch) + tk.Scale(content, from_=1, to=13, variable=mesh_ch_var, + **scale_style).pack(fill="x", padx=16, pady=2) + + def save(): + # Mesh channel is device-local NVS (not part of the project), + # so push it straight to the device rather than via the project + # settings upload. + if self.serial_manager and self.serial_manager.connected: + try: + self.serial_manager.set_setting("mesh_ch", mesh_ch_var.get()) + except Exception: + pass + settings.hold_ms = hold_var.get() + settings.type_delay = delay_var.get() + settings.type_shift_extra_ms = shift_extra_var.get() + settings.type_settle_ms = settle_var.get() + settings.orientation = orient_var.get() + settings.resume_delay = resume_var.get() + settings.combo_pre_ms = combo_pre_var.get() + settings.combo_post_ms = combo_post_var.get() + settings.probe_timeout_ms = probe_var.get() + settings.media_hold_ms = media_var.get() + settings.pause_margin_left = pml_var.get() + settings.pause_margin_right = pmr_var.get() + settings.pause_margin_top = pmt_var.get() + settings.pause_margin_bottom = pmb_var.get() + dialog.destroy() + if self.on_settings_change: + self.on_settings_change() + + btn_frame = tk.Frame(dialog, bg="#2D2D3D") + btn_frame.pack(fill="x", side="bottom", pady=10) + tk.Button(btn_frame, text="Save", command=save, bg="#27AE60", fg="white", + font=("Segoe UI", 10, "bold"), relief="flat", padx=20, pady=6).pack() diff --git a/wipe_device.py b/wipe_device.py new file mode 100644 index 0000000..7fbaf61 --- /dev/null +++ b/wipe_device.py @@ -0,0 +1,231 @@ +""" +wipe_device.py — Wipe ATOMS3 MacroPad NVS and LittleFS partitions. + +Usage: + python wipe_device.py # auto-detect COM port + python wipe_device.py COM3 # use specified COM port + +The script will: + 1. Find the connected MacroPad automatically + 2. Send {"cmd":"bootloader"} to trigger ROM download mode + 3. Wait for the device to re-enumerate as USB-JTAG + 4. Erase NVS (0x009000, 20 KB) — clears settings + 5. Erase LittleFS (0x670000, 1.5 MB) — clears all macros + 6. Reset the device back into normal firmware +""" + +import glob +import json +import os +import subprocess +import sys +import time + +import serial +import serial.tools.list_ports + +# Force UTF-8 output on Windows terminals +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +ESPRESSIF_VID = 0x303A +DEVICE_ID = "ATOMS3-MACROPAD" +BAUD_RATE = 115200 + +# Partition layout (matches default_8MB.csv flashed with firmware) +NVS_OFFSET = 0x009000 +NVS_SIZE = 0x005000 # 20 KB +LITTLEFS_OFFSET = 0x670000 +LITTLEFS_SIZE = 0x180000 # 1.5 MB + + +def find_esptool() -> str: + """Locate esptool.exe inside the Arduino15 package cache.""" + pattern = os.path.join( + os.environ.get("LOCALAPPDATA", ""), + "Arduino15", "packages", "*", "tools", "esptool_py", "*", "esptool.exe", + ) + matches = sorted(glob.glob(pattern), reverse=True) # newest version first + if matches: + return matches[0] + raise FileNotFoundError( + "esptool.exe not found in Arduino15 package cache.\n" + "Make sure the M5Stack board package is installed via arduino-cli." + ) + + +def list_espressif_ports() -> set[str]: + """Return the set of COM port names currently belonging to Espressif devices.""" + return { + p.device + for p in serial.tools.list_ports.comports() + if p.vid == ESPRESSIF_VID + } + + +def find_macropad_port() -> str | None: + """Scan Espressif COM ports and return the one that responds as the MacroPad.""" + for p in serial.tools.list_ports.comports(): + if p.vid != ESPRESSIF_VID: + continue + try: + ser = serial.Serial() + ser.port = p.device + ser.baudrate = BAUD_RATE + ser.timeout = 2 + ser.dtr = False + ser.rts = False + ser.open() + time.sleep(0.1) + ser.dtr = True # signal host-connected to TinyUSB CDC + time.sleep(0.3) + ser.reset_input_buffer() + ser.write(b'{"cmd":"ping"}\n') + line = ser.readline().decode("utf-8", errors="ignore").strip() + ser.close() + if line: + data = json.loads(line) + if data.get("id") == DEVICE_ID: + return p.device + except (serial.SerialException, json.JSONDecodeError, OSError): + pass + return None + + +def send_bootloader_command(port: str) -> None: + """Open the port and fire the bootloader command. Ignores read errors on disconnect.""" + ser = serial.Serial() + ser.port = port + ser.baudrate = BAUD_RATE + ser.timeout = 2 + ser.dtr = False + ser.rts = False + ser.open() + time.sleep(0.1) + ser.dtr = True + time.sleep(0.3) + ser.reset_input_buffer() + ser.write(b'{"cmd":"bootloader"}\n') + ser.flush() + try: + # Read {"rsp":"ok"} — may throw if device resets mid-read + ser.read(64) + except (serial.SerialException, OSError): + pass + finally: + try: + ser.close() + except Exception: + pass + + +def wait_for_port_change(old_ports: set[str], timeout: float = 10.0) -> str: + """ + Block until a new Espressif COM port appears that wasn't in old_ports. + Returns the new port name, or raises TimeoutError. + """ + deadline = time.time() + timeout + while time.time() < deadline: + current = list_espressif_ports() + new = current - old_ports + if new: + return new.pop() + time.sleep(0.25) + raise TimeoutError( + "Timed out waiting for device to re-enumerate in download mode.\n" + "Check USB connection and try again." + ) + + +def erase_region(esptool: str, port: str, offset: int, size: int, keep_stub: bool) -> None: + """Run esptool to erase one flash region. + + keep_stub=True leaves the stub running so the next call can reconnect + without a full reset cycle. + """ + after = "no-reset" if keep_stub else "watchdog-reset" + cmd = [ + esptool, + "--chip", "esp32s3", + "--port", port, + "--baud", "460800", + "--before", "no-reset", + "--after", after, + "erase-region", + hex(offset), + hex(size), + ] + result = subprocess.run(cmd, capture_output=True, text=True) + output = result.stdout + result.stderr + if result.returncode != 0: + print(output) + raise RuntimeError(f"esptool failed (exit {result.returncode})") + + +def main() -> None: + print("=" * 40) + print(" ATOMS3 MacroPad -- Device Wiper") + print("=" * 40 + "\n") + + print("[ 1/5 ] Locating esptool...", end=" ", flush=True) + esptool = find_esptool() + print(f"OK\n {esptool}") + + forced_port = sys.argv[1].upper() if len(sys.argv) > 1 else None + if forced_port: + print(f"[ 2/5 ] Using specified port {forced_port}...", end=" ", flush=True) + port = forced_port + print("OK") + else: + print("[ 2/5 ] Searching for MacroPad...", end=" ", flush=True) + port = find_macropad_port() + if not port: + print("NOT FOUND") + print("\nNo MacroPad detected. Make sure the device is connected and firmware is running.") + sys.exit(1) + print(f"found on {port}") + + print("[ 3/5 ] Entering download mode...", end=" ", flush=True) + ports_before = list_espressif_ports() + send_bootloader_command(port) + # Wait for old port to vanish, then for the JTAG port to appear + time.sleep(0.5) + try: + jtag_port = wait_for_port_change(ports_before - {port}, timeout=10.0) + except TimeoutError as e: + print("TIMEOUT") + print(f"\n{e}") + sys.exit(1) + print(f"device on {jtag_port}") + + # Let the ROM fully settle + time.sleep(0.5) + + print(f"[ 4/5 ] Erasing NVS " + f"(0x{NVS_OFFSET:06X} – 0x{NVS_OFFSET + NVS_SIZE - 1:06X}, " + f"{NVS_SIZE // 1024} KB)...", end=" ", flush=True) + try: + erase_region(esptool, jtag_port, NVS_OFFSET, NVS_SIZE, keep_stub=True) + except RuntimeError as e: + print("FAILED") + print(f"\n{e}") + sys.exit(1) + print("OK") + + # Final erase resets the device + print(f"[ 5/5 ] Erasing LittleFS " + f"(0x{LITTLEFS_OFFSET:06X} – 0x{LITTLEFS_OFFSET + LITTLEFS_SIZE - 1:06X}, " + f"{LITTLEFS_SIZE // 1024} KB)...", end=" ", flush=True) + try: + erase_region(esptool, jtag_port, LITTLEFS_OFFSET, LITTLEFS_SIZE, keep_stub=False) + except RuntimeError as e: + print("FAILED") + print(f"\n{e}") + sys.exit(1) + print("OK") + + print("\nDone. NVS and LittleFS are blank -- device is in a fresh state.\n") + + +if __name__ == "__main__": + main()