"""Status display support for the LILYGO T-Dongle-S3. The board's 160x80 ST7735 panel is wired to SPI2. This driver deliberately uses only MicroPython's built-in ``machine`` and ``framebuf`` modules. """ import framebuf import time import urandom from machine import Pin, SPI WIDTH = 160 HEIGHT = 80 # Verified against LILYGO's T-Dongle-S3 factory-screen example. _PIN_MOSI = 3 _PIN_SCK = 5 _PIN_CS = 4 _PIN_DC = 2 _PIN_RST = 1 _PIN_BACKLIGHT = 38 _X_OFFSET = 1 _Y_OFFSET = 26 _SWRESET = 0x01 _SLPOUT = 0x11 _DISPON = 0x29 _CASET = 0x2A _RASET = 0x2B _RAMWR = 0x2C _MADCTL = 0x36 _COLMOD = 0x3A _INVON = 0x21 _BLACK = 0x0000 _WHITE = 0xFFFF _CYAN = 0x07FF _GREEN = 0x07E0 _YELLOW = 0xFFE0 _GRAY = 0x8410 _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 network status plus small, non-blocking PolterHID animations.""" def __init__(self): self._cs = Pin(_PIN_CS, Pin.OUT, value=1) self._dc = Pin(_PIN_DC, Pin.OUT, value=0) self._reset = Pin(_PIN_RST, Pin.OUT, value=1) # The T-Dongle-S3 backlight enable is active-low. self._backlight = Pin(_PIN_BACKLIGHT, Pin.OUT, value=1) self._spi = SPI(2, baudrate=20_000_000, polarity=0, phase=0, sck=Pin(_PIN_SCK), mosi=Pin(_PIN_MOSI)) self._buffer = bytearray(WIDTH * HEIGHT * 2) self._framebuffer = framebuf.FrameBuffer( self._buffer, WIDTH, HEIGHT, framebuf.RGB565 ) self._shown = None self._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() def _command(self, command, data=None): self._cs.off() self._dc.off() self._spi.write(bytes((command,))) if data is not None: self._dc.on() self._spi.write(data) self._cs.on() def _initialise(self): self._reset.off() time.sleep_ms(20) self._reset.on() time.sleep_ms(120) self._command(_SWRESET) time.sleep_ms(150) self._command(_SLPOUT) time.sleep_ms(120) self._command(_COLMOD, b"\x05") # 16-bit RGB565. # Landscape, BGR colour order, matching the vendor panel setup. self._command(_MADCTL, b"\xA8") self._command(_INVON) self._command(_DISPON) time.sleep_ms(20) def 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 the normal network-status screen when its state changes.""" state = (ssid, address) if state == self._shown: return self._shown = state framebuffer = self._framebuffer framebuffer.fill(_BLACK) framebuffer.text("PolterHID", 3, 3, _CYAN) framebuffer.text("WiFi", 3, 23, _GRAY) framebuffer.text(_fit(ssid, 19), 42, 23, _WHITE) framebuffer.text("IP", 3, 47, _GRAY) if address: framebuffer.text(_fit(address, 19), 42, 47, _GREEN) else: framebuffer.text("connecting...", 42, 47, _YELLOW) self.show() def _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 # ST7735 expects the most-significant byte first on the SPI bus. for index in range(0, len(self._buffer), 2): low = self._buffer[index] self._buffer[index] = self._buffer[index + 1] self._buffer[index + 1] = low self._command(_CASET, _coordinates(_X_OFFSET, _X_OFFSET + WIDTH - 1)) self._command(_RASET, _coordinates(_Y_OFFSET, _Y_OFFSET + HEIGHT - 1)) self._cs.off() self._dc.off() self._spi.write(bytes((_RAMWR,))) self._dc.on() self._spi.write(self._buffer) self._cs.on() # Restore native byte order before subsequent framebuf drawing. for index in range(0, len(self._buffer), 2): low = self._buffer[index] self._buffer[index] = self._buffer[index + 1] self._buffer[index + 1] = low def _coordinates(start, end): return bytes((start >> 8, start & 0xFF, end >> 8, end & 0xFF)) def _fit(text, maximum): if len(text) <= maximum: return text return text[:maximum - 3] + "..." 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