Add maintenance mode and board status indicators
Provide BOOT-triggered USB CDC maintenance mode for file updates, plus onboard LED activity flashes and enhanced display status information.
This commit is contained in:
@@ -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.
|
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
|
## 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.
|
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.
|
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.
|
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 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
|
## Operational safeguards
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
import maintenance
|
||||||
|
|
||||||
|
# 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
|
from hid import initialise
|
||||||
|
|
||||||
# Keep the configured USB device and its callbacks alive for the whole session.
|
# Keep the configured USB device and its callbacks alive for the session.
|
||||||
hid = initialise()
|
hid = initialise()
|
||||||
|
|||||||
+138
-175
@@ -6,7 +6,6 @@ only MicroPython's built-in ``machine`` and ``framebuf`` modules.
|
|||||||
|
|
||||||
import framebuf
|
import framebuf
|
||||||
import time
|
import time
|
||||||
import urandom
|
|
||||||
from machine import Pin, SPI
|
from machine import Pin, SPI
|
||||||
|
|
||||||
WIDTH = 160
|
WIDTH = 160
|
||||||
@@ -42,14 +41,45 @@ _DARK_BLUE = 0x1085
|
|||||||
_GHOST = 0xD7FF
|
_GHOST = 0xD7FF
|
||||||
_GHOST_SHADE = 0x9E7F
|
_GHOST_SHADE = 0x9E7F
|
||||||
_PURPLE = 0x8A3F
|
_PURPLE = 0x8A3F
|
||||||
_ORANGE = 0xFD20
|
|
||||||
|
|
||||||
_FRAME_MS = 90
|
_FRAME_MS = 90
|
||||||
_INTRO_FLY_MS = 950
|
_INTRO_STARS_MS = 600
|
||||||
|
_INTRO_GHOST_MS = 1100
|
||||||
_INTRO_TITLE_MS = 1350
|
_INTRO_TITLE_MS = 1350
|
||||||
_STATUS_MS = 15000
|
_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 = {
|
_TITLE_FONT = {
|
||||||
"P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
|
"P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
|
||||||
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
|
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
|
||||||
@@ -64,7 +94,7 @@ _TITLE_FONT = {
|
|||||||
|
|
||||||
|
|
||||||
class StatusDisplay:
|
class StatusDisplay:
|
||||||
"""Render network status plus small, non-blocking PolterHID animations."""
|
"""Render network status and the concise PolterHID boot sequence."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._cs = Pin(_PIN_CS, Pin.OUT, value=1)
|
self._cs = Pin(_PIN_CS, Pin.OUT, value=1)
|
||||||
@@ -79,15 +109,13 @@ class StatusDisplay:
|
|||||||
self._buffer, WIDTH, HEIGHT, framebuf.RGB565
|
self._buffer, WIDTH, HEIGHT, framebuf.RGB565
|
||||||
)
|
)
|
||||||
self._shown = None
|
self._shown = None
|
||||||
self._mode = "intro_fly"
|
self._mode = "intro_stars"
|
||||||
self._mode_started = None
|
self._mode_started = None
|
||||||
self._next_frame = None
|
self._next_frame = None
|
||||||
self._next_idle = None
|
|
||||||
self._idle_animation = None
|
|
||||||
self._peek_from_left = True
|
|
||||||
self._status_until = None
|
self._status_until = None
|
||||||
|
self._button_was_pressed = False
|
||||||
self._initialise()
|
self._initialise()
|
||||||
self._backlight.off()
|
self._set_backlight(True)
|
||||||
|
|
||||||
def _command(self, command, data=None):
|
def _command(self, command, data=None):
|
||||||
self._cs.off()
|
self._cs.off()
|
||||||
@@ -114,27 +142,45 @@ class StatusDisplay:
|
|||||||
self._command(_DISPON)
|
self._command(_DISPON)
|
||||||
time.sleep_ms(20)
|
time.sleep_ms(20)
|
||||||
|
|
||||||
def tick(self, now, ssid, address=None):
|
def tick(self, now, ssid, address=None, mac=None, rssi=None,
|
||||||
"""Advance the display without delaying the application's main loop."""
|
button_pressed=False):
|
||||||
|
"""Advance display state without delaying the application's main loop."""
|
||||||
if self._mode_started is None:
|
if self._mode_started is None:
|
||||||
self._mode_started = now
|
self._mode_started = now
|
||||||
self._next_frame = 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:
|
if time.ticks_diff(now, self._next_frame) < 0:
|
||||||
return
|
return
|
||||||
self._next_frame = time.ticks_add(now, _FRAME_MS)
|
self._next_frame = time.ticks_add(now, _FRAME_MS)
|
||||||
elapsed = time.ticks_diff(now, self._mode_started)
|
elapsed = time.ticks_diff(now, self._mode_started)
|
||||||
|
|
||||||
if self._mode == "intro_fly":
|
if self._mode == "intro_stars":
|
||||||
self._draw_background()
|
self._draw_starfield(elapsed)
|
||||||
self._ghost(-14 + (elapsed * 188 // _INTRO_FLY_MS), 37)
|
|
||||||
self.show()
|
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)
|
self._enter_mode("intro_title", now)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._mode == "intro_title":
|
if self._mode == "intro_title":
|
||||||
self._draw_title()
|
self._draw_title(elapsed)
|
||||||
self.show()
|
self.show()
|
||||||
if elapsed >= _INTRO_TITLE_MS:
|
if elapsed >= _INTRO_TITLE_MS:
|
||||||
self._status_until = time.ticks_add(now, _STATUS_MS)
|
self._status_until = time.ticks_add(now, _STATUS_MS)
|
||||||
@@ -143,183 +189,99 @@ class StatusDisplay:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self._mode == "status":
|
if self._mode == "status":
|
||||||
self.update(ssid, address)
|
self.update(ssid, address, mac, rssi)
|
||||||
if time.ticks_diff(now, self._status_until) >= 0:
|
if time.ticks_diff(now, self._status_until) >= 0:
|
||||||
self._draw_background()
|
self._framebuffer.fill(_BLACK)
|
||||||
self.show()
|
self.show()
|
||||||
self._next_idle = time.ticks_add(now, 800)
|
self._set_backlight(False)
|
||||||
self._enter_mode("idle", now)
|
self._enter_mode("idle", now)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._mode == "idle":
|
# Idle is intentionally a blank, unlit screen. It is still polled so a
|
||||||
if time.ticks_diff(now, self._next_idle) >= 0:
|
# BOOT press can restore the status screen without a reset.
|
||||||
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
|
|
||||||
|
|
||||||
self._draw_idle_animation(elapsed)
|
def update(self, ssid, address=None, mac=None, rssi=None):
|
||||||
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):
|
|
||||||
"""Redraw the normal network-status screen when its state changes."""
|
"""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:
|
if state == self._shown:
|
||||||
return
|
return
|
||||||
self._shown = state
|
self._shown = state
|
||||||
framebuffer = self._framebuffer
|
framebuffer = self._framebuffer
|
||||||
framebuffer.fill(_BLACK)
|
framebuffer.fill(_BLACK)
|
||||||
framebuffer.text("PolterHID", 3, 3, _CYAN)
|
framebuffer.text("PolterHID", 3, 3, _CYAN)
|
||||||
framebuffer.text("WiFi", 3, 23, _GRAY)
|
self._draw_wifi_icon(137, 2, signal_level)
|
||||||
framebuffer.text(_fit(ssid, 19), 42, 23, _WHITE)
|
framebuffer.text("WiFi: " + _fit(ssid, 13), 3, 21, _WHITE)
|
||||||
framebuffer.text("IP", 3, 47, _GRAY)
|
|
||||||
if address:
|
if address:
|
||||||
framebuffer.text(_fit(address, 19), 42, 47, _GREEN)
|
framebuffer.text("IP: " + _fit(address, 16), 3, 39, _GREEN)
|
||||||
else:
|
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()
|
self.show()
|
||||||
|
|
||||||
def _enter_mode(self, mode, now):
|
def _enter_mode(self, mode, now):
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self._mode_started = now
|
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 = self._framebuffer
|
||||||
framebuffer.fill(_DARK_BLUE)
|
framebuffer.fill(_DARK_BLUE)
|
||||||
for x, y in ((12, 12), (48, 20), (92, 10), (142, 18), (25, 66),
|
phase = elapsed // 180
|
||||||
(73, 58), (126, 68), (151, 48)):
|
for x, y, offset in _STARS:
|
||||||
framebuffer.pixel(x, y, _CYAN)
|
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):
|
def _draw_ghost_sprite(self, x, y):
|
||||||
"""Draw a tiny comic ghost centred at x/y; fading omits body stripes."""
|
"""Draw the small right-facing bitmap ghost at a 2x pixel scale."""
|
||||||
framebuffer = self._framebuffer
|
framebuffer = self._framebuffer
|
||||||
body = _GHOST if fading < 2 else _GHOST_SHADE
|
for row, pixels in enumerate(_GHOST_SPRITE):
|
||||||
framebuffer.ellipse(x - 11, y - 15, x + 11, y + 8, body, True)
|
for column, pixel in enumerate(pixels):
|
||||||
framebuffer.rect(x - 11, y - 3, 23, 14, body, True)
|
if pixel == "1":
|
||||||
framebuffer.ellipse(x - 11, y + 3, x - 3, y + 14, body, True)
|
framebuffer.rect(x + column * 2, y + row * 2, 2, 2, _GHOST, 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)
|
|
||||||
|
|
||||||
def _draw_title(self):
|
# One eye, raised brow, and a tiny smile establish the side profile.
|
||||||
self._draw_background()
|
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 = self._framebuffer
|
||||||
framebuffer.rect(5, 16, 150, 48, _PURPLE, True)
|
framebuffer.rect(5, 16, 150, 48, _PURPLE, True)
|
||||||
framebuffer.rect(7, 18, 146, 44, _DARK_BLUE, True)
|
framebuffer.rect(7, 18, 146, 44, _DARK_BLUE, True)
|
||||||
_big_text(framebuffer, "POLTERHID", 8, 23, _CYAN, 3, 5)
|
_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):
|
def show(self):
|
||||||
"""Transfer the RGB565 framebuffer, converting its byte order for SPI."""
|
"""Transfer the RGB565 framebuffer, converting its byte order for SPI."""
|
||||||
# framebuf stores RGB565 words in native little-endian order, while the
|
# framebuf stores RGB565 words in native little-endian order, while the
|
||||||
@@ -355,18 +317,19 @@ def _fit(text, maximum):
|
|||||||
return text[:maximum - 3] + "..."
|
return text[:maximum - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
def _random_below(limit):
|
def _wifi_signal_level(rssi):
|
||||||
return urandom.getrandbits(30) % limit
|
"""Map RSSI dBm to the four bands in the Wi-Fi indicator."""
|
||||||
|
if not isinstance(rssi, int):
|
||||||
|
return 0
|
||||||
def _animation_duration(scene):
|
if rssi >= -55:
|
||||||
return {
|
return 4
|
||||||
"peek": 1000,
|
if rssi >= -65:
|
||||||
"look": 1600,
|
return 3
|
||||||
"dissolve": 1550,
|
if rssi >= -75:
|
||||||
"bonk": 1400,
|
return 2
|
||||||
"poke": 1400,
|
if rssi >= -85:
|
||||||
}[scene]
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _big_text(framebuffer, text, x, y, colour, scale_x, scale_y):
|
def _big_text(framebuffer, text, x, y, colour, scale_x, scale_y):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -4,8 +4,10 @@ import network
|
|||||||
import time
|
import time
|
||||||
import urandom
|
import urandom
|
||||||
|
|
||||||
|
import maintenance
|
||||||
from display import StatusDisplay
|
from display import StatusDisplay
|
||||||
from hid import get_hid
|
from hid import get_hid
|
||||||
|
from led import ActivityLed
|
||||||
from settings import load, save
|
from settings import load, save
|
||||||
from web import WebServer
|
from web import WebServer
|
||||||
|
|
||||||
@@ -51,13 +53,39 @@ def ip_address(wlan):
|
|||||||
return wlan.ifconfig()[0]
|
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():
|
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()
|
settings = load()
|
||||||
display = None
|
display = None
|
||||||
if settings["display_enabled"]:
|
if settings["display_enabled"]:
|
||||||
display = StatusDisplay()
|
display = StatusDisplay()
|
||||||
hid = get_hid()
|
hid = get_hid()
|
||||||
|
activity_led = ActivityLed()
|
||||||
wlan = connect_wifi(settings)
|
wlan = connect_wifi(settings)
|
||||||
volume_requests = []
|
volume_requests = []
|
||||||
|
|
||||||
@@ -76,9 +104,14 @@ def main():
|
|||||||
# connect_wifi() already started the first attempt; do not call connect()
|
# connect_wifi() already started the first attempt; do not call connect()
|
||||||
# again while the ESP32 station is still in STAT_CONNECTING state.
|
# again while the ESP32 station is still in STAT_CONNECTING state.
|
||||||
next_wifi_retry = time.ticks_add(now, 10000)
|
next_wifi_retry = time.ticks_add(now, 10000)
|
||||||
|
next_display_network_refresh = now
|
||||||
|
display_address = None
|
||||||
|
display_mac = None
|
||||||
|
display_rssi = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
now = time.ticks_ms()
|
now = time.ticks_ms()
|
||||||
|
activity_led.tick(now)
|
||||||
server.poll()
|
server.poll()
|
||||||
|
|
||||||
# Reconnect without blocking injections or the web server indefinitely.
|
# Reconnect without blocking injections or the web server indefinitely.
|
||||||
@@ -89,8 +122,17 @@ def main():
|
|||||||
next_wifi_retry = time.ticks_add(now, 10000)
|
next_wifi_retry = time.ticks_add(now, 10000)
|
||||||
|
|
||||||
if display is not None:
|
if display is not None:
|
||||||
address = ip_address(wlan) if wlan.isconnected() else None
|
# The display itself is frame-driven; query WLAN state only once per
|
||||||
display.tick(now, settings["wifi_ssid"], address)
|
# 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:
|
if release_at is not None and time.ticks_diff(now, release_at) >= 0:
|
||||||
@@ -105,11 +147,13 @@ def main():
|
|||||||
hid.press_volume_up()
|
hid.press_volume_up()
|
||||||
else:
|
else:
|
||||||
hid.press_volume_down()
|
hid.press_volume_down()
|
||||||
|
activity_led.pulse(0, 0, 255, now)
|
||||||
consumer_release_at = time.ticks_add(now, KEY_HOLD_MS)
|
consumer_release_at = time.ticks_add(now, KEY_HOLD_MS)
|
||||||
|
|
||||||
if (settings["enabled"] and settings["return_enabled"] and
|
if (settings["enabled"] and settings["return_enabled"] and
|
||||||
release_at is None and time.ticks_diff(now, next_return) >= 0):
|
release_at is None and time.ticks_diff(now, next_return) >= 0):
|
||||||
hid.press_return()
|
hid.press_return()
|
||||||
|
activity_led.pulse(255, 0, 0, now)
|
||||||
release_at = time.ticks_add(now, KEY_HOLD_MS)
|
release_at = time.ticks_add(now, KEY_HOLD_MS)
|
||||||
next_return = choose_due(
|
next_return = choose_due(
|
||||||
now, settings["return_min_seconds"], settings["return_max_seconds"]
|
now, settings["return_min_seconds"], settings["return_max_seconds"]
|
||||||
@@ -124,6 +168,7 @@ def main():
|
|||||||
if x == 0 and y == 0:
|
if x == 0 and y == 0:
|
||||||
x = distance
|
x = distance
|
||||||
hid.move(x, y)
|
hid.move(x, y)
|
||||||
|
activity_led.pulse(0, 255, 0, now)
|
||||||
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"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user