295 lines
11 KiB
Python
295 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise real ST7735 allocation, RGB332 scanout, and retained wake paths.
|
|
|
|
The fixture records SPI wire bytes instead of driving a physical panel. It
|
|
compiles the production methods for both framebuffer depths and SPI APIs.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from cpp_source import body
|
|
from test_serial_wifi_sessions import SANITIZER_FLAGS
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DRIVER = ROOT / "src/helpers/ui/ST7735Display.cpp"
|
|
|
|
|
|
HARNESS = r'''
|
|
#include <algorithm>
|
|
#include <cassert>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <vector>
|
|
|
|
constexpr int INPUT = 0, OUTPUT = 1, LOW = 0, HIGH = 1;
|
|
constexpr int PIN_TFT_RST = 1, PIN_TFT_CS = 2, PIN_TFT_DC = 3;
|
|
constexpr int PIN_TFT_LEDA_CTL = 4, PIN_TFT_SDA = 5, PIN_TFT_SCL = 6;
|
|
constexpr int PIN_TFT_LEDA_CTL_ACTIVE = HIGH, DISPLAY_ROTATION = 1;
|
|
constexpr int SPI_CLOCK_DIV2 = 2, ST77XX_NORON = 0x13;
|
|
constexpr int ST77XX_DISPON = 0x29, ST77XX_DISPOFF = 0x28, ST77XX_SLPIN = 0x10;
|
|
constexpr uint16_t ST77XX_BLACK = 0, ST77XX_WHITE = 0xffff;
|
|
constexpr int NRF_GPIO_PIN_DIR_OUTPUT = 0, NRF_GPIO_PIN_INPUT_CONNECT = 1;
|
|
constexpr int NRF_GPIO_PIN_INPUT_DISCONNECT = 2, NRF_GPIO_PIN_NOPULL = 3;
|
|
constexpr int NRF_GPIO_PIN_H0H1 = 4, NRF_GPIO_PIN_NOSENSE = 5;
|
|
static unsigned gpio_calls, high_drive_calls, errors, resets;
|
|
static unsigned objects, destroyed, allocations, fills, color_calls;
|
|
static bool fail_pixels;
|
|
static std::vector<uint8_t> wire;
|
|
static unsigned transactions, finishes, windows, cs_calls;
|
|
|
|
void digitalWrite(int, int) { ++gpio_calls; }
|
|
void pinMode(int, int) { ++gpio_calls; }
|
|
void delay(unsigned) {}
|
|
void nrf_gpio_cfg(int, int, int, int, int, int) { ++high_drive_calls; }
|
|
void sendCommand(int) {}
|
|
void set_CS(int) { ++cs_calls; }
|
|
void setAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
|
|
assert(x == 0 && y == windows && w == 160 && h == 1);
|
|
++windows;
|
|
}
|
|
static void resetWire() {
|
|
wire.clear(); transactions = finishes = windows = cs_calls = color_calls = 0;
|
|
}
|
|
struct Spi {
|
|
void begin() {}
|
|
void begin(int, int, int, int) {}
|
|
void setClockDivider(int) {}
|
|
void beginTransaction(int) { ++transactions; }
|
|
void endTransaction() { ++finishes; }
|
|
void transfer(const void* data, void* output, size_t length) {
|
|
assert(output == nullptr && length == 320);
|
|
auto* bytes = static_cast<const uint8_t*>(data);
|
|
wire.insert(wire.end(), bytes, bytes + length);
|
|
}
|
|
void transferBytes(const uint8_t* data, uint8_t* output, size_t length) {
|
|
transfer(data, output, length);
|
|
}
|
|
} spi;
|
|
static Spi* _spi = &spi;
|
|
static int _spiSettings;
|
|
static int _height, _width, _colstart, _rowstart;
|
|
struct TFT_eSPI {
|
|
// Same public RGB332-to-RGB565 mapping as the pinned TFT_eSPI dependency.
|
|
uint16_t color8to16(uint8_t color) {
|
|
++color_calls;
|
|
const uint8_t blue[] = {0, 11, 21, 31};
|
|
return (color & 0x1c) << 6 | (color & 0xc0) << 5
|
|
| (color & 0xe0) << 8 | (color & 0x1c) << 3 | blue[color & 3];
|
|
}
|
|
} lcd;
|
|
struct TFT_eSprite {
|
|
int depth = 16;
|
|
bool depth_selected = false;
|
|
bool valid = false;
|
|
std::vector<uint8_t> pixels;
|
|
explicit TFT_eSprite(TFT_eSPI*) { ++objects; }
|
|
~TFT_eSprite() { ++destroyed; }
|
|
void setColorDepth(int value) {
|
|
assert(!valid && pixels.empty());
|
|
depth = value; depth_selected = true;
|
|
}
|
|
void* createSprite(int width, int height) {
|
|
assert(depth_selected && width == 160 && height == 80);
|
|
++allocations;
|
|
if (fail_pixels) { fail_pixels = false; return nullptr; }
|
|
pixels.resize((width * height + 1) * (depth / 8));
|
|
valid = true;
|
|
return getPointer();
|
|
}
|
|
void* getPointer() { return valid ? pixels.data() : nullptr; }
|
|
void fillScreen(uint16_t color) {
|
|
assert(valid && color == 0); ++fills;
|
|
std::fill(pixels.begin(), pixels.end(), 0);
|
|
}
|
|
void setTextColor(uint16_t color) { assert(color == 0xffff); }
|
|
};
|
|
@DEPTH_POLICY@
|
|
static TFT_eSprite* sprite;
|
|
static uint32_t curr_color;
|
|
@READY@
|
|
namespace mesh {
|
|
struct Logger { void println(const char*) { ++errors; } } logger;
|
|
Logger& usbLoggingPort() { return logger; }
|
|
}
|
|
struct Rail {
|
|
unsigned claims = 0;
|
|
void claim() { ++claims; }
|
|
void release() { assert(claims); --claims; }
|
|
};
|
|
class ST7735Display {
|
|
public:
|
|
bool _isOn = false;
|
|
Rail* _peripher_power;
|
|
explicit ST7735Display(Rail* rail) : _peripher_power(rail) {}
|
|
bool begin();
|
|
void turnOn();
|
|
void turnOff();
|
|
void endFrame();
|
|
void _resetAndInit() { ++resets; }
|
|
};
|
|
@METHODS@
|
|
|
|
static uint16_t expandExpected(uint8_t value) {
|
|
// Independent component arithmetic, not the driver's bit-expression.
|
|
unsigned r = value >> 5, g = value >> 2 & 7, b = value & 3;
|
|
unsigned red = r * 4 + r / 2;
|
|
unsigned green = g * 8 + g;
|
|
unsigned blue = (b * 31 + 2) / 3;
|
|
return static_cast<uint16_t>((red << 11) | (green << 5) | blue);
|
|
}
|
|
static uint8_t pack332(uint16_t value) {
|
|
return static_cast<uint8_t>((value >> 8 & 0xe0)
|
|
| (value >> 6 & 0x1c) | (value >> 3 & 3));
|
|
}
|
|
static void checkWire() {
|
|
assert(transactions == 1 && finishes == 1 && windows == 80 && cs_calls == 2);
|
|
assert(wire.size() == 160 * 80 * 2);
|
|
for (size_t pixel = 0; pixel < 160 * 80; ++pixel) {
|
|
#if ST7735_BUFFER_COLOR_DEPTH == 8
|
|
uint16_t expected = expandExpected(static_cast<uint8_t>(pixel));
|
|
assert(wire[2 * pixel] == expected >> 8);
|
|
assert(wire[2 * pixel + 1] == (expected & 0xff));
|
|
#else
|
|
// RGB565 storage is already in panel wire order; retain every byte.
|
|
assert(wire[2 * pixel] == static_cast<uint8_t>(pixel >> 5));
|
|
assert(wire[2 * pixel + 1] == static_cast<uint8_t>(pixel));
|
|
#endif
|
|
}
|
|
#if ST7735_BUFFER_COLOR_DEPTH == 8
|
|
assert(color_calls == 160 * 80);
|
|
#else
|
|
assert(color_calls == 0);
|
|
#endif
|
|
}
|
|
int main() {
|
|
Rail rail;
|
|
ST7735Display display(&rail);
|
|
// An absent or failed canvas must never be dereferenced or touch SPI.
|
|
display.endFrame();
|
|
assert(wire.empty() && transactions == 0);
|
|
fail_pixels = true;
|
|
assert(!display.begin());
|
|
assert(sprite == nullptr && objects == 1 && destroyed == 1);
|
|
assert(errors == 1 && gpio_calls == 0 && rail.claims == 0);
|
|
|
|
assert(display.begin());
|
|
assert(objects == 2 && allocations == 2 && fills == 1);
|
|
assert(sprite->pixels.size() == (160 * 80 + 1) * (ST7735_BUFFER_COLOR_DEPTH / 8));
|
|
assert(display._isOn && rail.claims == 1 && resets == 1);
|
|
assert(wire.size() == 160 * 80 * 2);
|
|
assert(std::all_of(wire.begin(), wire.end(), [](uint8_t value) { return value == 0; }));
|
|
#if defined(NRF52_PLATFORM)
|
|
assert(high_drive_calls == 2);
|
|
#endif
|
|
const unsigned first_gpio_calls = gpio_calls;
|
|
const void* original_pixels = sprite->getPointer();
|
|
assert(display.begin());
|
|
assert(objects == 2 && allocations == 2 && fills == 1 && resets == 1);
|
|
assert(gpio_calls == first_gpio_calls && rail.claims == 1);
|
|
|
|
// Every one of the 256 RGB332 codes appears repeatedly, including row
|
|
// boundaries. The sentinel byte/pixel is not transmitted.
|
|
for (size_t pixel = 0; pixel < 160 * 80; ++pixel) {
|
|
#if ST7735_BUFFER_COLOR_DEPTH == 8
|
|
sprite->pixels[pixel] = static_cast<uint8_t>(pixel);
|
|
#else
|
|
sprite->pixels[2 * pixel] = static_cast<uint8_t>(pixel >> 5);
|
|
sprite->pixels[2 * pixel + 1] = static_cast<uint8_t>(pixel);
|
|
#endif
|
|
}
|
|
resetWire();
|
|
display.endFrame();
|
|
checkWire();
|
|
|
|
// The UI's actual eight named colors remain exact, not merely similar.
|
|
for (uint16_t color : {0x0000, 0xffff, 0xf800, 0x07e0,
|
|
0x001f, 0x07ff, 0xf81f, 0xffe0}) {
|
|
assert(expandExpected(pack332(color)) == color);
|
|
}
|
|
|
|
// Sleep/wake repairs the bus but does not allocate, clear, or replace the
|
|
// retained frame. A second wake while already on is a complete no-op.
|
|
display.turnOff();
|
|
assert(!display._isOn && rail.claims == 0);
|
|
resetWire();
|
|
display.turnOn();
|
|
checkWire();
|
|
assert(display._isOn && rail.claims == 1 && resets == 2);
|
|
assert(sprite->getPointer() == original_pixels);
|
|
assert(objects == 2 && allocations == 2 && fills == 1);
|
|
resetWire();
|
|
display.turnOn();
|
|
assert(wire.empty() && transactions == 0 && allocations == 2);
|
|
|
|
// Corrupt/failed library canvas state also fails closed before any SPI or
|
|
// reinitialization. It is not silently reinterpreted as another depth.
|
|
sprite->valid = false;
|
|
resetWire();
|
|
assert(!display.begin());
|
|
display.endFrame();
|
|
assert(wire.empty() && transactions == 0 && allocations == 2);
|
|
delete sprite;
|
|
sprite = nullptr;
|
|
}
|
|
'''
|
|
|
|
|
|
class ST7735FramebufferTests(unittest.TestCase):
|
|
def source(self):
|
|
driver = DRIVER.read_text(encoding="utf-8")
|
|
start = driver.index("#ifndef ST7735_BUFFER_COLOR_DEPTH")
|
|
end = driver.index("#if defined(NRF52_PLATFORM)", start)
|
|
methods = "\n".join(body(driver, signature) for signature in (
|
|
"bool ST7735Display::begin()", "void ST7735Display::endFrame()",
|
|
"void ST7735Display::turnOff()", "void ST7735Display::turnOn()"))
|
|
return (HARNESS.replace("@DEPTH_POLICY@", driver[start:end])
|
|
.replace("@READY@", body(driver, "static bool spriteReady()"))
|
|
.replace("@METHODS@", methods))
|
|
|
|
def test_depths_spi_wire_order_allocation_and_wake(self):
|
|
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
|
if compiler is None:
|
|
self.skipTest("a host C++17 compiler is required")
|
|
with tempfile.TemporaryDirectory(prefix="meshcore-st7735-buffer-") as directory:
|
|
cpp = Path(directory) / "framebuffer.cpp"
|
|
cpp.write_text(self.source(), encoding="utf-8")
|
|
for depth in (8, 16):
|
|
for platform in ("ESP_PLATFORM", "NRF52_PLATFORM"):
|
|
with self.subTest(depth=depth, platform=platform):
|
|
binary = Path(directory) / f"framebuffer-{depth}-{platform}.exe"
|
|
result = subprocess.run([
|
|
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
|
"-Wno-unused-variable", "-Wno-unused-function", *SANITIZER_FLAGS,
|
|
f"-DST7735_BUFFER_COLOR_DEPTH={depth}", f"-D{platform}=1",
|
|
str(cpp), "-o", str(binary),
|
|
], capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
result = subprocess.run([str(binary)], capture_output=True,
|
|
text=True, timeout=30)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
def test_unsupported_depth_is_rejected(self):
|
|
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
|
if compiler is None:
|
|
self.skipTest("a host C++17 compiler is required")
|
|
with tempfile.TemporaryDirectory(prefix="meshcore-st7735-depth-") as directory:
|
|
cpp = Path(directory) / "invalid.cpp"
|
|
cpp.write_text(self.source(), encoding="utf-8")
|
|
result = subprocess.run([
|
|
compiler, "-std=c++17", "-DST7735_BUFFER_COLOR_DEPTH=4", "-c",
|
|
str(cpp), "-o", str(Path(directory) / "invalid.o"),
|
|
], capture_output=True, text=True, timeout=60)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("ST7735_BUFFER_COLOR_DEPTH must be 8 or 16", result.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|