diff --git a/README.md b/README.md index eab22d3..8bcbe14 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,14 @@ If that import fails, use an up-to-date native-USB MicroPython build for the boa ## 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. -2. Copy `main.py`, `hid.py`, `settings.py`, and `web.py` to the device root filesystem. -3. Reset the board and plug its native USB connector into the demonstration host. -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. -5. Sign in with username `admin` and the `web_password` from `settings.json`. +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 `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. 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`. -`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 @@ -40,4 +41,4 @@ If that import fails, use an up-to-date native-USB MicroPython build for the boa ## 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. diff --git a/boot.py b/boot.py new file mode 100644 index 0000000..a1f569b --- /dev/null +++ b/boot.py @@ -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() diff --git a/display.py b/display.py new file mode 100644 index 0000000..d1e9124 --- /dev/null +++ b/display.py @@ -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] + "..." diff --git a/hid.py b/hid.py index 7d9340b..0dc5b66 100644 --- a/hid.py +++ b/hid.py @@ -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 -support). The report descriptor uses report ID 1 for a boot-style keyboard, -report ID 2 for a relative three-button mouse, and report ID 3 for media keys. +The interface must be initialised by ``boot.py`` so it is configured before the +ESP32-S3 native USB subsystem starts. """ from machine import USBDevice @@ -27,7 +26,16 @@ REPORT_DESCRIPTOR = bytes(( 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(( 0x09, 0x02, 0x22, 0x00, 0x01, 0x01, 0x00, 0x80, 0x32, 0x09, 0x04, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, @@ -36,13 +44,14 @@ CONFIG_DESCRIPTOR = bytes(( 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 _DESCRIPTOR_TYPE_REPORT = 0x22 +_INSTANCE = None 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): self._usb = USBDevice() @@ -52,22 +61,20 @@ class HID: self._configure() def _configure(self): - # USBDevice's custom-device API was introduced with a positional - # BUILTIN_NONE argument. The fallback supports earlier preview builds. - kwargs = { - "strs": [None, "PolterHID", "Awareness trainer", "PHID-001"], - "open_itf_cb": self._open_interface, - "reset_cb": self._reset, - "control_xfer_cb": self._control_transfer, - "xfer_cb": self._transfer_done, - } - builtin_none = getattr(USBDevice, "BUILTIN_NONE", None) - if builtin_none is None: - # Current stable custom-device API. - self._usb.config(CONFIG_DESCRIPTOR, **kwargs) - else: - # API variant that requires an explicit built-in driver selection. - self._usb.config(builtin_none, CONFIG_DESCRIPTOR, **kwargs) + # A custom descriptor replaces the built-in USB CDC configuration. + # This must run from boot.py, before native USB is made visible. + self._usb.active(False) + self._usb.builtin_driver = USBDevice.BUILTIN_NONE + self._usb.config( + DEVICE_DESCRIPTOR, + CONFIG_DESCRIPTOR, + STRINGS, + self._open_interface, + self._reset, + self._control_transfer, + self._transfer_done, + ) + self._usb.active(True) def _open_interface(self, interface_descriptor): # Endpoint descriptors follow the nine-byte interface descriptor. @@ -88,9 +95,8 @@ class HID: self._pending = [] def _control_transfer(self, stage, request): - # Return our report descriptor during the setup/data stage. HID has no - # feature reports or output reports in this intentionally small design. - if stage == 1 and request[1] == _GET_DESCRIPTOR and request[3] == _DESCRIPTOR_TYPE_REPORT: + if (stage == 1 and request[1] == _GET_DESCRIPTOR and + request[3] == _DESCRIPTOR_TYPE_REPORT): return REPORT_DESCRIPTOR return False @@ -104,11 +110,9 @@ class HID: return report = self._pending.pop(0) try: - self._usb.submit_xfer(0x81, report) - self._busy = True + self._busy = self._usb.submit_xfer(0x81, report) except OSError: - # A host may disconnect between scheduling and transfer. Dropping - # an old movement/key event is safer than replaying it on reconnect. + # Drop the event if the host disconnects between scheduling and IO. self._busy = False def _send(self, report): @@ -116,7 +120,6 @@ class HID: self._flush() 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))) def release_keys(self): @@ -135,3 +138,18 @@ class HID: def release_consumer(self): 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 diff --git a/main.py b/main.py index 27a166d..751d2ac 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,8 @@ import network import time import urandom -from hid import HID +from display import StatusDisplay +from hid import get_hid from settings import load, save 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))) +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(): """Advertise the configuration page as http://polterhid.local/.""" import mdns @@ -44,7 +53,9 @@ def start_mdns(): def main(): settings = load() - hid = HID() + display = StatusDisplay() + display.update(settings["wifi_ssid"]) + hid = get_hid() wlan = connect_wifi(settings) volume_requests = [] @@ -63,6 +74,7 @@ def main(): next_wifi_retry = now mdns_server = None next_mdns_retry = now + next_display_refresh = now while True: now = time.ticks_ms() @@ -73,6 +85,11 @@ def main(): 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: + 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 time.ticks_diff(now, next_mdns_retry) >= 0): try: