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

162 lines
4.6 KiB
Python

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