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 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-13 00:19:25 +02:00
1 parent 75f677a2eb
commit 80f3f78152
8 files changed
+429 -279

No files matched your search

+3 -1
View File
@@ -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`.
+12 -1
View File
@@ -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 |
|--------|--------|
+258
View File
@@ -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);
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <Arduino.h>
#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();
};
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <Arduino.h>
// ---------------------------------------------------------------------------
// 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;
+30 -277
View File
@@ -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 <Arduino.h>
#include "BROSE9323.h"
// fixes
#include <SPI.h>
#include <Adafruit_I2CDevice.h>
#include "command_parser.h"
#include "config.h"
#include "settings.h"
#include <EEPROM.h>
//#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();
}
+27
View File
@@ -0,0 +1,27 @@
#include "settings.h"
#include <EEPROM.h>
#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);
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <Arduino.h>
// 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);