Tests against lost bytes in dense frames

- test_nano: every hex digit of B data is one drawDots() call, never single
  drawPixel() calls (the slow path that broke dense images in 2.19.2)
- tools/display_smoke_test.py: all yellow, a dense image and a checkerboard
  in live mode on the real display; fails on any frame check problem (wrong
  image, no answer, Nano restart, ERR reply)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-22 00:30:31 +02:00
1 parent 7a28e34380
commit abd63d070f
3 files changed
+126 -1

No files matched your search

+5 -1
View File
@@ -74,7 +74,11 @@ See README.md for the overview.
`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).
clock in the tests doesn't count CPU time); `test_nano` only checks that each digit is
one `drawDots()` call. After flashing Nano or driver changes, run
`python tools/display_smoke_test.py` (asks the user first: it flips the dots for about
20 s): all yellow, a dense image and a checkerboard in live mode, fails on any frame
check problem.
- 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
+16
View File
@@ -193,6 +193,21 @@ void test_full_screen_bitmap_is_streamed()
TEST_ASSERT_EQUAL(84 * 19, display.count("pixel"));
}
// A hex digit arrives every 87 us, so the Nano must draw each one with a single quick
// drawDots() call. Four drawPixel() calls and a division per dot took about 105 us: dense
// images (all yellow) overflowed the receive buffer (2.19.3). The PC can't time the Nano,
// so this checks the way it is drawn; the speed itself: tools/display_smoke_test.py.
void test_bitmap_digits_are_drawn_with_one_quick_call_each()
{
send("B000054131" + std::string(399, 'F') + "\n");
TEST_ASSERT_EQUAL(399, display.count("dots"));
TEST_ASSERT_EQUAL(4 * 399, display.count("pixel")); // only the ones drawDots() stands for
const DisplayCall *last = display.last("dots");
TEST_ASSERT_EQUAL(80, last->a); // the last digit of the last row
TEST_ASSERT_EQUAL(18, last->b);
TEST_ASSERT_EQUAL(0xF, last->c);
}
// 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;
@@ -711,6 +726,7 @@ int main()
RUN_TEST(test_bitmap_pixels);
RUN_TEST(test_full_screen_bitmap_is_streamed);
RUN_TEST(test_bitmap_digits_are_drawn_with_one_quick_call_each);
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);
+105
View File
@@ -0,0 +1,105 @@
"""Checks on the real display that the Nano gets every frame right, also very dense ones.
Run it after changes to the Nano firmware, the driver (lib/BROSE9323) or the frames.
python tools/display_smoke_test.py [ESP32 address, default 10.1.1.198]
It shows a few test images in live mode (all yellow, a dense image, a checkerboard), waits
for the frame check of each (the Nano's checksum of what it received) and then goes back
to normal. It fails when the frame check found a wrong image, a missing answer, a Nano
restart or an ERR reply (e.g. "Line too long": lost bytes). This is the speed test the PC
tests can't do: with 2.19.2 the Nano drew dense images slower than they arrived, which
broke all yellow. The dots flip for about 20 s. Only the Python standard library is used.
"""
import json
import sys
import time
import urllib.error
import urllib.request
ESP = 'http://' + (sys.argv[1] if len(sys.argv) > 1 else '10.1.1.198')
W, H, ROW_BYTES = 84, 19, 11
CHECK_TIMEOUT = 20 # s: the ESP asks every 3 s at most, the answer takes up to 2 s
COUNTERS = ('mismatches', 'no_reply', 'errors', 'restarts', 'repairs')
def request(path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(ESP + path, data=data, headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
sys.exit('%s: %s' % (path, e.read().decode(errors='replace')))
def image(dot):
"""Bitmap hex (logic/bitmap_format.h: rows of 11 bytes, MSB = left dot) of dot(x, y)."""
out = []
for y in range(H):
for i in range(ROW_BYTES):
v = 0
for k in range(8):
x = i * 8 + k
if x < W and dot(x, y):
v |= 0x80 >> k
out.append('%02x' % v)
return ''.join(out)
TESTS = [
('all yellow', image(lambda x, y: True)),
# About 90 % of the dots, scattered: like the panda bitmap that failed
('dense image', image(lambda x, y: (x * 7 + y * 13) % 11 != 0)),
('checkerboard', image(lambda x, y: (x + y) % 2 == 0)),
]
def link():
return request('/api/status')['nano_link']
def answered(counts):
return counts['checks'] + counts['mismatches'] + counts['no_reply']
def main():
status = request('/api/status')
if not status['power_enabled']:
sys.exit('The Power switch is off (Home Assistant): live mode is not possible')
effect_s = request('/api/settings')['drive']['effect_time'] / 1000
start = link()
print('Firmware %s, frame check before: %s' % (status['version'], start))
failed = False
try:
for name, data in TESTS:
before = link()
request('/api/live', {'data': data})
deadline = time.time() + CHECK_TIMEOUT
now = before
while answered(now) == answered(before) and time.time() < deadline:
time.sleep(0.5)
now = link()
if answered(now) == answered(before):
print('%-13s no frame check within %d s' % (name, CHECK_TIMEOUT))
failed = True
continue
bad = {c: now[c] - before[c] for c in COUNTERS if now[c] != before[c]}
print('%-13s %s' % (name, 'ok' if not bad else 'FAILED %s %s' % (bad, now['last_error'])))
failed |= bool(bad)
time.sleep(effect_s + 1) # let it finish flipping, so each image can be seen
finally:
request('/api/live/stop', {})
end = link()
bad = {c: end[c] - start[c] for c in COUNTERS if end[c] != start[c]}
if bad:
print('Frame check after: %s, last error: %s' % (bad, end['last_error']))
failed = True
print('FAILED' if failed else 'All frames arrived right')
sys.exit(1 if failed else 0)
if __name__ == '__main__':
main()