Files
Flipdot-display/README.md
T

300 lines
16 KiB
Markdown
Raw Normal View History

# 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 │
2026-10-13 00:07:35 +02:00
│ 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, exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
| **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
src/recovery/ ESP32 recovery app: WiFi setup page + OTA
partitions.csv ESP32 flash layout (recovery app + two clock firmware slots)
2026-10-13 00:23:07 +02:00
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
2026-10-13 00:35:28 +02:00
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`) |
2026-10-13 00:35:28 +02:00
| `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
2026-10-13 00:35:28 +02:00
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
```
2026-10-13 00:23:07 +02:00
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
2026-10-13 00:35:28 +02:00
> fail. Flashing over WiFi (`nano_ota`) doesn't have this problem.
## 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
2026-10-13 00:35:28 +02:00
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`.
2026-10-13 00:35:28 +02:00
**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`.
2026-10-13 00:35:28 +02:00
**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 |
2026-10-13 00:35:28 +02:00
| `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
2026-10-13 01:34:04 +02:00
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 app | ota_0: clock | ota_1: clock
```
**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").
2026-10-13 01:34:04 +02:00
**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
```
2026-10-13 01:34:04 +02:00
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 |
2026-10-13 00:35:28 +02:00
| 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 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,
2026-10-13 00:35:28 +02:00
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.