Add native USB HID boot setup and status display

This commit is contained in:
2026-08-26 14:10:31 +02:00
parent d0c926abd6
commit 0e9637c474
5 changed files with 218 additions and 40 deletions
+8 -7
View File
@@ -23,13 +23,14 @@ If that import fails, use an up-to-date native-USB MicroPython build for the boa
## Install ## Install
1. 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. 1. Flash a current ESP32-S3 MicroPython build, then verify that `from machine import USBDevice` and `import mdns` both succeed at the REPL.
2. Copy `main.py`, `hid.py`, `settings.py`, and `web.py` to the device root filesystem. 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.
3. Reset the board and plug its native USB connector into the demonstration host. 3. Copy `boot.py`, `main.py`, `hid.py`, `settings.py`, `web.py`, and `display.py` to the device root filesystem.
4. 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. 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. Sign in with username `admin` and the `web_password` from `settings.json`. 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`.
`main.py` runs automatically after boot in MicroPython. It advertises the HTTP page through mDNS as `polterhid.local`; this requires a MicroPython ESP32 build containing the `mdns` module. 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. 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. It advertises the HTTP page through mDNS as `polterhid.local`; this requires a MicroPython ESP32 build containing the `mdns` module. Configuration saved in the web page is retained in `settings.json` and takes effect immediately.
## Operational safeguards ## Operational safeguards
@@ -40,4 +41,4 @@ If that import fails, use an up-to-date native-USB MicroPython build for the boa
## USB implementation note ## USB implementation note
`hid.py` exposes one composite HID interface with keyboard report ID `1`, mouse report ID `2`, and Consumer Control/media report ID `3` (Volume up/down). It queues reports while the endpoint is busy and discards a transfer if the host disconnects, avoiding stale events being replayed after a reconnect. `hid.py` exposes one composite HID interface with keyboard report ID `1`, mouse report ID `2`, and Consumer Control/media report ID `3` (Volume up/down). `boot.py` selects `USBDevice.BUILTIN_NONE`, supplies explicit USB device/configuration descriptors, and activates the interface. It queues reports while the endpoint is busy and discards a transfer if the host disconnects, avoiding stale events being replayed after a reconnect.
+6
View File
@@ -0,0 +1,6 @@
"""Configure native USB HID before MicroPython starts main.py."""
from hid import initialise
# Keep the configured USB device and its callbacks alive for the whole session.
hid = initialise()
+136
View File
@@ -0,0 +1,136 @@
"""Status display support for the LILYGO T-Dongle-S3.
The board's 160x80 ST7735 panel is wired to SPI2. This driver deliberately uses
only MicroPython's built-in ``machine`` and ``framebuf`` modules.
"""
import framebuf
import time
from machine import Pin, SPI
WIDTH = 160
HEIGHT = 80
# Verified against LILYGO's T-Dongle-S3 factory-screen example.
_PIN_MOSI = 3
_PIN_SCK = 5
_PIN_CS = 4
_PIN_DC = 2
_PIN_RST = 1
_PIN_BACKLIGHT = 38
_X_OFFSET = 1
_Y_OFFSET = 26
_SWRESET = 0x01
_SLPOUT = 0x11
_DISPON = 0x29
_CASET = 0x2A
_RASET = 0x2B
_RAMWR = 0x2C
_MADCTL = 0x36
_COLMOD = 0x3A
_INVON = 0x21
_BLACK = 0x0000
_WHITE = 0xFFFF
_CYAN = 0x07FF
_GREEN = 0x07E0
_YELLOW = 0xFFE0
_GRAY = 0x8410
class StatusDisplay:
"""Render the configured SSID and current DHCP address on the LCD."""
def __init__(self):
self._cs = Pin(_PIN_CS, Pin.OUT, value=1)
self._dc = Pin(_PIN_DC, Pin.OUT, value=0)
self._reset = Pin(_PIN_RST, Pin.OUT, value=1)
# The T-Dongle-S3 backlight enable is active-low.
self._backlight = Pin(_PIN_BACKLIGHT, Pin.OUT, value=1)
self._spi = SPI(2, baudrate=20_000_000, polarity=0, phase=0,
sck=Pin(_PIN_SCK), mosi=Pin(_PIN_MOSI))
self._buffer = bytearray(WIDTH * HEIGHT * 2)
self._framebuffer = framebuf.FrameBuffer(
self._buffer, WIDTH, HEIGHT, framebuf.RGB565
)
self._shown = None
self._initialise()
self._backlight.off()
def _command(self, command, data=None):
self._cs.off()
self._dc.off()
self._spi.write(bytes((command,)))
if data is not None:
self._dc.on()
self._spi.write(data)
self._cs.on()
def _initialise(self):
self._reset.off()
time.sleep_ms(20)
self._reset.on()
time.sleep_ms(120)
self._command(_SWRESET)
time.sleep_ms(150)
self._command(_SLPOUT)
time.sleep_ms(120)
self._command(_COLMOD, b"\x05") # 16-bit RGB565.
# Landscape, BGR colour order, matching the vendor panel setup.
self._command(_MADCTL, b"\xA8")
self._command(_INVON)
self._command(_DISPON)
time.sleep_ms(20)
def update(self, ssid, address=None):
"""Redraw only when the displayed network state changed."""
state = (ssid, address)
if state == self._shown:
return
self._shown = state
framebuffer = self._framebuffer
framebuffer.fill(_BLACK)
framebuffer.text("PolterHID", 3, 3, _CYAN)
framebuffer.text("WiFi", 3, 23, _GRAY)
framebuffer.text(_fit(ssid, 19), 42, 23, _WHITE)
framebuffer.text("IP", 3, 47, _GRAY)
if address:
framebuffer.text(_fit(address, 19), 42, 47, _GREEN)
else:
framebuffer.text("connecting...", 42, 47, _YELLOW)
self.show()
def show(self):
"""Transfer the RGB565 framebuffer, converting its byte order for SPI."""
# framebuf stores RGB565 words in native little-endian order, while the
# ST7735 expects the most-significant byte first on the SPI bus.
for index in range(0, len(self._buffer), 2):
low = self._buffer[index]
self._buffer[index] = self._buffer[index + 1]
self._buffer[index + 1] = low
self._command(_CASET, _coordinates(_X_OFFSET, _X_OFFSET + WIDTH - 1))
self._command(_RASET, _coordinates(_Y_OFFSET, _Y_OFFSET + HEIGHT - 1))
self._cs.off()
self._dc.off()
self._spi.write(bytes((_RAMWR,)))
self._dc.on()
self._spi.write(self._buffer)
self._cs.on()
# Restore native byte order before subsequent framebuf drawing.
for index in range(0, len(self._buffer), 2):
low = self._buffer[index]
self._buffer[index] = self._buffer[index + 1]
self._buffer[index + 1] = low
def _coordinates(start, end):
return bytes((start >> 8, start & 0xFF, end >> 8, end & 0xFF))
def _fit(text, maximum):
if len(text) <= maximum:
return text
return text[:maximum - 3] + "..."
+49 -31
View File
@@ -1,8 +1,7 @@
"""Minimal composite keyboard/mouse USB HID device for MicroPython ESP32-S2/S3. """Composite keyboard, mouse, and media-key USB HID device for MicroPython.
Requires a MicroPython build exposing `machine.USBDevice` (native USB device The interface must be initialised by ``boot.py`` so it is configured before the
support). The report descriptor uses report ID 1 for a boot-style keyboard, ESP32-S3 native USB subsystem starts.
report ID 2 for a relative three-button mouse, and report ID 3 for media keys.
""" """
from machine import USBDevice from machine import USBDevice
@@ -27,7 +26,16 @@ REPORT_DESCRIPTOR = bytes((
0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0xC0, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0xC0,
)) ))
# Configuration descriptor: one HID interface, one interrupt-IN endpoint. # This is a HID-only USB device. 0xCAFE/0x4001 are development identifiers,
# not allocated USB identifiers; replace them with assigned values for any
# product distribution.
DEVICE_DESCRIPTOR = bytes((
0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40,
0xFE, 0xCA, 0x01, 0x40, 0x00, 0x01, 0x01, 0x02,
0x03, 0x01,
))
# One HID interface and one interrupt-IN endpoint.
CONFIG_DESCRIPTOR = bytes(( CONFIG_DESCRIPTOR = bytes((
0x09, 0x02, 0x22, 0x00, 0x01, 0x01, 0x00, 0x80, 0x32, 0x09, 0x02, 0x22, 0x00, 0x01, 0x01, 0x00, 0x80, 0x32,
0x09, 0x04, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x09, 0x04, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00,
@@ -36,13 +44,14 @@ CONFIG_DESCRIPTOR = bytes((
0x07, 0x05, 0x81, 0x03, 0x10, 0x00, 0x0A, 0x07, 0x05, 0x81, 0x03, 0x10, 0x00, 0x0A,
)) ))
# HID class requests used by common desktop hosts. STRINGS = [None, "PolterHID", "Awareness trainer", "PHID-001"]
_GET_DESCRIPTOR = 0x06 _GET_DESCRIPTOR = 0x06
_DESCRIPTOR_TYPE_REPORT = 0x22 _DESCRIPTOR_TYPE_REPORT = 0x22
_INSTANCE = None
class HID: class HID:
"""Queues one USB report at a time, avoiding writes to a busy endpoint.""" """Queue reports while the USB interrupt endpoint is busy."""
def __init__(self): def __init__(self):
self._usb = USBDevice() self._usb = USBDevice()
@@ -52,22 +61,20 @@ class HID:
self._configure() self._configure()
def _configure(self): def _configure(self):
# USBDevice's custom-device API was introduced with a positional # A custom descriptor replaces the built-in USB CDC configuration.
# BUILTIN_NONE argument. The fallback supports earlier preview builds. # This must run from boot.py, before native USB is made visible.
kwargs = { self._usb.active(False)
"strs": [None, "PolterHID", "Awareness trainer", "PHID-001"], self._usb.builtin_driver = USBDevice.BUILTIN_NONE
"open_itf_cb": self._open_interface, self._usb.config(
"reset_cb": self._reset, DEVICE_DESCRIPTOR,
"control_xfer_cb": self._control_transfer, CONFIG_DESCRIPTOR,
"xfer_cb": self._transfer_done, STRINGS,
} self._open_interface,
builtin_none = getattr(USBDevice, "BUILTIN_NONE", None) self._reset,
if builtin_none is None: self._control_transfer,
# Current stable custom-device API. self._transfer_done,
self._usb.config(CONFIG_DESCRIPTOR, **kwargs) )
else: self._usb.active(True)
# API variant that requires an explicit built-in driver selection.
self._usb.config(builtin_none, CONFIG_DESCRIPTOR, **kwargs)
def _open_interface(self, interface_descriptor): def _open_interface(self, interface_descriptor):
# Endpoint descriptors follow the nine-byte interface descriptor. # Endpoint descriptors follow the nine-byte interface descriptor.
@@ -88,9 +95,8 @@ class HID:
self._pending = [] self._pending = []
def _control_transfer(self, stage, request): def _control_transfer(self, stage, request):
# Return our report descriptor during the setup/data stage. HID has no if (stage == 1 and request[1] == _GET_DESCRIPTOR and
# feature reports or output reports in this intentionally small design. request[3] == _DESCRIPTOR_TYPE_REPORT):
if stage == 1 and request[1] == _GET_DESCRIPTOR and request[3] == _DESCRIPTOR_TYPE_REPORT:
return REPORT_DESCRIPTOR return REPORT_DESCRIPTOR
return False return False
@@ -104,11 +110,9 @@ class HID:
return return
report = self._pending.pop(0) report = self._pending.pop(0)
try: try:
self._usb.submit_xfer(0x81, report) self._busy = self._usb.submit_xfer(0x81, report)
self._busy = True
except OSError: except OSError:
# A host may disconnect between scheduling and transfer. Dropping # Drop the event if the host disconnects between scheduling and IO.
# an old movement/key event is safer than replaying it on reconnect.
self._busy = False self._busy = False
def _send(self, report): def _send(self, report):
@@ -116,7 +120,6 @@ class HID:
self._flush() self._flush()
def press_return(self): def press_return(self):
# Report ID, modifiers, reserved, six key slots. 0x28 is Enter/Return.
self._send(bytes((1, 0, 0, 0x28, 0, 0, 0, 0, 0))) self._send(bytes((1, 0, 0, 0x28, 0, 0, 0, 0, 0)))
def release_keys(self): def release_keys(self):
@@ -135,3 +138,18 @@ class HID:
def release_consumer(self): def release_consumer(self):
self._send(bytes((3, 0))) self._send(bytes((3, 0)))
def initialise():
"""Configure and retain the singleton HID device from boot.py."""
global _INSTANCE
if _INSTANCE is None:
_INSTANCE = HID()
return _INSTANCE
def get_hid():
"""Return the HID interface configured by boot.py."""
if _INSTANCE is None:
raise RuntimeError("HID is not initialised; install boot.py before main.py")
return _INSTANCE
+19 -2
View File
@@ -4,7 +4,8 @@ import network
import time import time
import urandom import urandom
from hid import HID from display import StatusDisplay
from hid import get_hid
from settings import load, save from settings import load, save
from web import WebServer from web import WebServer
@@ -32,6 +33,14 @@ def choose_due(now, minimum, maximum):
return time.ticks_add(now, seconds_to_ms(randint(minimum, maximum))) return time.ticks_add(now, seconds_to_ms(randint(minimum, maximum)))
def ip_address(wlan):
"""Return the station IPv4 address across supported MicroPython versions."""
try:
return wlan.ipconfig("addr4")[0]
except AttributeError:
return wlan.ifconfig()[0]
def start_mdns(): def start_mdns():
"""Advertise the configuration page as http://polterhid.local/.""" """Advertise the configuration page as http://polterhid.local/."""
import mdns import mdns
@@ -44,7 +53,9 @@ def start_mdns():
def main(): def main():
settings = load() settings = load()
hid = HID() display = StatusDisplay()
display.update(settings["wifi_ssid"])
hid = get_hid()
wlan = connect_wifi(settings) wlan = connect_wifi(settings)
volume_requests = [] volume_requests = []
@@ -63,6 +74,7 @@ def main():
next_wifi_retry = now next_wifi_retry = now
mdns_server = None mdns_server = None
next_mdns_retry = now next_mdns_retry = now
next_display_refresh = now
while True: while True:
now = time.ticks_ms() now = time.ticks_ms()
@@ -73,6 +85,11 @@ def main():
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:
address = ip_address(wlan) if wlan.isconnected() else None
display.update(settings["wifi_ssid"], address)
next_display_refresh = time.ticks_add(now, 1000)
if (wlan.isconnected() and mdns_server is None and if (wlan.isconnected() and mdns_server is None and
time.ticks_diff(now, next_mdns_retry) >= 0): time.ticks_diff(now, next_mdns_retry) >= 0):
try: try: