Add native USB HID boot setup and status display
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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] + "..."
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user