mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-16 01:01:58 +00:00
Add Loki and Sentinel utility classes for web API endpoints
- Implemented LokiUtils class with GET and POST endpoints for managing scripts, jobs, and payloads. - Added SentinelUtils class with GET and POST endpoints for managing events, rules, devices, and notifications. - Both classes include error handling and JSON response formatting.
This commit is contained in:
408
loki/hid_controller.py
Normal file
408
loki/hid_controller.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Low-level USB HID controller for Loki.
|
||||
Writes keyboard and mouse reports to /dev/hidg0 and /dev/hidg1.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
import select
|
||||
from threading import Event
|
||||
|
||||
from logger import Logger
|
||||
from loki.layouts import load as load_layout
|
||||
|
||||
logger = Logger(name="loki.hid_controller", level=logging.DEBUG)
|
||||
|
||||
# ── HID Keycodes ──────────────────────────────────────────────
|
||||
# USB HID Usage Tables — Keyboard/Keypad Page (0x07)
|
||||
|
||||
KEY_NONE = 0x00
|
||||
KEY_A = 0x04
|
||||
KEY_B = 0x05
|
||||
KEY_C = 0x06
|
||||
KEY_D = 0x07
|
||||
KEY_E = 0x08
|
||||
KEY_F = 0x09
|
||||
KEY_G = 0x0A
|
||||
KEY_H = 0x0B
|
||||
KEY_I = 0x0C
|
||||
KEY_J = 0x0D
|
||||
KEY_K = 0x0E
|
||||
KEY_L = 0x0F
|
||||
KEY_M = 0x10
|
||||
KEY_N = 0x11
|
||||
KEY_O = 0x12
|
||||
KEY_P = 0x13
|
||||
KEY_Q = 0x14
|
||||
KEY_R = 0x15
|
||||
KEY_S = 0x16
|
||||
KEY_T = 0x17
|
||||
KEY_U = 0x18
|
||||
KEY_V = 0x19
|
||||
KEY_W = 0x1A
|
||||
KEY_X = 0x1B
|
||||
KEY_Y = 0x1C
|
||||
KEY_Z = 0x1D
|
||||
KEY_1 = 0x1E
|
||||
KEY_2 = 0x1F
|
||||
KEY_3 = 0x20
|
||||
KEY_4 = 0x21
|
||||
KEY_5 = 0x22
|
||||
KEY_6 = 0x23
|
||||
KEY_7 = 0x24
|
||||
KEY_8 = 0x25
|
||||
KEY_9 = 0x26
|
||||
KEY_0 = 0x27
|
||||
KEY_ENTER = 0x28
|
||||
KEY_ESC = 0x29
|
||||
KEY_BACKSPACE = 0x2A
|
||||
KEY_TAB = 0x2B
|
||||
KEY_SPACE = 0x2C
|
||||
KEY_MINUS = 0x2D
|
||||
KEY_EQUAL = 0x2E
|
||||
KEY_LEFTBRACE = 0x2F
|
||||
KEY_RIGHTBRACE = 0x30
|
||||
KEY_BACKSLASH = 0x31
|
||||
KEY_SEMICOLON = 0x33
|
||||
KEY_APOSTROPHE = 0x34
|
||||
KEY_GRAVE = 0x35
|
||||
KEY_COMMA = 0x36
|
||||
KEY_DOT = 0x37
|
||||
KEY_SLASH = 0x38
|
||||
KEY_CAPSLOCK = 0x39
|
||||
KEY_F1 = 0x3A
|
||||
KEY_F2 = 0x3B
|
||||
KEY_F3 = 0x3C
|
||||
KEY_F4 = 0x3D
|
||||
KEY_F5 = 0x3E
|
||||
KEY_F6 = 0x3F
|
||||
KEY_F7 = 0x40
|
||||
KEY_F8 = 0x41
|
||||
KEY_F9 = 0x42
|
||||
KEY_F10 = 0x43
|
||||
KEY_F11 = 0x44
|
||||
KEY_F12 = 0x45
|
||||
KEY_PRINTSCREEN = 0x46
|
||||
KEY_SCROLLLOCK = 0x47
|
||||
KEY_PAUSE = 0x48
|
||||
KEY_INSERT = 0x49
|
||||
KEY_HOME = 0x4A
|
||||
KEY_PAGEUP = 0x4B
|
||||
KEY_DELETE = 0x4C
|
||||
KEY_END = 0x4D
|
||||
KEY_PAGEDOWN = 0x4E
|
||||
KEY_RIGHT = 0x4F
|
||||
KEY_LEFT = 0x50
|
||||
KEY_DOWN = 0x51
|
||||
KEY_UP = 0x52
|
||||
KEY_NUMLOCK = 0x53
|
||||
|
||||
# ── Modifier bitmasks ─────────────────────────────────────────
|
||||
MOD_NONE = 0x00
|
||||
MOD_LEFT_CONTROL = 0x01
|
||||
MOD_LEFT_SHIFT = 0x02
|
||||
MOD_LEFT_ALT = 0x04
|
||||
MOD_LEFT_GUI = 0x08
|
||||
MOD_RIGHT_CONTROL = 0x10
|
||||
MOD_RIGHT_SHIFT = 0x20
|
||||
MOD_RIGHT_ALT = 0x40
|
||||
MOD_RIGHT_GUI = 0x80
|
||||
|
||||
# ── Combo name → (modifier_mask, keycode) ─────────────────────
|
||||
_COMBO_MAP = {
|
||||
# Modifiers (used standalone or in combos)
|
||||
"CTRL": (MOD_LEFT_CONTROL, KEY_NONE),
|
||||
"CONTROL": (MOD_LEFT_CONTROL, KEY_NONE),
|
||||
"SHIFT": (MOD_LEFT_SHIFT, KEY_NONE),
|
||||
"ALT": (MOD_LEFT_ALT, KEY_NONE),
|
||||
"GUI": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"WIN": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"WINDOWS": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"COMMAND": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"META": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"RCTRL": (MOD_RIGHT_CONTROL, KEY_NONE),
|
||||
"RSHIFT": (MOD_RIGHT_SHIFT, KEY_NONE),
|
||||
"RALT": (MOD_RIGHT_ALT, KEY_NONE),
|
||||
"RGUI": (MOD_RIGHT_GUI, KEY_NONE),
|
||||
# Special keys
|
||||
"ENTER": (MOD_NONE, KEY_ENTER),
|
||||
"RETURN": (MOD_NONE, KEY_ENTER),
|
||||
"ESC": (MOD_NONE, KEY_ESC),
|
||||
"ESCAPE": (MOD_NONE, KEY_ESC),
|
||||
"BACKSPACE": (MOD_NONE, KEY_BACKSPACE),
|
||||
"TAB": (MOD_NONE, KEY_TAB),
|
||||
"SPACE": (MOD_NONE, KEY_SPACE),
|
||||
"CAPSLOCK": (MOD_NONE, KEY_CAPSLOCK),
|
||||
"DELETE": (MOD_NONE, KEY_DELETE),
|
||||
"INSERT": (MOD_NONE, KEY_INSERT),
|
||||
"HOME": (MOD_NONE, KEY_HOME),
|
||||
"END": (MOD_NONE, KEY_END),
|
||||
"PAGEUP": (MOD_NONE, KEY_PAGEUP),
|
||||
"PAGEDOWN": (MOD_NONE, KEY_PAGEDOWN),
|
||||
"UP": (MOD_NONE, KEY_UP),
|
||||
"DOWN": (MOD_NONE, KEY_DOWN),
|
||||
"LEFT": (MOD_NONE, KEY_LEFT),
|
||||
"RIGHT": (MOD_NONE, KEY_RIGHT),
|
||||
"PRINTSCREEN": (MOD_NONE, KEY_PRINTSCREEN),
|
||||
"SCROLLLOCK": (MOD_NONE, KEY_SCROLLLOCK),
|
||||
"PAUSE": (MOD_NONE, KEY_PAUSE),
|
||||
"NUMLOCK": (MOD_NONE, KEY_NUMLOCK),
|
||||
# F keys
|
||||
"F1": (MOD_NONE, KEY_F1), "F2": (MOD_NONE, KEY_F2),
|
||||
"F3": (MOD_NONE, KEY_F3), "F4": (MOD_NONE, KEY_F4),
|
||||
"F5": (MOD_NONE, KEY_F5), "F6": (MOD_NONE, KEY_F6),
|
||||
"F7": (MOD_NONE, KEY_F7), "F8": (MOD_NONE, KEY_F8),
|
||||
"F9": (MOD_NONE, KEY_F9), "F10": (MOD_NONE, KEY_F10),
|
||||
"F11": (MOD_NONE, KEY_F11), "F12": (MOD_NONE, KEY_F12),
|
||||
# Letters (for combo usage like "GUI r")
|
||||
"A": (MOD_NONE, KEY_A), "B": (MOD_NONE, KEY_B),
|
||||
"C": (MOD_NONE, KEY_C), "D": (MOD_NONE, KEY_D),
|
||||
"E": (MOD_NONE, KEY_E), "F": (MOD_NONE, KEY_F),
|
||||
"G": (MOD_NONE, KEY_G), "H": (MOD_NONE, KEY_H),
|
||||
"I": (MOD_NONE, KEY_I), "J": (MOD_NONE, KEY_J),
|
||||
"K": (MOD_NONE, KEY_K), "L": (MOD_NONE, KEY_L),
|
||||
"M": (MOD_NONE, KEY_M), "N": (MOD_NONE, KEY_N),
|
||||
"O": (MOD_NONE, KEY_O), "P": (MOD_NONE, KEY_P),
|
||||
"Q": (MOD_NONE, KEY_Q), "R": (MOD_NONE, KEY_R),
|
||||
"S": (MOD_NONE, KEY_S), "T": (MOD_NONE, KEY_T),
|
||||
"U": (MOD_NONE, KEY_U), "V": (MOD_NONE, KEY_V),
|
||||
"W": (MOD_NONE, KEY_W), "X": (MOD_NONE, KEY_X),
|
||||
"Y": (MOD_NONE, KEY_Y), "Z": (MOD_NONE, KEY_Z),
|
||||
}
|
||||
|
||||
# ── LED bitmasks (host → device output report) ────────────────
|
||||
LED_NUM = 0x01
|
||||
LED_CAPS = 0x02
|
||||
LED_SCROLL = 0x04
|
||||
LED_ANY = 0xFF
|
||||
|
||||
|
||||
class HIDController:
|
||||
"""Low-level USB HID report writer."""
|
||||
|
||||
def __init__(self):
|
||||
self._kbd_fd = None # /dev/hidg0
|
||||
self._mouse_fd = None # /dev/hidg1
|
||||
self._layout = load_layout("us")
|
||||
self._speed_min = 0 # ms between keystrokes (0 = instant)
|
||||
self._speed_max = 0
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────
|
||||
|
||||
def open(self):
|
||||
"""Open HID gadget device files."""
|
||||
try:
|
||||
self._kbd_fd = os.open("/dev/hidg0", os.O_RDWR | os.O_NONBLOCK)
|
||||
logger.info("Opened /dev/hidg0 (keyboard)")
|
||||
except OSError as e:
|
||||
logger.error("Cannot open /dev/hidg0: %s", e)
|
||||
raise
|
||||
|
||||
try:
|
||||
self._mouse_fd = os.open("/dev/hidg1", os.O_RDWR | os.O_NONBLOCK)
|
||||
logger.info("Opened /dev/hidg1 (mouse)")
|
||||
except OSError as e:
|
||||
logger.warning("Cannot open /dev/hidg1 (mouse disabled): %s", e)
|
||||
self._mouse_fd = None
|
||||
|
||||
def close(self):
|
||||
"""Close HID device files."""
|
||||
self.release_all()
|
||||
if self._kbd_fd is not None:
|
||||
try:
|
||||
os.close(self._kbd_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._kbd_fd = None
|
||||
if self._mouse_fd is not None:
|
||||
try:
|
||||
os.close(self._mouse_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._mouse_fd = None
|
||||
logger.debug("HID devices closed")
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._kbd_fd is not None
|
||||
|
||||
# ── Layout ─────────────────────────────────────────────────
|
||||
|
||||
def set_layout(self, name: str):
|
||||
"""Switch keyboard layout."""
|
||||
self._layout = load_layout(name)
|
||||
logger.debug("Layout switched to '%s'", name)
|
||||
|
||||
def set_typing_speed(self, min_ms: int, max_ms: int):
|
||||
"""Set random delay range between keystrokes (ms)."""
|
||||
self._speed_min = max(0, min_ms)
|
||||
self._speed_max = max(self._speed_min, max_ms)
|
||||
|
||||
# ── Keyboard Reports ───────────────────────────────────────
|
||||
|
||||
def send_key_report(self, modifiers: int, keys: list):
|
||||
"""Send an 8-byte keyboard report: [mod, 0x00, key1..key6]."""
|
||||
if self._kbd_fd is None:
|
||||
return
|
||||
report = bytearray(8)
|
||||
report[0] = modifiers & 0xFF
|
||||
for i, k in enumerate(keys[:6]):
|
||||
report[2 + i] = k & 0xFF
|
||||
os.write(self._kbd_fd, bytes(report))
|
||||
|
||||
def release_all(self):
|
||||
"""Send empty keyboard + mouse reports (release everything)."""
|
||||
if self._kbd_fd is not None:
|
||||
try:
|
||||
os.write(self._kbd_fd, bytes(8))
|
||||
except OSError:
|
||||
pass
|
||||
if self._mouse_fd is not None:
|
||||
try:
|
||||
os.write(self._mouse_fd, bytes([0x01, 0, 0, 0, 0, 0]))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def press_combo(self, combo_str: str):
|
||||
"""Press a key combination like 'GUI r', 'CTRL ALT DELETE'.
|
||||
|
||||
Keys are separated by spaces. All are pressed simultaneously, then released.
|
||||
"""
|
||||
parts = combo_str.strip().split()
|
||||
mod_mask = 0
|
||||
keycodes = []
|
||||
|
||||
for part in parts:
|
||||
upper = part.upper()
|
||||
if upper in _COMBO_MAP:
|
||||
m, k = _COMBO_MAP[upper]
|
||||
mod_mask |= m
|
||||
if k != KEY_NONE:
|
||||
keycodes.append(k)
|
||||
else:
|
||||
# Try single char via layout
|
||||
if len(part) == 1 and part in self._layout:
|
||||
char_mod, char_key = self._layout[part]
|
||||
mod_mask |= char_mod
|
||||
keycodes.append(char_key)
|
||||
else:
|
||||
logger.warning("Unknown combo key: '%s'", part)
|
||||
|
||||
if keycodes or mod_mask:
|
||||
self.send_key_report(mod_mask, keycodes)
|
||||
time.sleep(0.02)
|
||||
self.send_key_report(0, []) # release
|
||||
|
||||
def type_string(self, text: str, stop_event: Event = None):
|
||||
"""Type a string character by character using the current layout."""
|
||||
for ch in text:
|
||||
if stop_event and stop_event.is_set():
|
||||
return
|
||||
if ch in self._layout:
|
||||
mod, key = self._layout[ch]
|
||||
self.send_key_report(mod, [key])
|
||||
time.sleep(0.01)
|
||||
self.send_key_report(0, []) # release
|
||||
else:
|
||||
logger.warning("Unmapped char: %r", ch)
|
||||
continue
|
||||
|
||||
# Inter-keystroke delay
|
||||
if self._speed_max > 0:
|
||||
delay = random.randint(self._speed_min, self._speed_max) / 1000.0
|
||||
if stop_event:
|
||||
stop_event.wait(delay)
|
||||
else:
|
||||
time.sleep(delay)
|
||||
else:
|
||||
time.sleep(0.005) # tiny default gap for reliability
|
||||
|
||||
# ── LED State ──────────────────────────────────────────────
|
||||
|
||||
def read_led_state(self) -> int:
|
||||
"""Read current LED state from host (non-blocking). Returns bitmask."""
|
||||
if self._kbd_fd is None:
|
||||
return 0
|
||||
try:
|
||||
r, _, _ = select.select([self._kbd_fd], [], [], 0)
|
||||
if r:
|
||||
data = os.read(self._kbd_fd, 1)
|
||||
if data:
|
||||
return data[0]
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def wait_led(self, mask: int, stop_event: Event = None, timeout: float = 0):
|
||||
"""Block until host LED state matches mask.
|
||||
|
||||
mask=LED_ANY matches any LED change.
|
||||
Returns True if matched, False if stopped/timed out.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
initial = self.read_led_state()
|
||||
while True:
|
||||
if stop_event and stop_event.is_set():
|
||||
return False
|
||||
if timeout > 0 and (time.monotonic() - start) > timeout:
|
||||
return False
|
||||
current = self.read_led_state()
|
||||
if mask == LED_ANY:
|
||||
if current != initial:
|
||||
return True
|
||||
else:
|
||||
if current & mask:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
|
||||
def wait_led_repeat(self, mask: int, count: int, stop_event: Event = None):
|
||||
"""Wait for LED to toggle count times."""
|
||||
for _ in range(count):
|
||||
if not self.wait_led(mask, stop_event):
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Mouse Reports ──────────────────────────────────────────
|
||||
# P4wnP1 mouse descriptor uses Report ID 1 for relative mode.
|
||||
# Report format: [0x01, buttons, X, Y, 0x00, 0x00] = 6 bytes
|
||||
|
||||
def send_mouse_report(self, buttons: int, x: int, y: int, wheel: int = 0):
|
||||
"""Send a 6-byte relative mouse report with Report ID 1.
|
||||
|
||||
Format: [report_id=1, buttons, X, Y, pad, pad]
|
||||
"""
|
||||
if self._mouse_fd is None:
|
||||
return
|
||||
# Clamp to signed byte range
|
||||
x = max(-127, min(127, x))
|
||||
y = max(-127, min(127, y))
|
||||
report = struct.pack("BBbbBB", 0x01, buttons & 0xFF, x, y, 0, 0)
|
||||
os.write(self._mouse_fd, report)
|
||||
|
||||
def mouse_move(self, x: int, y: int):
|
||||
"""Move mouse by (x, y) relative pixels."""
|
||||
self.send_mouse_report(0, x, y)
|
||||
|
||||
def mouse_move_stepped(self, x: int, y: int, step: int = 10):
|
||||
"""Move mouse in small increments for better tracking."""
|
||||
while x != 0 or y != 0:
|
||||
dx = max(-step, min(step, x))
|
||||
dy = max(-step, min(step, y))
|
||||
self.send_mouse_report(0, dx, dy)
|
||||
x -= dx
|
||||
y -= dy
|
||||
time.sleep(0.005)
|
||||
|
||||
def mouse_click(self, button: int = 1):
|
||||
"""Click a mouse button (1=left, 2=right, 4=middle)."""
|
||||
self.send_mouse_report(button, 0, 0)
|
||||
time.sleep(0.05)
|
||||
self.send_mouse_report(0, 0, 0)
|
||||
|
||||
def mouse_double_click(self, button: int = 1):
|
||||
"""Double-click a mouse button."""
|
||||
self.mouse_click(button)
|
||||
time.sleep(0.05)
|
||||
self.mouse_click(button)
|
||||
Reference in New Issue
Block a user