Fix dense images on the Nano and the off image in slow mode

- Nano: B data is drawn fast enough for the serial line. A hex digit arrives
  every 87 us; with a division and four drawPixel() calls per digit a lit
  digit took about 105 us, so dense frames (all yellow, the panda bitmap)
  overflowed the 64 byte receive buffer: frames not shown or wrong, "Line
  too long", frame check repairs that failed the same way. Now the parser
  counts columns and rows and BROSE9323::drawDots() sets the 4 dots
  (about 20 us).
- ESP32: Power off and the start of the night draw the off image also when
  slow mode had the power off already (it powers on for it, once)
- Tests: drawDots() against drawPixel(), off image in slow mode
- Version 2.19.3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-22 00:24:14 +02:00
1 parent 41af2944bc
commit 7a28e34380
13 files changed
+130 -17

No files matched your search

+6
View File
@@ -69,6 +69,12 @@ See README.md for the overview.
longer than about 5 ms overflows the 64-byte serial receive buffer; lost bytes shift the
rest of a bitmap. That was the "picture moved up" bug. `test_driver` checks the time per
`update()` call with a simulated panel.
- The `B` data path must also keep up with the line: a hex digit arrives every 87 µs.
`_bitmapData()` counts columns and rows (no divisions) and sets each digit's 4 dots with
`BROSE9323::drawDots()`. With `p % w`, `p / w` and four `drawPixel()` calls it took about
105 µs per `F`, so dense images (all yellow, a full picture) lost bytes ("Line too long",
frame check mismatches). Check new per-character work with `avr-objdump` (the mock
clock in the tests doesn't count CPU time).
- Flip time is stored in EEPROM as a magic word at address 0 and a `uint16_t` at
address 2. Values outside 50–2000 µs are rejected.
- The Nano replies `READY` (at boot), `ERR: ...` and `C xxxx` (checksum of its image, for
+2 -1
View File
@@ -29,7 +29,8 @@
Schedule pages and rotation rules with "skip in slow mode" are left out of the rotation
(none left in the block: the chosen page).
- **23:31–08:00:** the screen is cleared (or shows the off image chosen in the web UI)
and power is cut after 30 seconds.
and power is cut after 30 seconds. When slow mode had the power off already, it is
switched on for this. The same goes for the Power switch.
3. **Home Assistant**: switches, dropdowns and sensors over MQTT, see
[Home Assistant](home-assistant.md).
4. **Nano.** The Nano reads commands from the serial line and updates its frame buffer.
+19
View File
@@ -290,6 +290,25 @@ void BROSE9323::printBuffer(void)
}
}
void BROSE9323::drawDots(int16_t x, int16_t y, uint8_t bits, bool color)
{
if (!(bits & 0xF) || y < 0 || y >= height())
return;
// Never used in direct mode (B commands), so only the buffer changes
_drawing = true;
uint8_t *row = _new_buffer + y * _buffer_width;
for (uint8_t mask = 8; mask; mask >>= 1, x++)
{
if (!(bits & mask) || x < 0 || x >= width())
continue;
uint8_t bit = 1 << (x & 7);
if (color)
row[x >> 3] |= bit;
else
row[x >> 3] &= ~bit;
}
}
void BROSE9323::_selectColumn(uint8_t col)
{
if (_active_col == col)
+4
View File
@@ -154,6 +154,10 @@ public:
bool refreshing(void) const { return _refreshing; }
#endif
#ifndef ESPSERIAL
// Draws the set bits of a hex digit of B data into the buffer (bit 3 at x, bit 0 at
// x + 3), like drawPixel() but fast enough for the serial port: dense B lines take
// about 23 us per dot with drawPixel(), and a digit arrives every 87 us
void drawDots(int16_t x, int16_t y, uint8_t bits, bool color);
void printBuffer(void);
#endif
size_t print(const char *ifsh);
+1 -1
View File
@@ -118,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.19.2"
#define FIRMWARE_VERSION "2.19.3"
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+6 -2
View File
@@ -103,8 +103,9 @@ void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &t
if (!_power_enabled)
{
if (_power.isOn())
if (_power.isOn() || _off_needed)
{
_ensureOn();
_showOnce(Screen::Off);
_off_timer.request(now, CLEAR_BEFORE_OFF_TIME + _effect_time);
}
@@ -129,8 +130,9 @@ void DisplayController::update(uint32_t now, bool time_valid, const LocalTime &t
case ScheduleMode::Night:
_slow_last = -1; // the morning starts with an update
_slow_keep = false;
if (_power.isOn())
if (_power.isOn() || _off_needed)
{
_ensureOn();
_showOnce(Screen::Off);
_off_timer.request(now, POWER_OFF_DELAY);
}
@@ -209,6 +211,7 @@ void DisplayController::_showDynamic(uint32_t now, const LocalTime &time, bool n
_dynamic_due = now + wait;
}
_screen = screen;
_off_needed = true;
_shown_hour = time.hour;
_shown_minute = time.minute;
_shown_revision = _content_revision;
@@ -232,5 +235,6 @@ void DisplayController::_showOnce(Screen screen)
break;
}
_screen = screen;
_off_needed = screen != Screen::Off;
_shown_revision = _content_revision;
}
+3
View File
@@ -109,6 +109,9 @@ private:
bool _power_enabled = true;
Screen _screen = Screen::Unknown;
// The dots show a frame other than the off screen, also while the power is off (slow
// mode): Power off and night power on to draw the off screen
bool _off_needed = false;
int _shown_hour = -1;
int _shown_minute = -1;
uint32_t _dynamic_due = 0; // millis() when a moving dynamic page is drawn again
+2 -1
View File
@@ -49,7 +49,8 @@ Arduino core, EEPROM and the display driver are replaced by fakes in
- **Nothing blocks.** Every frame (also instant ones, `D1`) is flipped from `update()` in
slices of about 1 ms, so `loop()` keeps reading the serial port. A blocking redraw used
to overflow the 64-byte receive buffer; the lost bytes shifted the rest of a bitmap
(the picture moved up and left) and nothing repaired it.
(the picture moved up and left) and nothing repaired it. Bitmap data is drawn as fast as
it arrives too (one hex digit every 87 µs); dense images used to be slower than that.
- **Every 2 minutes** (`I` command, stored in EEPROM, 0 = never) it flips every dot once
more, with one pulse, to fix dots that got stuck. This also runs in slices, only while
no frame is being received or shown, and continues where it was after a frame.
+11 -11
View File
@@ -182,28 +182,28 @@ bool CommandParser::_bitmapStart()
_error(F("Invalid bitmap height (0)"));
return false;
}
_bitmap_nibble = 0;
_bitmap_col = 0;
_bitmap_row = 0;
_bitmap_active = true;
return true;
}
// Runs for every hex digit while the line arrives (one every 87 us at 115200 baud), so it
// must be quick: no divisions, and drawDots() instead of four drawPixel() calls. Slower
// than that, a dense line (all yellow) overflowed the 64 byte receive buffer.
void CommandParser::_bitmapData(char c)
{
if (!isHex(c))
return;
uint16_t pixel = _bitmap_nibble * 4;
if (pixel >= (uint16_t)_bitmap_w * _bitmap_h)
if (_bitmap_row >= _bitmap_h)
return; // more data than the bitmap size, ignore
uint8_t b = hex2dec(c);
for (uint8_t i = 0; i < 4; i++)
_display.drawDots(_bitmap_x + _bitmap_col, _bitmap_y + _bitmap_row, hex2dec(c), _bitmap_color);
_bitmap_col += 4;
if (_bitmap_col >= _bitmap_w)
{
if (b & (0x8 >> i))
{
uint16_t p = pixel + i;
_display.drawPixel(_bitmap_x + p % _bitmap_w, _bitmap_y + p / _bitmap_w, _bitmap_color);
}
_bitmap_col = 0;
_bitmap_row++;
}
_bitmap_nibble++;
}
// Fc (c = 0/1 color)
+1 -1
View File
@@ -33,7 +33,7 @@ private:
bool _bitmap_active = false;
uint8_t _bitmap_x, _bitmap_y, _bitmap_w, _bitmap_h;
bool _bitmap_color;
uint16_t _bitmap_nibble;
uint8_t _bitmap_col, _bitmap_row; // where the next hex digit goes, in the bitmap
void _runLine();
void _error(const __FlashStringHelper *msg);
+8
View File
@@ -27,6 +27,14 @@ public:
void begin() { log("begin"); }
void display(bool force = false, bool instant = false) { log("display", force, instant); }
void drawPixel(int16_t x, int16_t y, uint16_t color) { log(direct ? "directPixel" : "pixel", x, y, color); }
// Recorded as the pixel calls it stands for (the real one is tested in test_driver)
void drawDots(int16_t x, int16_t y, uint8_t bits, bool color)
{
log("dots", x, y, bits);
for (int i = 0; i < 4; i++)
if (bits & (8 >> i))
drawPixel(x + i, y, color);
}
void fillScreen(uint16_t color) { log("fill", color); }
void setDirect(bool d) { direct = d; }
void setTiming(uint16_t t) { log("timing", t); }
+33
View File
@@ -226,6 +226,38 @@ void test_crc_of_equal_images_is_equal()
delete b;
}
// The fast path for B data must give the same image as drawPixel(), also at the right edge
// (B widths are rounded up to 4, so x + 3 can be past it) and below the last row
void test_draw_dots_is_like_draw_pixel()
{
BROSE9323 *a = make(), *b = make();
a->fillScreen(1);
b->fillScreen(1);
for (int y = 0; y <= H; y++)
for (int x = 0; x < W; x += 4)
{
uint8_t bits = (x * 5 + y * 3) & 0xF;
bool color = (x / 4 + y) % 3 == 0;
a->drawDots(x + 2, y, bits, color);
for (int i = 0; i < 4; i++)
if (bits & (8 >> i))
b->drawPixel(x + 2 + i, y, color);
}
TEST_ASSERT_EQUAL_HEX16(b->crc(), a->crc());
for (int x = 0; x < W; x++)
for (int y = 0; y < H; y++)
{
a->drawPixel(x, y, 0);
b->drawPixel(x, y, 0);
}
a->drawDots(-2, 0, 0xF, 1); // dots 0 and 1 of the first row
b->drawPixel(0, 0, 1);
b->drawPixel(1, 0, 1);
TEST_ASSERT_EQUAL_HEX16(b->crc(), a->crc());
delete a;
delete b;
}
int main()
{
UNITY_BEGIN();
@@ -237,5 +269,6 @@ int main()
RUN_TEST(test_refresh_waits_for_a_running_effect);
RUN_TEST(test_interrupted_first_frame_still_reaches_every_dot);
RUN_TEST(test_crc_of_equal_images_is_equal);
RUN_TEST(test_draw_dots_is_like_draw_pixel);
return UNITY_END();
}
+34
View File
@@ -583,6 +583,38 @@ void test_power_switch_off_keeps_display_off_during_the_day()
TEST_ASSERT_EQUAL(0, power.on_count);
}
// Slow mode had the power off already: the dots still show the clock, so the power goes on
// for the off image, once
void test_power_switch_off_in_slow_mode_draws_the_off_image()
{
slowMode(true);
run(12, 0, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
controller->setPowerEnabled(false);
run(12, 1);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
run(12, 1, CLEAR_BEFORE_OFF_TIME + 200);
TEST_ASSERT_FALSE(power.is_on);
run(12, 2, 60000);
TEST_ASSERT_EQUAL(2, power.on_count);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
}
void test_night_in_slow_mode_draws_the_off_image()
{
slowMode(true);
run(23, 15, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
run(23, 31);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
run(23, 31, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
run(3, 0, 60000);
TEST_ASSERT_EQUAL(2, power.on_count);
}
void test_power_switch_on_again_shows_clock()
{
run(12, 0);
@@ -3270,6 +3302,8 @@ int main()
RUN_TEST(test_power_switch_off_clears_and_powers_off_quickly);
RUN_TEST(test_power_switch_off_waits_for_the_refresh_effect);
RUN_TEST(test_power_switch_off_keeps_display_off_during_the_day);
RUN_TEST(test_power_switch_off_in_slow_mode_draws_the_off_image);
RUN_TEST(test_night_in_slow_mode_draws_the_off_image);
RUN_TEST(test_power_switch_on_again_shows_clock);
RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere);
RUN_TEST(test_no_wifi_is_shown_after_a_minute_without_wifi);