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:
|
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
|
- 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.
|
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
|
## 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
|
## 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.
|
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.
|
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.
|
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.
|
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`.
|
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
|
## Operational safeguards
|
||||||
|
|
||||||
|
|||||||
@@ -77,16 +77,8 @@ class HID:
|
|||||||
self._usb.active(True)
|
self._usb.active(True)
|
||||||
|
|
||||||
def _open_interface(self, interface_descriptor):
|
def _open_interface(self, interface_descriptor):
|
||||||
# Endpoint descriptors follow the nine-byte interface descriptor.
|
# MicroPython opens the endpoints described by this accepted interface;
|
||||||
offset = 9
|
# USBDevice 1.29 does not expose an ep_open() method to Python.
|
||||||
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
|
|
||||||
self._ready = True
|
self._ready = True
|
||||||
|
|
||||||
def _reset(self):
|
def _reset(self):
|
||||||
@@ -95,9 +87,18 @@ class HID:
|
|||||||
self._pending = []
|
self._pending = []
|
||||||
|
|
||||||
def _control_transfer(self, stage, request):
|
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):
|
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
|
return False
|
||||||
|
|
||||||
def _transfer_done(self, endpoint, result, transferred):
|
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)))
|
self._send(bytes((1, 0, 0, 0, 0, 0, 0, 0, 0)))
|
||||||
|
|
||||||
def move(self, x, y):
|
def move(self, x, y):
|
||||||
x = max(-1, min(1, int(x)))
|
x = max(-127, min(127, int(x)))
|
||||||
y = max(-1, min(1, int(y)))
|
y = max(-127, min(127, int(y)))
|
||||||
self._send(bytes((2, 0, x & 0xFF, y & 0xFF, 0)))
|
self._send(bytes((2, 0, x & 0xFF, y & 0xFF, 0)))
|
||||||
|
|
||||||
def press_volume_up(self):
|
def press_volume_up(self):
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ def ip_address(wlan):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
settings = load()
|
settings = load()
|
||||||
|
display = None
|
||||||
|
if settings["display_enabled"]:
|
||||||
display = StatusDisplay()
|
display = StatusDisplay()
|
||||||
display.update(settings["wifi_ssid"])
|
display.update(settings["wifi_ssid"])
|
||||||
hid = get_hid()
|
hid = get_hid()
|
||||||
@@ -72,7 +74,9 @@ def main():
|
|||||||
next_mouse = choose_due(now, settings["mouse_min_seconds"], settings["mouse_max_seconds"])
|
next_mouse = choose_due(now, settings["mouse_min_seconds"], settings["mouse_max_seconds"])
|
||||||
release_at = None
|
release_at = None
|
||||||
consumer_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
|
next_display_refresh = now
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -80,11 +84,13 @@ def main():
|
|||||||
server.poll()
|
server.poll()
|
||||||
|
|
||||||
# Reconnect without blocking injections or the web server indefinitely.
|
# 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"])
|
wlan.connect(settings["wifi_ssid"], settings["wifi_password"])
|
||||||
next_wifi_retry = time.ticks_add(now, 10000)
|
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
|
address = ip_address(wlan) if wlan.isconnected() else None
|
||||||
display.update(settings["wifi_ssid"], address)
|
display.update(settings["wifi_ssid"], address)
|
||||||
next_display_refresh = time.ticks_add(now, 1000)
|
next_display_refresh = time.ticks_add(now, 1000)
|
||||||
@@ -115,10 +121,11 @@ def main():
|
|||||||
if (settings["enabled"] and settings["mouse_enabled"] and
|
if (settings["enabled"] and settings["mouse_enabled"] and
|
||||||
time.ticks_diff(now, next_mouse) >= 0):
|
time.ticks_diff(now, next_mouse) >= 0):
|
||||||
# Never send (0, 0), so each scheduled jitter is observable.
|
# Never send (0, 0), so each scheduled jitter is observable.
|
||||||
x = randint(-1, 1)
|
distance = settings["mouse_distance"]
|
||||||
y = randint(-1, 1)
|
x = randint(-distance, distance)
|
||||||
|
y = randint(-distance, distance)
|
||||||
if x == 0 and y == 0:
|
if x == 0 and y == 0:
|
||||||
x = 1
|
x = distance
|
||||||
hid.move(x, y)
|
hid.move(x, y)
|
||||||
next_mouse = choose_due(
|
next_mouse = choose_due(
|
||||||
now, settings["mouse_min_seconds"], settings["mouse_max_seconds"]
|
now, settings["mouse_min_seconds"], settings["mouse_max_seconds"]
|
||||||
@@ -128,4 +135,5 @@ def main():
|
|||||||
time.sleep_ms(25)
|
time.sleep_ms(25)
|
||||||
|
|
||||||
|
|
||||||
main()
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
"wifi_ssid": "TrainingWiFi",
|
"wifi_ssid": "TrainingWiFi",
|
||||||
"wifi_password": "replace-with-the-network-password",
|
"wifi_password": "replace-with-the-network-password",
|
||||||
"web_password": "replace-with-a-long-admin-password",
|
"web_password": "replace-with-a-long-admin-password",
|
||||||
|
"display_enabled": true,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"return_enabled": true,
|
"return_enabled": true,
|
||||||
"mouse_enabled": true,
|
"mouse_enabled": true,
|
||||||
|
"mouse_distance": 1,
|
||||||
"return_min_seconds": 45,
|
"return_min_seconds": 45,
|
||||||
"return_max_seconds": 120,
|
"return_max_seconds": 120,
|
||||||
"mouse_min_seconds": 20,
|
"mouse_min_seconds": 20,
|
||||||
|
|||||||
+8
-1
@@ -14,9 +14,12 @@ DEFAULTS = {
|
|||||||
"wifi_ssid": "CHANGE_ME",
|
"wifi_ssid": "CHANGE_ME",
|
||||||
"wifi_password": "CHANGE_ME",
|
"wifi_password": "CHANGE_ME",
|
||||||
"web_password": "change-this-before-deploying",
|
"web_password": "change-this-before-deploying",
|
||||||
|
# Set false when validating on a board without the T-Dongle-S3 display.
|
||||||
|
"display_enabled": False,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"return_enabled": True,
|
"return_enabled": True,
|
||||||
"mouse_enabled": True,
|
"mouse_enabled": True,
|
||||||
|
"mouse_distance": 1,
|
||||||
"return_min_seconds": 45,
|
"return_min_seconds": 45,
|
||||||
"return_max_seconds": 120,
|
"return_max_seconds": 120,
|
||||||
"mouse_min_seconds": 20,
|
"mouse_min_seconds": 20,
|
||||||
@@ -65,7 +68,7 @@ def apply_form(values, form):
|
|||||||
|
|
||||||
for key in (
|
for key in (
|
||||||
"return_min_seconds", "return_max_seconds",
|
"return_min_seconds", "return_max_seconds",
|
||||||
"mouse_min_seconds", "mouse_max_seconds",
|
"mouse_min_seconds", "mouse_max_seconds", "mouse_distance",
|
||||||
):
|
):
|
||||||
if key in form:
|
if key in form:
|
||||||
try:
|
try:
|
||||||
@@ -81,6 +84,10 @@ def _validate(values):
|
|||||||
if not isinstance(values.get(key), str) or not values[key]:
|
if not isinstance(values.get(key), str) or not values[key]:
|
||||||
raise ValueError("%s must be a non-empty string" % 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"):
|
for prefix in ("return", "mouse"):
|
||||||
minimum = values[prefix + "_min_seconds"]
|
minimum = values[prefix + "_min_seconds"]
|
||||||
maximum = values[prefix + "_max_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>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><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 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>
|
<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>""" % (
|
<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"),
|
_checked(settings, "enabled"), _checked(settings, "return_enabled"),
|
||||||
settings["return_min_seconds"], settings["return_max_seconds"],
|
settings["return_min_seconds"], settings["return_max_seconds"],
|
||||||
_checked(settings, "mouse_enabled"), settings["mouse_min_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