Provide BOOT-triggered USB CDC maintenance mode for file updates, plus onboard LED activity flashes and enhanced display status information.
348 lines
12 KiB
Python
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
|