Add configurable mouse movement and improve HID handling

This commit is contained in:
2026-08-26 20:52:20 +02:00
parent 2a0869743f
commit be37343ec1
6 changed files with 48 additions and 29 deletions
+4 -4
View File
@@ -3,11 +3,11 @@
A deliberately limited MicroPython awareness-training firmware for an ESP32 USB stick. On boot, it presents as a USB keyboard/mouse and, at independently random intervals, sends only:
- one `Return` key press and release; and
- a one-pixel relative mouse movement.
- a configurable relative mouse movement, from 1 to 127 pixels per event.
The authenticated web page also provides manual **Volume up** and **Volume down** controls for a clearly observable, non-destructive HID demonstration.
A password-protected local web page changes the enabled event types and their timing. The device connects to one preconfigured Wi-Fi network; it does not create an access point.
A password-protected local web page changes the enabled event types, mouse movement distance, and timing. The device connects to one preconfigured Wi-Fi network; it does not create an access point.
## Hardware and firmware requirement
@@ -24,13 +24,13 @@ If that import fails, use an up-to-date native-USB MicroPython build for the boa
## Install
1. Flash a current ESP32-S3 MicroPython build, then verify that `from machine import USBDevice` succeeds at the REPL. A separate `mdns` module is not required on the standard ESP32 port.
2. Copy `settings.example.json` to the device filesystem as `settings.json` and replace all three credentials. Do this **before first boot**: the firmware cannot join Wi-Fi with the placeholder defaults.
2. Copy `settings.example.json` to the device filesystem as `settings.json` and replace all three credentials. Set `display_enabled` to `false` when validating on a board without the T-Dongle-S3 display; leave it `true` for the T-Dongle-S3. Do this **before first boot**: the firmware cannot join Wi-Fi with the placeholder defaults.
3. Copy `boot.py`, `main.py`, `hid.py`, `settings.py`, `web.py`, and `display.py` to the device root filesystem.
4. Reset the board and plug its native USB connector into a dedicated test host. It should enumerate as a keyboard, mouse, and media-control HID device.
5. Once it joins Wi-Fi, visit `http://polterhid.local/`. If the client/network does not support mDNS, find the device IP address in the training Wi-Fi DHCP leases and visit `http://DEVICE_IP/` instead.
6. Sign in with username `admin` and the `web_password` from `settings.json`.
`boot.py` configures the native USB interface before USB initialisation, while `main.py` runs the application. The device is intentionally **HID-only**: its built-in USB serial/CDC REPL is replaced by the HID device. Use the BOOT button to enter download mode for recovery, and verify the network configuration before deployment. On a LILYGO T-Dongle-S3, its built-in 160×80 ST7735 screen shows the configured Wi-Fi SSID and either `connecting...` or the DHCP-assigned IPv4 address. The firmware sets the ESP32 network hostname to `polterhid` before joining Wi-Fi; standard ESP32 MicroPython builds use their built-in mDNS responder to announce `polterhid.local`. Configuration saved in the web page is retained in `settings.json` and takes effect immediately.
`boot.py` configures the native USB interface before USB initialisation, while `main.py` runs the application when executed by the runtime. Importing `main` from the REPL only loads its functions; call `main.main()` explicitly if needed. The device is intentionally **HID-only**: its built-in USB serial/CDC REPL is replaced by the HID device after `boot.py` runs. Use the BOOT button to enter download mode for recovery, and verify the network configuration before deployment. On a LILYGO T-Dongle-S3, its built-in 160×80 ST7735 screen shows the configured Wi-Fi SSID and either `connecting...` or the DHCP-assigned IPv4 address. The screen driver is skipped when `display_enabled` is `false`, which is useful on a display-less DevKit. The firmware sets the ESP32 network hostname to `polterhid` before joining Wi-Fi; standard ESP32 MicroPython builds use their built-in mDNS responder to announce `polterhid.local`. Configuration saved in the web page is retained in `settings.json` and takes effect immediately.
## Operational safeguards
+15 -14
View File
@@ -77,16 +77,8 @@ class HID:
self._usb.active(True)
def _open_interface(self, interface_descriptor):
# Endpoint descriptors follow the nine-byte interface descriptor.
offset = 9
while offset + 1 < len(interface_descriptor):
length = interface_descriptor[offset]
descriptor_type = interface_descriptor[offset + 1]
if length < 2:
break
if descriptor_type == 0x05:
self._usb.ep_open(interface_descriptor[offset:offset + length])
offset += length
# MicroPython opens the endpoints described by this accepted interface;
# USBDevice 1.29 does not expose an ep_open() method to Python.
self._ready = True
def _reset(self):
@@ -95,9 +87,18 @@ class HID:
self._pending = []
def _control_transfer(self, stage, request):
if (stage == 1 and request[1] == _GET_DESCRIPTOR and
# The report descriptor buffer is supplied at SETUP, but the same
# request must be accepted through its DATA and ACK stages.
if (request[1] == _GET_DESCRIPTOR and
request[3] == _DESCRIPTOR_TYPE_REPORT):
return REPORT_DESCRIPTOR
return REPORT_DESCRIPTOR if stage == 1 else True
# Desktop HID drivers commonly issue these class requests while
# initialising. There is no protocol/idle state to maintain here, but
# they must not be stalled.
if request[1] in (0x0A, 0x0B): # SET_IDLE, SET_PROTOCOL
return True
return False
def _transfer_done(self, endpoint, result, transferred):
@@ -126,8 +127,8 @@ class HID:
self._send(bytes((1, 0, 0, 0, 0, 0, 0, 0, 0)))
def move(self, x, y):
x = max(-1, min(1, int(x)))
y = max(-1, min(1, int(y)))
x = max(-127, min(127, int(x)))
y = max(-127, min(127, int(y)))
self._send(bytes((2, 0, x & 0xFF, y & 0xFF, 0)))
def press_volume_up(self):
+15 -7
View File
@@ -54,6 +54,8 @@ def ip_address(wlan):
def main():
settings = load()
display = None
if settings["display_enabled"]:
display = StatusDisplay()
display.update(settings["wifi_ssid"])
hid = get_hid()
@@ -72,7 +74,9 @@ def main():
next_mouse = choose_due(now, settings["mouse_min_seconds"], settings["mouse_max_seconds"])
release_at = None
consumer_release_at = None
next_wifi_retry = now
# connect_wifi() already started the first attempt; do not call connect()
# again while the ESP32 station is still in STAT_CONNECTING state.
next_wifi_retry = time.ticks_add(now, 10000)
next_display_refresh = now
while True:
@@ -80,11 +84,13 @@ def main():
server.poll()
# Reconnect without blocking injections or the web server indefinitely.
if not wlan.isconnected() and time.ticks_diff(now, next_wifi_retry) >= 0:
if (not wlan.isconnected() and
time.ticks_diff(now, next_wifi_retry) >= 0 and
wlan.status() != network.STAT_CONNECTING):
wlan.connect(settings["wifi_ssid"], settings["wifi_password"])
next_wifi_retry = time.ticks_add(now, 10000)
if time.ticks_diff(now, next_display_refresh) >= 0:
if display is not None and time.ticks_diff(now, next_display_refresh) >= 0:
address = ip_address(wlan) if wlan.isconnected() else None
display.update(settings["wifi_ssid"], address)
next_display_refresh = time.ticks_add(now, 1000)
@@ -115,10 +121,11 @@ def main():
if (settings["enabled"] and settings["mouse_enabled"] and
time.ticks_diff(now, next_mouse) >= 0):
# Never send (0, 0), so each scheduled jitter is observable.
x = randint(-1, 1)
y = randint(-1, 1)
distance = settings["mouse_distance"]
x = randint(-distance, distance)
y = randint(-distance, distance)
if x == 0 and y == 0:
x = 1
x = distance
hid.move(x, y)
next_mouse = choose_due(
now, settings["mouse_min_seconds"], settings["mouse_max_seconds"]
@@ -128,4 +135,5 @@ def main():
time.sleep_ms(25)
main()
if __name__ == "__main__":
main()
+2
View File
@@ -2,9 +2,11 @@
"wifi_ssid": "TrainingWiFi",
"wifi_password": "replace-with-the-network-password",
"web_password": "replace-with-a-long-admin-password",
"display_enabled": true,
"enabled": true,
"return_enabled": true,
"mouse_enabled": true,
"mouse_distance": 1,
"return_min_seconds": 45,
"return_max_seconds": 120,
"mouse_min_seconds": 20,
+8 -1
View File
@@ -14,9 +14,12 @@ DEFAULTS = {
"wifi_ssid": "CHANGE_ME",
"wifi_password": "CHANGE_ME",
"web_password": "change-this-before-deploying",
# Set false when validating on a board without the T-Dongle-S3 display.
"display_enabled": False,
"enabled": True,
"return_enabled": True,
"mouse_enabled": True,
"mouse_distance": 1,
"return_min_seconds": 45,
"return_max_seconds": 120,
"mouse_min_seconds": 20,
@@ -65,7 +68,7 @@ def apply_form(values, form):
for key in (
"return_min_seconds", "return_max_seconds",
"mouse_min_seconds", "mouse_max_seconds",
"mouse_min_seconds", "mouse_max_seconds", "mouse_distance",
):
if key in form:
try:
@@ -81,6 +84,10 @@ def _validate(values):
if not isinstance(values.get(key), str) or not values[key]:
raise ValueError("%s must be a non-empty string" % key)
distance = values["mouse_distance"]
if not isinstance(distance, int) or distance < 1 or distance > 127:
raise ValueError("mouse distance must be between 1 and 127 pixels")
for prefix in ("return", "mouse"):
minimum = values[prefix + "_min_seconds"]
maximum = values[prefix + "_max_seconds"]
+2 -1
View File
@@ -151,10 +151,11 @@ def _page(settings):
<label>Return interval <input name=return_min_seconds type=number min=1 value=%d> to <input name=return_max_seconds type=number min=1 value=%d> seconds</label>
<label><input type=checkbox name=mouse_enabled%s> Inject mouse jitter</label>
<label>Mouse interval <input name=mouse_min_seconds type=number min=1 value=%d> to <input name=mouse_max_seconds type=number min=1 value=%d> seconds</label>
<label>Mouse movement distance <input name=mouse_distance type=number min=1 max=127 value=%d> pixels per event</label>
<button>Save</button> <button name=action value=volume_up>Volume up</button> <button name=action value=volume_down>Volume down</button></form>
<p><small>Changes take effect immediately. This HTTP interface has no TLS; use only a trusted training network.</small></p>""" % (
_checked(settings, "enabled"), _checked(settings, "return_enabled"),
settings["return_min_seconds"], settings["return_max_seconds"],
_checked(settings, "mouse_enabled"), settings["mouse_min_seconds"],
settings["mouse_max_seconds"],
settings["mouse_max_seconds"], settings["mouse_distance"],
)