diff --git a/CLAUDE.md b/CLAUDE.md index 03b8c16..3f7cde2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/lib/BROSE9323/BROSE9323.cpp b/lib/BROSE9323/BROSE9323.cpp index fc6ce8c..aa72c84 100644 --- a/lib/BROSE9323/BROSE9323.cpp +++ b/lib/BROSE9323/BROSE9323.cpp @@ -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 diff --git a/src/nano/README.md b/src/nano/README.md index f9f460b..963a625 100644 --- a/src/nano/README.md +++ b/src/nano/README.md @@ -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): diff --git a/src/nano/main.cpp b/src/nano/main.cpp index 5d9d99b..256a570 100644 --- a/src/nano/main.cpp +++ b/src/nano/main.cpp @@ -6,40 +6,42 @@ #include -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; - - display.setTiming(_flip_time); +// 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.begin(); - delay(1000); +// 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.fillScreen(1); - display.display(); - display.fillScreen(0); - uint32_t t1 = millis(); - display.display(); - uint32_t t2 = millis(); - Serial.println(t2 - t1); -} +// Longest command line (bitmap data is streamed and does not count) +const uint8_t LINE_MAX = 64; + +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(); +}