Files
m5stack-automation-tool/app.py
T
2026-07-17 15:29:53 -04:00

549 lines
20 KiB
Python

"""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()