385 lines
13 KiB
Python
385 lines
13 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
|
|
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
|