Add configurable mouse movement and improve HID handling
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user