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
+428 -278

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`) ## 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 - 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 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. 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 - 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 acknowledgement. Whether the Nano's TX is wired to the ESP's RX is unconfirmed, so every
wait for a reply needs a timeout. 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 provides it at `C:\Strawberry\c\bin`) and mock headers for `Arduino.h`, `EEPROM.h` and
`BROSE9323.h`. `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 buffer and takes drawing commands over serial (115200 baud). It has no clock or network
logic. 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 ## Behaviour
- **Boot:** the Nano loads the flip time from EEPROM, sets up the pins and sends `READY`. - **Boot:** the Nano loads the flip time from EEPROM, sets up the pins and sends `READY`.
@@ -24,7 +35,7 @@ logic.
### Debug options ### Debug options
Uncomment these at the top of `main.cpp`: Uncomment these in `config.h`:
| Define | Effect | | 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;
+29 -276
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 <Arduino.h>
#include "BROSE9323.h" #include "BROSE9323.h"
// fixes #include "command_parser.h"
#include <SPI.h> #include "config.h"
#include <Adafruit_I2CDevice.h> #include "settings.h"
#include <EEPROM.h> BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH, FLIP_TIME_DEFAULT);
CommandParser parser(display, Serial);
//#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;
uint32_t last_refresh; uint32_t last_refresh;
uint32_t last_rx; uint32_t last_rx;
uint8_t hex2dec(char c) // 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
if (c >= '0' && c <= '9') // finished a frame, or when the serial line is idle.
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.
void refreshIfDue() 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); display.display(true);
last_refresh = millis(); 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 #ifdef MEASURE_REFRESH
void measureRefresh()
{
delay(1000); delay(1000);
display.fillScreen(1); display.fillScreen(1);
@@ -261,6 +39,18 @@ void setup()
display.display(); display.display();
uint32_t t2 = millis(); uint32_t t2 = millis();
Serial.println(t2 - t1); Serial.println(t2 - t1);
}
#endif
void setup()
{
Serial.begin(SERIAL_BAUD);
display.setTiming(loadFlipTime());
display.begin();
#ifdef MEASURE_REFRESH
measureRefresh();
#endif #endif
last_refresh = millis(); last_refresh = millis();
@@ -274,46 +64,9 @@ void loop()
{ {
while (Serial.available()) while (Serial.available())
{ {
char c = Serial.read();
last_rx = millis(); last_rx = millis();
parser.feed(Serial.read());
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(); 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);