diff --git a/CLAUDE.md b/CLAUDE.md index 75d9b10..fd54464 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/test/test_nano/test_main.cpp b/test/test_nano/test_main.cpp index b06120e..1fe2843 100644 --- a/test/test_nano/test_main.cpp +++ b/test/test_nano/test_main.cpp @@ -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); diff --git a/tools/display_smoke_test.py b/tools/display_smoke_test.py new file mode 100644 index 0000000..6627288 --- /dev/null +++ b/tools/display_smoke_test.py @@ -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()