velxio/test/test_epaper/test_ssd168x_protocol.py

241 lines
8.7 KiB
Python
Raw Normal View History

"""Pure-Python tests for the SSD168x SPI decoder.
These tests are the **specification** for the future Velxio frontend
emulator. They walk the decoder through the exact byte sequences GxEPD2
and Adafruit_EPD emit and assert the resulting framebuffer is what a
real panel would have shown.
No QEMU, no DOM, no backend just bytes in and pixels out.
"""
from __future__ import annotations
from pathlib import Path
import sys
import pytest
_HERE = Path(__file__).parent
sys.path.insert(0, str(_HERE))
from ssd168x_decoder import ( # noqa: E402 (sys.path tweak above)
SSD168xDecoder,
Frame,
CMD_SW_RESET,
CMD_DRIVER_OUTPUT_CTRL,
CMD_DATA_ENTRY_MODE,
CMD_SET_RAMX_RANGE,
CMD_SET_RAMY_RANGE,
CMD_BORDER_WAVEFORM,
CMD_DISP_UPDATE_CTRL_1,
CMD_TEMP_SENSOR,
CMD_SET_RAMX_COUNTER,
CMD_SET_RAMY_COUNTER,
CMD_WRITE_BLACK_VRAM,
CMD_WRITE_RED_VRAM,
CMD_DISP_UPDATE_CTRL_2,
CMD_MASTER_ACTIVATION,
CMD_DEEP_SLEEP,
)
# ── Helpers ───────────────────────────────────────────────────────────────────
def cmd(c):
"""Return one (byte, dc=False) tuple — DC LOW means command."""
return [(c, False)]
def data(*bs):
"""Return n (byte, dc=True) tuples — DC HIGH means data."""
return [(b, True) for b in bs]
def feed_all(d, *streams):
"""Feed a flat sequence of (byte, dc) tuples into the decoder."""
for stream in streams:
for byte, dc_high in stream:
d.feed(byte, dc_high)
def gxepd2_init_154(d):
"""The init sequence GxEPD2 emits for the GxEPD2_154_D67 (200×200, SSD1681).
Lifted from GxEPD2's src/epd/GxEPD2_154_D67.cpp _InitDisplay() and
cross-checked against esp-bsp's esp_lcd_ssd1681_commands.h init.
"""
feed_all(
d,
cmd(CMD_SW_RESET),
cmd(CMD_DRIVER_OUTPUT_CTRL), data(0xC7, 0x00, 0x00),
cmd(CMD_DATA_ENTRY_MODE), data(0x03),
cmd(CMD_SET_RAMX_RANGE), data(0x00, 0x18),
cmd(CMD_SET_RAMY_RANGE), data(0x00, 0x00, 0xC7, 0x00),
cmd(CMD_BORDER_WAVEFORM), data(0x05),
cmd(CMD_DISP_UPDATE_CTRL_1), data(0x00, 0x80),
cmd(CMD_TEMP_SENSOR), data(0x80),
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
)
# ── Tests ─────────────────────────────────────────────────────────────────────
class TestInitSequence:
"""The decoder must accept GxEPD2's init without complaint."""
def test_clean_init_for_154_panel(self):
d = SSD168xDecoder(width=200, height=200)
gxepd2_init_154(d)
assert d.unknown_cmds == [], (
f"GxEPD2 init emitted unknown cmds: {d.unknown_cmds!r}"
)
assert d.refreshed_count == 0, "Init alone must NOT trigger a refresh"
assert not d.in_deep_sleep
def test_sw_reset_clears_state(self):
d = SSD168xDecoder(width=200, height=200)
# Pollute the BW plane.
feed_all(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0x00, 0x00))
assert any(b != 0xFF for b in d.bw_ram[:25])
# Reset.
feed_all(d, cmd(CMD_SW_RESET))
assert all(b == 0xFF for b in d.bw_ram[:25]), "SW reset must clear BW RAM"
class TestRamWindowing:
"""The X/Y range + entry-mode commands must steer pixel writes correctly."""
def test_ramx_ramy_range_sets_window(self):
d = SSD168xDecoder(width=200, height=200)
feed_all(
d,
cmd(CMD_SET_RAMX_RANGE), data(0x00, 0x18), # full width (0..24)
cmd(CMD_SET_RAMY_RANGE), data(0x00, 0x00, 0xC7, 0x00), # full height (0..199)
)
assert d._xrange == (0x00, 0x18)
assert d._yrange == (0x0000, 0x00C7)
def test_set_counters_seek_position(self):
d = SSD168xDecoder(width=200, height=200)
feed_all(
d,
cmd(CMD_SET_RAMX_COUNTER), data(0x05),
cmd(CMD_SET_RAMY_COUNTER), data(0x10, 0x00),
)
assert d._x_byte == 0x05
assert d._y == 0x0010
class TestPixelWriting:
"""Writing to the BW VRAM must land bytes in the right framebuffer position."""
def test_write_black_vram_increments_x(self):
d = SSD168xDecoder(width=200, height=200)
gxepd2_init_154(d)
feed_all(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0xFF, 0xAA))
assert d.bw_ram[0] == 0x00
assert d.bw_ram[1] == 0xFF
assert d.bw_ram[2] == 0xAA
assert d._x_byte == 3
assert d._y == 0
def test_write_wraps_to_next_row(self):
# Tiny window so we can hit the wrap fast (x range 0..1 = 2 bytes wide)
d = SSD168xDecoder(width=200, height=200)
feed_all(
d,
cmd(CMD_DATA_ENTRY_MODE), data(0x03),
cmd(CMD_SET_RAMX_RANGE), data(0x00, 0x01),
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
)
feed_all(d, cmd(CMD_WRITE_BLACK_VRAM), data(0xAA, 0xBB, 0xCC, 0xDD))
# First two bytes go to row 0; next two to row 1.
assert d.bw_ram[0] == 0xAA
assert d.bw_ram[1] == 0xBB
bpr = 25
assert d.bw_ram[bpr + 0] == 0xCC
assert d.bw_ram[bpr + 1] == 0xDD
class TestFrameLatchAndCompose:
"""0x20 ACTIVATE must trigger flush; red plane must win over black."""
def test_activate_calls_on_flush(self):
seen = []
d = SSD168xDecoder(width=200, height=200, on_flush=lambda f: seen.append(f))
gxepd2_init_154(d)
# White everywhere → all bits are already 0xFF (white).
feed_all(
d,
cmd(CMD_DISP_UPDATE_CTRL_2), data(0xF7),
cmd(CMD_MASTER_ACTIVATION),
)
assert len(seen) == 1
assert d.refreshed_count == 1
frame: Frame = seen[0]
assert frame.width == 200 and frame.height == 200
assert all(p == 1 for p in frame.pixels), (
"Default RAM is 0xFF (all bits=1) → all pixels must be white"
)
def test_red_plane_wins_over_black(self):
d = SSD168xDecoder(width=8, height=2) # tiny 1-byte-wide panel
# Black plane: row 0 all-black (0x00), row 1 all-white (0xFF)
feed_all(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0xFF))
# Red plane: row 0 first 4 px red (0xF0), row 1 nothing (0x00)
feed_all(d, cmd(CMD_SET_RAMX_COUNTER), data(0x00))
feed_all(d, cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00))
feed_all(d, cmd(CMD_WRITE_RED_VRAM), data(0xF0, 0x00))
frame = d.compose_frame()
# Row 0 cols 0..3: red (2). Cols 4..7: still black (0). Row 1 all white (1).
assert frame.pixels[0:4] == [2, 2, 2, 2]
assert frame.pixels[4:8] == [0, 0, 0, 0]
assert frame.pixels[8:16] == [1, 1, 1, 1, 1, 1, 1, 1]
class TestDeepSleepAndUnknownCmds:
"""Deep sleep + tolerance for vendor-specific quirks."""
def test_deep_sleep_flag_is_set(self):
d = SSD168xDecoder(width=200, height=200)
feed_all(d, cmd(CMD_DEEP_SLEEP), data(0x01))
assert d.in_deep_sleep is True
def test_unknown_cmd_is_logged_not_raised(self):
"""Real panel firmware sometimes emits vendor-specific bytes;
the decoder logs them so we can audit later, but never raises."""
d = SSD168xDecoder(width=200, height=200)
feed_all(d, cmd(0xAB), data(0x01, 0x02)) # 0xAB is undefined
assert 0xAB in d.unknown_cmds
class TestEndToEndHelloWorld:
"""A canonical 'init → write white frame with one black pixel → activate'
flow reaches the on_flush callback with exactly one black pixel."""
def test_single_black_pixel_at_origin(self):
captured = []
d = SSD168xDecoder(width=200, height=200, on_flush=lambda f: captured.append(f))
gxepd2_init_154(d)
# Write the BW plane: byte 0 = 0x7F (top-left pixel = 0/black, others = 1/white)
feed_all(d, cmd(CMD_SET_RAMX_COUNTER), data(0x00))
feed_all(d, cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00))
feed_all(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x7F))
feed_all(
d,
cmd(CMD_DISP_UPDATE_CTRL_2), data(0xF7),
cmd(CMD_MASTER_ACTIVATION),
)
assert len(captured) == 1
frame = captured[0]
# Top-left pixel should be black (0); pixel (1, 0) should be white (1).
assert frame.pixels[0] == 0, "top-left expected black"
assert frame.pixels[1] == 1, "pixel (1,0) expected white"
if __name__ == "__main__":
pytest.main([__file__, "-v"])