Files
PolterHID/display.py
T
Commander1024 9e49484354 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.
2026-08-27 20:33:24 +02:00

348 lines
12 KiB
Python

"""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