Initial public release
This commit is contained in:
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user