diff --git a/README.md b/README.md index 87850a2..63554d7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assets/polterhid-ghost-square.svg b/assets/polterhid-ghost-square.svg new file mode 100644 index 0000000..d38a7e0 --- /dev/null +++ b/assets/polterhid-ghost-square.svg @@ -0,0 +1,51 @@ + + Square PolterHID mascot + A cute comic ghost with an off-centre tiny keyboard and computer mouse. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/display.py b/display.py index d1e9124..af838bb 100644 --- a/display.py +++ b/display.py @@ -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 diff --git a/main.py b/main.py index 0dce495..f57476a 100644 --- a/main.py +++ b/main.py @@ -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: