From be37343ec1f34ddd1c4ede749d1fa81b67604e24 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Wed, 26 Aug 2026 20:52:20 +0200 Subject: [PATCH] Add configurable mouse movement and improve HID handling --- README.md | 8 ++++---- hid.py | 29 +++++++++++++++-------------- main.py | 26 +++++++++++++++++--------- settings.example.json | 2 ++ settings.py | 9 ++++++++- web.py | 3 ++- 6 files changed, 48 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c820e5f..87850a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/hid.py b/hid.py index 0dc5b66..71d2913 100644 --- a/hid.py +++ b/hid.py @@ -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): diff --git a/main.py b/main.py index 30bf4ba..0dce495 100644 --- a/main.py +++ b/main.py @@ -54,8 +54,10 @@ def ip_address(wlan): def main(): settings = load() - display = StatusDisplay() - display.update(settings["wifi_ssid"]) + display = None + if settings["display_enabled"]: + display = StatusDisplay() + display.update(settings["wifi_ssid"]) hid = get_hid() wlan = connect_wifi(settings) volume_requests = [] @@ -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() diff --git a/settings.example.json b/settings.example.json index 4f5b956..804b544 100644 --- a/settings.example.json +++ b/settings.example.json @@ -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, diff --git a/settings.py b/settings.py index 40ae435..00847e4 100644 --- a/settings.py +++ b/settings.py @@ -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"] diff --git a/web.py b/web.py index 667d37c..a27b104 100644 --- a/web.py +++ b/web.py @@ -151,10 +151,11 @@ def _page(settings): +

Changes take effect immediately. This HTTP interface has no TLS; use only a trusted training network.

""" % ( _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"], )