diff --git a/README.md b/README.md index 63554d7..d2ddcfd 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A deliberately limited MicroPython awareness-training firmware for an ESP32 USB 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, mouse movement distance, and 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. On the T-Dongle-S3, its onboard RGB LED briefly flashes red for a Return press, green for mouse jitter, and blue for a volume action. ## Hardware and firmware requirement @@ -27,12 +27,32 @@ If that import fails, use an up-to-date native-USB MicroPython build for the boa 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. 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 `main.py`, `hid.py`, `led.py`, `maintenance.py`, `settings.py`, `web.py`, and `display.py` to the device root filesystem, then copy `boot.py` last. This ordering ensures the module imported by `boot.py` is already present. 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 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 plays a boot animation, then shows the configured Wi-Fi SSID and either `connecting...` or the DHCP-assigned IPv4 address for 15 seconds. It subsequently enters an idle mode with occasional short PolterHID animations. 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. +`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. On a LILYGO T-Dongle-S3, its built-in 160×80 ST7735 screen shows a short sparkling starfield, a side-profile ghost crossing the screen, and the `POLTERHID` title before showing the configured Wi-Fi SSID, IP address, station MAC address, and a top-right Wi-Fi signal indicator for 15 seconds. It then clears the panel and turns its backlight off; press **BOOT** once to show the information again for 10 seconds. 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. + +## Maintenance mode and file uploads + +Use maintenance mode to regain the normal MicroPython USB serial/CDC REPL and update files without reflashing the board. + +1. Disconnect power or press reset **without** holding the T-Dongle-S3 **BOOT** button. +2. During the first **1.5 seconds** after reset, press **BOOT** once. +3. The firmware leaves USB CDC enabled and exits before starting HID injection, Wi-Fi, the web server, or display animations. +4. Wait for the host to enumerate the device as a MicroPython serial port, then connect with `mpremote`, Thonny, or another serial REPL tool. +5. Upload the changed files and reset normally without pressing BOOT to return to HID mode. + +For example, with `mpremote`: + +```sh +mpremote connect auto fs cp main.py : +mpremote connect auto fs cp display.py : +mpremote connect auto reset +``` + +> **Important:** Do not hold BOOT while resetting or powering on. BOOT is connected to GPIO0, an ESP ROM boot strap; holding it at reset enters the ESP download bootloader rather than PolterHID maintenance mode. If updating `boot.py`, upload `maintenance.py` first because `boot.py` imports it. ## Operational safeguards diff --git a/boot.py b/boot.py index a1f569b..cf3083b 100644 --- a/boot.py +++ b/boot.py @@ -1,6 +1,13 @@ -"""Configure native USB HID before MicroPython starts main.py.""" +"""Select USB HID or local USB-REPL maintenance mode before main.py.""" -from hid import initialise +import maintenance -# Keep the configured USB device and its callbacks alive for the whole session. -hid = initialise() +# Press BOOT during this short window after a normal reset to retain the stock +# USB CDC REPL. Holding BOOT while resetting still enters ESP download mode. +maintenance.check_button() + +if not maintenance.active: + from hid import initialise + + # Keep the configured USB device and its callbacks alive for the session. + hid = initialise() diff --git a/display.py b/display.py index af838bb..f704c0c 100644 --- a/display.py +++ b/display.py @@ -6,7 +6,6 @@ only MicroPython's built-in ``machine`` and ``framebuf`` modules. import framebuf import time -import urandom from machine import Pin, SPI WIDTH = 160 @@ -42,14 +41,45 @@ _DARK_BLUE = 0x1085 _GHOST = 0xD7FF _GHOST_SHADE = 0x9E7F _PURPLE = 0x8A3F -_ORANGE = 0xFD20 _FRAME_MS = 90 -_INTRO_FLY_MS = 950 +_INTRO_STARS_MS = 600 +_INTRO_GHOST_MS = 1100 _INTRO_TITLE_MS = 1350 _STATUS_MS = 15000 +_WAKE_STATUS_MS = 10000 -# A compact 5x7 font, stretched for the boot title. +# Fixed coordinates and phases make a tiny, inexpensive twinkling starfield. +_STARS = ( + (12, 12, 0), (31, 49, 1), (48, 20, 2), (67, 64, 0), + (92, 10, 1), (113, 51, 2), (142, 18, 0), (151, 48, 1), +) + +# A compact side-profile sprite inspired by the repository mascot: a rounded +# head, one visible eye, forward face, and a three-wave ghost tail. +_GHOST_SPRITE = ( + "0000011110000000", + "0001111111100000", + "0011111111110000", + "0111111111111000", + "0111111111111100", + "1111111111111100", + "1111111111111000", + "1111111111110000", + "1111111111100000", + "1111111111110000", + "1111111111111000", + "1111111111111100", + "1111111111111100", + "1111111111111100", + "1111111111111100", + "1111110111011000", + "1111101110111000", + "1111001100110000", + "0110000000000000", +) + +# A compact 5x7 font, stretched for the existing boot title. _TITLE_FONT = { "P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"), "O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"), @@ -64,7 +94,7 @@ _TITLE_FONT = { class StatusDisplay: - """Render network status plus small, non-blocking PolterHID animations.""" + """Render network status and the concise PolterHID boot sequence.""" def __init__(self): self._cs = Pin(_PIN_CS, Pin.OUT, value=1) @@ -79,15 +109,13 @@ class StatusDisplay: self._buffer, WIDTH, HEIGHT, framebuf.RGB565 ) self._shown = None - self._mode = "intro_fly" + self._mode = "intro_stars" self._mode_started = None self._next_frame = None - self._next_idle = None - self._idle_animation = None - self._peek_from_left = True self._status_until = None + self._button_was_pressed = False self._initialise() - self._backlight.off() + self._set_backlight(True) def _command(self, command, data=None): self._cs.off() @@ -114,27 +142,45 @@ class StatusDisplay: self._command(_DISPON) time.sleep_ms(20) - def tick(self, now, ssid, address=None): - """Advance the display without delaying the application's main loop.""" + def tick(self, now, ssid, address=None, mac=None, rssi=None, + button_pressed=False): + """Advance display state without delaying the application's main loop.""" if self._mode_started is None: self._mode_started = now self._next_frame = now + # A fresh BOOT press restores information only from the dark idle state. + if (button_pressed and not self._button_was_pressed and + self._mode == "idle"): + self._set_backlight(True) + self._shown = None + self._status_until = time.ticks_add(now, _WAKE_STATUS_MS) + self._enter_mode("status", now) + self._button_was_pressed = button_pressed + if time.ticks_diff(now, self._next_frame) < 0: return self._next_frame = time.ticks_add(now, _FRAME_MS) elapsed = time.ticks_diff(now, self._mode_started) - if self._mode == "intro_fly": - self._draw_background() - self._ghost(-14 + (elapsed * 188 // _INTRO_FLY_MS), 37) + if self._mode == "intro_stars": + self._draw_starfield(elapsed) self.show() - if elapsed >= _INTRO_FLY_MS: + if elapsed >= _INTRO_STARS_MS: + self._enter_mode("intro_ghost", now) + return + + if self._mode == "intro_ghost": + self._draw_starfield(elapsed) + x = -32 + elapsed * 192 // _INTRO_GHOST_MS + self._draw_ghost_sprite(x, 21) + self.show() + if elapsed >= _INTRO_GHOST_MS: self._enter_mode("intro_title", now) return if self._mode == "intro_title": - self._draw_title() + self._draw_title(elapsed) self.show() if elapsed >= _INTRO_TITLE_MS: self._status_until = time.ticks_add(now, _STATUS_MS) @@ -143,183 +189,99 @@ class StatusDisplay: return if self._mode == "status": - self.update(ssid, address) + self.update(ssid, address, mac, rssi) if time.ticks_diff(now, self._status_until) >= 0: - self._draw_background() + self._framebuffer.fill(_BLACK) self.show() - self._next_idle = time.ticks_add(now, 800) + self._set_backlight(False) self._enter_mode("idle", now) return - if self._mode == "idle": - if time.ticks_diff(now, self._next_idle) >= 0: - scenes = ("peek", "look", "dissolve", "bonk", "poke") - self._idle_animation = scenes[_random_below(len(scenes))] - self._peek_from_left = bool(_random_below(2)) - self._enter_mode("animate", now) - return + # Idle is intentionally a blank, unlit screen. It is still polled so a + # BOOT press can restore the status screen without a reset. - self._draw_idle_animation(elapsed) - self.show() - if elapsed >= _animation_duration(self._idle_animation): - self._draw_background() - self.show() - self._next_idle = time.ticks_add(now, 1400 + _random_below(3000)) - self._enter_mode("idle", now) - - def update(self, ssid, address=None): + def update(self, ssid, address=None, mac=None, rssi=None): """Redraw the normal network-status screen when its state changes.""" - state = (ssid, address) + signal_level = _wifi_signal_level(rssi) + state = (ssid, address, mac, signal_level) 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) + self._draw_wifi_icon(137, 2, signal_level) + framebuffer.text("WiFi: " + _fit(ssid, 13), 3, 21, _WHITE) if address: - framebuffer.text(_fit(address, 19), 42, 47, _GREEN) + framebuffer.text("IP: " + _fit(address, 16), 3, 39, _GREEN) else: - framebuffer.text("connecting...", 42, 47, _YELLOW) + framebuffer.text("IP: connecting...", 3, 39, _YELLOW) + framebuffer.text("MAC", 3, 55, _GRAY) + framebuffer.text(mac if mac else "unavailable", 12, 67, _WHITE) self.show() def _enter_mode(self, mode, now): self._mode = mode self._mode_started = now - def _draw_background(self): + def _draw_wifi_icon(self, x, y, level): + """Draw the familiar Material Design Wi-Fi fan in the top-right corner.""" + framebuffer = self._framebuffer + colour = _GREEN if level else _GRAY + if level >= 4: + for offset_x, offset_y, width in ( + (7, 0, 6), (4, 1, 12), (2, 2, 16), (0, 3, 20), + (1, 4, 3), (16, 4, 3), (2, 5, 3), (15, 5, 3)): + framebuffer.hline(x + offset_x, y + offset_y, width, colour) + if level >= 3: + for offset_x, offset_y, width in ( + (7, 7, 6), (5, 8, 10), (4, 9, 3), (13, 9, 3)): + framebuffer.hline(x + offset_x, y + offset_y, width, colour) + if level >= 2: + framebuffer.hline(x + 8, y + 11, 4, colour) + framebuffer.hline(x + 7, y + 12, 6, colour) + if level >= 1: + framebuffer.rect(x + 8, y + 15, 4, 3, colour, True) + + def _set_backlight(self, enabled): + if enabled: + self._backlight.off() + else: + self._backlight.on() + + def _draw_starfield(self, elapsed): framebuffer = self._framebuffer framebuffer.fill(_DARK_BLUE) - for x, y in ((12, 12), (48, 20), (92, 10), (142, 18), (25, 66), - (73, 58), (126, 68), (151, 48)): - framebuffer.pixel(x, y, _CYAN) + phase = elapsed // 180 + for x, y, offset in _STARS: + brightness = (phase + offset) % 3 + colour = _WHITE if brightness == 0 else _CYAN + framebuffer.pixel(x, y, colour) + if brightness == 0: + framebuffer.hline(x - 1, y, 3, colour) + framebuffer.vline(x, y - 1, 3, colour) - def _ghost(self, x, y, direction=1, pout=False, fading=0): - """Draw a tiny comic ghost centred at x/y; fading omits body stripes.""" + def _draw_ghost_sprite(self, x, y): + """Draw the small right-facing bitmap ghost at a 2x pixel scale.""" framebuffer = self._framebuffer - body = _GHOST if fading < 2 else _GHOST_SHADE - framebuffer.ellipse(x - 11, y - 15, x + 11, y + 8, body, True) - framebuffer.rect(x - 11, y - 3, 23, 14, body, True) - framebuffer.ellipse(x - 11, y + 3, x - 3, y + 14, body, True) - framebuffer.ellipse(x - 4, y + 4, x + 4, y + 14, body, True) - framebuffer.ellipse(x + 3, y + 3, x + 11, y + 14, body, True) - if fading: - for stripe in range(fading): - framebuffer.hline(x - 9 + stripe * 4, y - 8 + stripe * 5, 3, _DARK_BLUE) - eye_x = x + direction * 4 - framebuffer.ellipse(eye_x - 5, y - 3, eye_x - 2, y + 2, _DARK_BLUE, True) - framebuffer.ellipse(eye_x + 3, y - 3, eye_x + 6, y + 2, _DARK_BLUE, True) - if pout: - framebuffer.line(x - 5, y + 7, x, y + 5, _DARK_BLUE) - framebuffer.line(x, y + 5, x + 5, y + 7, _DARK_BLUE) - framebuffer.line(x - 7, y - 8, x - 2, y - 10, _DARK_BLUE) - framebuffer.line(x + 2, y - 10, x + 7, y - 8, _DARK_BLUE) - else: - framebuffer.line(x - 4, y + 6, x, y + 8, _DARK_BLUE) - framebuffer.line(x, y + 8, x + 4, y + 6, _DARK_BLUE) + for row, pixels in enumerate(_GHOST_SPRITE): + for column, pixel in enumerate(pixels): + if pixel == "1": + framebuffer.rect(x + column * 2, y + row * 2, 2, 2, _GHOST, True) - def _draw_title(self): - self._draw_background() + # One eye, raised brow, and a tiny smile establish the side profile. + framebuffer.rect(x + 22, y + 12, 4, 4, _DARK_BLUE, True) + framebuffer.hline(x + 20, y + 9, 7, _GHOST_SHADE) + framebuffer.pixel(x + 27, y + 24, _DARK_BLUE) + framebuffer.pixel(x + 28, y + 25, _DARK_BLUE) + + def _draw_title(self, elapsed): + self._draw_starfield(elapsed) framebuffer = self._framebuffer framebuffer.rect(5, 16, 150, 48, _PURPLE, True) framebuffer.rect(7, 18, 146, 44, _DARK_BLUE, True) _big_text(framebuffer, "POLTERHID", 8, 23, _CYAN, 3, 5) - def _draw_idle_animation(self, elapsed): - scene = self._idle_animation - self._draw_background() - if scene == "peek": - self._draw_peek(elapsed) - elif scene == "look": - self._draw_look(elapsed) - elif scene == "dissolve": - self._draw_dissolve(elapsed) - elif scene == "bonk": - self._draw_bonk(elapsed) - else: - self._draw_poke(elapsed) - - def _draw_peek(self, elapsed): - from_left = self._peek_from_left - progress = elapsed if elapsed < 500 else 1000 - elapsed - offset = progress * 24 // 500 - x = -10 + offset if from_left else 170 - offset - self._ghost(x, 40, 1 if from_left else -1, True) - - def _draw_look(self, elapsed): - if elapsed < 500: - x = -12 + elapsed * 92 // 500 - elif elapsed < 1050: - x = 80 - else: - x = 80 + (elapsed - 1050) * 94 // 550 - direction = -1 if 650 < elapsed < 850 else 1 - self._ghost(x, 39, direction, elapsed >= 500 and elapsed < 1050) - - def _draw_dissolve(self, elapsed): - if elapsed < 480: - self._ghost(-12 + elapsed * 92 // 480, 39) - return - if elapsed < 800: - self._ghost(80, 39, 1, True) - return - amount = (elapsed - 800) // 150 + 1 - if amount < 5: - self._ghost(80, 39, 1, True, amount) - for x, y in ((63, 26), (93, 23), (72, 48), (88, 55), (102, 38), (56, 42)): - if (x + y + amount) % 3: - framebuffer = self._framebuffer - framebuffer.pixel(x + amount * 3, y - amount * 2, _GHOST_SHADE) - - def _mouse(self, x, y, bonked=False): - framebuffer = self._framebuffer - framebuffer.ellipse(x - 10, y - 8, x + 10, y + 9, _GRAY, True) - framebuffer.line(x, y - 7, x, y + 2, _DARK_BLUE) - framebuffer.line(x - 10, y + 1, x + 10, y + 1, _DARK_BLUE) - if bonked: - for dx, dy in ((-13, -10), (13, -10), (0, -16)): - framebuffer.line(x + dx - 2, y + dy, x + dx + 2, y + dy, _YELLOW) - framebuffer.line(x + dx, y + dy - 2, x + dx, y + dy + 2, _YELLOW) - - def _keyboard(self, x, y, pressed=False): - framebuffer = self._framebuffer - framebuffer.rect(x, y, 55, 22, _PURPLE, True) - framebuffer.rect(x + 2, y + 2, 51, 18, _DARK_BLUE, True) - for row in range(2): - for column in range(6): - framebuffer.rect(x + 5 + column * 8, y + 5 + row * 6, 5, 3, _WHITE, True) - framebuffer.rect(x + 29, y + 17, 20, 2, _YELLOW if pressed else _WHITE, True) - - def _draw_bonk(self, elapsed): - mouse_x = 119 - if elapsed < 520: - ghost_x = -12 + elapsed * 86 // 520 - elif elapsed < 900: - ghost_x = 74 - else: - ghost_x = 74 + (elapsed - 900) * 95 // 500 - hit = 510 <= elapsed <= 720 - self._mouse(mouse_x, 55, hit) - self._ghost(ghost_x, 39, 1, hit) - if hit: - self._framebuffer.line(88, 43, 106, 50, _GHOST) - - def _draw_poke(self, elapsed): - keyboard_x = 94 - if elapsed < 480: - ghost_x = -12 + elapsed * 82 // 480 - elif elapsed < 900: - ghost_x = 70 - else: - ghost_x = 70 + (elapsed - 900) * 95 // 500 - pressed = 520 <= elapsed <= 740 - self._keyboard(keyboard_x, 52, pressed) - self._ghost(ghost_x, 38, 1, pressed) - if pressed: - self._framebuffer.line(82, 44, 111, 65, _YELLOW) - def show(self): """Transfer the RGB565 framebuffer, converting its byte order for SPI.""" # framebuf stores RGB565 words in native little-endian order, while the @@ -355,18 +317,19 @@ def _fit(text, maximum): return text[:maximum - 3] + "..." -def _random_below(limit): - return urandom.getrandbits(30) % limit - - -def _animation_duration(scene): - return { - "peek": 1000, - "look": 1600, - "dissolve": 1550, - "bonk": 1400, - "poke": 1400, - }[scene] +def _wifi_signal_level(rssi): + """Map RSSI dBm to the four bands in the Wi-Fi indicator.""" + if not isinstance(rssi, int): + return 0 + if rssi >= -55: + return 4 + if rssi >= -65: + return 3 + if rssi >= -75: + return 2 + if rssi >= -85: + return 1 + return 0 def _big_text(framebuffer, text, x, y, colour, scale_x, scale_y): diff --git a/led.py b/led.py new file mode 100644 index 0000000..a1d5c34 --- /dev/null +++ b/led.py @@ -0,0 +1,59 @@ +"""Short APA102 activity flashes for the T-Dongle-S3's onboard RGB LED.""" + +import time +from machine import Pin + +# Verified against LILYGO's T-Dongle-S3 pin map. The APA102 uses BGR order. +_DATA_PIN = 40 +_CLOCK_PIN = 39 +_PULSE_MS = 90 +_BRIGHTNESS = 1 # APA102 global brightness: 1 (dim) through 31 (full). + +class ActivityLed: + """Drive the single onboard APA102 LED without delaying the main loop.""" + + def __init__(self): + self._available = True + self._off_at = None + try: + self._data = Pin(_DATA_PIN, Pin.OUT, value=0) + self._clock = Pin(_CLOCK_PIN, Pin.OUT, value=0) + self._write(0, 0, 0) + except (OSError, ValueError): + # Keep display-less or alternate ESP32-S3 development boards usable. + self._available = False + + def pulse(self, red, green, blue, now): + """Show one colour briefly; a later event simply extends the flash.""" + if not self._available: + return + self._write(red, green, blue) + self._off_at = time.ticks_add(now, _PULSE_MS) + + def tick(self, now): + """Turn the LED off after its scheduled pulse without sleeping.""" + if (self._available and self._off_at is not None and + time.ticks_diff(now, self._off_at) >= 0): + self._write(0, 0, 0) + self._off_at = None + + def _write(self, red, green, blue): + # APA102: start frame, global-brightness pixel frame, end frame. + self._send_byte(0) + self._send_byte(0) + self._send_byte(0) + self._send_byte(0) + self._send_byte(0xE0 | _BRIGHTNESS) + self._send_byte(blue) + self._send_byte(green) + self._send_byte(red) + self._send_byte(0xFF) + self._send_byte(0xFF) + self._send_byte(0xFF) + self._send_byte(0xFF) + + def _send_byte(self, value): + for bit in range(7, -1, -1): + self._data.value((value >> bit) & 1) + self._clock.on() + self._clock.off() diff --git a/main.py b/main.py index f57476a..53e4024 100644 --- a/main.py +++ b/main.py @@ -4,8 +4,10 @@ import network import time import urandom +import maintenance from display import StatusDisplay from hid import get_hid +from led import ActivityLed from settings import load, save from web import WebServer @@ -51,13 +53,39 @@ def ip_address(wlan): return wlan.ifconfig()[0] +def mac_address(wlan): + """Return the station interface MAC address in conventional display form.""" + try: + mac = wlan.config("mac") + except (AttributeError, OSError, ValueError): + return None + if not isinstance(mac, (bytes, bytearray)) or len(mac) != 6: + return None + return "%02X:%02X:%02X:%02X:%02X:%02X" % tuple(mac) + + +def wifi_rssi(wlan): + """Return RSSI in dBm when the current ESP32 port exposes it.""" + if not wlan.isconnected(): + return None + try: + return wlan.status("rssi") + except (AttributeError, OSError, ValueError): + return None + def main(): + # boot.py leaves USB CDC enabled in maintenance mode; do not activate Wi-Fi, + # the display, web server, or injection scheduler in that local service mode. + if maintenance.active: + return + settings = load() display = None if settings["display_enabled"]: display = StatusDisplay() hid = get_hid() + activity_led = ActivityLed() wlan = connect_wifi(settings) volume_requests = [] @@ -76,9 +104,14 @@ def main(): # 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_network_refresh = now + display_address = None + display_mac = None + display_rssi = None while True: now = time.ticks_ms() + activity_led.tick(now) server.poll() # Reconnect without blocking injections or the web server indefinitely. @@ -89,8 +122,17 @@ def main(): next_wifi_retry = time.ticks_add(now, 10000) if display is not None: - address = ip_address(wlan) if wlan.isconnected() else None - display.tick(now, settings["wifi_ssid"], address) + # The display itself is frame-driven; query WLAN state only once per + # second rather than for every 25 ms application-loop iteration. + if time.ticks_diff(now, next_display_network_refresh) >= 0: + display_address = ip_address(wlan) if wlan.isconnected() else None + display_mac = mac_address(wlan) + display_rssi = wifi_rssi(wlan) + next_display_network_refresh = time.ticks_add(now, 1000) + display.tick( + now, settings["wifi_ssid"], display_address, display_mac, + display_rssi, maintenance.button_pressed() + ) if release_at is not None and time.ticks_diff(now, release_at) >= 0: @@ -105,11 +147,13 @@ def main(): hid.press_volume_up() else: hid.press_volume_down() + activity_led.pulse(0, 0, 255, now) consumer_release_at = time.ticks_add(now, KEY_HOLD_MS) if (settings["enabled"] and settings["return_enabled"] and release_at is None and time.ticks_diff(now, next_return) >= 0): hid.press_return() + activity_led.pulse(255, 0, 0, now) release_at = time.ticks_add(now, KEY_HOLD_MS) next_return = choose_due( now, settings["return_min_seconds"], settings["return_max_seconds"] @@ -124,6 +168,7 @@ def main(): if x == 0 and y == 0: x = distance hid.move(x, y) + activity_led.pulse(0, 255, 0, now) next_mouse = choose_due( now, settings["mouse_min_seconds"], settings["mouse_max_seconds"] ) diff --git a/maintenance.py b/maintenance.py new file mode 100644 index 0000000..8af77f3 --- /dev/null +++ b/maintenance.py @@ -0,0 +1,39 @@ +"""Local USB-REPL maintenance-mode selection for the T-Dongle-S3.""" + +import time +from machine import Pin + +# The T-Dongle-S3 BOOT switch connects GPIO0 to ground. +_BUTTON_PIN = 0 +_WINDOW_MS = 1500 + +active = False +_button = None + + +def button_pressed(): + """Return whether the BOOT button is currently pressed.""" + global _button + if _button is None: + _button = Pin(_BUTTON_PIN, Pin.IN, Pin.PULL_UP) + return not _button.value() + + +def check_button(): + """Return true when BOOT is pressed shortly after a normal reset. + + Do not hold BOOT while resetting: GPIO0 is also an ESP ROM boot strap and + that combination intentionally enters the download bootloader instead. + """ + global active + deadline = time.ticks_add(time.ticks_ms(), _WINDOW_MS) + while time.ticks_diff(deadline, time.ticks_ms()) > 0: + if button_pressed(): + # Debounce the switch and leave the user enough time to release it. + time.sleep_ms(40) + if button_pressed(): + active = True + return True + time.sleep_ms(20) + active = False + return False