diff --git a/CLAUDE.md b/CLAUDE.md index 9f12e4d..36e4ee0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/lib/BROSE9323/BROSE9323.cpp b/lib/BROSE9323/BROSE9323.cpp index e5a78be..cd14705 100644 --- a/lib/BROSE9323/BROSE9323.cpp +++ b/lib/BROSE9323/BROSE9323.cpp @@ -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) { diff --git a/lib/BROSE9323/BROSE9323.h b/lib/BROSE9323/BROSE9323.h index 4958227..41b3a6c 100644 --- a/lib/BROSE9323/BROSE9323.h +++ b/lib/BROSE9323/BROSE9323.h @@ -4,6 +4,9 @@ #include #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 diff --git a/lib/BROSE9323/frame_commands.h b/lib/BROSE9323/frame_commands.h new file mode 100644 index 0000000..bf5ccf4 --- /dev/null +++ b/lib/BROSE9323/frame_commands.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include + +// 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 +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; +} diff --git a/src/esp32/config.h b/src/esp32/config.h index d404de1..45a2b8d 100644 --- a/src/esp32/config.h +++ b/src/esp32/config.h @@ -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) diff --git a/src/esp32/display/frames.cpp b/src/esp32/display/frames.cpp index 081ad5f..d9ce125 100644 --- a/src/esp32/display/frames.cpp +++ b/src/esp32/display/frames.cpp @@ -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); } diff --git a/src/esp32/display/frames.h b/src/esp32/display/frames.h index 29b397a..14620aa 100644 --- a/src/esp32/display/frames.h +++ b/src/esp32/display/frames.h @@ -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); }; diff --git a/test/test_nano/test_main.cpp b/test/test_nano/test_main.cpp index 379699f..8916575 100644 --- a/test/test_nano/test_main.cpp +++ b/test/test_nano/test_main.cpp @@ -8,8 +8,11 @@ #include "Arduino.h" #include "BROSE9323.h" #include "EEPROM.h" +#include "frame_commands.h" // the real one, from lib/BROSE9323 #include +#include +#include #include // 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);