Files
Bjorn/loki/hidscript.py
infinition aac77a3e76 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.
2026-03-14 22:33:10 +01:00

749 lines
26 KiB
Python

"""
HIDScript parser and executor for Loki.
Supports P4wnP1-compatible HIDScript syntax:
- Function calls: type("hello"); press("GUI r"); delay(500);
- var declarations: var x = 1;
- for / while loops
- if / else conditionals
- // and /* */ comments
- String concatenation with +
- Basic arithmetic (+, -, *, /)
- console.log() for job output
Zero external dependencies — pure Python DSL parser.
"""
import re
import time
import logging
from threading import Event
from logger import Logger
logger = Logger(name="loki.hidscript", level=logging.DEBUG)
# ── LED constants (available in scripts) ──────────────────────
NUM = 0x01
CAPS = 0x02
SCROLL = 0x04
ANY = 0xFF
# ── Mouse button constants ────────────────────────────────────
BT1 = 1 # Left
BT2 = 2 # Right
BT3 = 4 # Middle
BTNONE = 0
class HIDScriptError(Exception):
"""Error during HIDScript execution."""
def __init__(self, message, line=None):
self.line = line
super().__init__(f"Line {line}: {message}" if line else message)
class HIDScriptParser:
"""Parse and execute P4wnP1-compatible HIDScript."""
def __init__(self, hid_controller, layout="us"):
self.hid = hid_controller
self._default_layout = layout
self._output = [] # console.log output
def execute(self, source: str, stop_event: Event = None, job_id: str = ""):
"""Parse and execute a HIDScript source string.
Returns list of console.log output lines.
"""
self._output = []
self._stop = stop_event or Event()
self._vars = {
# Built-in constants
"NUM": NUM, "CAPS": CAPS, "SCROLL": SCROLL, "ANY": ANY,
"BT1": BT1, "BT2": BT2, "BT3": BT3, "BTNONE": BTNONE,
"true": True, "false": False, "null": None,
}
# Strip comments
source = self._strip_comments(source)
# Tokenize into statements
stmts = self._parse_block(source)
# Execute
self._exec_stmts(stmts)
return self._output
# ── Comment stripping ──────────────────────────────────────
def _strip_comments(self, source: str) -> str:
"""Remove // and /* */ comments."""
# Block comments first
source = re.sub(r'/\*.*?\*/', '', source, flags=re.DOTALL)
# Line comments
source = re.sub(r'//[^\n]*', '', source)
return source
# ── Parser ─────────────────────────────────────────────────
def _parse_block(self, source: str) -> list:
"""Parse source into a list of statement dicts."""
stmts = []
pos = 0
source = source.strip()
while pos < len(source):
if self._stop.is_set():
break
pos = self._skip_ws(source, pos)
if pos >= len(source):
break
# var declaration
if source[pos:pos+4] == 'var ' or source[pos:pos+4] == 'let ':
end = source.find(';', pos)
if end == -1:
end = len(source)
decl = source[pos+4:end].strip()
eq = decl.find('=')
if eq >= 0:
name = decl[:eq].strip()
value_expr = decl[eq+1:].strip()
stmts.append({"type": "assign", "name": name, "expr": value_expr})
else:
stmts.append({"type": "assign", "name": decl.strip(), "expr": "null"})
pos = end + 1
# for loop
elif source[pos:pos+4] == 'for ' or source[pos:pos+4] == 'for(':
stmt, pos = self._parse_for(source, pos)
stmts.append(stmt)
# while loop
elif source[pos:pos+6] == 'while ' or source[pos:pos+6] == 'while(':
stmt, pos = self._parse_while(source, pos)
stmts.append(stmt)
# if statement
elif source[pos:pos+3] == 'if ' or source[pos:pos+3] == 'if(':
stmt, pos = self._parse_if(source, pos)
stmts.append(stmt)
# Block: { ... }
elif source[pos] == '{':
end = self._find_matching_brace(source, pos)
inner = source[pos+1:end]
stmts.extend(self._parse_block(inner))
pos = end + 1
# Expression statement (function call or assignment)
else:
end = source.find(';', pos)
if end == -1:
end = len(source)
expr = source[pos:end].strip()
if expr:
# Check for assignment: name = expr
m = re.match(r'^([a-zA-Z_]\w*)\s*=\s*(.+)$', expr)
if m and not expr.startswith('=='):
stmts.append({"type": "assign", "name": m.group(1), "expr": m.group(2)})
else:
stmts.append({"type": "expr", "expr": expr})
pos = end + 1
return stmts
def _parse_for(self, source, pos):
"""Parse: for (init; cond; incr) { body }"""
# Find parenthesized header
p_start = source.index('(', pos)
p_end = self._find_matching_paren(source, p_start)
header = source[p_start+1:p_end]
parts = header.split(';')
if len(parts) != 3:
raise HIDScriptError("Invalid for loop header")
init_expr = parts[0].strip()
cond_expr = parts[1].strip()
incr_expr = parts[2].strip()
# Remove var/let prefix from init
for prefix in ('var ', 'let '):
if init_expr.startswith(prefix):
init_expr = init_expr[len(prefix):]
# Find body
body_start = self._skip_ws(source, p_end + 1)
if body_start < len(source) and source[body_start] == '{':
body_end = self._find_matching_brace(source, body_start)
body = source[body_start+1:body_end]
next_pos = body_end + 1
else:
semi = source.find(';', body_start)
if semi == -1:
semi = len(source)
body = source[body_start:semi]
next_pos = semi + 1
return {
"type": "for",
"init": init_expr,
"cond": cond_expr,
"incr": incr_expr,
"body": body,
}, next_pos
def _parse_while(self, source, pos):
"""Parse: while (cond) { body }"""
p_start = source.index('(', pos)
p_end = self._find_matching_paren(source, p_start)
cond = source[p_start+1:p_end].strip()
body_start = self._skip_ws(source, p_end + 1)
if body_start < len(source) and source[body_start] == '{':
body_end = self._find_matching_brace(source, body_start)
body = source[body_start+1:body_end]
next_pos = body_end + 1
else:
semi = source.find(';', body_start)
if semi == -1:
semi = len(source)
body = source[body_start:semi]
next_pos = semi + 1
return {"type": "while", "cond": cond, "body": body}, next_pos
def _parse_if(self, source, pos):
"""Parse: if (cond) { body } [else { body }]"""
p_start = source.index('(', pos)
p_end = self._find_matching_paren(source, p_start)
cond = source[p_start+1:p_end].strip()
body_start = self._skip_ws(source, p_end + 1)
if body_start < len(source) and source[body_start] == '{':
body_end = self._find_matching_brace(source, body_start)
body = source[body_start+1:body_end]
next_pos = body_end + 1
else:
semi = source.find(';', body_start)
if semi == -1:
semi = len(source)
body = source[body_start:semi]
next_pos = semi + 1
# Check for else
else_body = None
check = self._skip_ws(source, next_pos)
if source[check:check+4] == 'else':
after_else = self._skip_ws(source, check + 4)
if after_else < len(source) and source[after_else] == '{':
eb_end = self._find_matching_brace(source, after_else)
else_body = source[after_else+1:eb_end]
next_pos = eb_end + 1
elif source[after_else:after_else+2] == 'if':
# else if — parse recursively
inner_if, next_pos = self._parse_if(source, after_else)
else_body = inner_if # will be a dict, handle in exec
else:
semi = source.find(';', after_else)
if semi == -1:
semi = len(source)
else_body = source[after_else:semi]
next_pos = semi + 1
return {"type": "if", "cond": cond, "body": body, "else": else_body}, next_pos
# ── Executor ───────────────────────────────────────────────
def _exec_stmts(self, stmts: list):
"""Execute a list of parsed statements."""
for stmt in stmts:
if self._stop.is_set():
return
stype = stmt["type"]
if stype == "assign":
self._vars[stmt["name"]] = self._eval_expr(stmt["expr"])
elif stype == "expr":
self._eval_expr(stmt["expr"])
elif stype == "for":
self._exec_for(stmt)
elif stype == "while":
self._exec_while(stmt)
elif stype == "if":
self._exec_if(stmt)
def _exec_for(self, stmt):
"""Execute a for loop."""
# Parse init as assignment
init = stmt["init"]
eq = init.find('=')
if eq >= 0:
name = init[:eq].strip()
self._vars[name] = self._eval_expr(init[eq+1:].strip())
max_iterations = 100000
i = 0
while i < max_iterations:
if self._stop.is_set():
return
if not self._eval_expr(stmt["cond"]):
break
self._exec_stmts(self._parse_block(stmt["body"]))
# Execute increment
incr = stmt["incr"]
if "++" in incr:
var_name = incr.replace("++", "").strip()
self._vars[var_name] = self._vars.get(var_name, 0) + 1
elif "--" in incr:
var_name = incr.replace("--", "").strip()
self._vars[var_name] = self._vars.get(var_name, 0) - 1
else:
eq = incr.find('=')
if eq >= 0:
name = incr[:eq].strip()
self._vars[name] = self._eval_expr(incr[eq+1:].strip())
i += 1
def _exec_while(self, stmt):
"""Execute a while loop."""
max_iterations = 1000000
i = 0
while i < max_iterations:
if self._stop.is_set():
return
if not self._eval_expr(stmt["cond"]):
break
self._exec_stmts(self._parse_block(stmt["body"]))
i += 1
def _exec_if(self, stmt):
"""Execute an if/else statement."""
if self._eval_expr(stmt["cond"]):
self._exec_stmts(self._parse_block(stmt["body"]))
elif stmt.get("else"):
else_part = stmt["else"]
if isinstance(else_part, dict):
# else if
self._exec_if(else_part)
else:
self._exec_stmts(self._parse_block(else_part))
# ── Expression Evaluator ───────────────────────────────────
def _eval_expr(self, expr):
"""Evaluate an expression string and return its value."""
if isinstance(expr, (int, float, bool)):
return expr
if not isinstance(expr, str):
return expr
expr = expr.strip()
if not expr:
return None
# String literal
if (expr.startswith('"') and expr.endswith('"')) or \
(expr.startswith("'") and expr.endswith("'")):
return self._unescape(expr[1:-1])
# Numeric literal
try:
if '.' in expr:
return float(expr)
return int(expr)
except ValueError:
pass
# Boolean / null
if expr == 'true':
return True
if expr == 'false':
return False
if expr == 'null':
return None
# String concatenation with +
if self._has_top_level_op(expr, '+') and self._contains_string(expr):
parts = self._split_top_level(expr, '+')
result = ""
for p in parts:
val = self._eval_expr(p.strip())
result += str(val) if val is not None else ""
return result
# Comparison operators
for op in ['===', '!==', '==', '!=', '>=', '<=', '>', '<']:
if self._has_top_level_op(expr, op):
parts = self._split_top_level(expr, op, max_splits=1)
if len(parts) == 2:
left = self._eval_expr(parts[0].strip())
right = self._eval_expr(parts[1].strip())
if op in ('==', '==='):
return left == right
elif op in ('!=', '!=='):
return left != right
elif op == '>':
return left > right
elif op == '<':
return left < right
elif op == '>=':
return left >= right
elif op == '<=':
return left <= right
# Logical operators
if self._has_top_level_op(expr, '&&'):
parts = self._split_top_level(expr, '&&', max_splits=1)
return self._eval_expr(parts[0]) and self._eval_expr(parts[1])
if self._has_top_level_op(expr, '||'):
parts = self._split_top_level(expr, '||', max_splits=1)
return self._eval_expr(parts[0]) or self._eval_expr(parts[1])
# Arithmetic
for op in ['+', '-']:
if self._has_top_level_op(expr, op) and not self._contains_string(expr):
parts = self._split_top_level(expr, op)
result = self._eval_expr(parts[0].strip())
for p in parts[1:]:
val = self._eval_expr(p.strip())
if op == '+':
result = (result or 0) + (val or 0)
else:
result = (result or 0) - (val or 0)
return result
for op in ['*', '/']:
if self._has_top_level_op(expr, op):
parts = self._split_top_level(expr, op)
result = self._eval_expr(parts[0].strip())
for p in parts[1:]:
val = self._eval_expr(p.strip())
if op == '*':
result = (result or 0) * (val or 0)
else:
result = (result or 0) / (val or 1)
return result
# Modulo
if self._has_top_level_op(expr, '%'):
parts = self._split_top_level(expr, '%')
result = self._eval_expr(parts[0].strip())
for p in parts[1:]:
val = self._eval_expr(p.strip())
result = (result or 0) % (val or 1)
return result
# Negation
if expr.startswith('!'):
return not self._eval_expr(expr[1:])
# Parenthesized expression
if expr.startswith('(') and self._find_matching_paren(expr, 0) == len(expr) - 1:
return self._eval_expr(expr[1:-1])
# Function call
m = re.match(r'^([a-zA-Z_][\w.]*)\s*\(', expr)
if m:
func_name = m.group(1)
p_start = expr.index('(')
p_end = self._find_matching_paren(expr, p_start)
args_str = expr[p_start+1:p_end]
args = self._parse_args(args_str)
return self._call_func(func_name, args)
# Variable reference
if re.match(r'^[a-zA-Z_]\w*$', expr):
return self._vars.get(expr, 0)
# Increment/decrement as expression
if expr.endswith('++'):
name = expr[:-2].strip()
val = self._vars.get(name, 0)
self._vars[name] = val + 1
return val
if expr.endswith('--'):
name = expr[:-2].strip()
val = self._vars.get(name, 0)
self._vars[name] = val - 1
return val
logger.warning("Cannot evaluate expression: %r", expr)
return 0
# ── Built-in Functions ─────────────────────────────────────
def _call_func(self, name: str, args: list):
"""Dispatch a built-in function call."""
# Evaluate all arguments
evaled = [self._eval_expr(a) for a in args]
if name == "type":
text = str(evaled[0]) if evaled else ""
self.hid.type_string(text, stop_event=self._stop)
elif name == "press":
combo = str(evaled[0]) if evaled else ""
self.hid.press_combo(combo)
elif name == "delay":
ms = int(evaled[0]) if evaled else 0
if ms > 0:
self._stop.wait(ms / 1000.0)
elif name == "layout":
name_val = str(evaled[0]) if evaled else self._default_layout
self.hid.set_layout(name_val)
elif name == "typingSpeed":
min_ms = int(evaled[0]) if len(evaled) > 0 else 0
max_ms = int(evaled[1]) if len(evaled) > 1 else min_ms
self.hid.set_typing_speed(min_ms, max_ms)
elif name == "move":
x = int(evaled[0]) if len(evaled) > 0 else 0
y = int(evaled[1]) if len(evaled) > 1 else 0
self.hid.mouse_move(x, y)
elif name == "moveTo":
x = int(evaled[0]) if len(evaled) > 0 else 0
y = int(evaled[1]) if len(evaled) > 1 else 0
self.hid.mouse_move_stepped(x, y, step=5)
elif name == "moveStepped":
x = int(evaled[0]) if len(evaled) > 0 else 0
y = int(evaled[1]) if len(evaled) > 1 else 0
step = int(evaled[2]) if len(evaled) > 2 else 10
self.hid.mouse_move_stepped(x, y, step=step)
elif name == "click":
btn = int(evaled[0]) if evaled else BT1
self.hid.mouse_click(btn)
elif name == "doubleClick":
btn = int(evaled[0]) if evaled else BT1
self.hid.mouse_double_click(btn)
elif name == "button":
mask = int(evaled[0]) if evaled else 0
self.hid.send_mouse_report(mask, 0, 0)
elif name == "waitLED":
mask = int(evaled[0]) if evaled else ANY
timeout = float(evaled[1]) / 1000 if len(evaled) > 1 else 0
return self.hid.wait_led(mask, self._stop, timeout)
elif name == "waitLEDRepeat":
mask = int(evaled[0]) if evaled else ANY
count = int(evaled[1]) if len(evaled) > 1 else 1
return self.hid.wait_led_repeat(mask, count, self._stop)
elif name == "console.log" or name == "log":
msg = " ".join(str(a) for a in evaled)
self._output.append(msg)
logger.debug("[HIDScript] %s", msg)
elif name in ("parseInt", "Number"):
try:
return int(float(evaled[0])) if evaled else 0
except (ValueError, TypeError):
return 0
elif name == "String":
return str(evaled[0]) if evaled else ""
elif name == "Math.random":
import random
return random.random()
elif name == "Math.floor":
import math
return math.floor(evaled[0]) if evaled else 0
else:
logger.warning("Unknown function: %s", name)
return None
return None
# ── Helpers ────────────────────────────────────────────────
def _parse_args(self, args_str: str) -> list:
"""Split function arguments respecting string literals and parens."""
args = []
depth = 0
current = ""
in_str = None
for ch in args_str:
if in_str:
current += ch
if ch == in_str and (len(current) < 2 or current[-2] != '\\'):
in_str = None
elif ch in ('"', "'"):
in_str = ch
current += ch
elif ch == '(':
depth += 1
current += ch
elif ch == ')':
depth -= 1
current += ch
elif ch == ',' and depth == 0:
if current.strip():
args.append(current.strip())
current = ""
else:
current += ch
if current.strip():
args.append(current.strip())
return args
def _unescape(self, s: str) -> str:
"""Process escape sequences in a string."""
return s.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r') \
.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
def _skip_ws(self, source: str, pos: int) -> int:
"""Skip whitespace."""
while pos < len(source) and source[pos] in ' \t\n\r':
pos += 1
return pos
def _find_matching_brace(self, source: str, pos: int) -> int:
"""Find matching } for { at pos."""
depth = 1
i = pos + 1
in_str = None
while i < len(source):
ch = source[i]
if in_str:
if ch == in_str and source[i-1] != '\\':
in_str = None
elif ch in ('"', "'"):
in_str = ch
elif ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
return i
i += 1
return len(source) - 1
def _find_matching_paren(self, source: str, pos: int) -> int:
"""Find matching ) for ( at pos."""
depth = 1
i = pos + 1
in_str = None
while i < len(source):
ch = source[i]
if in_str:
if ch == in_str and source[i-1] != '\\':
in_str = None
elif ch in ('"', "'"):
in_str = ch
elif ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if depth == 0:
return i
i += 1
return len(source) - 1
def _has_top_level_op(self, expr: str, op: str) -> bool:
"""Check if operator exists at top level (not inside parens/strings)."""
depth = 0
in_str = None
i = 0
while i < len(expr):
ch = expr[i]
if in_str:
if ch == in_str and (i == 0 or expr[i-1] != '\\'):
in_str = None
elif ch in ('"', "'"):
in_str = ch
elif ch == '(':
depth += 1
elif ch == ')':
depth -= 1
elif depth == 0 and expr[i:i+len(op)] == op:
# Don't match multi-char ops that are substrings of longer ones
if len(op) == 1 and op in '+-':
# Skip if part of ++ or --
if i + 1 < len(expr) and expr[i+1] == op:
i += 2
continue
if i > 0 and expr[i-1] == op:
i += 1
continue
return True
i += 1
return False
def _split_top_level(self, expr: str, op: str, max_splits: int = -1) -> list:
"""Split expression by operator at top level only."""
parts = []
depth = 0
in_str = None
current = ""
i = 0
splits = 0
while i < len(expr):
ch = expr[i]
if in_str:
current += ch
if ch == in_str and (i == 0 or expr[i-1] != '\\'):
in_str = None
elif ch in ('"', "'"):
in_str = ch
current += ch
elif ch == '(':
depth += 1
current += ch
elif ch == ')':
depth -= 1
current += ch
elif depth == 0 and expr[i:i+len(op)] == op and (max_splits < 0 or splits < max_splits):
# Don't split on ++ or -- when looking for + or -
if len(op) == 1 and op in '+-':
if i + 1 < len(expr) and expr[i+1] == op:
current += ch
i += 1
current += expr[i]
i += 1
continue
parts.append(current)
current = ""
i += len(op)
splits += 1
continue
else:
current += ch
i += 1
parts.append(current)
return parts
def _contains_string(self, expr: str) -> bool:
"""Check if expression contains a string literal at top level."""
depth = 0
in_str = None
for ch in expr:
if in_str:
if ch == in_str:
return True # Found complete string
elif ch in ('"', "'"):
in_str = ch
elif ch == '(':
depth += 1
elif ch == ')':
depth -= 1
return False