Public Access
- 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>
18 KiB
18 KiB
Flipdot display: notes for Claude
A PlatformIO project with two firmware targets for an 84×19 Brose flipdot clock. See README.md for the overview.
Layout and build
platformio.inihas the environmentsnano,nano_ota,esp32(OTA to 10.1.1.198),esp32_usb(COM11), and the PC test environmentstest_nano,test_esp32andtest_driver. The sources are split withbuild_src_filter(src/nano/,src/esp32/).lib/BROSE9323/is the shared driver. Its behaviour depends on build flags set inplatformio.ini, not in the header:ESPSERIAL(ESP32): draw calls become serial commands.FLIPDOT_PLCC_ADAPTER(Nano, optional): alternative pinout.
- PlatformIO CLI is not on PATH. Run
~/.platformio/penv/Scripts/pio.exe run(Git Bash) or$env:USERPROFILE\.platformio\penv\Scripts\pio.exe run(PowerShell). - If an ESP32 build fails with
No module named 'intelhex', run~/.platformio/penv/Scripts/python.exe -m pip install intelhex. nano_otaflashes the Nano over WiFi:tools/nano_net_upload.py(STK500v1 over TCP) talks toNanoFlashBridgeon the ESP32 (port 2323). The uploader first sendsFLASH <baud>\n. The ESP power cycles the Nano (plus an optional reset pulse onNANO_RESET_PIN), answersOK\n, then passes the bytes through at that baud rate.--baud=autotries 57600 (old ATmegaBOOT, which also starts after a power cycle), then 115200 (Optiboot, which only starts after a reset-pin reset, so it needsNANO_RESET_PIN). avrdude 6.3'snet:port is broken on Windows (ser_drain()fails), which is why the uploader is a Python script. The uploader only uses the Python standard library. Test it against a simulated bootloader before changing it.- After any change, build both targets (
pio run) and run the tests (pio test -e test_nano -e test_esp32 -e test_driver).
Constraints
- Nano RAM is tight (ATmega328P, 2 KB). The frame buffers are allocated with
calloc. AvoidString, large stack buffers and new globals in Nano code. Put constant data inPROGMEM. - The ESP32 talks to the Nano on
Serial2(owned byNanoLink, GPIO 17 TX / GPIO 16 RX).Serial(UART0/USB) is free for debug output. Never write debug text toSerial2: the Nano runs lines that start with s/S/F/D/P/B/R/T/G/N/E/I/C as commands. - Open hardware TODOs are listed in the README
TODOsection. Update it when one is done. - The README is the overview; the documentation is in
docs/(web UI, pages, Home Assistant, how it works, wiring, Nano over WiFi, recovery app). Screenshots:tools/make_webui_screenshots.py(headless Edge) andtools/make_page_screenshots.pywritePictures/web-ui/andPictures/pages/; run them again after UI changes. src/esp32/secrets.hholds the real credentials and is gitignored. Never commit it. Add new secrets tosecrets.example.has placeholders.- The relay (ESP32 GPIO 13,
LOW= on) powers both the panels and the Nano. Each time it switches on, the Nano cold-boots and then sendsREADY.NanoLink::on()waits for that message, at mostNANO_BOOT_TIMEOUT. The timeout also covers the case where the Nano's TX isn't wired to the ESP's RX. NanoLink::off()endsSerial2and sets its TX pin to input. Otherwise the idle-high TX line powers the Nano through its RX pin. Never send display commands whileNanoLink::isOn()is false (DisplayControllertakes care of this).- The Nano keeps no state over a power cycle except the flip time in EEPROM. The driver
forces a full redraw on the first
display()after boot (_force_next). - Hardware is not available here. Builds can be checked, but runtime behaviour can't.
Nano serial parser (src/nano/main.cpp)
- Files:
main.cpp(setup/loop, refresh),config.h(all constants and debug defines),command_parser.*(protocol, one method per command),settings.*(EEPROM). - Line based and non-blocking: bytes are collected into a 64-character buffer and a
command runs when
\narrives. Bitmap (B) pixel data is the exception. It is drawn while it arrives, so it can be longer than the buffer. Keeploop()free of busy-waits. - The driver never blocks while flipping:
display()only starts a run,update()flips dots in slices of about 1 ms (frames, effects, and the periodic dot refresh fromstartRefresh(), which waits while a frame is received or shown). Anything that blocks 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_driverchecks the time perupdate()call with a simulated panel. - The
Bdata 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 withBROSE9323::drawDots(). Withp % w,p / wand fourdrawPixel()calls it took about 105 µs perF, so dense images (all yellow, a full picture) lost bytes ("Line too long", frame check mismatches). Check new per-character work withavr-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_tat address 2. Values outside 50–2000 µs are rejected. - The Nano replies
READY(at boot),ERR: ...andC xxxx(checksum of its image, forC). There is no per-frame acknowledgement: the ESP paces frames by time.nano_link/nano_monitorreads the replies and sendsCafter frames;logic/frame_checkdecides (wrong/missing answer orREADYwhile on: send the whole image again viacontroller.pageChanged()). Every wait for a reply needs a timeout. - Driver tests:
pio test -e test_driverruns the realBROSE9323.cpp(Nano build) againsttest/driver_mocks/(Arduino, Adafruit GFX, a panel that decodes the pins). - Unit tests:
pio test -e test_nanorunstest/test_nano/on the PC with the fakes intest/mocks/(Arduino, EEPROM, and aBROSE9323that records calls). It needs gcc on PATH (Strawberry Perl provides it atC:\Strawberry\c\bin). Add a test for every protocol change. The mocks only implement what the Nano code uses, so extend them when new Arduino or driver functions are called. - The board environments have
test_ignore = *, so test firmware is never uploaded. Plainpio testruns nothing; always pass-e test_nano -e test_esp32 -e test_driver.
ESP32 structure (src/esp32/)
- Folders:
logic/(pure C++, no Arduino includes, tested intest/test_esp32),display/(frames and bitmaps),nano_link/(relay, serial, flash bridge),net/(WiFi/OTA, Home Assistant). Includes are relative tosrc/esp32(-I src/esp32), e.g.#include "logic/schedule.h". DisplayControllerholds the display logic and only uses theDisplayPowerandFramesinterfaces. Put new behaviour there, behind those interfaces, so it stays testable.logic/must not include Arduino headers, ortest_esp32stops building.- All settings are in
config.h. Keep it free of Arduino types for the same reason. - Frames are only sent when the screen changes. The schedule uses 08:00 as the start of the day on purpose (that is what the old code did, and the README documents it).
- Slow mode (
SlowModeSettings, replaced the late-evening quarter hours) lives inDisplayController::_slowClock(): one frame per update minute (slowModeUpdate()), power off in between, nothing else drawn (also no page changes). It updates at once after boot, night, the Power switch and live mode; rules withover_slow_modesuspend it (setSlowModeOverride(), fed fromPageChoiceinappPageLoop()). - Home Assistant switches are not optimistic: callbacks must call
sender->setState(). The unique ids (FlipDotDisplayPower,FlipDotDisplayMode,FlipDotDisplayOffImage,FlipDotDisplayStopwatch,FlipDotDisplaySlowMode,FlipDotDisplaySlowModeInterval, ...) andHA_DEVICE_IDmust stay the same, or Home Assistant creates new entities.
Keeping ESP32 OTA alive (most important requirement)
- The user never wants to need USB for the ESP32 again. Any change must keep OTA working, also when the new code crashes or hangs.
system/firmware_guard.cppoverrides the Arduino core's weakverifyRollbackLater()(C linkage) to return true. A new OTA image staysPENDING_VERIFYuntil it has runFIRMWARE_VALID_TIMEwith WiFi. Any restart before that makes the bootloader roll back. Check withnmthat the strong symbol is linked after toolchain or core updates.- The task watchdog (
WATCHDOG_TIMEOUT_S) coversloop(). ArduinoOTA'sonStartstops it, because an upload blocksloop(). Never blockloop()for 30 s or longer. - The crash counter lives in
RTC_NOINIT_ATTRmemory. Decisions are inlogic/boot_policy.*(tested). Safe mode runs only WiFi + OTA. - Keep
otaLoop()andfirmwareGuardLoop()at the top ofloop(), before anything that can return early or block. KeepfirmwareGuardBegin()first insetup(): it holds the power-cycle trigger for the recovery app. - Flash layout
partitions.csv: factory = recovery app (src/recovery, envrecovery, USB only), ota_0/ota_1 = clock firmware.esp32_usbuploads to 0x110000 (board_upload.offset_address) so it never overwrites the recovery app. Check withpio run -e <env> -t envdump(ESP32_APP_OFFSET) after changing upload settings. - The recovery app compiles
esp32/logic/boot_policy.cpp,esp32/system/system_store.cpp,esp32/system/boot_target.cpp,esp32/nano_link/nano_link.cpp,esp32/display/status_screen.cppandesp32/display/text.cpp(see itsbuild_src_filter). Keep those free of clock-firmware dependencies. - Status messages (
StatusScreen) are at most 14 characters per line, 2 lines. The recovery app powers the display off after each message (flipdots keep the image). Both apps share NVS namespaceflipdot. RTC memory is not shared between the apps (different layouts). rebootToRecovery()selects the factory partition, which erases otadata. The recovery app later selects the clock slot again (startMainFirmware()), so the clock firmware isPENDING_VERIFYagain and must pass the 60 s check, or the bootloader falls back to the recovery app.- The clock firmware still works on the old flash layout (no factory partition):
rebootToRecovery()returns, and the callers fall back to OTA-only mode or a restart. - Arduino's
esp_app_desc_tversion and date come from the precompiled core, so the clock firmware storesFIRMWARE_VERSIONper slot in NVS for the recovery page.
ESP32 web UI
app.cppowns the objects and the actions (settings, page, live mode, restart);web/web_ui.cppis the JSON API (endpoints listed inweb_ui.h),web/web_page.hthe single HTML/JS page (raw string literal in PROGMEM). Keep image work in the browser; the ESP32 only handles 418-character hex bitmaps (logic/bitmap_format.h, MSB = left dot).- Settings:
logic/settings.hstruct with defaults = original behaviour, stored as an NVS blob with a version (system/settings_store.cpp). When the struct changes, bumpVERSIONthere (old records then fall back to defaults). Only the current version is read: 2.15.0 converted all old settings, schedules and pages on the device, and 2.15.1 dropped the converters. To keep data over a format change, add a frozen copy of the old layout with a converter (git history before 2.15.1 has examples), and store the converted data only once the firmware is marked good (a rollback can't read it). - Bitmaps on LittleFS in the
spiffspartition (/bitmaps/<name>.bin, 209 bytes). Names are restricted (validBitmapName), which also makes them safe in paths and JSON. - Icons (
logic/icon_format.h,display/icon_store,/icons/<name>.bin: width, height, rows of (w+7)/8 bytes) are small images that pages use (Image elements, backgrounds at the top left); they are never shown on their own and are not in the page lists or HA dropdowns. Bitmaps, icons and pages share one namespace.PageResources::background()tries bitmaps, then icons (expanded at 0,0), so the renderer doesn't know the difference. - LittleFS is slow: listing a directory (
listBitmaps(),listPages(),storedNames()) takes about a second with 40 files. Never list per item in a loop. The first icon migration did that, hit the 30 s watchdog and crash-looped into the recovery app; work that touches many files runs in time-boxed steps over severalloop()passes. - The display mirror comes from the driver's shadow buffer (
BROSE9323::shownPixel). - Dynamic pages (
logic/page.h): background per time slot + elements, stored as a versioned binary struct (display/page_store.cpp, bumpVERSIONwhenDynamicPagechanges). They are drawn only on the ESP32 (logic/page_render), also for the web previews, so there is one renderer. Pages and bitmaps share one namespace (checked inapp.cpp); things a page uses can't be deleted. The page "Clock" replaces the old hardcoded clock (also the default and the HA "Clock" option).pageStoreBegin()stores a copy of each built-in page (logic/builtin_pages) at boot when none is stored, so it can be edited; the built-in one is the fallback, and deleting the copy resets it. - Pages hold 16 elements (format 7). A
DynamicPageis about 4 KB: allocate it withnew, and reset it withclearPage(), notpage = DynamicPage()(a temporary on the 8 KB loop stack). - Game of Life (
logic/life,display/life_player): a page element without fields of its own: it reusesbar_width/bar_height(area),format(edges),input_unit(start),text(pattern / name start),factor(density),scroll_speed,auto_unit,target(max generations),bar_max(hold s); the list is inlogic/page.h. JSON addsstartandgenerations. One per page:LifePlayerkeeps one board (about 7.5 KB) and ticks it inFlipdotFrames::dynamicPage(); reloading a page with the same start (sameLifeStart()) keeps it running. Rotating lists the icons only wheniconListRevision()changes. The pattern library (RLE) is in the web page; check new patterns with a simulation before adding them. - HA attributes may be paths into lists (
forecast.1.condition,net/ha_api). The forecast comes from the template sensor indocs/home-assistant.md. examples/home-pages.jsonis written bytools/export_examples.pyfrom the display. Its PRIVATE map replaces private parts of entity ids; extend it for new pages and check the output.ha_apiattribute lookups: castchararrays toconst char *for ArduinoJson keys. ArduinoJson 7.4 takes achar[N]as a literal of length N-1, which never matches (that broke all attributes from 2.9.0 to 2.10.0).- Moving pages:
renderPage()returns when the page changes next (scroll step, second);FlipdotFramessends those frames asD1(no effect) and paces them by the estimated receive and flip time, because the Nano has no acknowledgement. Full screen frames go throughBROSE9323::showBitmap(): only the changed dots, as twoBcommands over the changed rectangle (lib/BROSE9323/frame_commands.h, tested with the Nano parser intest_nano). Don't send frames pixel by pixel (scommands are 7 characters per dot). Character 127 is the degree sign in page texts and fonts (UTF-8 ° in JSON). - What is shown is decided by
logic/page_selector(rules > manual pick until the next block > rotating schedule > chosen page) inappPageLoop();settings.pageis the chosen page (the HA "Mode" dropdown). Schedule and rules are stored in NVS (system/schedule_store.cpp, bumpVERSIONwhenPageSchedulechanges). Pages used by the schedule or rules can't be deleted. - The ESP32 flash slot is about 90% full (web page and fonts are in flash). Check the size after adding features; the web page could be served gzipped if it gets tight.
- Fonts use the "FDF1" format of
logic/font.h; the web page writes the same format.logic/builtin_fonts.cppis generated: changetools/make_builtin_fonts.pyand rerun it. - Home Assistant values come from HA's REST API (
net/ha_api). The HTTP requests run in their own FreeRTOS task (core 0,haApiBegin()), one at a time; everything else (entries, watching, values) stays inloop(). The task gets copies of what it needs and answers through a queue of plain structs: never share Strings or other state with it, and never wait for it inloop(). The web UI's test button polls ({"pending":true}). The web server stays inloop()on purpose (its handlers touch everything). - The stopwatch (
logic/stopwatch.h) counts in real time (UTC ms fromepochMs(), notmillis()), stored in NVS on every change (system/stopwatch_store.cpp, bumpVERSIONwhenStopwatch::Statechanges). Start, stop and set need the synced time. - Entity ids may contain
{plex_user}(expandEntity()inlogic/page), filled in bynet/ha_apiwithsettings.plex_userin entity id form (plexUserSlug()). - Bitmaps are cached in RAM (
display/bitmap_store, also missing names): a LittleFS read takes about 100 ms, and pages are drawn for every frame and every web preview. Writes must go throughsaveBitmap()/deleteBitmap(), which clear the cache. Long JSON lists are sent in pieces of about 2 KB (sendPiece()inweb_ui.cpp). - Icons are cached too (
display/icon_store, all of them). A save or delete only forgets that name (forget()) and updates the cached name list in place (listAdd()/listRemove()): clearing it all made the next icon list read every icon again (7 s). - Storage usage (
bitmapStorageUsage(), for/api/status) is cached:usedBytes()walks the file system (about 0.5 s). Every store callsbitmapStorageChanged()after a write or delete; the cache also expires after 60 s. - The web UI reloads only the lists that changed (
loadLists(only), the ESP32 answers one request at a time): saving a bitmap, icon or font also reloads the pages when a page uses it (pagesUsing(),pagesWithFont()). - Check the page's JavaScript after changes: extract the
<script>part and runnode --checkon it (node is installed).
Git
- Remote: https://mangavalk.com/mangavalk/Flipdot-display.git, branch
main.