From 80f3f7815204b994cc7435fabc296f850896b564 Mon Sep 17 00:00:00 2001 From: MangaValk Date: Tue, 13 Oct 2026 00:19:15 +0200 Subject: [PATCH] Split Nano firmware into modules - config.h: all constants and debug defines - command_parser.h/.cpp: CommandParser class, one method per command - settings.h/.cpp: EEPROM flip time storage - main.cpp: setup/loop and the periodic full redraw only - Full redraw now checks frameEnded() after the serial buffer is drained - Drop the SPI/Adafruit_I2CDevice include workaround (not needed) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- src/nano/README.md | 13 +- src/nano/command_parser.cpp | 258 ++++++++++++++++++++++++++++++ src/nano/command_parser.h | 47 ++++++ src/nano/config.h | 42 +++++ src/nano/main.cpp | 307 ++++-------------------------------- src/nano/settings.cpp | 27 ++++ src/nano/settings.h | 10 ++ 8 files changed, 429 insertions(+), 279 deletions(-) create mode 100644 src/nano/command_parser.cpp create mode 100644 src/nano/command_parser.h create mode 100644 src/nano/config.h create mode 100644 src/nano/settings.cpp create mode 100644 src/nano/settings.h diff --git a/CLAUDE.md b/CLAUDE.md index b83ef65..b685bb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ See README.md for the overview. ## Nano serial parser (`src/nano/main.cpp`) +- Files: `main.cpp` (setup/loop, refresh), `config.h` (all constants and debug + defines), `command_parser.*` (protocol, one method per command), `settings.*` (EEPROM). - 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. @@ -53,7 +55,7 @@ See README.md for the overview. - The Nano only replies `READY` (at boot) and `ERR: ...`. There is no per-frame acknowledgement. Whether the Nano's TX is wired to the ESP's RX is unconfirmed, so every wait for a reply needs a timeout. -- To test the parser without hardware, compile `main.cpp` with g++ (Strawberry Perl +- To test the parser without hardware, compile all `src/nano/*.cpp` files with g++ (Strawberry Perl provides it at `C:\Strawberry\c\bin`) and mock headers for `Arduino.h`, `EEPROM.h` and `BROSE9323.h`. diff --git a/src/nano/README.md b/src/nano/README.md index a5b7026..91aae5f 100644 --- a/src/nano/README.md +++ b/src/nano/README.md @@ -6,6 +6,17 @@ The Nano is connected directly to the Brose panel electronics. It keeps an 84 × buffer and takes drawing commands over serial (115200 baud). It has no clock or network logic. +## Files + +| File | Contents | +|------|----------| +| `main.cpp` | `setup()` / `loop()`, feeds serial bytes to the parser, periodic full redraw | +| `config.h` | All settings: debug options, display size, flip time limits, timings | +| `command_parser.h/.cpp` | `CommandParser`: the serial protocol, one method per command | +| `settings.h/.cpp` | Flip time stored in EEPROM | + +The display driver itself is in [lib/BROSE9323](../../lib/BROSE9323/README.md). + ## Behaviour - **Boot:** the Nano loads the flip time from EEPROM, sets up the pins and sends `READY`. @@ -24,7 +35,7 @@ logic. ### Debug options -Uncomment these at the top of `main.cpp`: +Uncomment these in `config.h`: | Define | Effect | |--------|--------| diff --git a/src/nano/command_parser.cpp b/src/nano/command_parser.cpp new file mode 100644 index 0000000..00e1231 --- /dev/null +++ b/src/nano/command_parser.cpp @@ -0,0 +1,258 @@ +#include "command_parser.h" + +#include "settings.h" + +// --------------------------------------------------------------------------- +// Hex helpers +// --------------------------------------------------------------------------- + +static bool isHex(char c) +{ + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); +} + +static uint8_t hex2dec(char c) +{ + 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 0; +} + +static uint8_t hex2byte(const char *s) +{ + return (hex2dec(s[0]) << 4) | hex2dec(s[1]); +} + +static bool isBitmapCommand(char c) +{ + return c == 'b' || c == 'B'; +} + +// Length of a bitmap header: BxxyywwhhC +static const uint8_t BITMAP_HEADER_LEN = 10; + +// --------------------------------------------------------------------------- +// Input handling +// --------------------------------------------------------------------------- + +CommandParser::CommandParser(BROSE9323 &display, Print &reply) : _display(display), _reply(reply) +{ +} + +void CommandParser::feed(char c) +{ + if (_bitmap_active) + { + if (c == '\n') + _bitmap_active = false; + else + _bitmapData(c); + return; + } + + if (c == '\n') + { + if (!_discard_line) + _runLine(); + _line_len = 0; + _discard_line = false; + return; + } + if (c == '\r' || _discard_line) + return; + if (_line_len >= LINE_MAX) + { + _error(F("Line too long")); + _discard_line = true; + return; + } + _line[_line_len++] = c; + _frame_ended = false; + + // Bitmap header complete: draw the pixel data as it arrives + if (_line_len == BITMAP_HEADER_LEN && isBitmapCommand(_line[0])) + { + _line_len = 0; + if (!_bitmapStart()) + _discard_line = true; + } +} + +bool CommandParser::frameEnded() const +{ + return _frame_ended; +} + +// Run one complete command line (without the '\n') +void CommandParser::_runLine() +{ + if (_line_len == 0) + return; + _line[_line_len] = '\0'; + + char cmd = _line[0]; + switch (cmd) + { + case 'b': + case 'B': + // Complete bitmap headers are handled in feed() + _error(F("Incomplete bitmap header")); + break; + case 'd': + case 'D': + _display.display(); + _frame_ended = true; + break; + case 'f': + case 'F': + _fill(); + break; + case 'p': + case 'P': + _print(); + break; + case 'r': + case 'R': + _display.printBuffer(); + _reply.flush(); + break; + case 's': + case 'S': + _pixel(cmd == 'S'); + break; + case 't': + case 'T': + _timing(); + break; + default: +#ifdef DEBUG + _reply.print(F("0x")); + _reply.print(cmd >> 4, HEX); + _reply.print(cmd & 0xF, HEX); + _reply.println(F(" - command not known.")); +#endif + break; + } +} + +void CommandParser::_error(const __FlashStringHelper *msg) +{ + _reply.print(F("ERR: ")); + _reply.println(msg); +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +// 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 CommandParser::_bitmapStart() +{ + _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) + { + _error(F("Invalid bitmap width (not dividable by 4)")); + return false; + } + if (_bitmap_h == 0) + { + _error(F("Invalid bitmap height (0)")); + return false; + } + _bitmap_nibble = 0; + _bitmap_active = true; + return true; +} + +void CommandParser::_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++; +} + +// Fc (c = 0/1 color) +void CommandParser::_fill() +{ + if (_line_len < 2) + { + _error(F("Missing color")); + return; + } + _display.fillScreen(_line[1] != '0'); +} + +// 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) +void CommandParser::_print() +{ + if (_line_len < 7) + { + _error(F("Incomplete print command")); + return; + } + 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]); + } + } +} + +// sxxyyc / Sxxyyc (xx = 8 bit hex x position; yy = 8 bit hex y position; c = 0/1 color) +// s only changes the buffer, S flips the dot immediately +void CommandParser::_pixel(bool direct) +{ + if (_line_len < 6) + { + _error(F("Incomplete pixel command")); + return; + } + _display.setDirect(direct); + _display.drawPixel(hex2byte(_line + 1), hex2byte(_line + 3), hex2dec(_line[5])); + _display.setDirect(false); +} + +// Tttt (ttt = decimal flip time in us), stored in EEPROM +void CommandParser::_timing() +{ + long t = atol(_line + 1); + if (t < FLIP_TIME_MIN || t > FLIP_TIME_MAX) + { + _error(F("Flip time out of range (50-2000 us)")); + return; + } + _display.setTiming(t); + saveFlipTime(t); +} diff --git a/src/nano/command_parser.h b/src/nano/command_parser.h new file mode 100644 index 0000000..6b83a17 --- /dev/null +++ b/src/nano/command_parser.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include "BROSE9323.h" +#include "config.h" + +// Parses the line based serial protocol from the ESP32 (see README.md) and draws on the +// display. Bytes are fed one by one; a command runs when its '\n' arrives, so nothing +// here blocks while waiting for data. +class CommandParser +{ +public: + CommandParser(BROSE9323 &display, Print &reply); + + // Handle one received byte + void feed(char c); + + // True when the last command was D (end of a frame) and no new command has started + bool frameEnded() const; + +private: + BROSE9323 &_display; + Print &_reply; + + char _line[LINE_MAX + 1]; + uint8_t _line_len = 0; + bool _discard_line = false; // skip everything until the next '\n' + bool _frame_ended = false; + + // 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; + + void _runLine(); + void _error(const __FlashStringHelper *msg); + + // Commands + bool _bitmapStart(); + void _bitmapData(char c); + void _fill(); + void _print(); + void _pixel(bool direct); + void _timing(); +}; diff --git a/src/nano/config.h b/src/nano/config.h new file mode 100644 index 0000000..a41695f --- /dev/null +++ b/src/nano/config.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +// --------------------------------------------------------------------------- +// Debug options (uncomment to enable) +// --------------------------------------------------------------------------- + +// Print unknown command bytes on the serial port +//#define DEBUG + +// Time a full redraw at boot and print the duration in ms +//#define MEASURE_REFRESH + +// --------------------------------------------------------------------------- +// Display +// --------------------------------------------------------------------------- + +// Three Brose panels of 28 x 19 next to each other +const uint8_t DISPLAY_WIDTH = 84; +const uint8_t DISPLAY_HEIGHT = 19; +const uint8_t PANEL_WIDTH = 28; + +// 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; + +// Full redraw interval, fixes dots that did not flip +const uint32_t REFRESH_TIME = 120000UL; // 2 min + +// --------------------------------------------------------------------------- +// Serial link to the ESP32 +// --------------------------------------------------------------------------- + +const uint32_t SERIAL_BAUD = 115200; + +// Serial line quiet for this long: safe to do a due full redraw without waiting for a D +const uint32_t IDLE_TIME = 2000UL; + +// Longest command line (bitmap data is streamed and does not count) +const uint8_t LINE_MAX = 64; diff --git a/src/nano/main.cpp b/src/nano/main.cpp index 71daadf..2847341 100644 --- a/src/nano/main.cpp +++ b/src/nano/main.cpp @@ -1,257 +1,35 @@ +// Arduino Nano firmware: drives the Brose flipdot panels and draws what the ESP32 sends +// over serial. See README.md for the protocol and wiring. + #include + #include "BROSE9323.h" -// fixes -#include -#include +#include "command_parser.h" +#include "config.h" +#include "settings.h" -#include - -//#define DEBUG -// Print how long a full redraw takes (in ms) at startup -//#define MEASURE_REFRESH - -// 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; - -// 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; - -// 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; - -// 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; +BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH, FLIP_TIME_DEFAULT); +CommandParser parser(display, Serial); uint32_t last_refresh; uint32_t last_rx; -uint8_t hex2dec(char c) -{ - 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 0; -} - -bool isHex(char c) -{ - return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); -} - -uint8_t hex2byte(const char *s) -{ - return (hex2dec(s[0]) << 4) | hex2dec(s[1]); -} - -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. +// Force a full redraw every REFRESH_TIME. The redraw blocks long enough to overflow the +// 64 byte serial buffer, so it only runs when no data is expected: right after the ESP +// finished a frame, or when the serial line is idle. void refreshIfDue() { - if (millis() - last_refresh >= REFRESH_TIME) + bool quiet = parser.frameEnded() || millis() - last_rx >= IDLE_TIME; + if (quiet && millis() - last_refresh >= 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 +void measureRefresh() +{ delay(1000); display.fillScreen(1); @@ -261,6 +39,18 @@ void setup() display.display(); uint32_t t2 = millis(); Serial.println(t2 - t1); +} +#endif + +void setup() +{ + Serial.begin(SERIAL_BAUD); + + display.setTiming(loadFlipTime()); + display.begin(); + +#ifdef MEASURE_REFRESH + measureRefresh(); #endif last_refresh = millis(); @@ -274,46 +64,9 @@ 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; - } + parser.feed(Serial.read()); } - // No frames coming in (e.g. ESP idle): do the full redraw now if it is due - if (millis() - last_rx >= IDLE_TIME) - refreshIfDue(); + refreshIfDue(); } diff --git a/src/nano/settings.cpp b/src/nano/settings.cpp new file mode 100644 index 0000000..568ae8b --- /dev/null +++ b/src/nano/settings.cpp @@ -0,0 +1,27 @@ +#include "settings.h" + +#include + +#include "config.h" + +// 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; + +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); +} diff --git a/src/nano/settings.h b/src/nano/settings.h new file mode 100644 index 0000000..52372ca --- /dev/null +++ b/src/nano/settings.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +// Settings that survive a power cycle (stored in EEPROM) + +// Stored flip time, or FLIP_TIME_DEFAULT when nothing valid is stored +uint16_t loadFlipTime(); + +void saveFlipTime(uint16_t t);