Rewrite Nano serial parser as line based, fix protocol bugs

- Commands run on '\n' instead of busy-waiting for a byte count, which
  fixes s/F/T waiting for the next command and P drawing 0xFF garbage
- Invalid bitmaps no longer leak their data as commands; bitmap data is
  streamed so full-screen bitmaps still fit
- Store flip time as uint16 with magic marker, validate 50-2000 us
- Overflow-safe millis() handling for the periodic refresh
- Run the full refresh only after D or when serial is idle, so incoming
  data is not lost while the redraw blocks
- Put the boot refresh measurement behind MEASURE_REFRESH
- Fix DEBUG hex output and use F() strings to save RAM
- Driver: direct mode now updates the old buffer instead of re-setting
  the new one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-13 00:06:49 +02:00
1 parent 459937352a
commit f1dbc35ebd
4 files changed
+327 -230

No files matched your search

+14 -6
View File
@@ -31,13 +31,21 @@ See README.md for the overview.
Add new secrets to `secrets.example.h` as placeholders.
- Hardware is not available here. Builds can be checked, but runtime behaviour can't.
## Known quirks in the serial protocol (Nano `src/nano/main.cpp`)
## Nano serial parser (`src/nano/main.cpp`)
- `T` waits for more than 6 bytes before parsing, but `T225\n` has only 4 after the `T`.
It completes only when the next command arrives.
- The `T` value is stored with `EEPROM.update` in one byte, so values above 255 µs are
truncated after a reboot.
- `P` (print text) busy-waits for a `\n` and has no timeout.
- Line based and non-blocking: bytes are collected into a 64-character buffer and a
command runs when `\n` arrives. Bitmap (`B`) pixel data is the exception. It is drawn
while it arrives, so it can be longer than the buffer. Keep `loop()` free of busy-waits.
- A full redraw (`display(true)`) blocks for about 0.5 s, which overflows the 64-byte
serial receive buffer. Only run it when no data is expected: right after a `D`, or after
2 s with no serial data. The ESP sends one frame per second, ending with `D`.
- Flip time is stored in EEPROM as a magic word at address 0 and a `uint16_t` at
address 2. Values outside 50–2000 µs are rejected.
- The Nano doesn't send an acknowledgement. Whether the Nano's TX is wired to the ESP's RX
is unknown, so don't make the ESP wait for replies.
- To test the parser without hardware, compile `main.cpp` with g++ (Strawberry Perl
provides it at `C:\Strawberry\c\bin`) and mock headers for `Arduino.h`, `EEPROM.h` and
`BROSE9323.h`.
## Git
+3 -14
View File
@@ -151,22 +151,11 @@ void BROSE9323::drawPixel(int16_t x, int16_t y, uint16_t color)
_strobe();
#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__)
// The dot is flipped now, so the next display() does not have to flip it again
if (color)
{
if (_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7)))
{
return;
}
_new_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7);
}
_old_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7);
else
{
if (~_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7)))
{
return;
}
_new_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7));
}
_old_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7));
#endif
}
#endif
+27 -10
View File
@@ -8,18 +8,33 @@ logic.
## Behaviour
- **Boot:** the Nano flips the whole display on and then off, then prints the time a full
redraw took.
- **Boot:** the Nano loads the flip time from EEPROM and flips the whole display on and
then off.
- **`display()`:** compares the new buffer with the last one that was shown and strobes
only the dots that changed.
- **Every 2 minutes** (`REFRESH_TIME`) it forces a full redraw to fix dots that got stuck.
- **Flip pulse length** (µs) is set with the `T` command and stored in EEPROM, so it is
kept after a reboot.
- **Every 2 minutes** (`REFRESH_TIME`) it does a full redraw to fix dots that got stuck.
A full redraw blocks for about half a second, which is long enough to overflow the
64-byte serial buffer. So it only runs right after a `D` (the end of a frame, when the
ESP is quiet) or when no serial data has arrived for 2 seconds.
- **Flip pulse length** (µs, 50–2000) is set with the `T` command and stored in EEPROM,
so it is kept after a reboot. The default is 200 µs.
### Debug options
Uncomment these at the top of `main.cpp`:
| Define | Effect |
|--------|--------|
| `DEBUG` | Print unknown command bytes on the serial port |
| `MEASURE_REFRESH` | At boot, time a full redraw and print the duration in ms |
## Serial protocol
Each command is one ASCII line. Coordinates are 2-digit **hex**. Color is `0` (black) or
`1` (yellow). The ESP32 driver (`ESPSERIAL`) only uses `s`, `S`, `F`, `D` and `T`.
Each command is one ASCII line that **ends with `\n`** (`\r` is ignored). The Nano
collects a whole line before it runs the command. If a line is broken or unknown, it is
skipped and the next line starts clean. Coordinates are 2-digit **hex**. Color is `0`
(black) or `1` (yellow). The ESP32 driver (`ESPSERIAL`) only uses `s`, `S`, `F`, `D` and
`T`.
| Cmd | Format | Meaning |
|-----|--------|---------|
@@ -27,10 +42,12 @@ Each command is one ASCII line. Coordinates are 2-digit **hex**. Color is `0` (b
| `S` | `SXXYYc` | Set pixel and flip it right away (direct mode) |
| `F` | `Fc` | Fill the whole buffer |
| `D` | `D` | Push the buffer to the display (changed dots only) |
| `P` | `PXXYYcsTEXT` | Print text at (XX,YY), color `c`, size `s`. `\n` in TEXT starts a new line. |
| `B` | `BXXYYWWHHcHEX...` | Draw a bitmap. WW must be a multiple of 4. Pixel data is one hex digit per 4 pixels. |
| `P` | `PXXYYcsTEXT` | Print text at (XX,YY), color `c`, size `s`. A literal `\n` (backslash, n) in TEXT starts a new line. The whole line can be at most 64 characters. |
| `B` | `BXXYYWWHHcHEX...` | Draw a bitmap. WW must be a multiple of 4. Pixel data is one hex digit per 4 pixels, row by row. The data is drawn while it arrives, so it is not limited to 64 characters. |
| `R` | `R` | Print the buffer as ASCII art on the serial port |
| `T` | `Tnnn` | Set the flip pulse length in µs (decimal), stored in EEPROM |
| `T` | `Tnnn` | Set the flip pulse length in µs (decimal, 50–2000), stored in EEPROM |
Invalid commands get a reply that starts with `ERR:` on the Nano's TX pin.
Example (what the ESP32 sends for one pixel and a refresh):
+283 -200
View File
@@ -6,40 +6,42 @@
#include <EEPROM.h>
BROSE9323 display(84, 19, 28, 200);
uint32_t refresh_at;
const uint32_t REFRESH_TIME = 120000L; // 600000L // 10 min
uint16_t _flip_time = 200;
unsigned int address = 0;
unsigned int address2 = 1;
//#define DEBUG
void setup()
{
Serial.begin(115200);
Serial.println("Hello World!");
// Print how long a full redraw takes (in ms) at startup
//#define MEASURE_REFRESH
uint16_t read1 = EEPROM.read(address);
uint16_t read2 = EEPROM.read(address2);
// Flip pulse length in us, stored in EEPROM and changed with the T command
const uint16_t FLIP_TIME_DEFAULT = 200;
const uint16_t FLIP_TIME_MIN = 50;
const uint16_t FLIP_TIME_MAX = 2000;
if(read1 == read2)
_flip_time = read1;
// EEPROM layout: magic marker (marks the stored value as valid), then the flip time
const int EEPROM_MAGIC_ADDR = 0;
const int EEPROM_FLIP_TIME_ADDR = 2;
const uint16_t EEPROM_MAGIC = 0xF11D;
display.setTiming(_flip_time);
// Full redraw interval, fixes dots that did not flip
const uint32_t REFRESH_TIME = 120000UL; // 2 min
// Serial line quiet for this long: safe to do a due full redraw without waiting for a D
const uint32_t IDLE_TIME = 2000UL;
display.begin();
delay(1000);
// Longest command line (bitmap data is streamed and does not count)
const uint8_t LINE_MAX = 64;
display.fillScreen(1);
display.display();
display.fillScreen(0);
uint32_t t1 = millis();
display.display();
uint32_t t2 = millis();
Serial.println(t2 - t1);
}
BROSE9323 display(84, 19, 28, FLIP_TIME_DEFAULT);
char line[LINE_MAX + 1];
uint8_t line_len = 0;
bool discard_line = false; // skip everything until the next '\n'
// Bitmap (B) pixel data is drawn while it arrives instead of being buffered
bool bitmap_active = false;
uint8_t bitmap_x, bitmap_y, bitmap_w, bitmap_h;
bool bitmap_color;
uint16_t bitmap_nibble;
uint32_t last_refresh;
uint32_t last_rx;
uint8_t hex2dec(char c)
{
@@ -52,182 +54,263 @@ uint8_t hex2dec(char c)
return 0;
}
void loop()
bool isHex(char c)
{
if (Serial.available())
{
char cmd = Serial.read();
switch (cmd)
{
// Ignore CR
case 0x0A:
break;
// Draw Bitmap (must be n x 8 wide)
case 'b':
case 'B': //BxxyywwhhcBITMAP (xx/ww = 8 bit hex x position/width; yy/hh = 8 bit y position/height; c = 0/1 color; BITMAP = bytes as hex)
{
// May lead to buffer overflow, you may need to segment the bitmap into multiple frames
// Or just use the ser pixel function
while (Serial.available() < 9)
{
delay(1);
}
uint8_t x = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read());
uint8_t y = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read());
uint8_t w = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read());
if (w % 4)
{
Serial.println("ERR: Invalid bitmap width (not dividable by 4)");
Serial.flush();
break;
}
uint8_t h = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read());
if (h == 0)
{
Serial.println("ERR: Invalid bitmap height (0)");
Serial.flush();
break;
}
uint8_t c = Serial.read() - '0';
uint8_t b = 0;
for (uint8_t _y = 0; _y < h; _y++)
{
for (uint8_t _x = 0; _x < w; _x++)
{
if ((_x & 0x3) == 0)
{
if (!Serial.available())
{
Serial.println("WARN: OUTOFMEM");
}
while (!Serial.available())
{
delay(1);
}
b = hex2dec(Serial.read());
}
if (b & (1 << (0x3 - (_x & 0x3))))
{
display.drawPixel(x + _x, y + _y, c);
}
}
}
break;
}
// Display
case 'd':
case 'D': //D
{
display.display();
break;
}
// Fill Screen
case 'f':
case 'F': //Fc (c = 0/1 color)
{
while (Serial.available() <= 2)
{
delay(1);
}
display.fillScreen(Serial.read() - '0');
break;
}
// Print Text
case 'p':
case 'P': // PxxyycsTEXT (xx = 8 bit hex x position; yy = 8 bit y position; c = 0/1 color; s = 1/2 size; TEXT = string, newline = escaped \\n)
{
delay(5);
while (Serial.available() <= 6)
{
delay(1);
}
uint8_t x = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read());
uint8_t y = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read());
uint8_t c = Serial.read() - '0';
display.setCursor(x, y);
display.setTextColor(c, !c);
display.setTextSize(Serial.read() - '0');
while (true)
{
if (!Serial.available())
{
delay(1);
}
if (Serial.peek() == '\n')
break;
if (Serial.peek() == '\r')
{
Serial.read();
continue;
};
char c = Serial.read();
if (c == '\\' && Serial.peek() == 'n')
{
display.println();
Serial.read();
}
else
{
display.write(c);
}
}
return;
}
// Return buffer (show pixels on console)
case 'r':
case 'R': //R
{
display.printBuffer();
Serial.flush();
break;
}
// Set Pixel
case 'S':
display.setDirect(true);
case 's': // Sxxyyc (xx = 8 bit hex x position; y = 8 bit y position; c = 0/1 color)
{
while (Serial.available() <= 6)
{
delay(1);
}
uint8_t x = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read());
uint8_t y = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read());
bool color = hex2dec(Serial.read());
display.drawPixel(x, y, color);
display.setDirect(false);
break;
}
// Set Timing
case 'T':
case 't': // Tttt (ttt = decimal timing in us)
{
while (Serial.available() <= 6)
{
delay(1);
}
uint16_t t = atoi(Serial.readStringUntil('\n').c_str());
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f');
}
EEPROM.update(address, t);
EEPROM.update(address2, t);
uint8_t hex2byte(const char *s)
{
return (hex2dec(s[0]) << 4) | hex2dec(s[1]);
}
display.setTiming(t);
break;
}
default:
#ifdef DEBUG
Serial.print("0x");
Serial.print(cmd >> 4, HEX);
Serial.print(cmd & 3, HEX);
Serial.println(" - command not known.");
Serial.flush();
#endif
break;
}
}
// Refresh content every REFRESH_TIME (by default 2 min)
if (refresh_at < millis())
void printError(const __FlashStringHelper *msg)
{
Serial.print(F("ERR: "));
Serial.println(msg);
}
uint16_t loadFlipTime()
{
uint16_t magic, t;
EEPROM.get(EEPROM_MAGIC_ADDR, magic);
EEPROM.get(EEPROM_FLIP_TIME_ADDR, t);
if (magic != EEPROM_MAGIC || t < FLIP_TIME_MIN || t > FLIP_TIME_MAX)
return FLIP_TIME_DEFAULT;
return t;
}
void saveFlipTime(uint16_t t)
{
// put() only writes bytes that changed
EEPROM.put(EEPROM_FLIP_TIME_ADDR, t);
EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC);
}
// Force a full redraw every REFRESH_TIME. Only called when no data is expected,
// because the redraw blocks long enough to overflow the 64 byte serial buffer.
void refreshIfDue()
{
if (millis() - last_refresh >= REFRESH_TIME)
{
refresh_at = millis() + REFRESH_TIME;
display.display(true);
last_refresh = millis();
}
}
// BxxyywwhhcBITMAP (xx/ww = 8 bit hex x position/width; yy/hh = 8 bit hex y position/height;
// c = 0/1 color; BITMAP = one hex digit per 4 pixels, row by row, width must be a multiple of 4)
bool startBitmap()
{
bitmap_x = hex2byte(line + 1);
bitmap_y = hex2byte(line + 3);
bitmap_w = hex2byte(line + 5);
bitmap_h = hex2byte(line + 7);
bitmap_color = line[9] != '0';
if (bitmap_w == 0 || bitmap_w % 4)
{
printError(F("Invalid bitmap width (not dividable by 4)"));
return false;
}
if (bitmap_h == 0)
{
printError(F("Invalid bitmap height (0)"));
return false;
}
bitmap_nibble = 0;
bitmap_active = true;
return true;
}
void bitmapData(char c)
{
if (!isHex(c))
return;
uint16_t pixel = bitmap_nibble * 4;
if (pixel >= (uint16_t)bitmap_w * bitmap_h)
return; // more data than the bitmap size, ignore
uint8_t b = hex2dec(c);
for (uint8_t i = 0; i < 4; i++)
{
if (b & (0x8 >> i))
{
uint16_t p = pixel + i;
display.drawPixel(bitmap_x + p % bitmap_w, bitmap_y + p / bitmap_w, bitmap_color);
}
}
bitmap_nibble++;
}
// Handle one complete command line (without the '\n')
void processLine()
{
if (line_len == 0)
return;
line[line_len] = '\0';
char cmd = line[0];
switch (cmd)
{
// Bitmap header shorter than 10 characters (complete headers are handled in loop())
case 'b':
case 'B':
printError(F("Incomplete bitmap header"));
break;
// Display
case 'd':
case 'D': // D
display.display();
// The ESP sends D at the end of a frame, so the line is quiet now
refreshIfDue();
break;
// Fill Screen
case 'f':
case 'F': // Fc (c = 0/1 color)
if (line_len < 2)
{
printError(F("Missing color"));
break;
}
display.fillScreen(line[1] != '0');
break;
// Print Text
case 'p':
case 'P': // PxxyycsTEXT (xx = 8 bit hex x position; yy = 8 bit hex y position; c = 0/1 color; s = 1-9 size; TEXT = string, newline = escaped \n)
{
if (line_len < 7)
{
printError(F("Incomplete print command"));
break;
}
bool color = line[5] != '0';
display.setCursor(hex2byte(line + 1), hex2byte(line + 3));
display.setTextColor(color, !color);
display.setTextSize(line[6] - '0');
for (uint8_t i = 7; i < line_len; i++)
{
if (line[i] == '\\' && line[i + 1] == 'n')
{
display.println();
i++;
}
else
{
display.write(line[i]);
}
}
break;
}
// Return buffer (show pixels on console)
case 'r':
case 'R': // R
display.printBuffer();
Serial.flush();
break;
// Set Pixel (S = flip immediately, s = buffer only)
case 'S':
case 's': // Sxxyyc (xx = 8 bit hex x position; yy = 8 bit hex y position; c = 0/1 color)
if (line_len < 6)
{
printError(F("Incomplete pixel command"));
break;
}
display.setDirect(cmd == 'S');
display.drawPixel(hex2byte(line + 1), hex2byte(line + 3), hex2dec(line[5]));
display.setDirect(false);
break;
// Set Timing
case 'T':
case 't': // Tttt (ttt = decimal flip time in us)
{
long t = atol(line + 1);
if (t < FLIP_TIME_MIN || t > FLIP_TIME_MAX)
{
printError(F("Flip time out of range (50-2000 us)"));
break;
}
display.setTiming(t);
saveFlipTime(t);
break;
}
default:
#ifdef DEBUG
Serial.print("0x");
Serial.print(cmd >> 4, HEX);
Serial.print(cmd & 0xF, HEX);
Serial.println(" - command not known.");
Serial.flush();
#endif
break;
}
}
void setup()
{
Serial.begin(115200);
display.setTiming(loadFlipTime());
display.begin();
#ifdef MEASURE_REFRESH
delay(1000);
display.fillScreen(1);
display.display();
display.fillScreen(0);
uint32_t t1 = millis();
display.display();
uint32_t t2 = millis();
Serial.println(t2 - t1);
#endif
last_refresh = millis();
last_rx = millis();
}
void loop()
{
while (Serial.available())
{
char c = Serial.read();
last_rx = millis();
if (bitmap_active)
{
if (c == '\n')
bitmap_active = false;
else
bitmapData(c);
continue;
}
if (c == '\n')
{
if (!discard_line)
processLine();
line_len = 0;
discard_line = false;
continue;
}
if (c == '\r' || discard_line)
continue;
if (line_len >= LINE_MAX)
{
printError(F("Line too long"));
discard_line = true;
continue;
}
line[line_len++] = c;
// Bitmap header complete: draw the pixel data as it arrives
if (line_len == 10 && (line[0] == 'b' || line[0] == 'B'))
{
line_len = 0;
if (!startBitmap())
discard_line = true;
}
}
// No frames coming in (e.g. ESP idle): do the full redraw now if it is due
if (millis() - last_rx >= IDLE_TIME)
refreshIfDue();
}