/*
PRISME ESP32-S3 Controller Simulator v0.1
------------------------------------------------------------
Wokwi laboratory simulator for the future PRISME optical
controller boundary. This is separate from the original
PRISME browser assembler/encoder/VM.
Serial protocol: 115200 baud, ASCII line commands.
Permanent byte representation:
R = bits 7..6 (base-4 value 0..3)
G = bits 5..4 (base-4 value 0..3)
B = bits 3..2 (base-4 value 0..3)
V = bits 1..0 (base-4 value 0..3)
UV/white is a transient control/strobe channel only.
Buttons:
START GPIO15 / keyboard S
STOP GPIO16 / keyboard X
TEST GPIO17 / keyboard T
*/
#include <Arduino.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
// ILI9341 pins - matches diagram.json
static constexpr uint8_t TFT_CS = 7;
static constexpr uint8_t TFT_DC = 39;
static constexpr uint8_t TFT_MOSI = 35;
static constexpr uint8_t TFT_SCK = 36;
static constexpr uint8_t TFT_MISO = 37;
static constexpr uint8_t TFT_RST = 40;
// Five spectral outputs
static constexpr uint8_t LED_R = 4;
static constexpr uint8_t LED_G = 5;
static constexpr uint8_t LED_B = 6;
static constexpr uint8_t LED_V = 8;
static constexpr uint8_t LED_UV = 9;
// Local controls
static constexpr uint8_t BTN_START = 15;
static constexpr uint8_t BTN_STOP = 16;
static constexpr uint8_t BTN_TEST = 17;
static constexpr size_t MAX_FRAME_BYTES = 1024;
static constexpr uint32_t SERIAL_BAUD = 115200;
// Timing is deliberately slow enough to see in Wokwi.
// Physical timing must be measured and implemented separately.
static constexpr uint32_t START_STROBE_MS = 80;
static constexpr uint32_t DATA_FLASH_MS = 90;
static constexpr uint32_t UV_STROBE_MS = 30;
static constexpr uint32_t GAP_MS = 35;
static constexpr uint32_t END_STROBE_MS = 120;
static constexpr uint8_t LEVEL_PWM[4] = {0, 85, 170, 255};
static constexpr bool VERBOSE_BYTE_EVENTS = true;
// Use the ESP32-S3 hardware SPI peripheral with explicitly assigned pins.
// This is more reliable in Wokwi than the software-SPI constructor.
Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
uint8_t frameBuffer[MAX_FRAME_BYTES];
uint8_t readbackBuffer[MAX_FRAME_BYTES];
bool receivedMap[MAX_FRAME_BYTES];
size_t preparedLength = 0;
size_t receivedCount = 0;
size_t currentIndex = 0;
uint32_t expectedCrc32 = 0;
bool readbackReady = false;
bool running = false;
bool lastVerifyOk = false;
String inputLine;
enum class Phase : uint8_t {
IDLE,
START_UV_ON,
START_UV_OFF,
DATA_ON,
DATA_OFF,
UV_ON,
UV_OFF,
END_UV_ON
};
Phase phase = Phase::IDLE;
uint32_t phaseDeadline = 0;
String stateText = "READY";
struct ButtonState {
uint8_t pin;
bool stable;
bool lastRaw;
uint32_t changedAt;
};
ButtonState startButton{BTN_START, HIGH, HIGH, 0};
ButtonState stopButton{BTN_STOP, HIGH, HIGH, 0};
ButtonState testButton{BTN_TEST, HIGH, HIGH, 0};
static uint32_t crc32Bytes(const uint8_t* data, size_t length) {
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < length; ++i) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; ++bit) {
const uint32_t mask = -(crc & 1u);
crc = (crc >> 1) ^ (0xEDB88320u & mask);
}
}
return ~crc;
}
static void printHex8(uint8_t value) {
if (value < 0x10) Serial.print('0');
Serial.print(value, HEX);
}
static void printHex32(uint32_t value) {
char out[9];
snprintf(out, sizeof(out), "%08lX", static_cast<unsigned long>(value));
Serial.print(out);
}
static void allSpectralOff() {
analogWrite(LED_R, 0);
analogWrite(LED_G, 0);
analogWrite(LED_B, 0);
analogWrite(LED_V, 0);
digitalWrite(LED_UV, LOW);
}
static void setDataLevels(uint8_t value) {
analogWrite(LED_R, LEVEL_PWM[(value >> 6) & 0x03]);
analogWrite(LED_G, LEVEL_PWM[(value >> 4) & 0x03]);
analogWrite(LED_B, LEVEL_PWM[(value >> 2) & 0x03]);
analogWrite(LED_V, LEVEL_PWM[value & 0x03]);
}
static void emitError(const char* code, const char* message) {
Serial.print(F("{\"type\":\"error\",\"code\":\""));
Serial.print(code);
Serial.print(F("\",\"message\":\""));
Serial.print(message);
Serial.println(F("\"}"));
}
static void emitAck(const char* command) {
Serial.print(F("{\"type\":\"ack\",\"command\":\""));
Serial.print(command);
Serial.println(F("\"}"));
}
static void emitStatus() {
Serial.print(F("{\"type\":\"status\",\"state\":\""));
Serial.print(stateText);
Serial.print(F("\",\"running\":"));
Serial.print(running ? F("true") : F("false"));
Serial.print(F(",\"prepared_bytes\":"));
Serial.print(preparedLength);
Serial.print(F(",\"received_bytes\":"));
Serial.print(receivedCount);
Serial.print(F(",\"current_index\":"));
Serial.print(currentIndex);
Serial.print(F(",\"readback_ready\":"));
Serial.print(readbackReady ? F("true") : F("false"));
Serial.print(F(",\"last_verify_ok\":"));
Serial.print(lastVerifyOk ? F("true") : F("false"));
Serial.println('}');
}
static void emitCapabilities() {
Serial.println(
F("{\"type\":\"capabilities\","
"\"device\":\"PRISME-ESP32-S3-SIM\","
"\"protocol\":\"prisme-controller/0.1\","
"\"transport\":\"serial-jsonl\","
"\"baud\":115200,"
"\"channels\":[\"R\",\"G\",\"B\",\"V\",\"UV\"],"
"\"persistent_levels\":4,"
"\"uv_role\":\"transient-control-strobe\","
"\"max_frame_bytes\":1024,"
"\"readback\":\"simulated-byte-identical\","
"\"error_injection\":true,"
"\"commands\":["
"\"CAPABILITIES\",\"PREPARE_WRITE\",\"WRITE_FRAME\","
"\"START\",\"CAPTURE_READBACK\",\"VERIFY\","
"\"ABORT\",\"STATUS\",\"TEST\",\"INJECT_ERROR\","
"\"RESET\",\"PING\",\"HELP\""
"]}"
)
);
}
static void drawLevelBox(
int16_t x, int16_t y, const char* label,
uint16_t color, uint8_t level
) {
tft.drawRoundRect(x, y, 54, 46, 5, color);
tft.setTextColor(color);
tft.setTextSize(2);
tft.setCursor(x + 6, y + 5);
tft.print(label);
tft.setTextColor(ILI9341_WHITE);
tft.setCursor(x + 32, y + 5);
tft.print(level);
const int16_t barWidth = static_cast<int16_t>(level) * 11;
tft.drawRect(x + 6, y + 29, 40, 9, ILI9341_DARKGREY);
if (barWidth > 0) {
tft.fillRect(x + 7, y + 30, barWidth, 7, color);
}
}
static void renderScreen() {
tft.fillScreen(ILI9341_BLACK);
tft.fillRect(0, 0, 320, 31, tft.color565(9, 25, 68));
tft.setTextColor(ILI9341_WHITE);
tft.setTextSize(2);
tft.setCursor(8, 8);
tft.print(F("PRISME ESP32-S3 SIM"));
tft.setTextSize(2);
tft.setCursor(8, 42);
tft.setTextColor(ILI9341_CYAN);
tft.print(F("STATE: "));
tft.setTextColor(ILI9341_WHITE);
tft.println(stateText);
tft.setTextSize(1);
tft.setTextColor(ILI9341_LIGHTGREY);
tft.setCursor(8, 67);
tft.print(F("Frame: "));
tft.print(currentIndex);
tft.print('/');
tft.print(preparedLength);
tft.print(F(" Received: "));
tft.print(receivedCount);
uint8_t value = 0;
if (preparedLength > 0 && currentIndex < preparedLength) {
value = frameBuffer[currentIndex];
}
const uint8_t r = (value >> 6) & 0x03;
const uint8_t g = (value >> 4) & 0x03;
const uint8_t b = (value >> 2) & 0x03;
const uint8_t v = value & 0x03;
tft.setTextSize(2);
tft.setTextColor(ILI9341_YELLOW);
tft.setCursor(8, 84);
tft.print(F("BYTE 0x"));
if (value < 0x10) tft.print('0');
tft.print(value, HEX);
drawLevelBox(8, 111, "R", ILI9341_RED, r);
drawLevelBox(70, 111, "G", ILI9341_GREEN, g);
drawLevelBox(132, 111, "B", ILI9341_BLUE, b);
drawLevelBox(194, 111, "V", tft.color565(170, 75, 255), v);
drawLevelBox(256, 111, "U", ILI9341_WHITE, digitalRead(LED_UV) ? 3 : 0);
tft.drawRect(8, 171, 304, 15, ILI9341_DARKGREY);
if (preparedLength > 0) {
const uint32_t progress = min<uint32_t>(
302, (static_cast<uint32_t>(currentIndex) * 302u) /
static_cast<uint32_t>(preparedLength)
);
if (progress > 0) {
tft.fillRect(9, 172, progress, 13, ILI9341_GREEN);
}
}
tft.setTextSize(1);
tft.setTextColor(ILI9341_LIGHTGREY);
tft.setCursor(8, 196);
tft.print(F("S=START X=STOP T=TEST"));
tft.setCursor(8, 211);
tft.print(F("Serial: 115200 JSONL"));
tft.setCursor(180, 211);
tft.print(F("Verify: "));
tft.setTextColor(lastVerifyOk ? ILI9341_GREEN : ILI9341_ORANGE);
tft.print(lastVerifyOk ? F("PASS") : F("-"));
}
static bool parseHexByte(char high, char low, uint8_t& out) {
auto nibble = [](char c) -> int8_t {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
const int8_t h = nibble(high);
const int8_t l = nibble(low);
if (h < 0 || l < 0) return false;
out = static_cast<uint8_t>((h << 4) | l);
return true;
}
static bool frameComplete() {
return preparedLength > 0 && receivedCount == preparedLength;
}
static void clearFrame() {
running = false;
phase = Phase::IDLE;
allSpectralOff();
preparedLength = 0;
receivedCount = 0;
currentIndex = 0;
expectedCrc32 = 0;
readbackReady = false;
lastVerifyOk = false;
memset(frameBuffer, 0, sizeof(frameBuffer));
memset(readbackBuffer, 0, sizeof(readbackBuffer));
memset(receivedMap, 0, sizeof(receivedMap));
stateText = "READY";
renderScreen();
}
static void loadDemoFrame() {
static const char demo[] = "PRISME ESP32-S3 TEST";
clearFrame();
preparedLength = sizeof(demo) - 1;
for (size_t i = 0; i < preparedLength; ++i) {
frameBuffer[i] = static_cast<uint8_t>(demo[i]);
receivedMap[i] = true;
}
receivedCount = preparedLength;
expectedCrc32 = crc32Bytes(frameBuffer, preparedLength);
stateText = "TEST LOADED";
renderScreen();
}
static void abortRun(const char* reason) {
running = false;
phase = Phase::IDLE;
allSpectralOff();
stateText = reason;
Serial.print(F("{\"type\":\"aborted\",\"reason\":\""));
Serial.print(reason);
Serial.println(F("\"}"));
renderScreen();
}
static void startRun() {
if (running) {
emitError("BUSY", "A frame is already running");
return;
}
if (preparedLength == 0) {
loadDemoFrame();
}
if (!frameComplete()) {
emitError("FRAME_INCOMPLETE", "Not all prepared bytes have been received");
return;
}
currentIndex = 0;
readbackReady = false;
lastVerifyOk = false;
running = true;
stateText = "WRITING";
phase = Phase::START_UV_ON;
digitalWrite(LED_UV, HIGH);
phaseDeadline = millis() + START_STROBE_MS;
Serial.print(F("{\"type\":\"started\",\"bytes\":"));
Serial.print(preparedLength);
Serial.print(F(",\"crc32\":\""));
printHex32(crc32Bytes(frameBuffer, preparedLength));
Serial.println(F("\"}"));
renderScreen();
}
static void completeRun() {
running = false;
phase = Phase::IDLE;
allSpectralOff();
currentIndex = preparedLength;
stateText = "WRITE COMPLETE";
Serial.print(F("{\"type\":\"complete\",\"bytes\":"));
Serial.print(preparedLength);
Serial.print(F(",\"crc32\":\""));
printHex32(crc32Bytes(frameBuffer, preparedLength));
Serial.println(F("\"}"));
renderScreen();
}
static void updateSequencer() {
if (!running || static_cast<int32_t>(millis() - phaseDeadline) < 0) {
return;
}
switch (phase) {
case Phase::START_UV_ON:
digitalWrite(LED_UV, LOW);
phase = Phase::START_UV_OFF;
phaseDeadline = millis() + GAP_MS;
break;
case Phase::START_UV_OFF:
if (currentIndex >= preparedLength) {
digitalWrite(LED_UV, HIGH);
phase = Phase::END_UV_ON;
phaseDeadline = millis() + END_STROBE_MS;
break;
}
setDataLevels(frameBuffer[currentIndex]);
phase = Phase::DATA_ON;
phaseDeadline = millis() + DATA_FLASH_MS;
if (VERBOSE_BYTE_EVENTS) {
const uint8_t value = frameBuffer[currentIndex];
Serial.print(F("{\"type\":\"byte\",\"index\":"));
Serial.print(currentIndex);
Serial.print(F(",\"value\":\""));
printHex8(value);
Serial.print(F("\",\"levels\":["));
Serial.print((value >> 6) & 3);
Serial.print(',');
Serial.print((value >> 4) & 3);
Serial.print(',');
Serial.print((value >> 2) & 3);
Serial.print(',');
Serial.print(value & 3);
Serial.println(F("]}"));
}
renderScreen();
break;
case Phase::DATA_ON:
analogWrite(LED_R, 0);
analogWrite(LED_G, 0);
analogWrite(LED_B, 0);
analogWrite(LED_V, 0);
phase = Phase::DATA_OFF;
phaseDeadline = millis() + GAP_MS;
break;
case Phase::DATA_OFF:
digitalWrite(LED_UV, HIGH);
phase = Phase::UV_ON;
phaseDeadline = millis() + UV_STROBE_MS;
break;
case Phase::UV_ON:
digitalWrite(LED_UV, LOW);
phase = Phase::UV_OFF;
phaseDeadline = millis() + GAP_MS;
break;
case Phase::UV_OFF:
++currentIndex;
if (currentIndex >= preparedLength) {
digitalWrite(LED_UV, HIGH);
phase = Phase::END_UV_ON;
phaseDeadline = millis() + END_STROBE_MS;
} else {
phase = Phase::START_UV_OFF;
phaseDeadline = millis();
}
renderScreen();
break;
case Phase::END_UV_ON:
completeRun();
break;
case Phase::IDLE:
break;
}
}
static void captureReadback() {
if (running) {
emitError("BUSY", "Stop or wait for the current frame before readback");
return;
}
if (!frameComplete()) {
emitError("NO_FRAME", "No complete frame is available");
return;
}
memcpy(readbackBuffer, frameBuffer, preparedLength);
readbackReady = true;
stateText = "READBACK READY";
Serial.print(F("{\"type\":\"readback\",\"bytes\":"));
Serial.print(preparedLength);
Serial.print(F(",\"crc32\":\""));
printHex32(crc32Bytes(readbackBuffer, preparedLength));
Serial.println(F("\"}"));
renderScreen();
}
static void verifyReadback() {
if (!readbackReady) {
emitError("NO_READBACK", "Run CAPTURE_READBACK first");
return;
}
const uint32_t writeCrc = crc32Bytes(frameBuffer, preparedLength);
const uint32_t readCrc = crc32Bytes(readbackBuffer, preparedLength);
const bool byteEqual =
memcmp(frameBuffer, readbackBuffer, preparedLength) == 0;
const bool expectedOk =
expectedCrc32 == 0 || expectedCrc32 == writeCrc;
lastVerifyOk = byteEqual && expectedOk;
stateText = lastVerifyOk ? "VERIFY PASS" : "VERIFY FAIL";
Serial.print(F("{\"type\":\"verify\",\"ok\":"));
Serial.print(lastVerifyOk ? F("true") : F("false"));
Serial.print(F(",\"byte_equal\":"));
Serial.print(byteEqual ? F("true") : F("false"));
Serial.print(F(",\"expected_crc_ok\":"));
Serial.print(expectedOk ? F("true") : F("false"));
Serial.print(F(",\"write_crc32\":\""));
printHex32(writeCrc);
Serial.print(F("\",\"readback_crc32\":\""));
printHex32(readCrc);
Serial.println(F("\"}"));
renderScreen();
}
static void handlePrepareWrite(const String& args) {
if (running) {
emitError("BUSY", "Cannot prepare while running");
return;
}
unsigned long length = 0;
unsigned long crc = 0;
const int fields = sscanf(args.c_str(), "%lu %lx", &length, &crc);
if (fields < 1 || length == 0 || length > MAX_FRAME_BYTES) {
emitError("BAD_LENGTH", "Length must be 1..1024 bytes");
return;
}
clearFrame();
preparedLength = static_cast<size_t>(length);
expectedCrc32 = fields >= 2 ? static_cast<uint32_t>(crc) : 0;
stateText = "PREPARED";
Serial.print(F("{\"type\":\"prepared\",\"bytes\":"));
Serial.print(preparedLength);
Serial.print(F(",\"expected_crc32\":\""));
printHex32(expectedCrc32);
Serial.println(F("\"}"));
renderScreen();
}
static void handleWriteFrame(const String& args) {
if (running) {
emitError("BUSY", "Cannot receive frame data while running");
return;
}
if (preparedLength == 0) {
emitError("NOT_PREPARED", "Run PREPARE_WRITE first");
return;
}
const int separator = args.indexOf(' ');
if (separator <= 0) {
emitError("BAD_WRITE_FRAME", "Use WRITE_FRAME <offset> <hex>");
return;
}
const size_t offset = static_cast<size_t>(
args.substring(0, separator).toInt()
);
String hex = args.substring(separator + 1);
hex.trim();
hex.replace(" ", "");
if (hex.length() == 0 || (hex.length() % 2) != 0) {
emitError("BAD_HEX", "Hex data must contain complete bytes");
return;
}
const size_t byteCount = hex.length() / 2;
if (offset >= preparedLength || offset + byteCount > preparedLength) {
emitError("OUT_OF_RANGE", "Frame chunk exceeds prepared length");
return;
}
for (size_t i = 0; i < byteCount; ++i) {
uint8_t value = 0;
if (!parseHexByte(hex[i * 2], hex[i * 2 + 1], value)) {
emitError("BAD_HEX", "Frame contains a non-hex character");
return;
}
const size_t target = offset + i;
frameBuffer[target] = value;
if (!receivedMap[target]) {
receivedMap[target] = true;
++receivedCount;
}
}
stateText = frameComplete() ? "FRAME READY" : "RECEIVING";
Serial.print(F("{\"type\":\"frame_ack\",\"offset\":"));
Serial.print(offset);
Serial.print(F(",\"bytes\":"));
Serial.print(byteCount);
Serial.print(F(",\"received_total\":"));
Serial.print(receivedCount);
Serial.println('}');
renderScreen();
}
static void handleInjectError(const String& args) {
if (!readbackReady) {
emitError("NO_READBACK", "Run CAPTURE_READBACK before INJECT_ERROR");
return;
}
unsigned long offset = 0;
unsigned long mask = 0;
if (sscanf(args.c_str(), "%lu %lx", &offset, &mask) != 2 ||
offset >= preparedLength || mask > 0xFF) {
emitError("BAD_INJECT", "Use INJECT_ERROR <offset> <hex-mask>");
return;
}
readbackBuffer[offset] ^= static_cast<uint8_t>(mask);
lastVerifyOk = false;
stateText = "ERROR INJECTED";
Serial.print(F("{\"type\":\"error_injected\",\"offset\":"));
Serial.print(offset);
Serial.print(F(",\"mask\":\""));
printHex8(static_cast<uint8_t>(mask));
Serial.println(F("\"}"));
renderScreen();
}
static void printHelp() {
Serial.println(F(
"CAPABILITIES\n"
"PREPARE_WRITE <length> [crc32-hex]\n"
"WRITE_FRAME <offset> <contiguous-hex>\n"
"START\n"
"CAPTURE_READBACK\n"
"VERIFY\n"
"INJECT_ERROR <offset> <xor-mask-hex>\n"
"ABORT | STOP\n"
"STATUS\n"
"TEST\n"
"RESET\n"
"PING\n"
"HELP"
));
}
static void handleCommand(String line) {
line.trim();
if (line.length() == 0) return;
const int separator = line.indexOf(' ');
String command = separator < 0 ? line : line.substring(0, separator);
String args = separator < 0 ? "" : line.substring(separator + 1);
command.toUpperCase();
args.trim();
if (command == "CAPABILITIES") {
emitCapabilities();
} else if (command == "STATUS") {
emitStatus();
} else if (command == "PREPARE_WRITE") {
handlePrepareWrite(args);
} else if (command == "WRITE_FRAME") {
handleWriteFrame(args);
} else if (command == "START") {
startRun();
} else if (command == "CAPTURE_READBACK") {
captureReadback();
} else if (command == "VERIFY") {
verifyReadback();
} else if (command == "ABORT" || command == "STOP") {
abortRun("ABORTED");
} else if (command == "TEST") {
loadDemoFrame();
startRun();
} else if (command == "INJECT_ERROR") {
handleInjectError(args);
} else if (command == "RESET") {
clearFrame();
emitAck("RESET");
} else if (command == "PING") {
Serial.println(F("{\"type\":\"pong\"}"));
} else if (command == "HELP") {
printHelp();
} else {
emitError("UNKNOWN_COMMAND", "Use HELP for the supported command list");
}
}
static void serviceSerial() {
while (Serial.available() > 0) {
const char c = static_cast<char>(Serial.read());
if (c == '\n') {
handleCommand(inputLine);
inputLine = "";
} else if (c != '\r' && inputLine.length() < 4096) {
inputLine += c;
}
}
}
static bool buttonPressed(ButtonState& button) {
const bool raw = digitalRead(button.pin);
if (raw != button.lastRaw) {
button.lastRaw = raw;
button.changedAt = millis();
}
if (millis() - button.changedAt >= 35 && raw != button.stable) {
button.stable = raw;
return button.stable == LOW;
}
return false;
}
static void serviceButtons() {
if (buttonPressed(startButton)) {
startRun();
}
if (buttonPressed(stopButton)) {
abortRun("STOP BUTTON");
}
if (buttonPressed(testButton)) {
if (running) abortRun("TEST RESTART");
loadDemoFrame();
startRun();
}
}
void setup() {
Serial.begin(SERIAL_BAUD);
delay(750);
Serial.println();
Serial.println(F("PRISME BOOT 1/5: setup entered"));
pinMode(LED_R, OUTPUT);
pinMode(LED_G, OUTPUT);
pinMode(LED_B, OUTPUT);
pinMode(LED_V, OUTPUT);
pinMode(LED_UV, OUTPUT);
allSpectralOff();
pinMode(BTN_START, INPUT_PULLUP);
pinMode(BTN_STOP, INPUT_PULLUP);
pinMode(BTN_TEST, INPUT_PULLUP);
Serial.println(F("PRISME BOOT 2/5: GPIO configured"));
pinMode(TFT_CS, OUTPUT);
digitalWrite(TFT_CS, HIGH);
pinMode(TFT_RST, OUTPUT);
digitalWrite(TFT_RST, HIGH);
Serial.println(F("PRISME BOOT 3/5: starting SPI"));
SPI.begin(TFT_SCK, TFT_MISO, TFT_MOSI, TFT_CS);
delay(50);
Serial.println(F("PRISME BOOT 4/5: starting ILI9341"));
tft.begin();
tft.setRotation(1);
Serial.println(F("PRISME BOOT 5/5: display initialized"));
renderScreen();
Serial.println(F(
"{\"type\":\"ready\","
"\"device\":\"PRISME-ESP32-S3-SIM\","
"\"protocol\":\"prisme-controller/0.1.1\"}"
));
emitCapabilities();
}
void loop() {
serviceSerial();
serviceButtons();
updateSequencer();
delay(1);
}