"""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 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 _FRAME_MS = 90 _INTRO_STARS_MS = 600 _INTRO_GHOST_MS = 1100 _INTRO_TITLE_MS = 1350 _STATUS_MS = 15000 _WAKE_STATUS_MS = 10000 # 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"), "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 and the concise PolterHID boot sequence.""" 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_stars" self._mode_started = None self._next_frame = None self._status_until = None self._button_was_pressed = False self._initialise() self._set_backlight(True) 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, 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_stars": self._draw_starfield(elapsed) self.show() 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(elapsed) 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, mac, rssi) if time.ticks_diff(now, self._status_until) >= 0: self._framebuffer.fill(_BLACK) self.show() self._set_backlight(False) self._enter_mode("idle", 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. def update(self, ssid, address=None, mac=None, rssi=None): """Redraw the normal network-status screen when its state changes.""" 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) self._draw_wifi_icon(137, 2, signal_level) framebuffer.text("WiFi: " + _fit(ssid, 13), 3, 21, _WHITE) if address: framebuffer.text("IP: " + _fit(address, 16), 3, 39, _GREEN) else: 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_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) 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 _draw_ghost_sprite(self, x, y): """Draw the small right-facing bitmap ghost at a 2x pixel scale.""" framebuffer = self._framebuffer 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) # 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 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 _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): """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