Public Access
- Web UI on port 80: live view of the display, choose the page (clock or a bitmap), bitmaps (list, show, live, edit, delete), draw / image upload with conversion in the browser, live mode, settings, Home Assistant - Live view from a shadow buffer in the BROSE9323 driver (shownPixel) - Settings in NVS with the original behaviour as defaults: schedule times, quarter hours, yawning Pikachu, No WiFi screen, flip time, status LED, Home Assistant broker (restart on change) - Bitmaps on LittleFS; new 320 KB partition, ota slots now 1.31 MB - DisplayController: pages, live mode, settings; app.cpp holds the objects - README for the recovery app; docs for the web UI - Tests: settings, time parsing, bitmap format and names, schedule with settings, pages and live mode (95 total) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
330 lines
19 KiB
Markdown
330 lines
19 KiB
Markdown
# Flipdot Display
|
||
|
||
Firmware for an 84 × 19 Brose flipdot display (three 28 × 19 panels) that runs as a
|
||
Home Assistant–controlled clock.
|
||
|
||
The system uses two microcontrollers:
|
||
|
||
```
|
||
┌──────────────┐ WiFi / MQTT ┌────────────────┐ UART 115200 ┌──────────────┐ GPIO ┌──────────────┐
|
||
│ Home │ ◄─────────────► │ ESP32 │ ──────────────► │ Arduino Nano │ ───────► │ Brose │
|
||
│ Assistant │ │ (controller) │ text commands │ (driver) │ │ flipdot │
|
||
└──────────────┘ └───────┬────────┘ └──────▲───────┘ │ panels 84x19 │
|
||
│ GPIO 13 │ └──────▲───────┘
|
||
▼ │ power │
|
||
┌─────────────┐ │ │
|
||
│ Relay │ ─────────────────────────┴─────────────────────────┘
|
||
└─────────────┘
|
||
```
|
||
|
||
The relay powers both the panels **and the Nano**. When the relay is off, the Nano is off
|
||
too, and it boots again each time the relay switches on. Only the ESP32 runs all the time.
|
||
|
||
| Part | Folder | What it does |
|
||
|------|--------|--------------|
|
||
| **Arduino Nano** | [src/nano](src/nano/README.md) | Drives the panel's row and column electronics directly. Keeps a frame buffer and flips only the dots that changed. Takes simple text commands over serial. |
|
||
| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows a clock with Pikachu bitmaps or your own bitmaps, has a [web UI](#web-ui), exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
|
||
| **ESP32 recovery app** | [src/recovery](src/recovery/README.md) | Small second app: WiFi setup page and OTA, so the ESP32 stays reachable when the clock firmware fails. |
|
||
| **BROSE9323 driver** | [lib/BROSE9323](lib/BROSE9323/README.md) | Shared `Adafruit_GFX` display driver. On the Nano it toggles GPIO pins. On the ESP32 (`-D ESPSERIAL`) it turns every drawing call into a serial command for the Nano. |
|
||
|
||
Because the driver is an `Adafruit_GFX` subclass on both boards, the ESP32 can use the
|
||
normal GFX API (`print`, `drawBitmap`, `fillScreen`, ...). Each call is sent to the
|
||
Nano and drawn there.
|
||
|
||
## Repository layout
|
||
|
||
```
|
||
platformio.ini one PlatformIO project, multiple environments
|
||
lib/BROSE9323/ shared flipdot driver (used by both targets)
|
||
src/nano/ Arduino Nano firmware
|
||
src/esp32/ ESP32 clock firmware
|
||
secrets.example.h template for WiFi / MQTT credentials
|
||
web/ the web UI
|
||
src/recovery/ ESP32 recovery app: WiFi setup page + OTA
|
||
partitions.csv ESP32 flash layout (recovery app + two clock firmware slots)
|
||
test/test_nano/ unit tests for the Nano firmware (run on the PC)
|
||
test/test_esp32/ unit tests for the ESP32 logic (run on the PC)
|
||
test/mocks/ fake Arduino, EEPROM and display driver for the Nano tests
|
||
tools/nano_net_upload.py uploader for flashing the Nano over WiFi
|
||
archive/ old, unused sketches kept for reference
|
||
CLAUDE.md project notes for Claude Code
|
||
```
|
||
|
||
## Building and flashing
|
||
|
||
Install [PlatformIO](https://platformio.org/) (the VS Code extension is easiest) and open
|
||
this folder.
|
||
|
||
1. Copy `src/esp32/secrets.example.h` to `src/esp32/secrets.h` and fill in your WiFi and
|
||
MQTT details. `secrets.h` is gitignored.
|
||
2. Check the COM ports and the OTA IP in `platformio.ini`.
|
||
3. Build or upload:
|
||
|
||
| Environment | Board | Upload method |
|
||
|-------------|-------|---------------|
|
||
| `nano` | Arduino Nano (ATmega328P) | USB (`COM10`) |
|
||
| `nano_ota` | Arduino Nano (ATmega328P) | Over WiFi through the ESP32 (`10.1.1.198`). See [Flashing the Nano over WiFi](#flashing-the-nano-over-wifi). |
|
||
| `esp32` | ESP32 DevKit v1 | OTA over WiFi (`10.1.1.198`) |
|
||
| `esp32_usb` | ESP32 DevKit v1 | USB (`COM11`), into `ota_0`. Normally not needed. |
|
||
| `recovery` | ESP32 DevKit v1 | USB (`COM11`), one-time install of the [recovery app](#recovery-app) |
|
||
|
||
```sh
|
||
pio run # build nano + esp32
|
||
pio run -e nano -t upload # flash the Nano over USB
|
||
pio run -e nano_ota -t upload # flash the Nano over WiFi, through the ESP32
|
||
pio run -e esp32 -t upload # flash the ESP32 over the air
|
||
pio run -e esp32_usb -t upload # flash the ESP32 over USB
|
||
pio device monitor -e nano # serial monitor
|
||
pio test -e test_nano -e test_esp32 # run the unit tests on the PC
|
||
```
|
||
|
||
The unit tests need `gcc`/`g++` on your PATH. On Windows you can get it from
|
||
[MSYS2](https://www.msys2.org/) or Strawberry Perl.
|
||
|
||
In VS Code you can also pick the environment in the PlatformIO status bar.
|
||
|
||
> **Tip:** The ESP32 → Nano wire goes to the Nano's RX pin, which the Nano's USB chip
|
||
> also uses. Disconnect that wire before you flash the Nano over USB, or the upload may
|
||
> fail. Flashing over WiFi (`nano_ota`) doesn't have this problem.
|
||
|
||
## Web UI
|
||
|
||
Open `http://<ESP32 IP>/` (e.g. <http://10.1.1.198/>) in a browser. At the top is a live
|
||
view of the display: it shows what the dots show right now, updated every 1.5 s.
|
||
|
||
| Tab | What you can do |
|
||
|-----|-----------------|
|
||
| **Show** | Choose what the display shows during the day: the clock (Pikachu with the time) or any bitmap. The schedule still decides when the display is on. |
|
||
| **Bitmaps** | All bitmaps with a preview: show one as the page, send it live, open it in the editor, delete it. Two built-in ones (Nintendo) can't be deleted. |
|
||
| **Draw / Live** | Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears on the dots. Save it as a bitmap with a name. |
|
||
| **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; yawning Pikachu and its time; the "No WiFi" screen; the flip time; the status LED. |
|
||
| **Home Assistant** | On/off, broker, port, user, password. Saving restarts the ESP32. An empty broker uses `secrets.h`. |
|
||
| **System** | Firmware status, uptime, WiFi, storage; restart; start the recovery app. |
|
||
|
||
**Live mode** shows the image at once, also at night or before the time is known. It
|
||
ends with "Back to normal", or 30 minutes after the last image. The Home Assistant Power
|
||
switch still wins: when it is off, nothing is shown.
|
||
|
||
**Bitmaps** are stored on the ESP32 (LittleFS, 320 KB, room for well over a thousand). They
|
||
need the new flash layout from the [one-time installation](#one-time-installation-usb);
|
||
before that, only the built-in ones are available.
|
||
|
||
Settings are stored in the ESP32's NVS. The defaults are the original behaviour. There is
|
||
no login: anyone on your WiFi can use the page, as with OTA.
|
||
|
||
## Flashing the Nano over WiFi
|
||
|
||
The ESP32 can flash the Nano for you, so you don't need a USB cable at the display:
|
||
|
||
```sh
|
||
pio run -e nano_ota -t upload
|
||
```
|
||
|
||
What happens:
|
||
|
||
1. The uploader ([tools/nano_net_upload.py](tools/nano_net_upload.py)) connects to the
|
||
ESP32 on TCP port **2323** and tells it the bootloader's baud rate.
|
||
2. The ESP32 pauses the clock and switches the relay off for 1 second, then on again. If
|
||
the reset wire is fitted, it also pulses the Nano's RESET pin. This restarts the Nano,
|
||
and its bootloader starts.
|
||
3. The ESP32 switches its link to the bootloader's baud rate and passes bytes between
|
||
WiFi and the Nano.
|
||
4. The uploader writes the new firmware and reads it back to verify it.
|
||
5. When the uploader disconnects, the ESP32 switches the relay off. The clock powers the
|
||
Nano on again with the new firmware and continues.
|
||
|
||
The display goes dark for the duration of the upload, which takes a few seconds.
|
||
|
||
**Requirements**
|
||
|
||
- The reply wire must be connected: Nano D1 (TX) → voltage divider → ESP32 GPIO 16. The
|
||
uploader needs the bootloader's answers.
|
||
- The ESP32 must be running firmware with the flash bridge (this version or newer).
|
||
|
||
**Bootloaders**
|
||
|
||
Flashing the Nano's firmware never changes its bootloader. Both common Nano bootloaders
|
||
are supported:
|
||
|
||
| Bootloader | Baud | Starts after a power cycle | Reset wire |
|
||
|------------|------|----------------------------|------------|
|
||
| Old bootloader (ATmegaBOOT), the one this Nano has now | 57600 | Yes | Not needed |
|
||
| Optiboot ("new bootloader") | 115200 | No, only after a reset-pin reset | **Required** |
|
||
|
||
By default (`--baud=auto` in `upload_flags`) the uploader tries 57600 first and then
|
||
115200. Set `--baud=57600` or `--baud=115200` to skip the other attempt.
|
||
|
||
If you ever replace the bootloader with Optiboot:
|
||
|
||
- Fit the reset wire: ESP32 GPIO → 1 kΩ → base of an NPN transistor (e.g. 2N2222 or
|
||
BC547). Connect the collector to Nano RESET and the emitter to GND. Set `NANO_RESET_PIN` in
|
||
`src/esp32/config.h` to that GPIO, for example `4`. The transistor keeps the Nano's 5 V
|
||
reset line away from the 3.3 V ESP32 pin.
|
||
- For uploads over USB, change `board` in the `nano` environment to `nanoatmega328new`.
|
||
|
||
**Settings**
|
||
|
||
- ESP32 IP address: `upload_port` in the `nano_ota` environment in `platformio.ini`. You
|
||
can add a port number: `10.1.1.198:2323`. For a one-time different address, use
|
||
`pio run -e nano_ota -t upload --upload-port 10.1.1.50`.
|
||
- Port, reset pin and timings: `NANO_FLASH_PORT`, `NANO_RESET_PIN`, `NANO_POWER_OFF_TIME`
|
||
and `NANO_RESET_DELAY` in `src/esp32/config.h`.
|
||
|
||
**If it fails**
|
||
|
||
| Message | Cause |
|
||
|---------|-------|
|
||
| `can't connect to ...` | Wrong IP address, or the ESP32 is offline |
|
||
| `the ESP32 flash bridge didn't accept the request` | The ESP32 runs older firmware without the flash bridge. Flash it first (`esp32` or `esp32_usb`). |
|
||
| `no answer from the Nano bootloader` | Reply wire missing, or the Nano has Optiboot and the reset wire is missing |
|
||
| `bad reply to command ...` / `verify failed` | Connection problem. Try again. A failed upload can leave the Nano without working firmware, but the bootloader itself is never overwritten, so flashing again (over WiFi or USB) always fixes it. |
|
||
|
||
The ESP32 ends the session after 10 seconds without data, so the clock always resumes,
|
||
even if the uploader is interrupted.
|
||
|
||
> **Security:** anyone on your network can connect to port 2323 and flash the Nano, just
|
||
> like the ESP32's own OTA has no password. Keep the display on a trusted network.
|
||
|
||
## Recovery app
|
||
|
||
The ESP32 has a small second app, the **recovery app** ([src/recovery](src/recovery)). It
|
||
has its own place on the flash and is never changed by OTA updates. It contains only WiFi,
|
||
a setup web page and OTA, so the ESP32 can always be reached, even when the clock firmware
|
||
is broken. It shows what is going on as a short message on the display, and the built-in
|
||
LED double-blinks.
|
||
|
||
```
|
||
flash (4 MB): bootloader | nvs | otadata | factory: recovery | ota_0: clock | ota_1: clock | bitmaps
|
||
```
|
||
|
||
**When it starts**
|
||
|
||
| Trigger | Back to the clock |
|
||
|---------|-------------------|
|
||
| The clock firmware can't connect to WiFi (30 s at boot, or 10 min later on) | As soon as the WiFi works again (after at least 1 minute) |
|
||
| The clock firmware crashed 3 times in a row | After 10 minutes |
|
||
| **FlipDot Display Recovery** button in Home Assistant | After 10 minutes |
|
||
| Switching the power on and off 3 times, each time within 10 s | After 10 minutes |
|
||
| There is no working clock firmware (the bootloader falls back to it) | Never by itself |
|
||
|
||
The setup page can also start the clock right away, or keep the recovery app running
|
||
("Stay in recovery").
|
||
|
||
**What the display shows**
|
||
|
||
| Message | Meaning |
|
||
|---------|---------|
|
||
| `Connecting` / `to WiFi...` | The clock firmware is starting |
|
||
| `Getting` / `the time...` | Connected, waiting for the time from the internet (NTP) |
|
||
| `No WiFi` / `12:34` | The clock firmware lost WiFi for more than a minute; the time keeps running |
|
||
| `Crashed!` / `OTA open 15s` | The clock firmware crashed and waits 15 s for an OTA update before it starts again |
|
||
| `Crashed 3x` / `OTA only` | Repeated crashes without the recovery app installed: only OTA for 10 minutes |
|
||
| `Recovery` / reason | The recovery app started (`no WiFi`, `crashed`, `requested`, `power cycled`, `no firmware`) |
|
||
| `WiFi setup:` / `FlipDot-Setup` | Connect to this WiFi to choose another network, see below |
|
||
| `Recovery` / IP address | The recovery app is connected; open this address for the setup page |
|
||
|
||
The recovery app switches the display off after each message; the dots keep showing it
|
||
without power.
|
||
|
||
**WiFi setup.** The recovery app keeps trying the stored WiFi. If that fails for 20 s, it
|
||
also opens its own WiFi: **FlipDot-Setup**, with the password `SETUP_AP_PASSWORD` from
|
||
`src/esp32/secrets.h`. Connect a phone to it; the setup page opens by itself (otherwise go
|
||
to <http://192.168.4.1>). Pick a network, enter the password and save. The new WiFi is
|
||
stored in the ESP32's NVS and used by both apps from then on (instead of `WIFI_SSID` from
|
||
`secrets.h`). The setup page is also reachable on your normal WiFi, at the ESP32's IP
|
||
address.
|
||
|
||
**Updating the clock firmware from the recovery app.** Upload as usual,
|
||
`pio run -e esp32 -t upload`. On the setup WiFi, add `--upload-port 192.168.4.1`. The
|
||
recovery app writes the firmware to `ota_0` and starts it.
|
||
|
||
**Updating the recovery app itself** needs USB (`pio run -e recovery -t upload`). It
|
||
should rarely be necessary.
|
||
|
||
### One-time installation (USB)
|
||
|
||
This changes the flash layout, so it can only be done over USB. It is the last USB flash
|
||
the ESP32 needs. Best done together with the wiring TODOs, while the display is open
|
||
anyway.
|
||
|
||
1. Connect the ESP32 over USB (`COM11`, see `platformio.ini`).
|
||
2. Erase the flash and install the recovery app:
|
||
```sh
|
||
pio run -e recovery -t erase
|
||
pio run -e recovery -t upload
|
||
```
|
||
The recovery app starts (LED double-blinks, the display shows `Recovery` /
|
||
`no firmware`), connects to the WiFi from `secrets.h` and shows its IP address. It waits
|
||
there, because there is no clock firmware yet.
|
||
3. Install the clock firmware **over WiFi**. This also tests that the recovery app works:
|
||
```sh
|
||
pio run -e esp32 -t upload
|
||
```
|
||
4. Check Home Assistant: the **FlipDot Display Firmware** sensor should show
|
||
`2.1.0, testing, ...`, and after a minute `2.1.0, ok, 0 crashes, recovery ok`.
|
||
|
||
Erasing the flash also clears the NVS: WiFi settings made on the setup page are reset to
|
||
the ones in `secrets.h`.
|
||
|
||
## Wiring between ESP32 and Nano
|
||
|
||
The ESP32 talks to the Nano on **UART2** (`Serial2`, 115200 baud). The ESP32's own
|
||
`Serial` (UART0, USB) is free for debug output.
|
||
|
||
| ESP32 | Nano | Notes |
|
||
|-------|------|-------|
|
||
| GPIO 17 (TX2) | D0 (RX) | Display commands |
|
||
| GPIO 16 (RX2) | D1 (TX) | `READY` and `ERR` replies, and bootloader answers when flashing over WiFi. Use a 5 V → 3.3 V voltage divider (e.g. 1 kΩ / 2 kΩ), because the ESP32 is not 5 V tolerant. Without it, the ESP waits a fixed 3 s after power-on, and `nano_ota` doesn't work. |
|
||
| GPIO set in `NANO_RESET_PIN` | RESET (through an NPN transistor) | Optional, only needed to flash a Nano with Optiboot over WiFi. See [Bootloaders](#bootloaders). |
|
||
| GND | GND | Common ground |
|
||
| GPIO 13 | Relay input | Relay powers the panels and the Nano (`LOW` = on) |
|
||
|
||
## How it works
|
||
|
||
1. **ESP32 boot.** The ESP32 connects to WiFi and starts ArduinoOTA. After a crash it
|
||
runs only OTA for 15 seconds, and after repeated crashes it goes to safe mode. Bad OTA
|
||
firmware is rolled back automatically, see
|
||
[Keeping OTA alive](src/esp32/README.md#keeping-ota-alive). It then switches the display relay on, waits for
|
||
the Nano's `READY` message (at most 3 s), shows "Connecting...", starts NTP and
|
||
connects to MQTT.
|
||
2. **Clock.** Once the time is synced, the ESP32 sends a new frame whenever the screen
|
||
changes, mostly once a minute:
|
||
- **08:00–22:00:** Pikachu with a thought bubble showing `HH:MM`. A yawning Pikachu is
|
||
shown from 17:00.
|
||
- **22:00–23:30:** the clock is only shown during the minutes :00, :15, :30 and :45.
|
||
30 seconds after each of those minutes, the relay turns the display off.
|
||
- **23:31–08:00:** the screen is cleared and power is cut after 30 seconds.
|
||
3. **Home Assistant switches** (MQTT discovery):
|
||
- `FlipDot Display Clock`: off shows a Nintendo logo instead of the clock.
|
||
- `FlipDot Display Power`: off clears the display and cuts power through the relay.
|
||
4. **Nano.** The Nano reads commands from the serial line and updates its frame buffer.
|
||
On `D` it flips only the dots that changed. Every 2 minutes it redraws the whole screen
|
||
to fix dots that did not flip.
|
||
|
||
The serial protocol is described in [src/nano/README.md](src/nano/README.md).
|
||
|
||
## TODO
|
||
|
||
- [ ] **Install the recovery app** (one-time USB flash, see
|
||
[One-time installation](#one-time-installation-usb)). Until then the ESP32 runs the old
|
||
flash layout: the clock firmware works, but without the recovery app; the Home
|
||
Assistant sensor shows "no recovery app".
|
||
- [ ] **Test the web UI on the real hardware**: live view, uploading and drawing a bitmap,
|
||
live mode, choosing a page, settings, Home Assistant settings (restart).
|
||
- [ ] **Test the recovery app on the real hardware**: the Home Assistant button, the setup
|
||
WiFi with a wrong WiFi password, 3 quick power cycles, and an OTA upload into it.
|
||
|
||
- [ ] **Rewire the Nano link to UART2.** The firmware now uses `Serial2` (GPIO 16/17)
|
||
instead of `Serial` (GPIO 1/3). Move the ESP32 TX wire from GPIO 1 to **GPIO 17** before
|
||
you flash this firmware, or the display gets no data. See
|
||
[Wiring between ESP32 and Nano](#wiring-between-esp32-and-nano).
|
||
- [ ] **Add the reply wire:** Nano D1 (TX) → voltage divider → ESP32 GPIO 16 (RX2). With it,
|
||
the ESP sends the first frame as soon as the Nano is ready, instead of after 3 s, and
|
||
the Nano can be flashed over WiFi (`nano_ota`).
|
||
- [ ] **Test flashing over WiFi on the real hardware.** The uploader is tested against a
|
||
simulated bootloader, not yet against the real Nano. Check especially that the old
|
||
bootloader starts after the relay power cycle.
|
||
- [ ] **Check the boot timing.** Build the Nano with `MEASURE_REFRESH` to see how long a
|
||
full redraw takes. If the reply wire isn't fitted, check that 3 s
|
||
(`NANO_BOOT_TIMEOUT`) is enough for the Nano to boot.
|