Send only the changed dots of a frame to the Nano

Frames were sent as F0 plus one s command per yellow dot (thousands of
characters). During scrolling that overran the Nano's serial buffer, and
half-received frames redrew the whole screen. showBitmap() now sends two B
commands over the changed rectangle (dots off, dots on), or one B command for
a full frame, and the pacing counts the characters sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 21:14:48 +02:00
1 parent 89001ddbc6
commit 47ef65ee7d
8 files changed
+262 -25

No files matched your search

+4 -1
View File
@@ -147,7 +147,10 @@ See README.md for the overview.
old hardcoded clock (also the default and the HA "Clock" option).
- Moving pages: `renderPage()` returns when the page changes next (scroll step, second);
`FlipdotFrames` sends those frames as `D1` (no effect) and paces them by the estimated
flip time, because the Nano has no acknowledgement. Character 127 is the degree sign in
receive and flip time, because the Nano has no acknowledgement. Full screen frames go
through `BROSE9323::showBitmap()`: only the changed dots, as two `B` commands over the
changed rectangle (`lib/BROSE9323/frame_commands.h`, tested with the Nano parser in
`test_nano`). Don't send frames pixel by pixel (`s` commands are 7 characters per dot). Character 127 is the degree sign in
page texts and fonts (UTF-8 ° in JSON).
- Fonts use the "FDF1" format of `logic/font.h`; the web page writes the same format.
`logic/builtin_fonts.cpp` is generated: change `tools/make_builtin_fonts.py` and rerun it.
+32
View File
@@ -23,6 +23,38 @@ void BROSE9323::begin(Stream *s)
{
stream = s;
}
size_t BROSE9323::showBitmap(const uint8_t *bitmap, bool changes_only, bool instant)
{
// What the Nano shows now, in drawBitmap format (the buffers keep the bits the other
// way around)
uint8_t *shown = (uint8_t *)malloc(_buffer_size);
if (!shown)
return 0;
for (uint16_t i = 0; i < _buffer_size; i++)
{
uint8_t b = _old_buffer[i], r = 0;
for (uint8_t k = 0; k < 8; k++)
if (b & (1 << k))
r |= 0x80 >> k;
shown[i] = r;
}
size_t sent = frameCommands(bitmap, changes_only ? shown : nullptr, width(), height(), instant,
[this](const char *text) { stream->print(text); });
free(shown);
for (uint8_t y = 0; y < height(); y++)
for (uint8_t x = 0; x < width(); x++)
{
uint8_t &byte = _new_buffer[y * _buffer_width + x / 8];
if (bitmap[y * _buffer_width + x / 8] & (0x80 >> (x & 7)))
byte |= 1 << (x & 7);
else
byte &= ~(1 << (x & 7));
}
memcpy(_old_buffer, _new_buffer, _buffer_size);
return sent;
}
#else
void BROSE9323::begin(void)
{
+8
View File
@@ -4,6 +4,9 @@
#include <Adafruit_GFX.h>
#include "dot_sequence.h"
#ifdef ESPSERIAL
#include "frame_commands.h"
#endif
//#define FLIPDOT_PLCC_ADAPTER
// ESPSERIAL is set per target via build_flags in platformio.ini (-D ESPSERIAL for the ESP32)
@@ -103,6 +106,11 @@ public:
BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t ft = 280);
#ifdef ESPSERIAL
void begin(Stream * = &Serial);
// Shows a full screen bitmap (drawBitmap format) and ends the frame (D, or D1 when
// instant). changes_only: only the dots that differ from the last frame are sent
// (frame_commands.h); otherwise the whole screen. Returns the characters sent (0:
// nothing changed, nothing sent).
size_t showBitmap(const uint8_t *bitmap, bool changes_only, bool instant = false);
#endif
void begin(void);
// Shows the buffer: the changed dots, all with force. instant: without the refresh
+95
View File
@@ -0,0 +1,95 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
// The serial commands (src/nano/README.md) that bring the Nano from the image `shown` to
// `wanted`: over the rectangle that changed, one B command that switches dots off and one
// that switches dots on, then D (or D1 when instant). Only the changed dots are in them,
// so the Nano only touches those and the line stays short (scrolling text: a small
// window). Without `shown` (nullptr): F0 and the whole image as one B command.
//
// Bitmaps in drawBitmap format: rows of (width + 7) / 8 bytes, the most significant bit
// is the leftmost dot. `out(const char *)` gets the text in pieces. Returns the number of
// characters sent, 0 when nothing changed (then nothing is sent, not even D).
// Header only, so it is used by the ESP32 driver and tested with the Nano's parser.
template <typename Out>
size_t frameCommands(const uint8_t *wanted, const uint8_t *shown, uint8_t width, uint8_t height, bool instant,
Out out)
{
const int row_bytes = (width + 7) / 8;
auto dot = [row_bytes](const uint8_t *bitmap, int x, int y) {
return (bitmap[y * row_bytes + x / 8] >> (7 - (x & 7))) & 1;
};
size_t sent = 0;
auto emit = [&](const char *text) {
out(text);
sent += strlen(text);
};
int x0 = width, x1 = -1, y0 = height, y1 = -1;
if (shown)
{
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
if (dot(wanted, x, y) != dot(shown, x, y))
{
x0 = x < x0 ? x : x0;
x1 = x > x1 ? x : x1;
y0 = y < y0 ? y : y0;
y1 = y > y1 ? y : y1;
}
if (x1 < 0)
return 0;
}
else
{
x0 = 0;
y0 = 0;
x1 = width - 1;
y1 = height - 1;
emit("F0\n");
}
// B needs a width that is a multiple of 4; dots past the right edge are never set
int w = (x1 - x0 + 4) / 4 * 4, h = y1 - y0 + 1;
// Whether a dot is in the command for this color
auto in = [&](int x, int y, int color) {
if (x >= width || dot(wanted, x, y) != color)
return false;
return !shown || dot(shown, x, y) != color;
};
for (int color = shown ? 0 : 1; color <= 1; color++)
{
bool any = false;
for (int y = y0; y <= y1 && !any; y++)
for (int x = x0; x <= x1 && !any; x++)
any = in(x, y, color);
if (!any)
continue;
char text[24];
snprintf(text, sizeof(text), "B%02x%02x%02x%02x%d", x0, y0, w, h, color);
emit(text);
for (int y = y0; y <= y1; y++)
{
char row[64 + 1]; // up to 256 dots
int n = 0;
for (int i = 0; i < w / 4; i++)
{
int nibble = 0;
for (int k = 0; k < 4; k++)
if (in(x0 + i * 4 + k, y, color))
nibble |= 8 >> k;
row[n++] = "0123456789ABCDEF"[nibble];
}
row[n] = '\0';
emit(row);
}
emit("\n");
}
emit(instant ? "D1\n" : "D\n");
return sent;
}
+4 -3
View File
@@ -49,9 +49,10 @@ const uint32_t POWER_OFF_DELAY = 30000;
// timeout (a read blocks loop() for at most about twice this)
const uint32_t HA_POLL_INTERVAL = 60000;
const uint32_t HA_HTTP_TIMEOUT = 1500;
// Moving dynamic pages (scrolling, seconds): sending a frame to the Nano takes about this
// long (a bitmap at 115200 baud), plus the flip time of the changed dots
const uint32_t FRAME_SEND_TIME = 60;
// Moving dynamic pages (scrolling, seconds): the next frame waits until the Nano received
// this one (characters per ms at NANO_BAUD) and flipped its changed dots, plus a margin
const uint32_t NANO_CHARS_PER_MS = NANO_BAUD / 10000;
const uint32_t FRAME_MARGIN = 20;
// ---------------------------------------------------------------------------
// Flashing the Nano over WiFi (see README.md, pio run -e nano_ota -t upload)
+13 -15
View File
@@ -10,18 +10,15 @@ FlipdotFrames::FlipdotFrames(BROSE9323 &display, PageResources &resources) : _di
{
}
// Only the dots that changed are sent (unless redraw: the Nano's image is unknown)
uint32_t FlipdotFrames::dynamicPage(const LocalTime &time, uint32_t now, bool redraw)
{
uint8_t bitmap[BITMAP_BYTES];
uint32_t wait = renderPage(_dynamic, time, now, _resources, bitmap);
bool moving = wait != 0;
if (!redraw && memcmp(bitmap, _last_dynamic, BITMAP_BYTES) == 0)
return wait;
// The Nano has no acknowledgement: wait until it has flipped the dots of this frame,
// or the next frame overflows its serial buffer
uint32_t busy = _flipMs(bitmap, redraw);
_drawFullScreen(bitmap, moving);
memcpy(_last_dynamic, bitmap, BITMAP_BYTES);
// The Nano has no acknowledgement: the next frame waits until it has received this one
// and flipped its dots, or the next frame overflows its serial buffer
uint32_t busy = _show(bitmap, !redraw, moving);
return moving && wait < busy ? busy : wait;
}
@@ -30,14 +27,17 @@ void FlipdotFrames::setDrive(uint16_t flip_time, const DriveSettings &drive)
_dot_us = (uint32_t)drive.pulses * (flip_time + drive.flip_gap);
}
// About how long the Nano takes to receive and show the bitmap (all: every dot flips)
uint32_t FlipdotFrames::_flipMs(const uint8_t *bitmap, bool all) const
// Sends the bitmap; returns about how long the Nano takes to receive and show it in ms
uint32_t FlipdotFrames::_show(const uint8_t *bitmap, bool changes_only, bool instant)
{
uint32_t dots = 0;
for (int y = 0; y < BITMAP_HEIGHT; y++)
for (int x = 0; x < BITMAP_WIDTH; x++)
dots += all || bitmapPixel(bitmap, x, y) != _display.shownPixel(x, y);
return FRAME_SEND_TIME + dots * _dot_us / 1000;
dots += bitmapPixel(bitmap, x, y) != _display.shownPixel(x, y);
size_t chars = _display.showBitmap(bitmap, changes_only, instant);
if (chars == 0)
return 0;
return chars / NANO_CHARS_PER_MS + dots * _dot_us / 1000 + FRAME_MARGIN;
}
void FlipdotFrames::noWifi(int hour, int minute)
@@ -86,9 +86,7 @@ void FlipdotFrames::setOffBitmap(const uint8_t *bitmap)
memset(_off, 0, BITMAP_BYTES);
}
void FlipdotFrames::_drawFullScreen(const uint8_t *bitmap, bool instant)
void FlipdotFrames::_drawFullScreen(const uint8_t *bitmap)
{
_display.fillScreen(0);
_display.drawBitmap(0, 0, bitmap, BITMAP_WIDTH, BITMAP_HEIGHT, 1);
_display.display(false, instant);
_show(bitmap, false, false);
}
+5 -6
View File
@@ -12,9 +12,9 @@ class FlipdotFrames : public Frames
public:
FlipdotFrames(BROSE9323 &display, PageResources &resources);
// The page set with setDynamicPage(), drawn for this time. Not sent when it looks the
// same as the last frame (unless redraw). Moving pages are sent without the refresh
// effect, and not faster than the Nano can flip the dots.
// The page set with setDynamicPage(), drawn for this time. Only the changed dots are
// sent (nothing when it looks the same), unless redraw. Moving pages are sent without
// the refresh effect, and not faster than the Nano can receive and flip them.
uint32_t dynamicPage(const LocalTime &time, uint32_t now, bool redraw) override;
// "No WiFi" with the time below it
void noWifi(int hour, int minute) override;
@@ -37,12 +37,11 @@ private:
BROSE9323 &_display;
PageResources &_resources;
DynamicPage _dynamic;
uint8_t _last_dynamic[BITMAP_BYTES] = {};
uint32_t _dot_us = 1000; // per changed dot: pulses x (flip time + gap)
uint8_t _page[BITMAP_BYTES] = {};
uint8_t _live[BITMAP_BYTES] = {};
uint8_t _off[BITMAP_BYTES] = {}; // all zero: blank
void _drawFullScreen(const uint8_t *bitmap, bool instant = false);
uint32_t _flipMs(const uint8_t *bitmap, bool all) const;
void _drawFullScreen(const uint8_t *bitmap);
uint32_t _show(const uint8_t *bitmap, bool changes_only, bool instant);
};
+101
View File
@@ -8,8 +8,11 @@
#include "Arduino.h"
#include "BROSE9323.h"
#include "EEPROM.h"
#include "frame_commands.h" // the real one, from lib/BROSE9323
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
// From src/nano/main.cpp
@@ -190,6 +193,101 @@ void test_full_screen_bitmap_is_streamed()
TEST_ASSERT_EQUAL(84 * 19, display.count("pixel"));
}
// The commands the ESP32 driver sends for a frame (lib/BROSE9323/frame_commands.h), run
// through this parser: the drawn dots must give the wanted image.
static const int IMG_BYTES = 11 * 19;
static std::string frame(const uint8_t *wanted, const uint8_t *shown, bool instant)
{
std::string text;
frameCommands(wanted, shown, 84, 19, instant, [&text](const char *t) { text += t; });
return text;
}
static void setDot(uint8_t *img, int x, int y, bool on)
{
if (on)
img[y * 11 + x / 8] |= 0x80 >> (x & 7);
else
img[y * 11 + x / 8] &= ~(0x80 >> (x & 7));
}
// The recorded fill and pixel calls applied to an image
static void applyCalls(uint8_t *img)
{
for (const DisplayCall &call : display.calls)
if (call.what == "fill")
memset(img, call.a ? 0xFF : 0, IMG_BYTES);
else if (call.what == "pixel")
setDot(img, call.a, call.b, call.c);
}
static void pattern(uint8_t *img, int seed)
{
for (int i = 0; i < IMG_BYTES; i++)
img[i] = (uint8_t)(i * 37 + seed * 11);
for (int y = 0; y < 19; y++) // nothing past the 84th dot
img[y * 11 + 10] &= 0xF0;
}
void test_frame_commands_send_only_the_changed_dots()
{
uint8_t shown[IMG_BYTES], wanted[IMG_BYTES];
pattern(shown, 1);
memcpy(wanted, shown, IMG_BYTES);
int changed = 0;
for (int x = 30; x < 50; x++) // a scroll window, 6 rows high
for (int y = 12; y < 18; y++)
if ((x + y) % 3 == 0)
{
bool now = (shown[y * 11 + x / 8] >> (7 - (x & 7))) & 1;
setDot(wanted, x, y, !now);
changed++;
}
std::string text = frame(wanted, shown, true);
TEST_ASSERT_TRUE(text.size() < 150); // instead of the whole screen
send(text);
TEST_ASSERT_FALSE(replied("ERR"));
TEST_ASSERT_EQUAL(0, display.count("fill"));
TEST_ASSERT_EQUAL(changed, display.count("pixel")); // only the changed dots
uint8_t result[IMG_BYTES];
memcpy(result, shown, IMG_BYTES);
applyCalls(result);
TEST_ASSERT_EQUAL_MEMORY(wanted, result, IMG_BYTES);
TEST_ASSERT_EQUAL(1, display.count("display"));
TEST_ASSERT_EQUAL(1, display.last("display")->b); // D1: instant
}
void test_frame_commands_full_frame()
{
uint8_t wanted[IMG_BYTES];
pattern(wanted, 2);
std::string text = frame(wanted, nullptr, false);
TEST_ASSERT_TRUE(text.size() < 420);
send(text);
TEST_ASSERT_FALSE(replied("ERR"));
TEST_ASSERT_EQUAL(1, display.count("fill"));
uint8_t result[IMG_BYTES];
memset(result, 0xAA, IMG_BYTES);
applyCalls(result);
TEST_ASSERT_EQUAL_MEMORY(wanted, result, IMG_BYTES);
TEST_ASSERT_EQUAL(0, display.last("display")->b); // D: with the effect
}
void test_frame_commands_edge_and_nothing_changed()
{
uint8_t shown[IMG_BYTES] = {}, wanted[IMG_BYTES] = {};
TEST_ASSERT_EQUAL_STRING("", frame(wanted, shown, true).c_str());
setDot(wanted, 83, 18, true); // the last dot: the B width (4) goes past the edge
std::string text = frame(wanted, shown, false);
send(text);
TEST_ASSERT_FALSE(replied("ERR"));
TEST_ASSERT_EQUAL(1, display.count("pixel"));
TEST_ASSERT_EQUAL(83, display.last("pixel")->a);
TEST_ASSERT_EQUAL(18, display.last("pixel")->b);
}
void test_invalid_bitmap_data_is_not_run_as_commands()
{
// width 5 is invalid; the data contains the command letters B, D and F
@@ -528,6 +626,9 @@ int main()
RUN_TEST(test_bitmap_pixels);
RUN_TEST(test_full_screen_bitmap_is_streamed);
RUN_TEST(test_frame_commands_send_only_the_changed_dots);
RUN_TEST(test_frame_commands_full_frame);
RUN_TEST(test_frame_commands_edge_and_nothing_changed);
RUN_TEST(test_invalid_bitmap_data_is_not_run_as_commands);
RUN_TEST(test_incomplete_bitmap_header);