Skip to content

Instantly share code, notes, and snippets.

@pnck
Last active February 9, 2026 21:19
Show Gist options
  • Select an option

  • Save pnck/326ad2031af7945123269a1f23a5ab08 to your computer and use it in GitHub Desktop.

Select an option

Save pnck/326ad2031af7945123269a1f23a5ab08 to your computer and use it in GitHub Desktop.
CircuitPython-based HID macro executor
import usb_hid
import socketpool
import wifi
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keyboard_layout_us import KeyboardLayoutUS
from adafruit_hid.mouse import Mouse
from adafruit_httpserver import Request, Response, Server, FileResponse
from lib.macro import Macro
def urldecode(s):
"""Decode URL-encoded string."""
result = []
i = 0
while i < len(s):
if s[i] == "%" and i + 2 < len(s):
try:
result.append(chr(int(s[i+1:i+3], 16)))
i += 3
continue
except:
pass
elif s[i] == "+":
result.append(" ")
i += 1
continue
result.append(s[i])
i += 1
return "".join(result)
def htmldecode(s):
"""Decode common HTML entities."""
if not s:
return s
# Important: decode &amp; last
s = s.replace("&lt;", "<").replace("&gt;", ">")
s = s.replace("&quot;", "\"")
s = s.replace("&#39;", "'").replace("&apos;", "'")
s = s.replace("&amp;", "&")
return s
# Initialize HID devices
keyboard = Keyboard(usb_hid.devices)
layout = KeyboardLayoutUS(keyboard)
mouse = Mouse(usb_hid.devices)
# Initialize macro interpreter
macro = Macro(keyboard, layout, mouse)
# Global execution lock
macro_busy = False
# Minimum hold time for click (ms)
click_hold_ms = 10
# HTTP Server
pool = socketpool.SocketPool(wifi.radio)
server = Server(pool, debug=True)
@server.route("/")
def index(request: Request):
try:
return FileResponse(request, "/index.html", "/", content_type="text/html; charset=utf-8")
except:
return Response(request, "<h1>index.html not found</h1>", content_type="text/html; charset=utf-8")
@server.route("/macro", methods=["POST"])
def run_macro(request: Request):
global macro_busy, click_hold_ms
if macro_busy:
return Response(request, "Busy: Macro is running", content_type="text/plain")
macro_busy = True
try:
code = request.form_data.get("code", "")
hold = request.form_data.get("hold_ms", "")
if not code:
return Response(request, "Error: No macro code", content_type="text/plain")
if hold:
try:
click_hold_ms = max(0, min(1000, int(hold)))
except:
pass
# URL + HTML decode the input (defensive against entity encoding)
code = htmldecode(urldecode(code))
# Compile (syntax check)
bytecode, err = macro.compile(code)
if err:
return Response(request, f"Compile Error: {err}", content_type="text/plain; charset=utf-8")
# Print bytecode to serial console
print(f"\n=== Compiled Bytecode ({len(bytecode)} bytes) ===")
print(macro.disassemble(bytecode))
print("=" * 40)
# Execute macro, then return result
try:
macro.execute(bytecode, click_hold_ms=click_hold_ms)
return Response(request, f"OK: {len(bytecode)}B executed", content_type="text/plain; charset=utf-8")
except Exception as e:
return Response(request, f"Runtime Error: {e}", content_type="text/plain; charset=utf-8")
finally:
macro_busy = False
@server.route("/compile", methods=["POST"])
def compile_macro(request: Request):
global macro_busy, click_hold_ms
if macro_busy:
return Response(request, "Busy: Macro is running", content_type="text/plain")
macro_busy = True
try:
code = request.form_data.get("code", "")
hold = request.form_data.get("hold_ms", "")
if not code:
return Response(request, "Error: No macro code", content_type="text/plain")
if hold:
try:
click_hold_ms = max(0, min(1000, int(hold)))
except:
pass
# URL + HTML decode the input (defensive against entity encoding)
code = htmldecode(urldecode(code))
# Compile (syntax check)
bytecode, err = macro.compile(code)
if err:
return Response(request, f"Compile Error: {err}", content_type="text/plain; charset=utf-8")
# Return disassembly without executing
return Response(request, macro.disassemble(bytecode), content_type="text/plain; charset=utf-8")
finally:
macro_busy = False
@server.route("/mouse")
def mouse_control(request: Request):
action = request.query_params.get("action", "click")
p1 = request.query_params.get("p1", "L")
p2 = request.query_params.get("p2", "1")
if action == "click":
btn_map = {"l": 1, "left": 1, "r": 2, "right": 2, "m": 4, "middle": 4}
btn = btn_map.get(p1.lower(), 1)
try:
count = max(1, int(p2))
except:
count = 1
for _ in range(count):
mouse.click(btn)
return Response(request, f"Clicked {p1} x{count}", content_type="text/plain; charset=utf-8")
elif action == "move":
try:
x = int(p1)
except:
x = 0
try:
y = int(p2)
except:
y = 0
# Move in chunks
while x != 0 or y != 0:
mx = max(-127, min(127, x))
my = max(-127, min(127, y))
mouse.move(x=mx, y=my)
x -= mx
y -= my
return Response(request, f"Moved ({p1},{p2})", content_type="text/plain; charset=utf-8")
return Response(request, f"Unknown action: {action}", content_type="text/plain; charset=utf-8")
print(f"Starting server at http://{wifi.radio.ipv4_address}")
server.serve_forever(str(wifi.radio.ipv4_address), 80)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Macro HID</title>
<style>
body{font-family:sans-serif;margin:20px;max-width:900px}
textarea{font-family:monospace;width:100%;box-sizing:border-box}
.ref{background:#f5f5f5;padding:10px;margin:10px 0;border-left:3px solid #666}
.ref summary{cursor:pointer;padding:5px;user-select:none}
.ref summary:hover{background:#e8e8e8}
.ref h5{margin:10px 0 5px 0;display:inline-block}
.ref code{background:#fff;padding:2px 5px;border-radius:3px}
details{margin:5px 0}
table{border-collapse:collapse;width:100%;font-size:12px;margin:5px 0}
th,td{border:1px solid #ddd;padding:4px;text-align:left}
th{background:#eee}
code{font-family:monospace;background:#f0f0f0;padding:1px 4px}
</style>
</head>
<body>
<h2>Macro HID Controller</h2>
<form id="macroForm" action="/macro" method="POST" accept-charset="utf-8">
<textarea id="macroCode" name="code" rows="6" placeholder="Example: Hello World\enter"></textarea><br>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<input type="submit" value="Execute Macro">
<button type="button" id="compileBtn">Compile Only</button>
<label style="font-size:12px">Click hold (ms):
<input type="number" id="holdMs" name="hold_ms" value="10" min="0" max="1000" style="width:70px">
</label>
</div>
</form>
<pre id="macroOutput" style="white-space:pre-wrap;background:#f8f8f8;border:1px solid #ddd;padding:8px;margin-top:10px"></pre>
<script>
const form = document.getElementById("macroForm");
const codeEl = document.getElementById("macroCode");
const outEl = document.getElementById("macroOutput");
const compileBtn = document.getElementById("compileBtn");
const holdMsEl = document.getElementById("holdMs");
const executeBtn = form.querySelector("input[type=submit]");
let inFlight = false;
function post(path) {
if (inFlight) return;
inFlight = true;
if (executeBtn) executeBtn.disabled = true;
if (compileBtn) compileBtn.disabled = true;
outEl.textContent = "Working...";
fetch(path, {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: "code=" + encodeURIComponent(codeEl.value || "") + "&hold_ms=" + encodeURIComponent(holdMsEl.value || "")
})
.then(r => r.text())
.then(t => { outEl.textContent = t; })
.catch(e => { outEl.textContent = "Error: " + e; })
.finally(() => {
inFlight = false;
if (executeBtn) executeBtn.disabled = false;
if (compileBtn) compileBtn.disabled = false;
});
}
form.addEventListener("submit", (e) => {
e.preventDefault();
post("/macro");
});
compileBtn.addEventListener("click", () => post("/compile"));
</script>
<details class="ref">
<summary><strong>📖 Quick Reference (click to expand)</strong></summary>
<details open>
<summary><h5>Text & Escape</h5></summary>
<table>
<tr><th>Syntax</th><th>Description</th></tr>
<tr><td><code>abc 123</code></td><td>Plain text (space/newline become keys)</td></tr>
<tr><td><code>\\</code></td><td>Backslash</td></tr>
<tr><td><code>\{</code> <code>\}</code></td><td>Literal braces</td></tr>
<tr><td><code>\cmd{}</code></td><td>Empty {} separates command from text</td></tr>
</table>
</details>
<details>
<summary><h5>Special Keys</h5></summary>
<table>
<tr><th>Command</th><th>Key</th><th>Command</th><th>Key</th></tr>
<tr><td><code>\enter</code></td><td>Enter</td><td><code>\tab</code></td><td>Tab</td></tr>
<tr><td><code>\esc</code></td><td>Escape</td><td><code>\space</code></td><td>Space</td></tr>
<tr><td><code>\bs</code></td><td>Backspace</td><td><code>\del</code></td><td>Delete</td></tr>
<tr><td><code>\up \down \left \right</code></td><td>Arrow keys</td><td><code>\home \end</code></td><td>Home/End</td></tr>
<tr><td><code>\pgup \pgdn</code></td><td>Page Up/Down</td><td><code>\ins</code></td><td>Insert</td></tr>
<tr><td><code>\f1</code> ~ <code>\f12</code></td><td>Function keys</td><td><code>\caps</code></td><td>Caps Lock</td></tr>
</table>
</details>
<details>
<summary><h5>Combinations & Modifiers</h5></summary>
<table>
<tr><th>Modifier</th><th>Aliases</th><th>Example</th></tr>
<tr><td>Ctrl</td><td><code>\ctrl</code> <code>\control</code></td><td><code>\ctrl+c</code> (copy)</td></tr>
<tr><td>Shift</td><td><code>\shift</code></td><td><code>\shift+a</code> (capital A)</td></tr>
<tr><td>Alt</td><td><code>\alt</code> <code>\option</code> <code>\opt</code> </td><td><code>\alt+\f4</code> (close window)</td></tr>
<tr><td>Win/Cmd</td><td><code>\win</code> <code>\cmd</code> <code>\gui</code></td><td><code>\win+r</code> or <code>\cmd+space</code></td></tr>
</table>
<p><small><strong>Combo examples:</strong> <code>\ctrl+\shift+t</code> (reopen tab), <code>\alt+tab</code> (switch window)</small></p>
</details>
<details>
<summary><h5>Control Commands</h5></summary>
<table>
<tr><th>Command</th><th>Description</th></tr>
<tr><td><code>\delay{0.5}</code></td><td>Set delay (seconds) for following keys</td></tr>
<tr><td><code>\rep{5}{abc}</code></td><td>Repeat "abc" 5 times</td></tr>
<tr><td><code>\kdown{ctrl}</code></td><td>Press and hold Ctrl</td></tr>
<tr><td><code>\kup{ctrl}</code></td><td>Release Ctrl</td></tr>
<tr><td><code>\kup{}</code></td><td>Release all keys</td></tr>
</table>
</details>
<details>
<summary><h5>Mouse Commands</h5></summary>
<table>
<tr><th>Command</th><th>Description</th></tr>
<tr><td><code>\click{L}</code></td><td>Left click (L/R/M)</td></tr>
<tr><td><code>\click{R,3}</code></td><td>Right click 3 times</td></tr>
<tr><td><code>\move{100,-50}</code></td><td>Move mouse (x,y)</td></tr>
<tr><td><code>\mdown{L}</code></td><td>Press left button (hold)</td></tr>
<tr><td><code>\mup{L}</code></td><td>Release left button</td></tr>
</table>
</details>
<details open>
<summary><h5>Examples</h5></summary>
<table>
<tr><th>Macro</th><th>Description</th></tr>
<tr><td><code>Hello World\enter</code></td><td>Type text and press Enter</td></tr>
<tr><td><code>\ctrl+c\delay{0.2}\rep{3}{\ctrl+v}</code></td><td>Copy, wait, paste 3 times</td></tr>
<tr><td><code>\win+r\delay{0.3}notepad\enter</code></td><td>Open Notepad (Windows)</td></tr>
<tr><td><code>\cmd+space\delay{0.2}terminal\enter</code></td><td>Open Terminal (macOS)</td></tr>
<tr><td><code>\mdown{L}\move{100,0}\mup{L}</code></td><td>Drag mouse right 100px</td></tr>
<tr><td><code>\kdown{ctrl}ccc\kup{}</code></td><td>Hold Ctrl, type "ccc", release</td></tr>
</table>
</details>
<p><small>Limits: 1024 bytes bytecode, 2 nesting levels</small></p>
</details>
<hr>
<h3>Quick Mouse</h3>
<form action="/mouse" method="GET" accept-charset="utf-8" style="display:flex;gap:10px;align-items:center">
<select name="action">
<option value="click">Click</option>
<option value="move">Move</option>
</select>
<input type="text" name="p1" value="L" placeholder="Btn/X" style="width:60px">
<input type="text" name="p2" value="1" placeholder="Cnt/Y" style="width:60px">
<input type="submit" value="Go">
</form>
</body>
</html>
# Macro Interpreter for HID simulation
# LaTeX-style syntax: \enter \ctrl+c \delay{0.1} \rep{3}{abc}
import time
from adafruit_hid.keycode import Keycode
from adafruit_hid.mouse import Mouse
# Opcodes
OP_CHAR = 0x01 # <ascii>
OP_KEY = 0x02 # <keycode>
OP_COMBO = 0x03 # <mod_mask> <keycode>
OP_DELAY = 0x04 # <ms_lo> <ms_hi>
OP_MCLICK = 0x05 # <btn> <count_lo> <count_hi>
OP_MMOVE = 0x06 # <dx_lo> <dx_hi> <dy_lo> <dy_hi>
OP_LOOP = 0x10 # <count> <len_lo> <len_hi>
OP_LOOP_END = 0x11
OP_KEY_DOWN = 0x12 # <keycode>
OP_KEY_UP = 0x13 # <keycode> (0 = release all)
OP_MDOWN = 0x14 # <btn>
OP_MUP = 0x15 # <btn>
OP_END = 0xFF
MAX_BYTECODE = 4096
MAX_NEST = 2
# Character set helpers (CircuitPython str lacks isalnum/isalpha)
_ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
_ALNUM = _ALPHA + "0123456789"
def _is_alpha(ch):
return ch in _ALPHA
def _is_alnum(ch):
return ch in _ALNUM
class MacroError(Exception):
def __init__(self, msg, pos):
self.msg = msg
self.pos = pos
super().__init__(f"@{pos}: {msg}")
class Macro:
# Key mappings
KEYS = {
"enter": Keycode.ENTER,
"return": Keycode.RETURN,
"esc": Keycode.ESCAPE,
"escape": Keycode.ESCAPE,
"tab": Keycode.TAB,
"space": Keycode.SPACE,
"bs": Keycode.BACKSPACE,
"backspace": Keycode.BACKSPACE,
"del": Keycode.DELETE,
"delete": Keycode.DELETE,
"up": Keycode.UP_ARROW,
"down": Keycode.DOWN_ARROW,
"left": Keycode.LEFT_ARROW,
"right": Keycode.RIGHT_ARROW,
"home": Keycode.HOME,
"end": Keycode.END,
"pgup": Keycode.PAGE_UP,
"pgdn": Keycode.PAGE_DOWN,
"ins": Keycode.INSERT,
"caps": Keycode.CAPS_LOCK,
"f1": Keycode.F1,
"f2": Keycode.F2,
"f3": Keycode.F3,
"f4": Keycode.F4,
"f5": Keycode.F5,
"f6": Keycode.F6,
"f7": Keycode.F7,
"f8": Keycode.F8,
"f9": Keycode.F9,
"f10": Keycode.F10,
"f11": Keycode.F11,
"f12": Keycode.F12,
"ctrl": Keycode.CONTROL,
"shift": Keycode.SHIFT,
"alt": Keycode.ALT,
"win": Keycode.GUI,
"gui": Keycode.GUI,
}
MODS = {
"ctrl": 0x01,
"control": 0x01,
"shift": 0x02,
"alt": 0x04,
"option": 0x04,
"opt": 0x04,
"win": 0x08,
"gui": 0x08,
"cmd": 0x08,
}
MOD_KEYS = [Keycode.CONTROL, Keycode.SHIFT, Keycode.ALT, Keycode.GUI]
DIGIT_KEYS = {
"0": Keycode.ZERO,
"1": Keycode.ONE,
"2": Keycode.TWO,
"3": Keycode.THREE,
"4": Keycode.FOUR,
"5": Keycode.FIVE,
"6": Keycode.SIX,
"7": Keycode.SEVEN,
"8": Keycode.EIGHT,
"9": Keycode.NINE,
}
MOUSE_BTNS = {
"l": Mouse.LEFT_BUTTON,
"left": Mouse.LEFT_BUTTON,
"r": Mouse.RIGHT_BUTTON,
"right": Mouse.RIGHT_BUTTON,
"m": Mouse.MIDDLE_BUTTON,
"middle": Mouse.MIDDLE_BUTTON,
}
def __init__(self, keyboard, layout, mouse):
self.keyboard = keyboard
self.layout = layout
self.mouse = mouse
def compile(self, text):
"""Compile macro text to bytecode. Returns (bytecode, error_msg)."""
self._bc = bytearray()
self._text = text
self._pos = 0
self._len = len(text)
self._nest = [] # [(text_pos, bc_pos)]
try:
self._parse()
self._emit(OP_END)
return bytes(self._bc), None
except MacroError as e:
return None, str(e)
def disassemble(self, bytecode):
"""Disassemble bytecode to human-readable format."""
lines = []
i = 0
indent = 0
while i < len(bytecode):
op = bytecode[i]
prefix = " " * indent
if op == OP_CHAR:
ch = (
chr(bytecode[i + 1])
if bytecode[i + 1] < 128
else f"\\x{bytecode[i+1]:02x}"
)
lines.append(f"{prefix}CHAR '{ch}'")
i += 2
elif op == OP_KEY:
key_name = self._keycode_name(bytecode[i + 1])
lines.append(f"{prefix}KEY {key_name}")
i += 2
elif op == OP_COMBO:
mods = self._mod_names(bytecode[i + 1])
key_name = self._keycode_name(bytecode[i + 2])
lines.append(f"{prefix}COMBO {mods}+{key_name}")
i += 3
elif op == OP_DELAY:
ms = bytecode[i + 1] | (bytecode[i + 2] << 8)
lines.append(f"{prefix}DELAY {ms}ms")
i += 3
elif op == OP_MCLICK:
btn = (
"0LR3M"[bytecode[i + 1]]
if bytecode[i + 1] <= 4
else str(bytecode[i + 1])
)
count = bytecode[i + 2] | (bytecode[i + 3] << 8)
lines.append(f"{prefix}MCLICK {btn} x{count}")
i += 4
elif op == OP_MMOVE:
dx_raw = bytecode[i + 1] | (bytecode[i + 2] << 8)
dy_raw = bytecode[i + 3] | (bytecode[i + 4] << 8)
dx = dx_raw if dx_raw < 32768 else dx_raw - 65536
dy = dy_raw if dy_raw < 32768 else dy_raw - 65536
lines.append(f"{prefix}MMOVE ({dx},{dy})")
i += 5
elif op == OP_LOOP:
count = bytecode[i + 1]
length = bytecode[i + 2] | (bytecode[i + 3] << 8)
lines.append(f"{prefix}LOOP x{count} ({length}B) " + "{{")
indent += 1
i += 4
elif op == OP_LOOP_END:
indent -= 1
lines.append(f"{prefix}" + "}}LOOP_END")
i += 1
elif op == OP_KEY_DOWN:
key_name = self._keycode_name(bytecode[i + 1])
lines.append(f"{prefix}KEY_DOWN {key_name}")
i += 2
elif op == OP_KEY_UP:
if bytecode[i + 1] == 0:
lines.append(f"{prefix}KEY_UP all")
else:
key_name = self._keycode_name(bytecode[i + 1])
lines.append(f"{prefix}KEY_UP {key_name}")
i += 2
elif op == OP_MDOWN:
btn = (
"0LR3M"[bytecode[i + 1]]
if bytecode[i + 1] <= 4
else str(bytecode[i + 1])
)
lines.append(f"{prefix}MDOWN {btn}")
i += 2
elif op == OP_MUP:
btn = (
"0LR3M"[bytecode[i + 1]]
if bytecode[i + 1] <= 4
else str(bytecode[i + 1])
)
lines.append(f"{prefix}MUP {btn}")
i += 2
elif op == OP_END:
lines.append(f"{prefix}END")
break
else:
lines.append(f"{prefix}UNKNOWN 0x{op:02X}")
i += 1
return "\n".join(lines)
def _keycode_name(self, code):
"""Reverse lookup keycode name."""
for name, kc in self.KEYS.items():
if kc == code:
return name.upper()
# Try common ASCII codes
if code >= 0x04 and code <= 0x1D: # A-Z
return chr(ord("A") + code - 0x04)
return f"0x{code:02X}"
def _mod_names(self, mask):
"""Convert modifier mask to names."""
mods = []
if mask & 0x01:
mods.append("CTRL")
if mask & 0x02:
mods.append("SHIFT")
if mask & 0x04:
mods.append("ALT")
if mask & 0x08:
mods.append("GUI")
return "+".join(mods) if mods else "NONE"
def _char_keycode(self, ch):
if len(ch) != 1:
return None
if _is_alpha(ch):
return getattr(Keycode, ch.upper())
if ch in self.DIGIT_KEYS:
return self.DIGIT_KEYS[ch]
return None
def _resolve_key(self, name, pos):
if name in self.KEYS:
return self.KEYS[name]
if len(name) == 1:
key = self._char_keycode(name)
if key is None:
raise MacroError(f"Unknown key: {name}", pos)
return key
raise MacroError(f"Unknown key: {name}", pos)
def _emit(self, *args):
if len(self._bc) >= MAX_BYTECODE - 10:
raise MacroError("Bytecode limit exceeded", self._pos)
for b in args:
if isinstance(b, int):
self._bc.append(b & 0xFF)
else:
self._bc.extend(b)
def _emit_u16(self, v):
self._emit(v & 0xFF, (v >> 8) & 0xFF)
def _emit_i8(self, v):
v = max(-127, min(127, v))
self._emit(v & 0xFF)
def _emit_i16(self, v):
v = max(-32767, min(32767, v))
v = v & 0xFFFF if v >= 0 else (v + 65536) & 0xFFFF
self._emit(v & 0xFF, (v >> 8) & 0xFF)
def _peek(self):
return self._text[self._pos] if self._pos < self._len else None
def _advance(self):
ch = self._peek()
self._pos += 1
return ch
def _read_name(self):
"""Read alphanumeric command name."""
start = self._pos
while self._pos < self._len:
ch = self._text[self._pos]
if _is_alnum(ch) or ch == "_":
self._pos += 1
else:
break
return self._text[start : self._pos].lower()
def _read_braces(self):
"""Read content between { and }."""
if self._peek() != "{":
return None
self._advance() # skip {
start = self._pos
depth = 1
while self._pos < self._len and depth > 0:
ch = self._advance()
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth != 0:
raise MacroError("Unmatched {", start - 1)
return self._text[start : self._pos - 1]
def _parse_key_or_combo(self, name):
"""Parse key/combo starting with name, handle + chains."""
mods = 0
key = None
pos = self._pos
# First part
if name in self.MODS:
mods |= self.MODS[name]
else:
key = self._resolve_key(name, pos)
# Handle + chains: \ctrl+\shift+c
while self._peek() == "+":
self._advance() # skip +
if self._peek() == "\\":
self._advance() # skip \
next_name = self._read_name()
elif self._peek() and _is_alnum(self._peek()):
next_name = self._read_name()
else:
raise MacroError("Expected key after +", self._pos)
if next_name in self.MODS:
mods |= self.MODS[next_name]
else:
key = self._resolve_key(next_name, self._pos)
# Skip empty {} separator
if (
self._peek() == "{"
and self._pos + 1 < self._len
and self._text[self._pos + 1] == "}"
):
self._pos += 2
# Emit
if mods and key:
self._emit(OP_COMBO, mods, key)
elif mods:
self._emit(OP_COMBO, mods, 0)
elif key:
self._emit(OP_KEY, key)
def _parse_command(self):
"""Parse command after backslash."""
pos = self._pos
name = self._read_name()
if not name:
raise MacroError("Expected command name", pos)
handler = getattr(self, "_cmd_" + name, None)
if handler:
handler(pos)
return
# Otherwise it's a key or combo: \enter, \ctrl+c
self._parse_key_or_combo(name)
def _cmd_delay(self, pos):
arg = self._read_braces()
if arg is None:
raise MacroError("delay requires {value}", self._pos)
try:
ms = int(float(arg) * 1000)
ms = max(1, min(65535, ms))
self._emit(OP_DELAY)
self._emit_u16(ms)
except:
raise MacroError("Invalid delay value", pos)
def _cmd_rep(self, pos):
arg = self._read_braces()
if arg is None:
raise MacroError("rep requires {count}", self._pos)
try:
count = int(arg)
count = max(1, min(255, count))
except:
raise MacroError("Invalid rep count", pos)
body = self._read_braces()
if body is None:
raise MacroError("rep requires {body}", self._pos)
# Compile body
sub = Macro(self.keyboard, self.layout, self.mouse)
sub_bc, err = sub.compile(body)
if err:
raise MacroError(f"In rep body: {err}", pos)
# Remove trailing OP_END
sub_bc = sub_bc[:-1]
body_len = len(sub_bc) + 1 # +1 for LOOP_END
self._emit(OP_LOOP, count)
self._emit_u16(body_len)
self._emit(sub_bc)
self._emit(OP_LOOP_END)
def _cmd_kdown(self, pos):
arg = self._read_braces()
if arg is None:
raise MacroError("kdown requires {key}", self._pos)
arg = arg.strip().lower()
self._emit(OP_KEY_DOWN, self._resolve_key(arg, pos))
def _cmd_kup(self, pos):
arg = self._read_braces()
if arg is None:
raise MacroError("kup requires {key} or {}", self._pos)
arg = arg.strip().lower()
if not arg:
self._emit(OP_KEY_UP, 0) # release all
else:
self._emit(OP_KEY_UP, self._resolve_key(arg, pos))
def _cmd_click(self, pos):
arg = self._read_braces()
if arg is None:
raise MacroError("click requires {btn} or {btn,count}", self._pos)
parts = arg.split(",")
btn = self.MOUSE_BTNS.get(parts[0].strip().lower(), 1)
count = 1
if len(parts) >= 2:
try:
count = int(parts[1].strip())
count = max(1, min(65535, count))
except:
raise MacroError("Invalid click count", pos)
self._emit(OP_MCLICK, btn)
self._emit_u16(count)
def _cmd_move(self, pos):
arg = self._read_braces()
if arg is None:
raise MacroError("move requires {dx,dy}", self._pos)
parts = arg.split(",")
try:
dx = int(parts[0].strip())
except:
raise MacroError("Invalid move dx", pos)
dy = 0
if len(parts) >= 2:
try:
dy = int(parts[1].strip())
except:
raise MacroError("Invalid move dy", pos)
# Emit in chunks of 32767
while dx != 0 or dy != 0:
mx = max(-32767, min(32767, dx))
my = max(-32767, min(32767, dy))
self._emit(OP_MMOVE)
self._emit_i16(mx)
self._emit_i16(my)
dx -= mx
dy -= my
def _cmd_mdown(self, pos):
arg = self._read_braces()
if arg is None:
raise MacroError("mdown requires {btn}", self._pos)
btn = self.MOUSE_BTNS.get(arg.strip().lower(), 1)
self._emit(OP_MDOWN, btn)
def _cmd_mup(self, pos):
arg = self._read_braces()
if arg is None:
raise MacroError("mup requires {btn}", self._pos)
btn = self.MOUSE_BTNS.get(arg.strip().lower(), 1)
self._emit(OP_MUP, btn)
def _parse(self):
"""Main parse loop."""
while self._pos < self._len:
ch = self._peek()
# Command: \xxx
if ch == "\\":
self._advance()
next_ch = self._peek()
# Escape sequences
if next_ch in "\\{}":
self._emit(OP_CHAR, ord(next_ch))
self._advance()
elif next_ch and _is_alpha(next_ch):
self._parse_command()
else:
raise MacroError("Invalid escape sequence", self._pos)
continue
# Regular character
if ch == "\n":
# Unix/Mac line ending - emit ENTER
self._emit(OP_KEY, Keycode.ENTER)
elif ch == "\r":
# Windows CR or old Mac - check if followed by \n
if self._pos + 1 < self._len and self._text[self._pos + 1] == "\n":
# Windows \r\n - emit ENTER and skip both chars
self._emit(OP_KEY, Keycode.ENTER)
self._advance() # skip \r
self._advance() # skip \n
continue
else:
# Standalone \r - emit ENTER
self._emit(OP_KEY, Keycode.ENTER)
elif ch == "\t":
self._emit(OP_KEY, Keycode.TAB)
elif ord(ch) >= 32 and ord(ch) <= 126:
self._emit(OP_CHAR, ord(ch))
else:
raise MacroError(f"Invalid char: 0x{ord(ch):02X}", self._pos)
self._advance()
def execute(self, bytecode, default_delay_ms=50, click_hold_ms=10):
"""Execute compiled bytecode."""
bc = bytecode
n = len(bc)
ip = 0
delay_ms = default_delay_ms
loop_stack = [] # [(ip_start, remaining, saved_delay)]
def read_u8():
nonlocal ip
v = bc[ip]
ip += 1
return v
def read_u16():
nonlocal ip
v = bc[ip] | (bc[ip + 1] << 8)
ip += 2
return v
def read_i8():
v = read_u8()
return v - 256 if v > 127 else v
def read_i16():
v = read_u16()
return v - 65536 if v > 32767 else v
def do_delay():
if delay_ms > 0:
time.sleep(delay_ms / 1000.0)
def do_click_hold():
if click_hold_ms > 0:
time.sleep(click_hold_ms / 1000.0)
while ip < n:
op = read_u8()
if op == OP_END:
break
elif op == OP_CHAR:
ch = chr(read_u8())
try:
codes = self.layout.keycodes(ch)
except:
codes = None
if codes:
for kc in codes:
self.keyboard.press(kc)
do_click_hold()
self.keyboard.release_all()
else:
# Fallback: layout.write (no hold control)
self.layout.write(ch, delay=0)
do_click_hold()
do_delay()
elif op == OP_KEY:
self.keyboard.press(read_u8())
do_click_hold()
self.keyboard.release_all()
do_delay()
elif op == OP_COMBO:
mods = read_u8()
key = read_u8()
for i in range(4):
if mods & (1 << i):
self.keyboard.press(self.MOD_KEYS[i])
if key:
self.keyboard.press(key)
do_click_hold()
self.keyboard.release(key)
self.keyboard.release_all()
do_delay()
elif op == OP_DELAY:
delay_ms = read_u16()
# Immediately execute this delay
time.sleep(delay_ms / 1000.0)
elif op == OP_MCLICK:
btn = read_u8()
count = read_u16()
for i in range(count):
self.mouse.press(btn)
do_click_hold()
self.mouse.release(btn)
if i < count - 1:
do_delay()
do_delay()
elif op == OP_MMOVE:
dx = read_i16()
dy = read_i16()
self.mouse.move(x=dx, y=dy)
do_delay()
elif op == OP_LOOP:
count = read_u8()
body_len = read_u16()
if count > 1:
loop_stack.append((ip, count - 1, delay_ms))
elif op == OP_LOOP_END:
if loop_stack:
start, remaining, saved_delay = loop_stack[-1]
if remaining > 0:
loop_stack[-1] = (start, remaining - 1, saved_delay)
ip = start
else:
loop_stack.pop()
delay_ms = saved_delay
elif op == OP_KEY_DOWN:
self.keyboard.press(read_u8())
elif op == OP_KEY_UP:
key = read_u8()
if key == 0:
self.keyboard.release_all()
else:
self.keyboard.release(key)
elif op == OP_MDOWN:
self.mouse.press(read_u8())
elif op == OP_MUP:
self.mouse.release(read_u8())
else:
break # Unknown opcode
return True
def run(self, text, default_delay_ms=50):
"""Compile and execute macro. Returns (success, message)."""
bytecode, err = self.compile(text)
if err:
return False, f"Compile: {err}"
try:
self.execute(bytecode, default_delay_ms)
return True, f"OK: {len(bytecode)}B"
except Exception as e:
return False, f"Runtime: {e}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment