Add animated mascot display experience

This commit is contained in:
2026-08-27 19:40:47 +02:00
parent be37343ec1
commit 2218b20bd8
4 changed files with 306 additions and 8 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
# PolterHID
![Square PolterHID ghost mascot](assets/polterhid-ghost-square.svg)
A deliberately limited MicroPython awareness-training firmware for an ESP32 USB stick. On boot, it presents as a USB keyboard/mouse and, at independently random intervals, sends only:
- one `Return` key press and release; and
@@ -30,7 +32,7 @@ If that import fails, use an up-to-date native-USB MicroPython build for the boa
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 shows the configured Wi-Fi SSID and either `connecting...` or the DHCP-assigned IPv4 address. 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. 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.
## Operational safeguards
+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
<title id="title">Square PolterHID mascot</title>
<desc id="description">A cute comic ghost with an off-centre tiny keyboard and computer mouse.</desc>
<defs>
<radialGradient id="sky" cx="50%" cy="32%" r="78%">
<stop offset="0" stop-color="#4558a4"/>
<stop offset="1" stop-color="#171c42"/>
</radialGradient>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#c8e8ff"/>
</linearGradient>
<filter id="float" x="-25%" y="-25%" width="150%" height="150%">
<feDropShadow dx="0" dy="12" stdDeviation="9" flood-color="#0b0d25" flood-opacity=".52"/>
</filter>
</defs>
<rect width="512" height="512" rx="58" fill="url(#sky)"/>
<g fill="#a8b8f4" opacity=".75">
<circle cx="68" cy="101" r="5"/><circle cx="115" cy="56" r="3"/><circle cx="406" cy="79" r="4"/>
<circle cx="452" cy="143" r="3"/><circle cx="63" cy="366" r="4"/><circle cx="456" cy="388" r="5"/>
</g>
<path d="M386 77l7 16 16 7-16 7-7 16-7-16-16-7 16-7 7-16Z" fill="#ffdf83"/>
<g filter="url(#float)">
<path d="M256 92c-84 0-140 61-145 157-3 63 15 116 53 128 25 8 38-25 57-14 17 10 17 50 39 50 24 0 28-42 49-43 22-1 31 31 55 21 36-15 49-67 41-135-11-96-66-164-145-164Z" fill="url(#body)" stroke="#a7c9f2" stroke-width="9" stroke-linejoin="round"/>
<path d="M138 240c-37 8-58-15-70-47M375 241c35 10 60-8 72-39" fill="none" stroke="#c8e8ff" stroke-width="19" stroke-linecap="round"/>
<ellipse cx="198" cy="245" rx="23" ry="32" fill="#253360"/>
<ellipse cx="314" cy="245" rx="23" ry="32" fill="#253360"/>
<ellipse cx="190" cy="235" rx="7" ry="10" fill="#fff"/><ellipse cx="306" cy="235" rx="7" ry="10" fill="#fff"/>
<path d="M218 302c23 23 55 23 78 0" fill="none" stroke="#253360" stroke-width="11" stroke-linecap="round"/>
<path d="M161 188c17-21 39-30 61-29M351 188c-17-21-39-30-61-29" fill="none" stroke="#f5fbff" stroke-width="12" stroke-linecap="round"/>
</g>
<g filter="url(#float)">
<g transform="translate(-46 8)">
<rect x="127" y="353" width="191" height="66" rx="13" fill="#314578" stroke="#a9d4ff" stroke-width="5"/>
<g fill="#e8f7ff">
<rect x="143" y="369" width="19" height="13" rx="3"/><rect x="168" y="369" width="19" height="13" rx="3"/>
<rect x="193" y="369" width="19" height="13" rx="3"/><rect x="218" y="369" width="19" height="13" rx="3"/>
<rect x="243" y="369" width="19" height="13" rx="3"/><rect x="268" y="369" width="34" height="13" rx="3"/>
<rect x="153" y="390" width="26" height="13" rx="3"/><rect x="185" y="390" width="88" height="13" rx="3"/>
<rect x="279" y="390" width="23" height="13" rx="3"/>
</g>
</g>
<g transform="translate(25 -10)">
<path d="M345 383c0-25 17-39 35-39s34 14 34 39v17c0 14-11 25-25 25h-19c-14 0-25-11-25-25v-17Z" fill="#ffcb68" stroke="#b87532" stroke-width="5"/>
<path d="M380 348v29M346 383c14 8 52 8 68 0" fill="none" stroke="#b87532" stroke-width="5" stroke-linecap="round"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+250 -2
View File
@@ -6,6 +6,7 @@ only MicroPython's built-in ``machine`` and ``framebuf`` modules.
import framebuf
import time
import urandom
from machine import Pin, SPI
WIDTH = 160
@@ -37,10 +38,33 @@ _CYAN = 0x07FF
_GREEN = 0x07E0
_YELLOW = 0xFFE0
_GRAY = 0x8410
_DARK_BLUE = 0x1085
_GHOST = 0xD7FF
_GHOST_SHADE = 0x9E7F
_PURPLE = 0x8A3F
_ORANGE = 0xFD20
_FRAME_MS = 90
_INTRO_FLY_MS = 950
_INTRO_TITLE_MS = 1350
_STATUS_MS = 15000
# A compact 5x7 font, stretched for the boot title.
_TITLE_FONT = {
"P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
"L": ("10000", "10000", "10000", "10000", "10000", "10000", "11111"),
"T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
"E": ("11111", "10000", "10000", "11110", "10000", "10000", "11111"),
"R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
"H": ("10001", "10001", "10001", "11111", "10001", "10001", "10001"),
"I": ("11111", "00100", "00100", "00100", "00100", "00100", "11111"),
"D": ("11110", "10001", "10001", "10001", "10001", "10001", "11110"),
}
class StatusDisplay:
"""Render the configured SSID and current DHCP address on the LCD."""
"""Render network status plus small, non-blocking PolterHID animations."""
def __init__(self):
self._cs = Pin(_PIN_CS, Pin.OUT, value=1)
@@ -55,6 +79,13 @@ class StatusDisplay:
self._buffer, WIDTH, HEIGHT, framebuf.RGB565
)
self._shown = None
self._mode = "intro_fly"
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._initialise()
self._backlight.off()
@@ -83,8 +114,61 @@ 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."""
if self._mode_started is None:
self._mode_started = now
self._next_frame = now
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)
self.show()
if elapsed >= _INTRO_FLY_MS:
self._enter_mode("intro_title", now)
return
if self._mode == "intro_title":
self._draw_title()
self.show()
if elapsed >= _INTRO_TITLE_MS:
self._status_until = time.ticks_add(now, _STATUS_MS)
self._shown = None
self._enter_mode("status", now)
return
if self._mode == "status":
self.update(ssid, address)
if time.ticks_diff(now, self._status_until) >= 0:
self._draw_background()
self.show()
self._next_idle = time.ticks_add(now, 800)
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
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):
"""Redraw only when the displayed network state changed."""
"""Redraw the normal network-status screen when its state changes."""
state = (ssid, address)
if state == self._shown:
return
@@ -101,6 +185,141 @@ class StatusDisplay:
framebuffer.text("connecting...", 42, 47, _YELLOW)
self.show()
def _enter_mode(self, mode, now):
self._mode = mode
self._mode_started = now
def _draw_background(self):
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)
def _ghost(self, x, y, direction=1, pout=False, fading=0):
"""Draw a tiny comic ghost centred at x/y; fading omits body stripes."""
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)
def _draw_title(self):
self._draw_background()
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
@@ -134,3 +353,32 @@ def _fit(text, maximum):
if len(text) <= maximum:
return text
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 _big_text(framebuffer, text, x, y, colour, scale_x, scale_y):
"""Draw the boot title with deliberately chunky, screen-sized lettering."""
cursor = x
for character in text:
glyph = _TITLE_FONT[character]
for row, pixels in enumerate(glyph):
for column, pixel in enumerate(pixels):
if pixel == "1":
framebuffer.rect(
cursor + column * scale_x, y + row * scale_y,
scale_x, scale_y, colour, True
)
cursor += 5 * scale_x + 1
+2 -5
View File
@@ -57,7 +57,6 @@ def main():
display = None
if settings["display_enabled"]:
display = StatusDisplay()
display.update(settings["wifi_ssid"])
hid = get_hid()
wlan = connect_wifi(settings)
volume_requests = []
@@ -77,7 +76,6 @@ 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_refresh = now
while True:
now = time.ticks_ms()
@@ -90,10 +88,9 @@ def main():
wlan.connect(settings["wifi_ssid"], settings["wifi_password"])
next_wifi_retry = time.ticks_add(now, 10000)
if display is not None and time.ticks_diff(now, next_display_refresh) >= 0:
if display is not None:
address = ip_address(wlan) if wlan.isconnected() else None
display.update(settings["wifi_ssid"], address)
next_display_refresh = time.ticks_add(now, 1000)
display.tick(now, settings["wifi_ssid"], address)
if release_at is not None and time.ticks_diff(now, release_at) >= 0: