Restructure ESP32 firmware into modules, fix bugs, add tests

Structure (src/esp32/):
- logic/: DisplayController, schedule, power timer, LED pattern; pure C++
- display/: FlipdotFrames and bitmaps
- nano_link/: NanoLink (relay, serial, READY, bootloader) and flash bridge
- net/: WiFi/OTA/panic window, Home Assistant
- config.h with all settings; libs.h (globals in a header) removed

Bug fixes:
- Home Assistant switches now report their state back and start as ON
- loop() no longer blocks: WiFi.waitForConnectResult() (up to 60 s
  without WiFi) and getLocalTime() (5 s before NTP sync) were called
  every iteration
- Power switch off now powers off after 1 s even when a 30 s countdown
  is already running
- millis() overflow safe power-off timer; no frame catch-up bursts
- No garbage time string before NTP sync; 'Connecting...' stays instead
- Frames are only sent when the screen changes instead of every second
- Reset reasons are logged once instead of every 100 ms
- Removed dead code (unused fonts, WiFiMulti, rotated bitmap, ...)

Tests: new test_esp32 environment (26 tests); native renamed to test_nano

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-13 00:56:32 +02:00
1 parent cefb76c1ad
commit 683a47c055
29 files changed
+1288 -754

No files matched your search

+34 -19
View File
@@ -5,9 +5,9 @@ See README.md for the overview.
## Layout and build ## Layout and build
- `platformio.ini` has the environments `nano`, `esp32` (OTA to 10.1.1.198) and - `platformio.ini` has the environments `nano`, `nano_ota`, `esp32` (OTA to 10.1.1.198),
`esp32_usb` (COM11). The sources are split with `build_src_filter` (`src/nano/`, `esp32_usb` (COM11), and the PC test environments `test_nano` and `test_esp32`. The
`src/esp32/`). sources are split with `build_src_filter` (`src/nano/`, `src/esp32/`).
- `lib/BROSE9323/` is the shared driver. Its behaviour depends on build flags set in - `lib/BROSE9323/` is the shared driver. Its behaviour depends on build flags set in
`platformio.ini`, not in the header: `platformio.ini`, not in the header:
- `ESPSERIAL` (ESP32): draw calls become serial commands. - `ESPSERIAL` (ESP32): draw calls become serial commands.
@@ -19,36 +19,34 @@ See README.md for the overview.
- `nano_ota` flashes the Nano over WiFi: `tools/nano_net_upload.py` (STK500v1 over TCP) - `nano_ota` flashes the Nano over WiFi: `tools/nano_net_upload.py` (STK500v1 over TCP)
talks to `NanoFlashBridge` on the ESP32 (port 2323). The uploader first sends talks to `NanoFlashBridge` on the ESP32 (port 2323). The uploader first sends
`FLASH <baud>\n`. The ESP power cycles the Nano (plus an optional reset pulse on `FLASH <baud>\n`. The ESP power cycles the Nano (plus an optional reset pulse on
`nano_reset`), answers `OK\n`, then passes the bytes through at that baud rate. `NANO_RESET_PIN`), answers `OK\n`, then passes the bytes through at that baud rate.
`--baud=auto` tries 57600 (old ATmegaBOOT, which also starts after a power cycle), then `--baud=auto` tries 57600 (old ATmegaBOOT, which also starts after a power cycle), then
115200 (Optiboot, which only starts after a reset-pin reset, so it needs `nano_reset`). 115200 (Optiboot, which only starts after a reset-pin reset, so it needs `NANO_RESET_PIN`).
avrdude 6.3's `net:` port is broken on Windows (`ser_drain()` fails), which is why the avrdude 6.3's `net:` port is broken on Windows (`ser_drain()` fails), which is why the
uploader is a Python script. uploader is a Python script.
The uploader only uses the Python standard library. Test it against a simulated The uploader only uses the Python standard library. Test it against a simulated
bootloader before changing it. bootloader before changing it.
- `src/esp32/libs.h` defines globals in the header, so it can only be included from - After any change, build both targets (`pio run`) and run the tests
`main.cpp`. New ESP32 modules must be self-contained (see `nano_flash_bridge.*`). (`pio test -e test_nano -e test_esp32`).
- After any change, build both targets (`pio run`). After Nano changes, also run
`pio test -e native`.
## Constraints ## Constraints
- **Nano RAM is tight** (ATmega328P, 2 KB). The frame buffers are allocated with - **Nano RAM is tight** (ATmega328P, 2 KB). The frame buffers are allocated with
`calloc`. Avoid `String`, large stack buffers and new globals in Nano code. Put constant `calloc`. Avoid `String`, large stack buffers and new globals in Nano code. Put constant
data in `PROGMEM`. data in `PROGMEM`.
- The ESP32 talks to the Nano on `Serial2` (`nanoSerial` in `libs.h`, GPIO 17 TX / GPIO 16 - The ESP32 talks to the Nano on `Serial2` (owned by `NanoLink`, GPIO 17 TX / GPIO 16 RX).
RX). `Serial` (UART0/USB) is free for debug output. Never write debug text to `Serial` (UART0/USB) is free for debug output. Never write debug text to `Serial2`: the
`nanoSerial`: the Nano runs lines that start with s/S/F/D/P/B/R/T as commands. Nano runs lines that start with s/S/F/D/P/B/R/T as commands.
- Open hardware TODOs are listed in the README `TODO` section. Update it when one is done. - Open hardware TODOs are listed in the README `TODO` section. Update it when one is done.
- `src/esp32/secrets.h` holds the real credentials and is gitignored. Never commit it. - `src/esp32/secrets.h` holds the real credentials and is gitignored. Never commit it.
Add new secrets to `secrets.example.h` as placeholders. Add new secrets to `secrets.example.h` as placeholders.
- The relay (ESP32 GPIO 13, `LOW` = on) powers both the panels and the Nano. Each time it - 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 sends `READY`. The ESP's `relayOn()` waits for switches on, the Nano cold-boots and then sends `READY`. `NanoLink::on()` waits for
that message, at most `NANO_BOOT_TIMEOUT`. The timeout also covers the case where the that message, at most `NANO_BOOT_TIMEOUT`. The timeout also covers the case where the
Nano's TX isn't wired to the ESP's RX. Nano's TX isn't wired to the ESP's RX.
- `relayOff()` ends `nanoSerial` and sets its TX pin to input. Otherwise the idle-high TX line - `NanoLink::off()` ends `Serial2` and sets its TX pin to input. Otherwise the idle-high TX
powers the Nano through its RX pin. Never send display commands while `relay_on` is line powers the Nano through its RX pin. Never send display commands while
false. `NanoLink::isOn()` is false (`DisplayController` takes care of this).
- The Nano keeps no state over a power cycle except the flip time in EEPROM. The driver - 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`). 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. - Hardware is not available here. Builds can be checked, but runtime behaviour can't.
@@ -62,19 +60,36 @@ See README.md for the overview.
while it arrives, so it can be longer than the buffer. Keep `loop()` free of busy-waits. while it arrives, so it can be longer than the buffer. Keep `loop()` free of busy-waits.
- A full redraw (`display(true)`) blocks for about 0.5 s, which overflows the 64-byte - A full redraw (`display(true)`) blocks for about 0.5 s, which overflows the 64-byte
serial receive buffer. Only run it when no data is expected: right after a `D`, or after serial receive buffer. Only run it when no data is expected: right after a `D`, or after
2 s with no serial data. The ESP sends one frame per second, ending with `D`. 2 s with no serial data. The ESP only sends a frame (ending with `D`) when the screen
changes, mostly once a minute.
- Flip time is stored in EEPROM as a magic word at address 0 and a `uint16_t` at - 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. address 2. Values outside 50–2000 µs are rejected.
- The Nano only replies `READY` (at boot) and `ERR: ...`. There is no per-frame - The Nano only replies `READY` (at boot) and `ERR: ...`. There is no per-frame
acknowledgement. Whether the Nano's TX is wired to the ESP's RX is unconfirmed, so every acknowledgement. Whether the Nano's TX is wired to the ESP's RX is unconfirmed, so every
wait for a reply needs a timeout. wait for a reply needs a timeout.
- Unit tests: `pio test -e native` runs `test/test_nano/` on the PC with the fakes in - Unit tests: `pio test -e test_nano` runs `test/test_nano/` on the PC with the fakes in
`test/mocks/` (Arduino, EEPROM, and a `BROSE9323` that records calls). It needs gcc on `test/mocks/` (Arduino, EEPROM, and a `BROSE9323` that records calls). It needs gcc on
PATH (Strawberry Perl provides it at `C:\Strawberry\c\bin`). Add a test for every PATH (Strawberry Perl provides it at `C:\Strawberry\c\bin`). Add a test for every
protocol change. The mocks only implement what the Nano code uses, so extend them when protocol change. The mocks only implement what the Nano code uses, so extend them when
new Arduino or driver functions are called. new Arduino or driver functions are called.
- The board environments have `test_ignore = *`, so test firmware is never uploaded. Plain - The board environments have `test_ignore = *`, so test firmware is never uploaded. Plain
`pio test` runs nothing; always pass `-e native`. `pio test` runs nothing; always pass `-e test_nano -e test_esp32`.
## ESP32 structure (`src/esp32/`)
- Folders: `logic/` (pure C++, no Arduino includes, tested in `test/test_esp32`),
`display/` (frames and bitmaps), `nano_link/` (relay, serial, flash bridge), `net/`
(WiFi/OTA, Home Assistant). Includes are relative to `src/esp32` (`-I src/esp32`), e.g.
`#include "logic/schedule.h"`.
- `DisplayController` holds the display logic and only uses the `DisplayPower` and
`Frames` interfaces. Put new behaviour there, behind those interfaces, so it stays
testable. `logic/` must not include Arduino headers, or `test_esp32` stops 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).
- Home Assistant switches are not optimistic: callbacks must call `sender->setState()`.
The unique ids `FlipDotDisplayClock` / `FlipDotDisplayPower` and `HA_DEVICE_ID` must stay
the same, or Home Assistant creates new entities.
## Git ## Git
+12 -9
View File
@@ -39,7 +39,8 @@ src/nano/ Arduino Nano firmware
src/esp32/ ESP32 firmware src/esp32/ ESP32 firmware
secrets.example.h template for WiFi / MQTT credentials secrets.example.h template for WiFi / MQTT credentials
test/test_nano/ unit tests for the Nano firmware (run on the PC) test/test_nano/ unit tests for the Nano firmware (run on the PC)
test/mocks/ fake Arduino, EEPROM and display driver for the tests 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 tools/nano_net_upload.py uploader for flashing the Nano over WiFi
archive/ old, unused sketches kept for reference archive/ old, unused sketches kept for reference
CLAUDE.md project notes for Claude Code CLAUDE.md project notes for Claude Code
@@ -69,7 +70,7 @@ 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 -t upload # flash the ESP32 over the air
pio run -e esp32_usb -t upload # flash the ESP32 over USB pio run -e esp32_usb -t upload # flash the ESP32 over USB
pio device monitor -e nano # serial monitor pio device monitor -e nano # serial monitor
pio test -e native # run the Nano unit tests on the PC 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 The unit tests need `gcc`/`g++` on your PATH. On Windows you can get it from
@@ -126,8 +127,8 @@ By default (`--baud=auto` in `upload_flags`) the uploader tries 57600 first and
If you ever replace the bootloader with Optiboot: 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 - 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` in BC547). Connect the collector to Nano RESET and the emitter to GND. Set `NANO_RESET_PIN` in
`src/esp32/libs.h` to that GPIO, for example `4`. The transistor keeps the Nano's 5 V `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. reset line away from the 3.3 V ESP32 pin.
- For uploads over USB, change `board` in the `nano` environment to `nanoatmega328new`. - For uploads over USB, change `board` in the `nano` environment to `nanoatmega328new`.
@@ -136,8 +137,8 @@ If you ever replace the bootloader with Optiboot:
- ESP32 IP address: `upload_port` in the `nano_ota` environment in `platformio.ini`. You - 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 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`. `pio run -e nano_ota -t upload --upload-port 10.1.1.50`.
- Port, reset pin and timings: `NANO_FLASH_PORT`, `nano_reset`, `NANO_POWER_OFF_TIME` and - Port, reset pin and timings: `NANO_FLASH_PORT`, `NANO_RESET_PIN`, `NANO_POWER_OFF_TIME`
`NANO_RESET_DELAY` in `src/esp32/libs.h`. and `NANO_RESET_DELAY` in `src/esp32/config.h`.
**If it fails** **If it fails**
@@ -163,7 +164,7 @@ The ESP32 talks to the Nano on **UART2** (`Serial2`, 115200 baud). The ESP32's o
|-------|------|-------| |-------|------|-------|
| GPIO 17 (TX2) | D0 (RX) | Display commands | | 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 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` | RESET (through an NPN transistor) | Optional, only needed to flash a Nano with Optiboot over WiFi. See [Bootloaders](#bootloaders). | | 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 | | GND | GND | Common ground |
| GPIO 13 | Relay input | Relay powers the panels and the Nano (`LOW` = on) | | GPIO 13 | Relay input | Relay powers the panels and the Nano (`LOW` = on) |
@@ -172,8 +173,10 @@ The ESP32 talks to the Nano on **UART2** (`Serial2`, 115200 baud). The ESP32's o
1. **ESP32 boot.** The ESP32 connects to WiFi and starts ArduinoOTA. After a crash or 1. **ESP32 boot.** The ESP32 connects to WiFi and starts ArduinoOTA. After a crash or
unexpected reset it stays in an *OTA panic window* for 15 seconds, so you can flash a unexpected reset it stays in an *OTA panic window* for 15 seconds, so you can flash a
fix before the main code runs again. It then switches the display relay on, waits for fix before the main code runs again. It then switches the display relay on, waits for
the Nano's `READY` message (at most 3 s), starts NTP and connects to MQTT. the Nano's `READY` message (at most 3 s), shows "Connecting...", starts NTP and
2. **Clock loop (every second).** 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 - **08:00–22:00:** Pikachu with a thought bubble showing `HH:MM`. A yawning Pikachu is
shown from 17:00. shown from 17:00.
- **22:00–23:30:** the clock is only shown during the minutes :00, :15, :30 and :45. - **22:00–23:30:** the clock is only shown during the minutes :00, :15, :30 and :45.
+20 -7
View File
@@ -4,13 +4,14 @@
; nano_ota Same Nano firmware, flashed over WiFi through the ESP32 ; nano_ota Same Nano firmware, flashed over WiFi through the ESP32
; esp32 ESP32 "brain" (WiFi, NTP clock, Home Assistant) - OTA upload ; esp32 ESP32 "brain" (WiFi, NTP clock, Home Assistant) - OTA upload
; esp32_usb Same ESP32 firmware, flashed over USB (first flash / recovery) ; esp32_usb Same ESP32 firmware, flashed over USB (first flash / recovery)
; native Unit tests for the Nano firmware, run on the PC (needs gcc on PATH) ; test_nano Unit tests for the Nano firmware, run on the PC (needs gcc on PATH)
; test_esp32 Unit tests for the ESP32 logic, run on the PC
; ;
; Build all: pio run ; Build all: pio run
; Build one: pio run -e nano ; Build one: pio run -e nano
; Upload one: pio run -e nano -t upload ; Upload one: pio run -e nano -t upload
; Serial monitor: pio device monitor -e nano ; Serial monitor: pio device monitor -e nano
; Run tests: pio test -e native ; Run tests: pio test -e test_nano -e test_esp32
; ;
; https://docs.platformio.org/page/projectconf.html ; https://docs.platformio.org/page/projectconf.html
@@ -22,7 +23,7 @@ framework = arduino
monitor_speed = 115200 monitor_speed = 115200
lib_deps = lib_deps =
adafruit/Adafruit GFX Library@^1.10.12 adafruit/Adafruit GFX Library@^1.10.12
; Tests only run in the native environment, never on the boards ; Tests only run in the test_* environments on the PC, never on the boards
test_ignore = * test_ignore = *
; --------------------------------------------------------------------------- ; ---------------------------------------------------------------------------
@@ -58,7 +59,7 @@ board = esp32doit-devkit-v1
build_src_filter = +<esp32/> build_src_filter = +<esp32/>
; ESPSERIAL turns the BROSE9323 driver into a serial client: every draw call is ; ESPSERIAL turns the BROSE9323 driver into a serial client: every draw call is
; sent to the Nano as a text command instead of toggling GPIO pins. ; sent to the Nano as a text command instead of toggling GPIO pins.
build_flags = -D ESPSERIAL build_flags = -D ESPSERIAL -I src/esp32
lib_deps = lib_deps =
${env.lib_deps} ${env.lib_deps}
https://github.com/robjen/GFX_fonts.git https://github.com/robjen/GFX_fonts.git
@@ -74,15 +75,27 @@ upload_protocol = esptool
upload_port = COM11 upload_port = COM11
; --------------------------------------------------------------------------- ; ---------------------------------------------------------------------------
; Native - unit tests for the Nano firmware on the PC (test/test_nano) ; Unit tests on the PC (pio test -e test_nano -e test_esp32)
; The Arduino core, EEPROM and the display driver are replaced by test/mocks.
; --------------------------------------------------------------------------- ; ---------------------------------------------------------------------------
[env:native] [native]
platform = native platform = native
framework = framework =
lib_deps = lib_deps =
lib_ignore = BROSE9323 lib_ignore = BROSE9323
test_ignore = test_ignore =
test_build_src = yes test_build_src = yes
; Nano firmware (test/test_nano). The Arduino core, EEPROM and the display driver are
; replaced by test/mocks.
[env:test_nano]
extends = native
test_filter = test_nano
build_src_filter = +<nano/> build_src_filter = +<nano/>
build_flags = -std=gnu++17 -I test/mocks build_flags = -std=gnu++17 -I test/mocks
; ESP32 logic (test/test_esp32): only the modules without Arduino dependencies
[env:test_esp32]
extends = native
test_filter = test_esp32
build_src_filter = +<esp32/logic/>
build_flags = -std=gnu++17 -I src/esp32
+52 -23
View File
@@ -1,6 +1,6 @@
# ESP32: controller # ESP32: controller
PlatformIO environments: `esp32` (OTA) and `esp32_usb` (USB) PlatformIO environments: `esp32` (OTA) and `esp32_usb` (USB). Tests: `test_esp32`.
The ESP32 decides what is shown on the display. It never touches the panel itself. The The ESP32 decides what is shown on the display. It never touches the panel itself. The
BROSE9323 driver is built with `-D ESPSERIAL`, so every GFX call is sent to the Nano over BROSE9323 driver is built with `-D ESPSERIAL`, so every GFX call is sent to the Nano over
@@ -11,35 +11,55 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
| File | Contents | | File | Contents |
|------|----------| |------|----------|
| `main.cpp` | setup, OTA, clock / power logic, Home Assistant callbacks | | `main.cpp` | Creates the objects, `setup()` and `loop()` |
| `libs.h` | includes and global objects (display, MQTT, WiFi, pins, timezone) | | `config.h` | All settings: pins, timings, timezone, Home Assistant device id |
| `bitmaps.h` | 1-bit bitmaps (Pikachu, thought bubble, Nintendo logo, ...) | | `credentials.h` | Includes `secrets.h` (WiFi and MQTT credentials, **gitignored**); template: `secrets.example.h` |
| `nano_flash_bridge.h/.cpp` | WiFi to serial bridge for flashing the Nano over WiFi (TCP port 2323) | | **`logic/`** | **Pure logic without Arduino code, tested on the PC** |
| `secrets.example.h` | template for `secrets.h` (WiFi and MQTT credentials, **gitignored**) | | `logic/display_controller.*` | Decides what is shown and when the display is powered (`DisplayController`) |
| `logic/schedule.*` | When the clock is shown (night, clock, standby) |
| `logic/power_timer.*` | Countdown to switching the display off |
| `logic/status_led.h` | Blink pattern of the built-in LED |
| **`display/`** | **What is drawn** |
| `display/frames.*` | The screens: clock, logo, blank, text (`FlipdotFrames`) |
| `display/bitmaps.h` | 1-bit bitmaps (Pikachu, thought bubble, Nintendo logo, ...) |
| **`nano_link/`** | **Connection to the Nano** |
| `nano_link/nano_link.*` | Relay and serial link: power on (waits for `READY`), power off, bootloader start (`NanoLink`) |
| `nano_link/nano_flash_bridge.*` | WiFi to serial bridge for flashing the Nano over WiFi (TCP port 2323) |
| **`net/`** | **Network** |
| `net/wifi_ota.*` | WiFi, ESP32 OTA updates and the OTA panic window |
| `net/home_assistant.*` | MQTT and the two Home Assistant switches |
`DisplayController` only talks to two small interfaces, `DisplayPower` (implemented by
`NanoLink`) and `Frames` (implemented by `FlipdotFrames`). The tests replace both with
fakes.
## Features ## Features
- **Frames only when something changes:** a new frame is sent when the minute changes, the
screen changes (clock, logo, blank) or the Nano was just powered on. The serial line is
quiet in between, which also gives the Nano time for its periodic full redraw.
- **OTA updates** through ArduinoOTA. Hostname: `FlipDotDisplay`. - **OTA updates** through ArduinoOTA. Hostname: `FlipDotDisplay`.
- **OTA panic window:** after a crash or watchdog reset, the ESP32 runs only the OTA - **OTA panic window:** after a crash or watchdog reset, the ESP32 runs only the OTA
handler for 15 seconds before it starts the main code. A firmware that crashes on boot handler for 15 seconds before it starts the main code, with the LED blinking fast. A
can still be replaced over the air. firmware that crashes on boot can still be replaced over the air.
- **NTP time** with timezone `CET-1CEST,M3.5.0,M10.5.0/3` (Europe/Amsterdam). - **NTP time** with timezone `CET-1CEST,M3.5.0,M10.5.0/3` (Europe/Amsterdam). Until the
- **Home Assistant** through MQTT (`home-assistant-integration` library) with two switches: time is synced, the display shows "Connecting...".
- `FlipDotDisplayClock`: clock on, or the Nintendo logo when off - **Home Assistant** through MQTT (`home-assistant-integration` library) with two switches.
- `FlipDotDisplayPower`: turns the display off through the relay Each switch reports its new state back, so Home Assistant always shows the real state.
- `FlipDot Display Clock`: on = clock; off = Nintendo logo while the display is on,
then power off after 30 s
- `FlipDot Display Power`: off = clear the display and power it off after 1 s
- **Relay on GPIO 13** switches the power for both the panels and the Nano. `LOW` turns it - **Relay on GPIO 13** switches the power for both the panels and the Nano. `LOW` turns it
on. on.
- **On:** the ESP waits for the Nano's `READY` message, at most 3 s - **On:** the ESP waits for the Nano's `READY` message, at most 3 s
(`NANO_BOOT_TIMEOUT`), before it sends frames. The relay stays on while it is needed; (`NANO_BOOT_TIMEOUT`), before it sends frames.
the ESP only waits when it actually switches the relay on. - **Off:** the ESP releases its TX pin (`Serial2.end()` and the pin set to input). An
- **Off:** the ESP stops sending and releases its TX pin (`Serial.end()` and the pin set idle UART line is high and would otherwise power the Nano through its RX pin.
to input). An idle UART line is high and would otherwise power the Nano through its RX
pin.
- **Nano flash bridge:** `pio run -e nano_ota -t upload` flashes the Nano through the ESP32. - **Nano flash bridge:** `pio run -e nano_ota -t upload` flashes the Nano through the ESP32.
While a flash session runs, the clock is paused. See the While a flash session runs, the clock is paused. See the
[main README](../../README.md#flashing-the-nano-over-wifi). [main README](../../README.md#flashing-the-nano-over-wifi).
- **Status LED** (built-in) blinks fast when WiFi is down and slowly when connected. It is - **Status LED** (built-in): off while the display is off, slow blink with WiFi, fast blink
off at night or when the display is powered off. without WiFi.
## Schedule ## Schedule
@@ -47,11 +67,20 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
|------|---------| |------|---------|
| 08:00 – 17:00 | Pikachu and the clock, updated every minute | | 08:00 – 17:00 | Pikachu and the clock, updated every minute |
| 17:00 – 22:00 | Yawning Pikachu and the clock | | 17:00 – 22:00 | Yawning Pikachu and the clock |
| 22:00 – 23:30 | Clock shown only during the minutes :00, :15, :30 and :45; power is off in between | | 22:00 – 23:30 | Clock shown only during the minutes :00, :15, :30 and :45. In between the power is off, and the last time stays visible (flipdots keep their image without power). |
| 23:31 – 08:00 | Screen cleared, power off | | 23:31 – 08:00 | Screen cleared, power off after 30 s |
The times are in `logic/schedule.cpp`.
## Tests
`pio test -e test_esp32` runs [test/test_esp32](../../test/test_esp32/test_main.cpp) on
the PC: the schedule boundaries, the power timer (including `millis()` overflow), the LED
pattern and the display controller (power on/off, redraws, both Home Assistant switches).
## Adding bitmaps ## Adding bitmaps
Convert an image with [image2cpp](https://javl.github.io/image2cpp/) to "Arduino code", Convert an image with [image2cpp](https://javl.github.io/image2cpp/) to "Arduino code",
horizontal, 1 bit per pixel. Paste the array into `bitmaps.h` and draw it with horizontal, 1 bit per pixel. Paste the array into `display/bitmaps.h` and draw it in
`display.drawBitmap(x, y, bitmap, w, h, 1)`. The display is 84 × 19 pixels. `display/frames.cpp` with `_display.drawBitmap(x, y, bitmap, w, h, 1)`. The display is
84 × 19 pixels.
+69
View File
@@ -0,0 +1,69 @@
#pragma once
// All ESP32 settings. Credentials are in secrets.h (see secrets.example.h).
// Kept free of Arduino types so the logic modules can be tested on the PC.
#include <stdint.h>
// ---------------------------------------------------------------------------
// Pins
// ---------------------------------------------------------------------------
const int LED_PIN = 2; // built-in LED of the ESP32 DevKit v1
const int RELAY_PIN = 13; // powers the panels and the Nano (LOW = on)
// Link to the Nano on UART2. Serial (UART0) stays free for USB / debug output.
const int NANO_RX_PIN = 16; // wired to the Nano TX (through a 5V -> 3.3V divider)
const int NANO_TX_PIN = 17; // wired to the Nano RX
// Optional: GPIO that resets the Nano through an NPN transistor (HIGH = reset), -1 = not wired.
// Needed to flash a Nano with the Optiboot bootloader, which doesn't start after a power cycle.
const int NANO_RESET_PIN = -1;
// ---------------------------------------------------------------------------
// Display and Nano
// ---------------------------------------------------------------------------
const uint8_t DISPLAY_WIDTH = 84;
const uint8_t DISPLAY_HEIGHT = 19;
const uint8_t PANEL_WIDTH = 28;
const uint16_t FLIP_TIME = 225; // flip pulse length in us, sent to the Nano at boot
const uint32_t NANO_BAUD = 115200;
// Max wait for the Nano's READY after power on (also the wait when Nano TX is not wired to our RX)
const uint32_t NANO_BOOT_TIMEOUT = 3000;
// After a frame is cleared, wait this long before cutting the power, so the dots can flip
const uint32_t CLEAR_BEFORE_OFF_TIME = 1000;
// Power off this long after the display is no longer needed (night, standby, logo)
const uint32_t POWER_OFF_DELAY = 30000;
// ---------------------------------------------------------------------------
// Flashing the Nano over WiFi (see README.md, pio run -e nano_ota -t upload)
// ---------------------------------------------------------------------------
const uint16_t NANO_FLASH_PORT = 2323;
// The bootloader baud rate is chosen by the uploader (57600 old bootloader, 115200 Optiboot)
const uint32_t NANO_POWER_OFF_TIME = 1000; // relay off time to make sure the Nano resets
const uint32_t NANO_RESET_DELAY = 200; // after power on, before the reset pulse
const uint32_t NANO_RESET_PULSE = 50;
// ---------------------------------------------------------------------------
// Network, time and Home Assistant
// ---------------------------------------------------------------------------
#define HOSTNAME "FlipDotDisplay"
// Europe/Amsterdam
#define TIMEZONE "CET-1CEST,M3.5.0,M10.5.0/3"
#define NTP_SERVER "pool.ntp.org"
// After a crash or unexpected reset, only run OTA for this long before starting the
// main code, so a firmware that crashes at boot can still be replaced
const uint32_t OTA_PANIC_WINDOW = 15000;
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
#define HA_DEVICE_NAME "FlipDotDisplay"
#define HA_SOFTWARE_VERSION "2.0.0"
+8
View File
@@ -0,0 +1,8 @@
#pragma once
// WiFi / MQTT credentials from secrets.h, which is gitignored
#if __has_include("secrets.h")
#include "secrets.h"
#else
#error "Missing src/esp32/secrets.h - copy src/esp32/secrets.example.h to secrets.h and fill it in"
#endif
File renamed without changes.
+55
View File
@@ -0,0 +1,55 @@
#include "display/frames.h"
#include <stdio.h>
#include "display/bitmaps.h"
#include "logic/schedule.h"
// Horizontal offset that centers the 81 pixel wide clock on the 84 pixel display
static const int CLOCK_X = 3;
FlipdotFrames::FlipdotFrames(BROSE9323 &display) : _display(display)
{
}
void FlipdotFrames::clock(int hour, int minute)
{
char time[6];
snprintf(time, sizeof(time), "%02d:%02d", hour, minute);
_display.fillScreen(0);
if (isEvening(hour))
_display.drawBitmap(CLOCK_X, 0, yawning_pikachu_bitmap_yawning_pikachu_black_border, 35, 19, 1);
else
_display.drawBitmap(CLOCK_X, 0, pikachu_bitmap_pikachu_black_border, 35, 19, 1);
_display.drawBitmap(CLOCK_X + 35, 0, clock_thought_bubble_bitmap_clock_thought_bubble, 46, 19, 1);
_display.setTextSize(1);
_display.setTextWrap(false);
_display.setCursor(CLOCK_X + 43, 4);
_display.print(time);
_display.display();
}
void FlipdotFrames::logo()
{
_display.fillScreen(0);
_display.drawBitmap(0, 0, nintendo_bitmap_Nintendo_new, 84, 19, 1);
_display.display();
}
void FlipdotFrames::blank()
{
_display.fillScreen(0);
_display.display();
}
void FlipdotFrames::message(const char *text)
{
_display.fillScreen(0);
_display.setTextSize(1);
_display.setTextWrap(true);
_display.setCursor(0, 0);
_display.print(text);
_display.display();
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "BROSE9323.h"
#include "logic/display_controller.h"
// Draws the screens on the flipdot display. Each method sends one complete frame.
class FlipdotFrames : public Frames
{
public:
explicit FlipdotFrames(BROSE9323 &display);
// Pikachu with a thought bubble showing HH:MM (yawning in the evening)
void clock(int hour, int minute) override;
// Nintendo logo
void logo() override;
void blank() override;
// A line of text, e.g. while connecting
void message(const char *text);
private:
BROSE9323 &_display;
};
-90
View File
@@ -1,90 +0,0 @@
#include <Arduino.h>
#include "BROSE9323.h"
#include "bitmaps.h"
#include "nano_flash_bridge.h"
// WiFi / MQTT credentials (gitignored, copy secrets.example.h -> secrets.h)
#if __has_include("secrets.h")
#include "secrets.h"
#else
#error "Missing src/esp32/secrets.h - copy src/esp32/secrets.example.h to secrets.h and fill it in"
#endif
// fixes
#include <SPI.h>
#include <Adafruit_I2CDevice.h>
// fonts
#include <Fonts/FreeSerif12pt7b.h>
#include <Fonts/FreeSerifItalic12pt7b.h>
// #include <Font2x5FixedMonoNum.h>
#include <WiFi.h>
#include <ESPmDNS.h>
#include <WiFiUdp.h>
#include <ArduinoOTA.h>
#include <WiFiMulti.h>
// wdt?
#include <esp_task_wdt.h>
#include <rom/rtc.h>
// hassio
#include <ArduinoHA.h>
byte mac[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
// EthernetClient client;
WiFiClient client;
HADevice device(mac, sizeof(mac));
HAMqtt mqtt(client, device);
HASwitch flipDotDisplay("FlipDotDisplayClock");
bool hassioDisplayStatus = true;
HASwitch powerSwitch("FlipDotDisplayPower");
bool power = true;
const int led1 = BUILTIN_LED; // Pin of the LED
const int relay = 13; // Pin of the relay, powers the panels and the Nano (LOW = on)
// Link to the Nano on UART2. Serial (UART0) stays free for USB / debug output.
HardwareSerial &nanoSerial = Serial2;
const int nano_rx = 16; // UART2 RX, wired to the Nano TX (through a 5V -> 3.3V divider)
const int nano_tx = 17; // UART2 TX, wired to the Nano RX
// Optional: GPIO that resets the Nano through an NPN transistor (HIGH = reset), -1 = not wired.
// Needed to flash a Nano with the Optiboot bootloader, which doesn't start after a power cycle.
const int nano_reset = -1;
bool relay_on = false;
// Max wait for the Nano's READY after power on (also the wait when Nano TX is not wired to our RX)
const uint32_t NANO_BOOT_TIMEOUT = 3000;
const uint32_t NANO_BAUD = 115200;
// Flashing the Nano over WiFi (see README.md, pio run -e nano_ota -t upload)
const uint16_t NANO_FLASH_PORT = 2323;
// The bootloader baud rate is chosen by the uploader (57600 old bootloader, 115200 Optiboot)
const uint32_t NANO_POWER_OFF_TIME = 1000; // relay off time to make sure the Nano resets
const uint32_t NANO_RESET_DELAY = 200; // after power on, before the reset pulse
const uint32_t NANO_RESET_PULSE = 50;
int hour = 0;
int minute = 0;
int second = 0;
//
WiFiMulti wifiMulti;
const char *ssid = WIFI_SSID;
const char *password = WIFI_PASSWORD;
BROSE9323 display(84, 19, 28);
uint32_t next_display;
// ntp
#define MYTZ "CET-1CEST,M3.5.0,M10.5.0/3"
const char *ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 3600;
const int daylightOffset_sec = 3600;
// const int daylightOffset_sec = 0;
bool wifiStatus = false;
bool otaPanicMode = true;
+118
View File
@@ -0,0 +1,118 @@
#include "logic/display_controller.h"
#include "config.h"
#include "logic/schedule.h"
DisplayController::DisplayController(DisplayPower &power, Frames &frames) : _power(power), _frames(frames)
{
}
void DisplayController::setClockEnabled(bool enabled)
{
_clock_enabled = enabled;
}
void DisplayController::setPowerEnabled(bool enabled)
{
_power_enabled = enabled;
}
bool DisplayController::clockEnabled() const
{
return _clock_enabled;
}
bool DisplayController::powerEnabled() const
{
return _power_enabled;
}
void DisplayController::update(uint32_t now, bool time_valid, int hour, int minute)
{
// Switched off elsewhere (power timer, Nano flashing): the Nano lost its frame
if (!_power.isOn())
_screen = Screen::Unknown;
if (_off_timer.expired(now))
{
_power.off();
_screen = Screen::Unknown;
return;
}
if (!_power_enabled)
{
if (_power.isOn())
{
_showBlank();
_off_timer.request(now, CLEAR_BEFORE_OFF_TIME);
}
return;
}
// Keep the boot screen until the time is known
if (!time_valid)
return;
if (!_clock_enabled)
{
// The logo is only shown while the display is on; it never switches the power on
if (_power.isOn())
{
_showLogo();
_off_timer.request(now, POWER_OFF_DELAY);
}
return;
}
switch (scheduleFor(hour, minute))
{
case ScheduleMode::Night:
if (_power.isOn())
{
_showBlank();
_off_timer.request(now, POWER_OFF_DELAY);
}
break;
case ScheduleMode::Clock:
_off_timer.cancel();
if (!_power.isOn())
{
_power.on();
_screen = Screen::Unknown;
}
_showClock(hour, minute);
break;
case ScheduleMode::Standby:
// Flipdots keep their image without power, so the last time stays visible
if (_power.isOn())
_off_timer.request(now, POWER_OFF_DELAY);
break;
}
}
void DisplayController::_showClock(int hour, int minute)
{
if (_screen == Screen::Clock && _shown_hour == hour && _shown_minute == minute)
return;
_frames.clock(hour, minute);
_screen = Screen::Clock;
_shown_hour = hour;
_shown_minute = minute;
}
void DisplayController::_showLogo()
{
if (_screen == Screen::Logo)
return;
_frames.logo();
_screen = Screen::Logo;
}
void DisplayController::_showBlank()
{
if (_screen == Screen::Blank)
return;
_frames.blank();
_screen = Screen::Blank;
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <stdint.h>
#include "logic/power_timer.h"
// Power for the panels and the Nano (implemented by NanoLink)
class DisplayPower
{
public:
virtual bool isOn() = 0;
// Switch on and wait until the Nano is ready
virtual void on() = 0;
virtual void off() = 0;
};
// The screens the display can show (implemented by FlipdotFrames)
class Frames
{
public:
virtual void clock(int hour, int minute) = 0;
virtual void logo() = 0;
virtual void blank() = 0;
};
// Decides what the display shows and when it is powered, based on the time and the two
// Home Assistant switches. A frame is only sent when it changes. Pure logic, tested in
// test/test_esp32.
class DisplayController
{
public:
DisplayController(DisplayPower &power, Frames &frames);
// Home Assistant "FlipDot Display Clock" switch: off shows the logo instead of the clock
void setClockEnabled(bool enabled);
// Home Assistant "FlipDot Display Power" switch: off clears the display and powers it off
void setPowerEnabled(bool enabled);
bool clockEnabled() const;
bool powerEnabled() const;
// Call often (every loop). time_valid is false until the time is synced over NTP.
void update(uint32_t now, bool time_valid, int hour, int minute);
private:
enum class Screen
{
Unknown, // nothing sent since the last power on
Clock,
Logo,
Blank,
};
DisplayPower &_power;
Frames &_frames;
PowerTimer _off_timer;
bool _clock_enabled = true;
bool _power_enabled = true;
Screen _screen = Screen::Unknown;
int _shown_hour = -1;
int _shown_minute = -1;
void _showClock(int hour, int minute);
void _showLogo();
void _showBlank();
};
+33
View File
@@ -0,0 +1,33 @@
#include "logic/power_timer.h"
// a is before b, also when millis() overflowed in between
static bool before(uint32_t a, uint32_t b)
{
return (int32_t)(a - b) < 0;
}
void PowerTimer::request(uint32_t now, uint32_t delay)
{
uint32_t deadline = now + delay;
if (!_pending || before(deadline, _deadline))
_deadline = deadline;
_pending = true;
}
void PowerTimer::cancel()
{
_pending = false;
}
bool PowerTimer::pending() const
{
return _pending;
}
bool PowerTimer::expired(uint32_t now)
{
if (!_pending || before(now, _deadline))
return false;
_pending = false;
return true;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <stdint.h>
// Countdown to switching the display off. Works across millis() overflow.
class PowerTimer
{
public:
// Switch off `delay` ms from `now`. An earlier pending deadline is kept.
void request(uint32_t now, uint32_t delay);
void cancel();
bool pending() const;
// True once when the deadline has passed (the timer is then cleared)
bool expired(uint32_t now);
private:
bool _pending = false;
uint32_t _deadline = 0;
};
+21
View File
@@ -0,0 +1,21 @@
#include "logic/schedule.h"
static const int DAY_START_HOUR = 8;
static const int LATE_START_HOUR = 22;
static const int NIGHT_START_HOUR = 23;
static const int NIGHT_START_MINUTE = 31;
static const int EVENING_START_HOUR = 17;
ScheduleMode scheduleFor(int hour, int minute)
{
if (hour < DAY_START_HOUR || (hour >= NIGHT_START_HOUR && minute >= NIGHT_START_MINUTE))
return ScheduleMode::Night;
if (hour < LATE_START_HOUR || minute % 15 == 0)
return ScheduleMode::Clock;
return ScheduleMode::Standby;
}
bool isEvening(int hour)
{
return hour >= EVENING_START_HOUR;
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
// When the clock is shown. Pure logic, tested in test/test_esp32.
//
// 00:00 - 07:59 Night screen cleared, power off
// 08:00 - 21:59 Clock clock updated every minute
// 22:00 - 23:30 Clock only during the minutes :00, :15, :30 and :45
// Standby in between: power off, the last time stays on the dots
// 23:31 - 23:59 Night
enum class ScheduleMode
{
Night,
Clock,
Standby,
};
ScheduleMode scheduleFor(int hour, int minute);
// Evening: the yawning Pikachu is shown
bool isEvening(int hour);
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <stdint.h>
// Built-in LED: off while the display is off, slow blink (1 s) when connected to WiFi,
// fast blink (200 ms) without WiFi. Pure logic, tested in test/test_esp32.
inline bool statusLedLevel(uint32_t now, bool wifi_connected, bool display_on)
{
if (!display_on)
return false;
uint32_t period = wifi_connected ? 1000 : 200;
return now % period < period / 2;
}
+46 -603
View File
@@ -1,621 +1,64 @@
#include "libs.h" // ESP32 firmware: decides what the flipdot display shows (clock, Home Assistant switches)
// and sends it to the Nano, which drives the panels. See README.md.
void toggleLED(void *parameter); #include <Arduino.h>
#include <time.h>
uint32_t power_off_delay; #include "BROSE9323.h"
int triggered_power_off = false; #include "config.h"
int turn_hassio_power_off = false; #include "display/frames.h"
#include "logic/display_controller.h"
#include "logic/status_led.h"
#include "nano_link/nano_flash_bridge.h"
#include "nano_link/nano_link.h"
#include "net/home_assistant.h"
#include "net/wifi_ota.h"
// Wait until the Nano reports READY after booting, or until NANO_BOOT_TIMEOUT NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
void waitForNano() BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
{ FlipdotFrames frames(display);
char line[16]; DisplayController controller(nano, frames);
uint8_t len = 0;
uint32_t start = millis();
while (millis() - start < NANO_BOOT_TIMEOUT)
{
while (nanoSerial.available())
{
char c = nanoSerial.read();
if (c == '\n')
{
line[len] = '\0';
if (strstr(line, "READY"))
return;
len = 0;
}
else if (len < sizeof(line) - 1)
line[len++] = c;
}
delay(10);
}
}
void relayOn() // Flashing the Nano over WiFi: restart it into its bootloader, switch it off afterwards.
{ // The controller then powers it on again with the new firmware.
// cancel a pending power off countdown NanoFlashBridge flashBridge(
triggered_power_off = false; NANO_FLASH_PORT, Serial2,
[](uint32_t baud) { nano.startBootloader(baud); },
if (relay_on) []() { nano.off(); });
return;
nanoSerial.begin(NANO_BAUD, SERIAL_8N1, nano_rx, nano_tx);
// drop old data (e.g. ERR replies) so only a new READY counts
while (nanoSerial.available())
nanoSerial.read();
digitalWrite(relay, LOW);
waitForNano();
relay_on = true;
}
void relayOff()
{
digitalWrite(relay, HIGH);
relay_on = false;
// An idle UART TX line is high and would power the Nano through its RX pin
nanoSerial.flush();
nanoSerial.end();
pinMode(nano_tx, INPUT);
}
// Flash session started: restart the Nano so its bootloader runs, and talk to the
// bootloader at its baud rate. The PC uploader keeps sending sync bytes until it answers.
// The old bootloader also starts after a power cycle; Optiboot only after a reset pulse.
void startNanoFlash(uint32_t baud)
{
if (relay_on)
relayOff();
delay(NANO_POWER_OFF_TIME);
nanoSerial.begin(baud, SERIAL_8N1, nano_rx, nano_tx);
digitalWrite(relay, LOW);
if (nano_reset >= 0)
{
delay(NANO_RESET_DELAY);
digitalWrite(nano_reset, HIGH);
delay(NANO_RESET_PULSE);
digitalWrite(nano_reset, LOW);
}
// drop anything the Nano sent before its bootloader started
while (nanoSerial.available())
nanoSerial.read();
}
// Flash session ended: switch off, the clock powers the Nano on again with the new firmware
void endNanoFlash()
{
relayOff();
// skip the frames that were missed while flashing
next_display = millis();
}
NanoFlashBridge nanoFlashBridge(NANO_FLASH_PORT, nanoSerial, startNanoFlash, endNanoFlash);
void turnPowerOff(uint32_t delay = 30000, bool _turn_hassio_power_off = false)
{
if(triggered_power_off == false)
{
// start new countdown
turn_hassio_power_off = _turn_hassio_power_off;
power_off_delay = millis() + delay;
triggered_power_off = true;
}
}
void updatePower()
{
if(millis() > power_off_delay && triggered_power_off == true)
{
// finished countdown
triggered_power_off = false;
// OFF
relayOff();
if(turn_hassio_power_off)
{
power = false;
powerSwitch.setState(false);
}
}
}
void onSwitchStateChanged(bool state, HASwitch *s)
{
hassioDisplayStatus = (state ? HIGH : LOW);
}
void onPowerSwitchStateChanged(bool state, HASwitch *s)
{
power = (state ? HIGH : LOW);
if(power == false)
{
if (relay_on)
{
display.fillScreen(0);
display.display();
delay(1000);
}
turnPowerOff(0);
return;
}
}
void print_reset_reason(RESET_REASON reason)
{
switch (reason)
{
case POWERON_RESET:
Serial.println("POWERON_RESET");
break; /**<1, Vbat power on reset*/
case 3:
Serial.println("SW_RESET");
break; /**<3, Software reset digital core*/
case 4:
Serial.println("OWDT_RESET");
break; /**<4, Legacy watch dog reset digital core*/
case 5:
Serial.println("DEEPSLEEP_RESET");
break; /**<5, Deep Sleep reset digital core*/
case 6:
Serial.println("SDIO_RESET");
break; /**<6, Reset by SLC module, reset digital core*/
case 7:
Serial.println("TG0WDT_SYS_RESET");
break; /**<7, Timer Group0 Watch dog reset digital core*/
case 8:
Serial.println("TG1WDT_SYS_RESET");
break; /**<8, Timer Group1 Watch dog reset digital core*/
case 9:
Serial.println("RTCWDT_SYS_RESET");
break; /**<9, RTC Watch dog Reset digital core*/
case 10:
Serial.println("INTRUSION_RESET");
break; /**<10, Instrusion tested to reset CPU*/
case 11:
Serial.println("TGWDT_CPU_RESET");
break; /**<11, Time Group reset CPU*/
case SW_CPU_RESET:
Serial.println("SW_CPU_RESET");
break; /**<12, Software reset CPU*/
case 13:
Serial.println("RTCWDT_CPU_RESET");
break; /**<13, RTC Watch dog Reset CPU*/
case EXT_CPU_RESET:
Serial.println("EXT_CPU_RESET");
break; /**<14, for APP CPU, reseted by PRO CPU*/
case 15:
Serial.println("RTCWDT_BROWN_OUT_RESET");
break; /**<15, Reset when the vdd voltage is not stable*/
case 16:
Serial.println("RTCWDT_RTC_RESET");
break; /**<16, RTC Watch dog reset digital core and rtc module*/
default:
Serial.println("NO_MEAN");
}
}
bool checkResetStatus()
{
Serial.println("CPU0 reset reason: ");
RESET_REASON reason0 = rtc_get_reset_reason(0);
print_reset_reason(reason0);
// if(reason0 == SW_CPU_RESET)
// return true;
Serial.println("CPU1 reset reason: ");
RESET_REASON reason1 = rtc_get_reset_reason(1);
print_reset_reason(reason1);
// if(reason1 == SW_CPU_RESET)
// return true;
if (reason0 != POWERON_RESET && reason1 != EXT_CPU_RESET)
return true;
return false;
}
void setup() void setup()
{ {
otaPanicMode = true; Serial.begin(115200);
pinMode(led1, OUTPUT); pinMode(LED_PIN, OUTPUT);
nano.begin();
Serial.begin(115200); wifiBegin();
otaBegin();
flashBridge.begin();
otaPanicWindow(LED_PIN);
WiFi.setHostname("FlipDotDisplay"); nano.on();
wifiMulti.addAP(ssid, password); display.begin(&nano.stream());
display.setTiming(FLIP_TIME);
// Shown until the time is synced
frames.message("Connecting...");
Serial.println("Booting"); configTzTime(TIMEZONE, NTP_SERVER);
WiFi.mode(WIFI_STA); homeAssistantBegin(controller);
WiFi.begin(ssid, password);
while (WiFi.waitForConnectResult() != WL_CONNECTED)
{
Serial.println("Connection Failed! Rebooting...");
delay(5000);
ESP.restart();
}
ArduinoOTA
.onStart([]()
{
String type;
if (ArduinoOTA.getCommand() == U_FLASH)
type = "sketch";
else // U_SPIFFS
type = "filesystem";
// NOTE: if updating SPIFFS this would be the place to unmount SPIFFS using SPIFFS.end()
// USE_SERIAL.write(0x0C); //CLEAR SCREEN CMD
// Serial.println("Start updating " + type);
// if (!otaPanicMode)
// {
// display.begin(&Serial);
// display.fillScreen(0);
// display.setTextSize(1);
// display.setCursor(0, 0);
// display.print("ota starting!");
// display.display();
// }
})
.onEnd([]()
{
// USE_SERIAL.write(0x0C); //CLEAR SCREEN CMD
// Serial.println("\nEnd");
})
.onProgress([](unsigned int progress, unsigned int total)
{
// USE_SERIAL.write(0x0C); //CLEAR SCREEN CMD
// USE_SERIAL.printf("Progress: %u%%\r", (progress / (total / 100)));
// if (!otaPanicMode)
// {
// display.begin(&Serial);
// display.fillScreen(0);
// display.setTextSize(1);
// display.setCursor(0, 0);
// display.printf("ota: %u%%\r", (progress / (total / 100)));
// display.display();
// }
})
.onError([](ota_error_t error)
{
// // USE_SERIAL.write(0x0C); //CLEAR SCREEN CMD
// Serial.printf("Error[%u]: ", error);
// if (error == OTA_AUTH_ERROR)
// Serial.println("Auth Failed");
// else if (error == OTA_BEGIN_ERROR)
// Serial.println("Begin Failed");
// else if (error == OTA_CONNECT_ERROR)
// Serial.println("Connect Failed");
// else if (error == OTA_RECEIVE_ERROR)
// Serial.println("Receive Failed");
// else if (error == OTA_END_ERROR)
// Serial.println("End Failed");
});
ArduinoOTA.begin();
nanoFlashBridge.begin();
do
{
ArduinoOTA.handle();
digitalWrite(led1, HIGH);
vTaskDelay(50 / portTICK_PERIOD_MS);
// Turn the LED off
digitalWrite(led1, LOW);
vTaskDelay(50 / portTICK_PERIOD_MS);
} while (checkResetStatus() && millis() < 1000 * 15);
otaPanicMode = false;
pinMode(relay, OUTPUT);
if (nano_reset >= 0)
{
digitalWrite(nano_reset, LOW);
pinMode(nano_reset, OUTPUT);
}
relayOn();
display.begin(&nanoSerial);
// display.setTiming(200);
// display.setTiming(180);
// display.setTiming(220); // works best so far
display.setTiming(225);
// delay(4000);
display.fillScreen(0);
display.setCursor(0, 0);
display.setTextSize(1);
display.println("Connecting...");
display.display();
// display.setFont(&FreeSerif12pt7b);
// display.setFont(&FreeSerifItalic12pt7b);
//init and get the time
// configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
configTzTime(MYTZ, ntpServer);
xTaskCreate(
toggleLED, // Function that should be called
"Toggle LED", // Name of the task (for debugging)
1000, // Stack size (bytes)
NULL, // Parameter to pass
1, // Task priority
NULL // Task handle
);
next_display = millis() / 1000 * 1000;
// // TEST CODE
// do{
// ArduinoOTA.handle();
// digitalWrite(led1, HIGH);
// vTaskDelay(50 / portTICK_PERIOD_MS);
// // Turn the LED off
// digitalWrite(led1, LOW);
// vTaskDelay(50 / portTICK_PERIOD_MS);
// }while(true);
// hassio
// you don't need to verify return status
// client.begin(mac);
// set device's details (optional)
device.setName("FlipDotDisplay");
device.setSoftwareVersion("1.0.0");
// set icon (optional)
flipDotDisplay.setIcon("mdi:lightbulb");
flipDotDisplay.setName("FlipDot Display Clock");
// handle switch state
flipDotDisplay.onCommand(onSwitchStateChanged);
powerSwitch.setIcon("mdi:lightbulb");
powerSwitch.setName("FlipDot Display Power");
powerSwitch.onCommand(onPowerSwitchStateChanged);
mqtt.begin(BROKER_ADDR, MQTT_USER, MQTT_PASSWORD);
display.fillScreen(0);
display.display();
hour = millis() / 1000 / 60 / 60;
minute = (millis() / 1000 / 60) % 60;
second = (millis() / 1000 % 60);
}
void toggleLED(void *parameter)
{
for (;;)
{ // infinite loop
if((power == false) || (!(hour >= 7 && hour < 22)))
{
// OFF
digitalWrite(led1, LOW);
vTaskDelay(1000 / portTICK_PERIOD_MS);
continue;
}
// Turn the LED on
digitalWrite(led1, HIGH);
if (wifiStatus)
{
// Pause the task for 500ms
vTaskDelay(500 / portTICK_PERIOD_MS);
}
else
vTaskDelay(100 / portTICK_PERIOD_MS);
// Turn the LED off
digitalWrite(led1, LOW);
if (wifiStatus)
{
// Pause the task again for 500ms
vTaskDelay(500 / portTICK_PERIOD_MS);
}
else
vTaskDelay(100 / portTICK_PERIOD_MS);
}
}
#define DEG2RAD 0.0174532925
void drawRotatedBitmap(int16_t x, int16_t y, const uint8_t *bitmap, int16_t w, int16_t h, bool color, uint16_t angle)
{
int16_t newx, newy;
uint8_t data = 0;
float cosa = cos(angle * DEG2RAD), sina = sin(angle * DEG2RAD);
x = x - ((w * cosa / 2) - (h * sina / 2));
y = y - ((h * cosa / 2) + (w * sina / 2));
for (int16_t j = 0; j < h; j++)
{
for (int16_t i = 0; i < w; i++)
{
if ((j * w + i) & 7)
data <<= 1;
else
data = pgm_read_byte(bitmap++);
newx = 0.5 + x + ((i * cosa) - (j * sina));
newy = 0.5 + y + ((j * cosa) + (i * sina));
if (data & 0x80)
display.drawPixel(newx, newy, color);
//else display.drawPixel(newx, newy, 0);
}
}
}
void turnPowerOn(bool overide = false)
{
if(power == true || overide == true)
relayOn();
} }
void loop() void loop()
{ {
ArduinoOTA.handle(); otaLoop();
mqtt.loop(); homeAssistantLoop();
// While the Nano is being flashed, nothing else may talk to it // While the Nano is being flashed, nothing else may talk to it
if (nanoFlashBridge.handle()) if (flashBridge.handle())
return; return;
updatePower(); struct tm now = {};
bool time_valid = getLocalTime(&now, 0);
controller.update(millis(), time_valid, now.tm_hour, now.tm_min);
wifiStatus = WiFi.waitForConnectResult() == WL_CONNECTED; digitalWrite(LED_PIN, statusLedLevel(millis(), wifiConnected(), nano.isOn()));
if(next_display < millis())
{
if (hassioDisplayStatus)
// if (false)
{
char t[20];
char timeStringBuff[50]; //50 chars should be enough
struct tm timeinfo;
if (!getLocalTime(&timeinfo))
{
// printFromStart("Failed to get time");
// return;
// sprintf(t, "%02d:%02d:%02d\0", millis() / 1000 / 60 / 60, (millis() / 1000 / 60) % 60, (millis() / 1000 % 60));
// sprintf(t, "%02d%02d%02d\0", millis() / 1000 / 60 / 60, (millis() / 1000 / 60) % 60, (millis() / 1000 % 60));
}
else
{
// const char *format = "%H:%M:%S";
const char *format = "%H:%M";
// strftime(timeStringBuff, sizeof(timeStringBuff), "%A, %B %d %Y %H:%M:%S", &timeinfo);
strftime(timeStringBuff, sizeof(timeStringBuff), format, &timeinfo);
String asString(timeStringBuff);
hour = timeinfo.tm_hour;
minute = timeinfo.tm_min;
second = timeinfo.tm_sec;
}
// display.setFont(&FreeSerif12pt7b);
// if ((millis() / 1000 % 60) <= 30)
// display.setFont(&FreeSerif12pt7b);
// else
// display.setFont(&FreeSerifItalic12pt7b);
if(hour <= 7 || (hour >= 23 && minute > 30))
{
if (relay_on)
{
display.fillScreen(0);
display.display();
}
turnPowerOff();
}
// show time between 7 and 23 with updates every minute, and after that; every 15 minutes.
else if((hour >= 7 && hour < 22) || (minute % 15 == 0))
// else if((hour >= 7 && hour < 19) || (minute % 5 == 0))
// else if(true)
{
turnPowerOn();
// power can be off (Home Assistant power switch): then nothing is sent
if (relay_on)
{
int xoffset = 3;
display.fillScreen(0);
// display.setTextSize(2);
display.setTextSize(1);
// display.setCursor(7, 3);
display.setCursor(xoffset + 0, 19 - 2);
display.setTextWrap(false);
// display.print(t);
// if(minute % 2 == 0)
if(hour >= 17 || hour <= 6)
display.drawBitmap(xoffset + 0, 0, yawning_pikachu_bitmap_yawning_pikachu_black_border, 35, 19, 1);
else
display.drawBitmap(xoffset + 0, 0, pikachu_bitmap_pikachu_black_border, 35, 19, 1);
display.drawBitmap(xoffset + 35, 0, clock_thought_bubble_bitmap_clock_thought_bubble, 46, 19, 1);
display.setCursor(xoffset + 43, (19/2) - 5);
display.print(timeStringBuff);
display.display();
}
}
else
{
turnPowerOff();
}
}
else
{
if (relay_on)
{
display.fillScreen(0);
display.drawBitmap(0, 0, nintendo_bitmap_Nintendo_new, 84, 19, 1);
// display.drawBitmap(0, 0, nintendo_bitmap_panda_inverted, 17, 18, 1);
// display.drawBitmap(18, 0, nintendo_bitmap_panda_inverted, 17, 18, 1);
// display.drawBitmap(18*2, 0, nintendo_bitmap_panda_inverted, 17, 18, 1);
// display.drawBitmap(18*3, 0, nintendo_bitmap_panda_inverted, 17, 18, 1);
// display.drawBitmap(18*4, 0, nintendo_bitmap_panda_inverted, 17, 18, 1);
// drawRotatedBitmap(0, 0, nintendo_bitmap_Nintendo_new, 17, 18, 1, 90);
// Text inside border.
// display.drawBitmap(0, 0, nintendo_bitmap_nintendo_border, 84, 19, 1);
// display.setFont();
// display.setTextSize(1);
// display.setTextWrap(false);
// display.setCursor(5, 4);
// display.print("Nintendo");
// display.setFont();
// // display.setTextSize(2);
// display.setTextSize(1);
// display.setCursor(0, 0);
// display.setTextWrap(true);
// // display.print(t);
// display.print("Carla & Jan Kragt");
display.display();
}
turnPowerOff();
}
next_display += 1000;
// next_display += 500;
}
} }
@@ -1,4 +1,4 @@
#include "nano_flash_bridge.h" #include "nano_link/nano_flash_bridge.h"
NanoFlashBridge::NanoFlashBridge(uint16_t port, HardwareSerial &serial, void (*onStart)(uint32_t baud), void (*onEnd)()) NanoFlashBridge::NanoFlashBridge(uint16_t port, HardwareSerial &serial, void (*onStart)(uint32_t baud), void (*onEnd)())
: _server(port), _serial(serial), _onStart(onStart), _onEnd(onEnd) : _server(port), _serial(serial), _onStart(onStart), _onEnd(onEnd)
File renamed without changes.
+116
View File
@@ -0,0 +1,116 @@
#include "nano_link/nano_link.h"
#include "config.h"
// Relay input is active low
static const uint8_t RELAY_ON = LOW;
static const uint8_t RELAY_OFF = HIGH;
NanoLink::NanoLink(HardwareSerial &serial, int rx_pin, int tx_pin, int relay_pin, int reset_pin)
: _serial(serial), _rx_pin(rx_pin), _tx_pin(tx_pin), _relay_pin(relay_pin), _reset_pin(reset_pin)
{
}
void NanoLink::begin()
{
digitalWrite(_relay_pin, RELAY_OFF);
pinMode(_relay_pin, OUTPUT);
pinMode(_tx_pin, INPUT);
if (_reset_pin >= 0)
{
digitalWrite(_reset_pin, LOW);
pinMode(_reset_pin, OUTPUT);
}
}
Stream &NanoLink::stream()
{
return _serial;
}
bool NanoLink::isOn()
{
return _on;
}
void NanoLink::on()
{
if (_on)
return;
_beginSerial(NANO_BAUD);
digitalWrite(_relay_pin, RELAY_ON);
_waitForReady();
_on = true;
}
void NanoLink::off()
{
digitalWrite(_relay_pin, RELAY_OFF);
_on = false;
// An idle UART TX line is high and would power the Nano through its RX pin
_serial.flush();
_serial.end();
pinMode(_tx_pin, INPUT);
}
// The old bootloader also starts after a power cycle; Optiboot only after a reset pulse.
void NanoLink::startBootloader(uint32_t baud)
{
off();
delay(NANO_POWER_OFF_TIME);
_beginSerial(baud);
digitalWrite(_relay_pin, RELAY_ON);
if (_reset_pin >= 0)
{
delay(NANO_RESET_DELAY);
digitalWrite(_reset_pin, HIGH);
delay(NANO_RESET_PULSE);
digitalWrite(_reset_pin, LOW);
}
// drop anything the Nano sent before its bootloader started
_drain();
// Not "on" for the display: the bootloader is running, not the display firmware
}
void NanoLink::_beginSerial(uint32_t baud)
{
_serial.begin(baud, SERIAL_8N1, _rx_pin, _tx_pin);
// drop old data (e.g. ERR replies) so only a new READY counts
_drain();
}
void NanoLink::_drain()
{
while (_serial.available())
_serial.read();
}
// Wait until the Nano reports READY after booting, or until NANO_BOOT_TIMEOUT
void NanoLink::_waitForReady()
{
char line[16];
uint8_t len = 0;
uint32_t start = millis();
while (millis() - start < NANO_BOOT_TIMEOUT)
{
while (_serial.available())
{
char c = _serial.read();
if (c == '\n')
{
line[len] = '\0';
if (strstr(line, "READY"))
return;
len = 0;
}
else if (len < sizeof(line) - 1)
line[len++] = c;
}
delay(10);
}
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <Arduino.h>
#include "logic/display_controller.h"
// The relay that powers the panels and the Nano, and the serial link to the Nano.
class NanoLink : public DisplayPower
{
public:
NanoLink(HardwareSerial &serial, int rx_pin, int tx_pin, int relay_pin, int reset_pin);
// Set up the pins; the display starts switched off
void begin();
// The serial port to the Nano (for the BROSE9323 driver)
Stream &stream();
bool isOn() override;
// Switch on, then wait for the Nano's READY (or NANO_BOOT_TIMEOUT)
void on() override;
// Switch off and release the TX pin
void off() override;
// For flashing: restart the Nano so its bootloader runs, and talk to it at `baud`
void startBootloader(uint32_t baud);
private:
HardwareSerial &_serial;
const int _rx_pin, _tx_pin, _relay_pin, _reset_pin;
bool _on = false;
void _beginSerial(uint32_t baud);
void _drain();
void _waitForReady();
};
+55
View File
@@ -0,0 +1,55 @@
#include "net/home_assistant.h"
#include <ArduinoHA.h>
#include <WiFi.h>
#include "config.h"
#include "credentials.h"
static WiFiClient client;
static HADevice device(HA_DEVICE_ID, sizeof(HA_DEVICE_ID));
static HAMqtt mqtt(client, device);
// Unique ids: do not change, Home Assistant's entities are linked to them
static HASwitch clockSwitch("FlipDotDisplayClock");
static HASwitch powerSwitch("FlipDotDisplayPower");
static DisplayController *displayController = nullptr;
// The switches are not optimistic: the new state must be reported back to Home Assistant
static void onClockCommand(bool state, HASwitch *sender)
{
displayController->setClockEnabled(state);
sender->setState(state);
}
static void onPowerCommand(bool state, HASwitch *sender)
{
displayController->setPowerEnabled(state);
sender->setState(state);
}
void homeAssistantBegin(DisplayController &controller)
{
displayController = &controller;
device.setName(HA_DEVICE_NAME);
device.setSoftwareVersion(HA_SOFTWARE_VERSION);
clockSwitch.setName("FlipDot Display Clock");
clockSwitch.setIcon("mdi:lightbulb");
clockSwitch.setCurrentState(controller.clockEnabled());
clockSwitch.onCommand(onClockCommand);
powerSwitch.setName("FlipDot Display Power");
powerSwitch.setIcon("mdi:lightbulb");
powerSwitch.setCurrentState(controller.powerEnabled());
powerSwitch.onCommand(onPowerCommand);
mqtt.begin(BROKER_ADDR, MQTT_USER, MQTT_PASSWORD);
}
void homeAssistantLoop()
{
mqtt.loop();
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include "logic/display_controller.h"
// Home Assistant integration over MQTT (with discovery). Two switches:
// FlipDot Display Clock on = clock, off = Nintendo logo
// FlipDot Display Power off = clear the display and switch it off
void homeAssistantBegin(DisplayController &controller);
void homeAssistantLoop();
+70
View File
@@ -0,0 +1,70 @@
#include "net/wifi_ota.h"
#include <ArduinoOTA.h>
#include <WiFi.h>
#include <rom/rtc.h>
#include "config.h"
#include "credentials.h"
void wifiBegin()
{
WiFi.setHostname(HOSTNAME);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.println("Connecting to WiFi");
if (WiFi.waitForConnectResult() != WL_CONNECTED)
{
Serial.println("WiFi connection failed, restarting");
delay(5000);
ESP.restart();
}
// Later disconnects are handled by the WiFi driver's auto reconnect
Serial.print("Connected, IP ");
Serial.println(WiFi.localIP());
}
bool wifiConnected()
{
return WiFi.status() == WL_CONNECTED;
}
void otaBegin()
{
ArduinoOTA.setHostname(HOSTNAME);
ArduinoOTA.onStart([]() { Serial.println("OTA update started"); });
ArduinoOTA.onEnd([]() { Serial.println("OTA update done"); });
ArduinoOTA.onError([](ota_error_t error) { Serial.printf("OTA error %u\n", error); });
ArduinoOTA.begin();
}
void otaLoop()
{
ArduinoOTA.handle();
}
// A normal start is a power on of CPU 0 or a start of CPU 1 by CPU 0
static bool unexpectedReset()
{
RESET_REASON cpu0 = rtc_get_reset_reason(0);
RESET_REASON cpu1 = rtc_get_reset_reason(1);
Serial.printf("Reset reason: CPU0 %d, CPU1 %d\n", cpu0, cpu1);
return cpu0 != POWERON_RESET && cpu1 != EXT_CPU_RESET;
}
void otaPanicWindow(int led_pin)
{
if (!unexpectedReset())
return;
Serial.println("Unexpected reset: waiting for an OTA update");
uint32_t start = millis();
while (millis() - start < OTA_PANIC_WINDOW)
{
ArduinoOTA.handle();
// fast blink
digitalWrite(led_pin, (millis() / 50) % 2);
delay(10);
}
digitalWrite(led_pin, LOW);
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
// Connect to WiFi. Restarts the ESP32 when the first connection fails.
void wifiBegin();
bool wifiConnected();
// Over-the-air updates of the ESP32 firmware (pio run -e esp32 -t upload)
void otaBegin();
void otaLoop();
// After a crash or unexpected reset, run only OTA for OTA_PANIC_WINDOW, so a firmware that
// crashes at boot can still be replaced. Does nothing after a normal power on.
void otaPanicWindow(int led_pin);
+1 -1
View File
@@ -19,7 +19,7 @@ The display driver itself is in [lib/BROSE9323](../../lib/BROSE9323/README.md).
## Tests ## Tests
`pio test -e native` runs the unit tests in [test/test_nano](../../test/test_nano/test_main.cpp) `pio test -e test_nano` runs the unit tests in [test/test_nano](../../test/test_nano/test_main.cpp)
on the PC. They feed serial data to the firmware and check what it asks the display to do: on the PC. They feed serial data to the firmware and check what it asks the display to do:
every command, bitmaps, bad input, flip time storage and the full-redraw timing. The every command, bitmaps, bad input, flip time storage and the full-redraw timing. The
Arduino core, EEPROM and the display driver are replaced by fakes in Arduino core, EEPROM and the display driver are replaced by fakes in
+371
View File
@@ -0,0 +1,371 @@
// Tests for the ESP32 logic (src/esp32/logic), run on the PC: pio test -e test_esp32
//
// The display controller is tested with a fake power switch and fake frames that record
// what the controller asks for.
#include <unity.h>
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#include "config.h"
#include "logic/display_controller.h"
#include "logic/power_timer.h"
#include "logic/schedule.h"
#include "logic/status_led.h"
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
class FakePower : public DisplayPower
{
public:
bool is_on = false;
int on_count = 0;
int off_count = 0;
bool isOn() override { return is_on; }
void on() override
{
is_on = true;
on_count++;
}
void off() override
{
is_on = false;
off_count++;
}
};
class FakeFrames : public Frames
{
public:
std::vector<std::string> shown;
void clock(int hour, int minute) override
{
char s[16];
snprintf(s, sizeof(s), "clock %02d:%02d", hour, minute);
shown.push_back(s);
}
void logo() override { shown.push_back("logo"); }
void blank() override { shown.push_back("blank"); }
std::string last() const { return shown.empty() ? "" : shown.back(); }
};
static FakePower power;
static FakeFrames frames;
static DisplayController *controller;
static uint32_t now;
void setUp()
{
power = FakePower();
frames = FakeFrames();
delete controller;
controller = new DisplayController(power, frames);
now = 1000;
}
void tearDown() {}
// Run the controller at a time of day; `ms` passes in steps like the real loop
static void run(int hour, int minute, uint32_t ms = 0)
{
uint32_t end = now + ms;
do
{
controller->update(now, true, hour, minute);
now += 100;
} while ((int32_t)(end - now) >= 0);
}
// ---------------------------------------------------------------------------
// Schedule
// ---------------------------------------------------------------------------
void test_schedule_night_until_8()
{
TEST_ASSERT_TRUE(scheduleFor(0, 0) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(7, 0) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(7, 59) == ScheduleMode::Night);
}
void test_schedule_clock_from_8_to_22()
{
TEST_ASSERT_TRUE(scheduleFor(8, 0) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(12, 34) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(21, 59) == ScheduleMode::Clock);
}
void test_schedule_quarter_hours_until_23_30()
{
TEST_ASSERT_TRUE(scheduleFor(22, 0) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(22, 1) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(22, 14) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(22, 15) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(22, 45) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(23, 0) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(23, 29) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(23, 30) == ScheduleMode::Clock);
}
void test_schedule_night_from_23_31()
{
TEST_ASSERT_TRUE(scheduleFor(23, 31) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(23, 45) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(23, 59) == ScheduleMode::Night);
}
void test_evening_from_17()
{
TEST_ASSERT_FALSE(isEvening(16));
TEST_ASSERT_TRUE(isEvening(17));
TEST_ASSERT_TRUE(isEvening(23));
}
// ---------------------------------------------------------------------------
// Power timer
// ---------------------------------------------------------------------------
void test_timer_expires_once_after_delay()
{
PowerTimer t;
t.request(1000, 500);
TEST_ASSERT_FALSE(t.expired(1499));
TEST_ASSERT_TRUE(t.expired(1500));
TEST_ASSERT_FALSE(t.expired(1600));
}
void test_timer_keeps_earlier_deadline()
{
PowerTimer t;
t.request(0, 1000);
t.request(500, 1000); // later deadline: ignored
TEST_ASSERT_TRUE(t.expired(1000));
t.request(0, 30000);
t.request(100, 1000); // earlier deadline: used
TEST_ASSERT_TRUE(t.expired(1100));
}
void test_timer_cancel()
{
PowerTimer t;
t.request(0, 100);
t.cancel();
TEST_ASSERT_FALSE(t.pending());
TEST_ASSERT_FALSE(t.expired(1000));
}
void test_timer_across_millis_overflow()
{
PowerTimer t;
t.request(0xFFFFFF00u, 0x200); // deadline wraps past 0
TEST_ASSERT_FALSE(t.expired(0xFFFFFFFFu));
TEST_ASSERT_FALSE(t.expired(0x00000050u));
TEST_ASSERT_TRUE(t.expired(0x00000100u));
}
// ---------------------------------------------------------------------------
// Status LED
// ---------------------------------------------------------------------------
void test_led_off_while_display_off()
{
for (uint32_t t = 0; t < 2000; t += 50)
TEST_ASSERT_FALSE(statusLedLevel(t, true, false));
}
void test_led_blinks_slow_with_wifi_and_fast_without()
{
TEST_ASSERT_TRUE(statusLedLevel(0, true, true));
TEST_ASSERT_TRUE(statusLedLevel(499, true, true));
TEST_ASSERT_FALSE(statusLedLevel(500, true, true));
TEST_ASSERT_TRUE(statusLedLevel(0, false, true));
TEST_ASSERT_FALSE(statusLedLevel(100, false, true));
TEST_ASSERT_TRUE(statusLedLevel(200, false, true));
}
// ---------------------------------------------------------------------------
// Display controller
// ---------------------------------------------------------------------------
void test_nothing_happens_until_time_is_synced()
{
power.is_on = true; // showing "Connecting..."
controller->update(now, false, 0, 0);
TEST_ASSERT_EQUAL(0, power.off_count);
TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
}
void test_daytime_powers_on_and_shows_clock()
{
run(12, 34);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 12:34", frames.last().c_str());
}
void test_clock_is_only_sent_when_the_minute_changes()
{
run(12, 34, 5000);
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
run(12, 35);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
TEST_ASSERT_EQUAL_STRING("clock 12:35", frames.last().c_str());
TEST_ASSERT_EQUAL(1, power.on_count);
}
void test_night_clears_then_powers_off_after_delay()
{
run(23, 30);
run(23, 31, POWER_OFF_DELAY - 200);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on);
run(23, 31, 300);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL(1, power.off_count);
}
void test_night_does_not_power_on()
{
run(3, 0, 60000);
TEST_ASSERT_EQUAL(0, power.on_count);
TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
}
void test_standby_keeps_the_last_time_and_powers_off()
{
run(22, 0);
run(22, 1, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 22:00", frames.last().c_str()); // no blank
}
void test_quarter_hour_powers_on_again_and_redraws()
{
run(22, 1, POWER_OFF_DELAY + 200);
run(22, 14);
TEST_ASSERT_FALSE(power.is_on);
run(22, 15);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 22:15", frames.last().c_str());
}
void test_clock_cancels_pending_power_off()
{
run(22, 0);
run(22, 14, 10000); // power off countdown running
TEST_ASSERT_TRUE(power.is_on);
run(22, 15, POWER_OFF_DELAY);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL(0, power.off_count);
}
void test_power_switch_off_clears_and_powers_off_quickly()
{
run(12, 0);
run(22, 14, 1000); // 30 s countdown already running
controller->setPowerEnabled(false);
run(22, 14, CLEAR_BEFORE_OFF_TIME + 200);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
TEST_ASSERT_FALSE(power.is_on);
}
void test_power_switch_off_keeps_display_off_during_the_day()
{
controller->setPowerEnabled(false);
run(12, 0, 60000);
TEST_ASSERT_EQUAL(0, power.on_count);
}
void test_power_switch_on_again_shows_clock()
{
run(12, 0);
controller->setPowerEnabled(false);
run(12, 0, CLEAR_BEFORE_OFF_TIME + 200);
controller->setPowerEnabled(true);
run(12, 1);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 12:01", frames.last().c_str());
}
void test_clock_switch_off_shows_logo_then_powers_off()
{
run(12, 0);
controller->setClockEnabled(false);
run(12, 0, POWER_OFF_DELAY + 200);
TEST_ASSERT_EQUAL_STRING("logo", frames.last().c_str());
TEST_ASSERT_EQUAL(1, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("logo")));
TEST_ASSERT_FALSE(power.is_on);
}
void test_clock_switch_off_never_powers_on()
{
controller->setClockEnabled(false);
run(12, 0, 60000);
TEST_ASSERT_EQUAL(0, power.on_count);
}
void test_clock_switch_on_again_shows_clock()
{
run(12, 0);
controller->setClockEnabled(false);
run(12, 0);
controller->setClockEnabled(true);
run(12, 0);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
void test_redraws_after_power_was_switched_off_elsewhere()
{
// e.g. after flashing the Nano: the Nano restarts without a frame
run(12, 0);
power.off();
run(12, 0);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
int main()
{
UNITY_BEGIN();
RUN_TEST(test_schedule_night_until_8);
RUN_TEST(test_schedule_clock_from_8_to_22);
RUN_TEST(test_schedule_quarter_hours_until_23_30);
RUN_TEST(test_schedule_night_from_23_31);
RUN_TEST(test_evening_from_17);
RUN_TEST(test_timer_expires_once_after_delay);
RUN_TEST(test_timer_keeps_earlier_deadline);
RUN_TEST(test_timer_cancel);
RUN_TEST(test_timer_across_millis_overflow);
RUN_TEST(test_led_off_while_display_off);
RUN_TEST(test_led_blinks_slow_with_wifi_and_fast_without);
RUN_TEST(test_nothing_happens_until_time_is_synced);
RUN_TEST(test_daytime_powers_on_and_shows_clock);
RUN_TEST(test_clock_is_only_sent_when_the_minute_changes);
RUN_TEST(test_night_clears_then_powers_off_after_delay);
RUN_TEST(test_night_does_not_power_on);
RUN_TEST(test_standby_keeps_the_last_time_and_powers_off);
RUN_TEST(test_quarter_hour_powers_on_again_and_redraws);
RUN_TEST(test_clock_cancels_pending_power_off);
RUN_TEST(test_power_switch_off_clears_and_powers_off_quickly);
RUN_TEST(test_power_switch_off_keeps_display_off_during_the_day);
RUN_TEST(test_power_switch_on_again_shows_clock);
RUN_TEST(test_clock_switch_off_shows_logo_then_powers_off);
RUN_TEST(test_clock_switch_off_never_powers_on);
RUN_TEST(test_clock_switch_on_again_shows_clock);
RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere);
return UNITY_END();
}
+1 -1
View File
@@ -232,7 +232,7 @@ def upload(hex_path, host, port, bauds):
raise NoBootloader( raise NoBootloader(
f"no answer from the Nano bootloader at {', '.join(map(str, bauds))} baud. " f"no answer from the Nano bootloader at {', '.join(map(str, bauds))} baud. "
"Check the Nano TX -> ESP32 GPIO 16 wire. A Nano with Optiboot also needs " "Check the Nano TX -> ESP32 GPIO 16 wire. A Nano with Optiboot also needs "
"the reset wire (nano_reset in src/esp32/libs.h).") "the reset wire (NANO_RESET_PIN in src/esp32/config.h).")
flash(sock, image, baud) flash(sock, image, baud)
print("Done. The ESP32 restarts the Nano with the new firmware.") print("Done. The ESP32 restarts the Nano with the new firmware.")
return return