feat: relative mouse on the HID interface, and the verifier to prove it
The board now enumerates as a boot keyboard plus a relative mouse on the same HID interface. The pointer is captured, never grabbed: Pointer Lock in the page, so Esc (the browser's own, never forwarded) always frees it, and every frame restates the whole button byte so a frame lost in the WiFi cannot leave a button held. Losing the lock, the tab or the link runs the same release funnel as a stuck key, and boot protocol or `mouse off` refuses mouse frames with E11 rather than sending bytes a boot interface would misparse as keys. src/mouse.h owns the report, src/output.h the queues and the funnel; the page, relayctl.py and watch_target.py carry the button bits and the evdev decoding. The verifier's own tables are pinned now — REL_WHEEL is 8, not 2, and the first version guessed from the report's field order, so a working wheel looked dead while the summary said no scroll happened. LLM-Generated: true
This commit is contained in:
@@ -8,6 +8,9 @@ installed to be useful.
|
||||
relayctl.py --host keyrelay.local status
|
||||
relayctl.py --host 192.168.4.1 arm
|
||||
relayctl.py --host 10.2.1.99 key Enter # one key, press and release
|
||||
relayctl.py --host 10.2.1.99 mouse move 120 -40 # relative movement, split to fit
|
||||
relayctl.py --host 10.2.1.99 mouse click right # press and release
|
||||
relayctl.py --host 10.2.1.99 mouse drag 200 0 # press, move, release, in order
|
||||
relayctl.py --host 10.2.1.99 cad # Ctrl+Alt+Del, with --yes
|
||||
relayctl.py --host keyrelay.local listen # print every status heartbeat
|
||||
relayctl.py --host keyrelay.local disarm
|
||||
@@ -24,6 +27,14 @@ a controller, so no separate `arm` command could have done it), the text is neve
|
||||
echoed back, and the run disarms again afterwards. What is still absent is a script or
|
||||
macro facility: the board cannot store a sequence, cannot be told to type later, and
|
||||
nothing here replays one. See README "What this deliberately is not".
|
||||
|
||||
`mouse` is the same argument with a pointer. Movement is relative -- the board reports how
|
||||
far, never where -- so the target's own pointer speed decides where a given `move` lands,
|
||||
and there is no position for this tool to be wrong about. `click` and `drag` press real
|
||||
buttons and need --yes for that reason; a movement does not, because moving a cursor is
|
||||
reversible by definition. There is no gesture recording, no auto-click and no "move to the
|
||||
OK button", because the moment this tool could actuate a target on its own it would be a
|
||||
different and worse device than a keyboard.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -49,6 +60,22 @@ MAX_SESSION_KEYS = 16
|
||||
TEXT_FRAME_MAX = 120 # characters per P= frame
|
||||
MAX_PASTE_CHARS = 2048 # characters one run holds on the board
|
||||
DEFAULT_TEXT_PACE_MS = 12 # what the board uses when asked for 0
|
||||
|
||||
# Mouse movement. Both numbers are the board's -- keep them in step with src/config.h
|
||||
# (kMouseDeltaMax, kMouseButtonMask) or this tool builds frames the board refuses.
|
||||
# One M= frame is one HID report, and a report carries one signed byte per axis, so a
|
||||
# larger movement is split into several frames here rather than guessed at there.
|
||||
MOUSE_DELTA_MAX = 127 # one signed byte, per axis, per frame
|
||||
MOUSE_BUTTON_MASK = 0x1f # five buttons: all the report can carry
|
||||
MOUSE_FRAME_MS = 8 # the pace the controller page sends at, matched here
|
||||
|
||||
# Button name -> the report's bit, in the order the HID report descriptor lays them out.
|
||||
# NOTE the DOM's MouseEvent.button numbers are NOT this order (there 1 is middle and 2 is
|
||||
# right), which is why src/web_page.h carries its own mapping and both are pinned by
|
||||
# tests: get it wrong and a right-click arrives as a middle-click, cleanly parsed.
|
||||
MOUSE_BUTTONS = {
|
||||
"left": 0x01, "right": 0x02, "middle": 0x04, "back": 0x08, "forward": 0x10,
|
||||
}
|
||||
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
OP_TEXT, OP_PING, OP_PONG, OP_CLOSE = 0x1, 0x9, 0xA, 0x8
|
||||
|
||||
@@ -117,6 +144,87 @@ def untypeable(text: str) -> list[str]:
|
||||
if not (ord(ch) == 9 or ord(ch) == 10 or 32 <= ord(ch) <= 126)]
|
||||
|
||||
|
||||
def mouse_frame(buttons: int, dx: int = 0, dy: int = 0, wheel: int = 0,
|
||||
pan: int = 0) -> str:
|
||||
"""Render `M=<btn>:<dx>:<dy>:<wheel>:<pan>` -- one mouse report, five fields.
|
||||
|
||||
Strict in the same direction the firmware parses: the button byte must fit the five
|
||||
bits the report has and every movement must fit one signed byte, or this raises. A
|
||||
frame the board would refuse is not sent, and nothing is clamped to make it fit --
|
||||
clamp a 300 and the cursor quietly travels half as far as was asked.
|
||||
"""
|
||||
if buttons & ~MOUSE_BUTTON_MASK:
|
||||
raise RelayError(f"buttons {buttons:#02x} does not fit a five-button report "
|
||||
f"(have: {', '.join(MOUSE_BUTTONS)})")
|
||||
for name, v in (("dx", dx), ("dy", dy), ("wheel", wheel), ("pan", pan)):
|
||||
if not -MOUSE_DELTA_MAX <= v <= MOUSE_DELTA_MAX:
|
||||
raise RelayError(f"{name}={v} is more than one report's worth "
|
||||
f"(+/-{MOUSE_DELTA_MAX}); split it")
|
||||
return f"M={buttons:02x}:{dx}:{dy}:{wheel}:{pan}"
|
||||
|
||||
|
||||
def split_moves(dx: int, dy: int, wheel: int = 0, pan: int = 0) -> list[tuple]:
|
||||
"""Greedy split of a movement into one-report chunks, same shape as the page's.
|
||||
|
||||
Both axes travel together in each chunk, so a diagonal keeps its shape rather than
|
||||
arriving as an L, and the sums are exact -- 300 px out means 300 px on the target.
|
||||
"""
|
||||
out: list[tuple] = []
|
||||
while dx or dy or wheel or pan:
|
||||
step = [max(-MOUSE_DELTA_MAX, min(MOUSE_DELTA_MAX, v)) for v in (dx, dy, wheel, pan)]
|
||||
out.append(tuple(step))
|
||||
dx -= step[0]
|
||||
dy -= step[1]
|
||||
wheel -= step[2]
|
||||
pan -= step[3]
|
||||
return out or [(0, 0, 0, 0)]
|
||||
|
||||
|
||||
def mouse_frames(action: str, values: list[str], button: str = "left") -> list[tuple]:
|
||||
"""Turn a `mouse` subcommand into the frames to send, as (buttons, dx, dy, wheel, pan).
|
||||
|
||||
A click is two frames -- the button restated down, then restated up -- with the
|
||||
movement in between, because the byte on the wire is the whole button state and the
|
||||
order is the entire difference between a click and a drag.
|
||||
"""
|
||||
if button not in MOUSE_BUTTONS:
|
||||
# Checked for every action, not only the ones that use it: `--button thumb` is a
|
||||
# typo, and a command that quietly ignores a nonsense option is one that will be
|
||||
# typed again.
|
||||
raise RelayError(f"unknown button {button!r} (have: {', '.join(MOUSE_BUTTONS)})")
|
||||
if button != "left" and action not in ("click", "drag"):
|
||||
# `mouse move 100 0 --button right` would otherwise move the cursor and pretend the
|
||||
# button was involved. It is not: only the verbs that press something take a
|
||||
# button, and saying so beats inventing a meaning for it later.
|
||||
raise RelayError(f"`{action}` does not press a button, so --button has no "
|
||||
f"meaning here (click and drag do take one)")
|
||||
|
||||
if action == "release":
|
||||
return [(0, 0, 0, 0, 0)]
|
||||
if action == "move":
|
||||
if len(values) not in (2, 3, 4):
|
||||
raise RelayError("usage: mouse move DX DY [WHEEL] [PAN]")
|
||||
nums = [int(v) for v in values] + [0] * (4 - len(values))
|
||||
return [(0, *rest) for rest in split_moves(*nums)]
|
||||
if action == "wheel":
|
||||
if len(values) != 1:
|
||||
raise RelayError("usage: mouse wheel NOTCHES (negative scrolls up)")
|
||||
# split_moves returns (dx, dy, wheel, pan); the wheel belongs in the third slot.
|
||||
return [(0, 0, 0, part[2], part[3])
|
||||
for part in split_moves(0, 0, int(values[0]))]
|
||||
bit = MOUSE_BUTTONS[button]
|
||||
if action == "click":
|
||||
return [(bit, 0, 0, 0, 0), (0, 0, 0, 0, 0)]
|
||||
if action == "drag":
|
||||
if len(values) not in (2, 3, 4):
|
||||
raise RelayError("usage: mouse drag DX DY [WHEEL] [PAN]")
|
||||
nums = [int(v) for v in values] + [0] * (4 - len(values))
|
||||
return [(bit, 0, 0, 0, 0)] + [(bit, *rest) for rest in split_moves(*nums)] \
|
||||
+ [(0, 0, 0, 0, 0)]
|
||||
raise RelayError(f"unknown mouse action {action!r} "
|
||||
"(try move, click, drag, wheel or release)")
|
||||
|
||||
|
||||
def text_frame(delay_ms: int, chunk: str) -> str:
|
||||
"""One `P=<ms>:<hex>` command, byte-identical to proto.h's formatText()."""
|
||||
return f"P={delay_ms}:{chunk.encode('utf-8').hex()}"
|
||||
@@ -416,6 +524,25 @@ class Relay:
|
||||
body = ",".join(f"{u:02x}" for u in usages)
|
||||
self._send(f"Y={mods:02x}:{body}")
|
||||
|
||||
def mouse(self, frames, gap_ms: float = MOUSE_FRAME_MS) -> int:
|
||||
"""Send mouse reports, paced so they arrive like a mouse and not a flood.
|
||||
|
||||
No waiting for acknowledgement between frames -- a movement is not a state the
|
||||
board has to confirm, and the board's own queue is what absorbs a burst. But the
|
||||
gap is real: sent as fast as a socket will take them, a few hundred reports would
|
||||
pile into the board's queue and trip the overflow fail-safe, which releases
|
||||
everything and disarms. An error frame (E10 no mouse interface, E11 boot
|
||||
protocol) raises out of the next read rather than being swallowed.
|
||||
"""
|
||||
sent = 0
|
||||
for buttons, dx, dy, wheel, pan in frames:
|
||||
self._send(mouse_frame(buttons, dx, dy, wheel, pan))
|
||||
sent += 1
|
||||
if gap_ms:
|
||||
time.sleep(gap_ms / 1000.0)
|
||||
self._drain(0.0) # pick up anything the board said about them
|
||||
return sent
|
||||
|
||||
def listen(self, seconds: float) -> None:
|
||||
"""Print heartbeats. Sends keepalives: a silent client is one the board disarms
|
||||
after kNoClientTimeoutMs, and watching must not change what is watched."""
|
||||
@@ -436,6 +563,8 @@ class Relay:
|
||||
print(f" net={s.get('net'):4s} ip={s.get('ip'):15s} "
|
||||
f"usb={s.get('usb')} armed={s.get('armed')} "
|
||||
f"keys={s.get('keys')} chords={s.get('chords')} "
|
||||
f"mframes={s.get('mframes', 0)} "
|
||||
f"mbtn=0x{s.get('mbtn', 0):02x} "
|
||||
f"drop={s.get('drop')} leds=0x{s.get('leds'):02x}")
|
||||
else:
|
||||
print(f" {text}")
|
||||
@@ -478,6 +607,8 @@ def describe(status: dict) -> str:
|
||||
f" armed {'YES -- keystrokes are being forwarded' if status.get('armed') else 'no'}",
|
||||
f" counters keys={status.get('keys')} chords={status.get('chords')} "
|
||||
f"dropped={status.get('drop')}",
|
||||
f" mouse {'can report' if status.get('mouse') else 'CANNOT REPORT'} "
|
||||
f"buttons=0x{status.get('mbtn', 0):02x} reports={status.get('mframes', 0)}",
|
||||
f" text {status.get('typed', 0)} characters typed"
|
||||
+ (f", {status['typing']} still to go" if status.get('typing') else ""),
|
||||
f" leds caps={bool(status.get('leds', 0) & 2)} "
|
||||
@@ -485,6 +616,10 @@ def describe(status: dict) -> str:
|
||||
]
|
||||
if status.get("dflt"):
|
||||
lines.append(" WARNING the board still uses the default passphrase")
|
||||
if status.get("usb") and not status.get("mouse"):
|
||||
lines.append(" NOTE no mouse reports: `mouse on` over serial and reboot, or"
|
||||
"\n the target chose boot protocol (it would read a mouse"
|
||||
"\n report as keystrokes, so the board refuses them)")
|
||||
if status.get("roll"):
|
||||
lines.append(" NOTE more than six keys are down: ErrorRollOver is out")
|
||||
return "\n".join(lines)
|
||||
@@ -534,6 +669,26 @@ def main() -> int:
|
||||
help="leave the bridge armed afterwards")
|
||||
p_paste.add_argument("--yes", action="store_true",
|
||||
help="acknowledge that this types into the target")
|
||||
p_mouse = sub.add_parser(
|
||||
"mouse", help="move the target's pointer, or press one of its buttons")
|
||||
p_mouse.add_argument("action", choices=["move", "click", "drag", "wheel", "release"],
|
||||
help="move: relative dx dy (and optional wheel, pan); "
|
||||
"click: press and release one button; drag: press, move, "
|
||||
"release; wheel: NOTCHES (negative scrolls up); "
|
||||
"release: every button up")
|
||||
p_mouse.add_argument("values", nargs="*", metavar="N",
|
||||
help="numbers for move / drag (px, px [wheel] [pan]) or "
|
||||
"wheel (notches)")
|
||||
p_mouse.add_argument("--button", default="left",
|
||||
help="left, right, middle, back or forward (default left)")
|
||||
p_mouse.add_argument("--gap", type=int, default=MOUSE_FRAME_MS, metavar="MS",
|
||||
help=f"milliseconds between reports (default {MOUSE_FRAME_MS}, "
|
||||
"the rate the controller page uses)")
|
||||
p_mouse.add_argument("--keep-armed", action="store_true",
|
||||
help="stay armed afterwards")
|
||||
p_mouse.add_argument("--yes", action="store_true",
|
||||
help="required for click and drag, which can actuate something")
|
||||
|
||||
p_keys = sub.add_parser(
|
||||
"keys",
|
||||
help="verification session: arm, press these keys, disarm again")
|
||||
@@ -646,6 +801,40 @@ def main() -> int:
|
||||
return 3
|
||||
print("sending Ctrl+Alt+Del ...")
|
||||
relay.chord(0x05, [0x4C])
|
||||
elif args.cmd == "mouse":
|
||||
# A click can actuate whatever is under the pointer, so it is gated like the
|
||||
# other things that have consequences beyond a character appearing. A
|
||||
# movement is not: it moves a cursor, which is reversible by definition.
|
||||
if args.action in ("click", "drag") and not args.yes:
|
||||
print(f"`{args.action}` presses a real mouse button on the machine the "
|
||||
"board is plugged into, wherever its pointer happens to be.\n"
|
||||
"Re-run with --yes if a human is looking at it.", file=sys.stderr)
|
||||
return 4
|
||||
try:
|
||||
frames = mouse_frames(args.action, args.values, args.button)
|
||||
except (RelayError, ValueError) as exc:
|
||||
print(f"{exc}", file=sys.stderr)
|
||||
return 2
|
||||
# Armed inside this one connection, like `keys` and `paste`: the board
|
||||
# disarms the moment a controller drops, so "arm in one invocation and use it
|
||||
# in the next" is not a thing that can work -- and the LED is red for exactly
|
||||
# as long as this session lasts.
|
||||
status = relay.status()
|
||||
if not status.get("mouse", 1):
|
||||
print("this board cannot report a mouse: `mouse on` over serial and "
|
||||
"reboot, or the target chose boot protocol (it would read a "
|
||||
"mouse report as keystrokes, so the board refuses them). Nothing "
|
||||
"was armed and nothing was sent.", file=sys.stderr)
|
||||
return 3
|
||||
relay.arm()
|
||||
print("armed -- LED should be solid red on the board")
|
||||
n = relay.mouse(frames, args.gap)
|
||||
print(f"{n} mouse report(s) sent ({args.action}"
|
||||
+ (f", {args.button}" if args.button != "left" else "") + ")")
|
||||
if not args.keep_armed:
|
||||
relay.disarm()
|
||||
print("disarmed, every key and button released")
|
||||
print(describe(relay.status()))
|
||||
elif args.cmd == "keys":
|
||||
# One deliberate session in one connection: the board disarms the moment
|
||||
# a controller drops (that is the safety property that makes the rest of
|
||||
|
||||
Reference in New Issue
Block a user