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