Initial public release

This commit is contained in:
2026-07-17 15:29:53 -04:00
commit 2d71ce77a1
81 changed files with 32056 additions and 0 deletions
+70
View File
@@ -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 "<profile>.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
+138
View File
@@ -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.
+179
View File
@@ -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 <output_port_file>", 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))
+35
View File
@@ -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%
+196
View File
@@ -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))
+292
View File
@@ -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%
+7
View File
@@ -0,0 +1,7 @@
@echo off
setlocal
cd /d "%~dp0.."
python wipe_device.py %1
echo.
pause
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
start "" pythonw main.py
+179
View File
@@ -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 (116 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.
+548
View File
@@ -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("<Control-c>", self._on_ctrl_c, add="+")
self.bind_all("<Control-v>", 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()
+84
View File
@@ -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
+115
View File
@@ -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
+158
View File
@@ -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())
+892
View File
@@ -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|<MAC>") bound as AAD. The 32-byte key lives on the
device's LittleFS partition and on the host's `<repo>/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("<BQQ", msg_type, session_id & 0xFFFFFFFFFFFFFFFF,
seq & 0xFFFFFFFFFFFFFFFF)
def _parse_header(plain: bytes):
if len(plain) < 17:
return None
msg_type, sid, seq = struct.unpack("<BQQ", plain[:17])
return msg_type, sid, seq, plain[17:]
class BLELiveKeystrokeClient:
"""Persistent BLE link for live keystroke streaming.
Lifecycle:
c = BLELiveKeystrokeClient()
c.start(on_status=cb, on_error=cb) # scan + connect + START
c.send_event(action, hid_code) # called from Tk thread
c.stop() # STOP + disconnect
All callbacks run on the asyncio worker thread — bounce through
Tk's `after(0, ...)` to touch widgets.
"""
def __init__(self, target_address: str | None = None):
"""
target_address — optional BLE MAC (Bleak's d.address, e.g.
"AA:BB:CC:DD:EE:FF"). When set, the scanner only accepts a
match on this address. Used by the multi-device manager
(ble_multi.py) to pin each client to a distinct device so
N>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("<Q", body[:8])
# The START ack is the only one we explicitly wait on.
if not self._start_acked.is_set():
self._start_acked.set()
return
if msg_type == MSG_ERROR:
if len(body) >= 9:
err = body[0]
ref_seq, = struct.unpack("<Q", body[1:9])
else:
err = body[0] if body else 0
ref_seq = None
label = ERR_LABELS.get(err, f"ERR_{err}")
log.warning("live: device error %s ref_seq=%s", label, ref_seq)
if self._on_error:
try:
self._on_error(err, ref_seq, label)
except Exception:
pass
try:
await client.start_notify(LIVE_KEYS_NOTIFY_UUID, handle_notify)
print("[live] subscribed to LIVE_KEYS_NOTIFY")
_dbg("subscribed")
except Exception as exc:
print(f"[live] start_notify failed: {exc!r}")
_dbg("start_notify_failed", error=repr(exc))
self._set_status(ST_ERROR)
return
# ---- Wait for hello ----
try:
await asyncio.wait_for(hello_evt, timeout=HELLO_TIMEOUT_S)
print("[live] received hello")
_dbg("hello_ok")
except asyncio.TimeoutError:
print(f"[live] hello timeout after {HELLO_TIMEOUT_S}s")
_dbg("hello_timeout", timeout_s=HELLO_TIMEOUT_S)
self._set_status(ST_ERROR)
return
if not self._running:
return
# ---- Send START ----
if not await self._send_control(client, LIVE_KEYS_WRITE_UUID, MSG_START):
print("[live] START write failed")
_dbg("start_write_failed")
self._set_status(ST_ERROR)
return
_dbg("start_sent")
try:
await asyncio.wait_for(self._start_acked.wait(),
timeout=START_ACK_TIMEOUT_S)
print("[live] START ack received")
_dbg("start_ack")
except asyncio.TimeoutError:
print(f"[live] START ack timeout after {START_ACK_TIMEOUT_S}s")
_dbg("start_ack_timeout", timeout_s=START_ACK_TIMEOUT_S)
self._set_status(ST_ERROR)
return
self._set_status(ST_CONNECTED)
print("[live] session ready — recording can begin")
_dbg("session_ready")
# Re-apply any pending identify request now that we're connected
# (e.g. set_identify(True) was called while we were still scanning).
if self._identify_on:
self._enqueue_identify_threadsafe(True)
# Re-send the device label so it survives reconnects / reboots.
if self._device_label:
self._enqueue_label_threadsafe(self._device_label)
# ---- Stream loop ----
try:
await self._stream_loop(client, LIVE_KEYS_WRITE_UUID)
finally:
# ---- Send STOP best-effort ----
try:
await asyncio.wait_for(
self._send_control(client, LIVE_KEYS_WRITE_UUID, MSG_STOP),
timeout=1.5)
except Exception:
pass
try:
await client.stop_notify(LIVE_KEYS_NOTIFY_UUID)
except Exception:
pass
async def _stream_loop(self, client, write_uuid: str) -> 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("<BBI", a & 0xFF, h & 0xFF, t & 0xFFFFFFFF)
for (a, h, t) in batch
)
plain = _pack_header(MSG_KEYS, sid, seq) + body
try:
frame = build_frame(self._key, self._device_tag, plain)
except Exception as exc:
print(f"[live] build_frame failed: {exc!r}")
continue
try:
await client.write_gatt_char(write_uuid, frame, response=True)
self.events_sent += len(batch)
self.bytes_sent += len(frame)
except Exception as exc:
print(f"[live] write failed: {exc!r}")
_dbg("write_failed", error=repr(exc))
# Treat as disconnect — the on_disc callback will also fire,
# but bail out promptly either way.
break
if pending_identify is not None:
await self._handle_control_item(client, write_uuid,
pending_identify)
async def _handle_control_item(self, client, write_uuid: str,
item: tuple) -> 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("<BHHb", buttons & 0xFF, x & 0xFFFF, y & 0xFFFF, w)
plain = _pack_header(MSG_MOUSE, sid, seq) + body
try:
frame = build_frame(self._key,
self._device_tag or self._scan_tag(), plain)
except Exception:
return False
reliable = (buttons != self._last_mouse_buttons) or (w != 0)
self._last_mouse_buttons = buttons
try:
await client.write_gatt_char(write_uuid, frame, response=reliable)
self.bytes_sent += len(frame)
return True
except Exception as exc:
_dbg("mouse_write_failed", error=repr(exc))
return False
async def _send_label(self, client, write_uuid: str, label: str) -> 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)
+298
View File
@@ -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)
+132
View File
@@ -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()
+487
View File
@@ -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
+161
View File
@@ -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": { "<folder>": { "<macro>": {"events":[...],
"duration_ms": int,
"created": <ts>} } } }
"""
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
+90
View File
@@ -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": { "<name>": [ {"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
View File
File diff suppressed because it is too large Load Diff
+205
View File
@@ -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"
}
]
}
]
}
+134
View File
@@ -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("<BQQ", MSG_START, sid & 0xFFFFFFFFFFFFFFFF,
seq & 0xFFFFFFFFFFFFFFFF)
frame = build_frame(key, tag, plain)
print(f"[dbg] plain hex: {hex16(plain)}")
print(f"[dbg] frame len={len(frame)} hex: {hex16(frame)}")
try:
await client.write_gatt_char(LIVE_KEYS_WRITE_UUID, frame,
response=True)
print(f"[dbg] START write completed")
except Exception as exc:
print(f"[dbg] START write FAILED: {exc!r}")
print(f"[dbg] waiting 15s more for response (ACK or anything)...")
for i in range(15):
await asyncio.sleep(1)
if i in (5, 10):
print(f"[dbg] ...{i}s, notify_count={notify_count}")
print(f"[dbg] done. total notifies received: {notify_count}")
try:
await client.stop_notify(LIVE_KEYS_NOTIFY_UUID)
except Exception:
pass
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(1)
+33
View File
@@ -0,0 +1,33 @@
"""Dump the M5Stack's in-RAM BLE debug ring via USB serial.
Use right after a failed Open BLE attempt — the device's ring holds
recent connect/subscribe/notify events that tell us exactly what its
side of the BLE handshake observed.
"""
import sys
from serial_manager import SerialManager
def main():
s = SerialManager()
if not s.scan_and_connect():
print("[dbg] could not find/connect to M5Stack over USB")
sys.exit(1)
print(f"[dbg] connected to {s.port}")
entries = s.get_ble_log()
if entries is None:
print("[dbg] get_ble_log returned None (device may not support it)")
return
if not entries:
print("[dbg] ring is empty")
return
print(f"[dbg] {len(entries)} entries:")
for e in entries:
t = e.get("t", "?")
m = e.get("m", "")
print(f" [{t:>10}] {m}")
if __name__ == "__main__":
main()
+592
View File
@@ -0,0 +1,592 @@
#include <M5Unified.h>
#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);
}
+114
View File
@@ -0,0 +1,114 @@
#pragma once
#include "USBHID.h"
#include <string.h>
// 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;
}
};
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <LittleFS.h>
#include <esp_random.h>
// 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;
};
File diff suppressed because it is too large Load Diff
+184
View File
@@ -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
+140
View File
@@ -0,0 +1,140 @@
#pragma once
#include <LittleFS.h>
#include "config.h"
// Rolling debug log stored in LittleFS at /debug.log
// Format: one JSON line per entry: {"t":<millis>,"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';
}
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+110
View File
@@ -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 <Arduino.h>
#include <mbedtls/gcm.h>
#include <esp_random.h>
#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;
}
+380
View File
@@ -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 <M5Unified.h>
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;
}
};
+250
View File
@@ -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 <Arduino.h>
#include <freertos/FreeRTOS.h>
#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;
};
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
#pragma once
#include <LittleFS.h>
#include <ArduinoJson.h>
#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<JsonArray>();
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<JsonArray>();
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();
}
}
};
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <Arduino.h>
// 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;
}
}
+861
View File
@@ -0,0 +1,861 @@
#pragma once
#include <ArduinoJson.h>
#include <esp_mac.h>
#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<JsonArray>();
int newOrder[MAX_MACROS];
int count = 0;
for (JsonVariant v : orderArr) {
if (count < MAX_MACROS) {
newOrder[count++] = v.as<int>();
}
}
_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();
}
};
+151
View File
@@ -0,0 +1,151 @@
#pragma once
#include <Preferences.h>
#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;
};
+542
View File
@@ -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;
+145
View File
@@ -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("<BQQ", msg_type, session_id & 0xFFFFFFFFFFFFFFFF,
seq & 0xFFFFFFFFFFFFFFFF)
def pack_keys_body(batch) -> bytes:
"""``batch`` is a list of (action, hid, t_ms) tuples (max 16)."""
return bytes([len(batch)]) + b"".join(
struct.pack("<BBI", a & 0xFF, h & 0xFF, t & 0xFFFFFFFF)
for (a, h, t) in batch
)
def pack_mouse_body(buttons: int, x: int, y: int, wheel: int) -> bytes:
w = max(-127, min(127, int(wheel)))
return struct.pack("<BHHb", buttons & 0xFF, x & 0xFFFF, y & 0xFFFF, w)
def pack_mesh_header(mtype: int, flags: int, seq: int, dest: bytes) -> bytes:
return struct.pack("<BBBBI", MESH_MAGIC, mtype & 0xFF, flags & 0xFF, 0,
seq & 0xFFFFFFFF) + dest[:6]
def parse_mesh_header(frame: bytes):
"""Return (type, flags, seq, dest_bytes, payload) or None."""
if len(frame) < MESH_HDR_LEN or frame[0] != MESH_MAGIC:
return None
magic, mtype, flags, _rsvd, seq = struct.unpack("<BBBBI", frame[:8])
return mtype, flags, seq, frame[8:14], frame[MESH_HDR_LEN:]
def frame_hub_message(htype: int, payload: bytes) -> 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]))
+21
View File
@@ -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()
+209
View File
@@ -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)
+547
View File
@@ -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
View File
+228
View File
@@ -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
+1011
View File
File diff suppressed because it is too large Load Diff
+230
View File
@@ -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()
+205
View File
@@ -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()
+65
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+465
View File
@@ -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
+562
View File
@@ -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()
+2933
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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})"
+62
View File
@@ -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))
+3
View File
@@ -0,0 +1,3 @@
pyserial
bleak
cryptography
+444
View File
@@ -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
+110
View File
@@ -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())
View File
+234
View File
@@ -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"
+89
View File
@@ -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)
+356
View File
@@ -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 <KeyPress> / <KeyRelease> 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
View File
+220
View File
@@ -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("<Configure>",
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("<Configure>",
lambda e: self._list_canvas.itemconfig("inner", width=e.width))
self._list_canvas.bind_all("<MouseWheel>",
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("<Enter>", lambda e, r=row: self._set_bg(r, hover_bg))
w.bind("<Leave>", 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)
+416
View File
@@ -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("<Return>", 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("<<ComboboxSelected>>", 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(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
)
canvas.bind(
"<Configure>",
lambda e: canvas.itemconfig(self._rows_window, width=e.width),
)
canvas.bind_all("<MouseWheel>", 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("<Return>", 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()
File diff suppressed because it is too large Load Diff
+66
View File
@@ -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)
+133
View File
@@ -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("<Return>", lambda _e: self._save())
self.dlg.bind("<Escape>", 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()
+248
View File
@@ -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("<<ListboxSelect>>", 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()
+374
View File
@@ -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("<Configure>",
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("<Button-2>", 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("<Button-2>", 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("<Button-1>", select)
def rename(event, idx=index):
self._rename_macro(idx)
name_label.bind("<Double-Button-1>", 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("<Enter>", on_enter)
item.bind("<Leave>", 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("<Double-Button-1>", do_duplicate)
listbox.bind("<Return>", 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("<Return>", save)
tk.Button(dialog, text="OK", command=save, bg="#3D3D5C", fg="white",
relief="flat").pack(pady=6)
+736
View File
@@ -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("<Double-Button-1>", self._on_tree_double_click)
self.tree.bind("<Button-3>", 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 <KeyPress>/<KeyRelease> 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("<KeyPress>", self._on_key_press, add="+")
self.dlg.bind("<KeyRelease>", 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("<KeyPress>")
self.dlg.unbind("<KeyRelease>")
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
+194
View File
@@ -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("<Configure>",
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("<Configure>",
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 <MouseWheel> 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("<MouseWheel>", _on_wheel)
def _unbind_wheel(_):
self.content_canvas.unbind_all("<MouseWheel>")
for w in (self.content_canvas, self.content_inner):
w.bind("<Enter>", _bind_wheel)
w.bind("<Leave>", _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()
+136
View File
@@ -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 <Return> 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("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.bind("<Configure>",
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()
+427
View File
@@ -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("<Return>", 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
+388
View File
@@ -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(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.bind(
"<Configure>",
lambda e: canvas.itemconfig(self._cards_window, width=e.width))
canvas.bind_all("<MouseWheel>", 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("<KeyRelease>", 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("<KeyRelease>", 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()
+321
View File
@@ -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"(?<![\w-])-(?:" + "|".join(_PS_OPERATORS) + r")\b",
re.IGNORECASE)),
("param", re.compile(r"(?<![\w-])-{1,2}[A-Za-z][A-Za-z0-9]*")),
("number", re.compile(r"\b(?:0x[0-9A-Fa-f]+|\d+(?:\.\d+)?)\b")),
("keyword", re.compile(r"\b(?:" + "|".join(sorted(_PS_KEYWORDS, key=len, reverse=True))
+ r")\b", re.IGNORECASE)),
]
class TextEditorDialog:
"""Modal pop-out editor for a Text node's body."""
def __init__(self, parent, data, on_save):
self.parent = parent
self.data = data
self.on_save = on_save
self._initial_text = data.get("text", "")
self._initial_lang = data.get("language", "none")
self._current_lang = self._initial_lang
# Debounce handle so typing stays smooth even with rich rule sets
self._hl_after_id: str | None = None
self._build_dialog()
def _build_dialog(self):
self.dlg = tk.Toplevel(self.parent)
self.dlg.title("Text Editor")
self.dlg.geometry("900x640")
self.dlg.configure(bg="#2D2D3D")
self.dlg.transient(self.parent.winfo_toplevel())
# Intentionally no grab_set() — leave the rest of the app usable
header = tk.Frame(self.dlg, bg="#2D2D3D")
header.pack(fill="x", padx=12, pady=(10, 6))
tk.Label(header, text="Type Text — pop-out editor",
bg="#2D2D3D", fg="white", font=("Segoe UI", 12, "bold")).pack(side="left")
lang_frame = tk.Frame(header, bg="#2D2D3D")
lang_frame.pack(side="right")
tk.Label(lang_frame, text="Language:",
bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
self.lang_var = tk.StringVar(value=self._display_name(self._initial_lang))
self.lang_box = ttk.Combobox(
lang_frame,
textvariable=self.lang_var,
values=[name for name, _ in LANGUAGE_CHOICES],
state="readonly", width=30,
)
self.lang_box.pack(side="left")
self.lang_box.bind("<<ComboboxSelected>>", 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)
# <<Modified>> fires on every change; KeyRelease/ButtonRelease debounce the re-highlight
self.text.bind("<<Modified>>", self._on_text_modified)
self.text.bind("<KeyRelease>", self._schedule_rehighlight)
self.text.bind("<ButtonRelease-1>", self._schedule_rehighlight)
self.text.bind("<MouseWheel>", self._on_mousewheel)
self.gutter.bind("<MouseWheel>", self._on_mousewheel)
self.dlg.protocol("WM_DELETE_WINDOW", self._cancel)
self.dlg.bind("<Control-s>", 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):
# <<Modified>> 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()
+601
View File
@@ -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("<<ComboboxSelected>>", 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("<Return>", 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("<Destroy>", _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("<Configure>", 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("<Configure>", lambda e: canvas.itemconfig("inner", width=e.width))
canvas.bind_all("<MouseWheel>",
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()
+231
View File
@@ -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()