From 683a47c05512954a129fc142dfb9fda1f6cb2c63 Mon Sep 17 00:00:00 2001 From: MangaValk Date: Tue, 13 Oct 2026 00:56:32 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 53 +- README.md | 21 +- platformio.ini | 27 +- src/esp32/README.md | 75 +- src/esp32/config.h | 69 ++ src/esp32/credentials.h | 8 + src/esp32/{ => display}/bitmaps.h | 0 src/esp32/display/frames.cpp | 55 ++ src/esp32/display/frames.h | 22 + src/esp32/libs.h | 90 --- src/esp32/logic/display_controller.cpp | 118 ++++ src/esp32/logic/display_controller.h | 68 ++ src/esp32/logic/power_timer.cpp | 33 + src/esp32/logic/power_timer.h | 22 + src/esp32/logic/schedule.cpp | 21 + src/esp32/logic/schedule.h | 21 + src/esp32/logic/status_led.h | 13 + src/esp32/main.cpp | 651 ++---------------- .../{ => nano_link}/nano_flash_bridge.cpp | 2 +- src/esp32/{ => nano_link}/nano_flash_bridge.h | 0 src/esp32/nano_link/nano_link.cpp | 116 ++++ src/esp32/nano_link/nano_link.h | 36 + src/esp32/net/home_assistant.cpp | 55 ++ src/esp32/net/home_assistant.h | 9 + src/esp32/net/wifi_ota.cpp | 70 ++ src/esp32/net/wifi_ota.h | 14 + src/nano/README.md | 2 +- test/test_esp32/test_main.cpp | 371 ++++++++++ tools/nano_net_upload.py | 2 +- 29 files changed, 1289 insertions(+), 755 deletions(-) create mode 100644 src/esp32/config.h create mode 100644 src/esp32/credentials.h rename src/esp32/{ => display}/bitmaps.h (100%) create mode 100644 src/esp32/display/frames.cpp create mode 100644 src/esp32/display/frames.h delete mode 100644 src/esp32/libs.h create mode 100644 src/esp32/logic/display_controller.cpp create mode 100644 src/esp32/logic/display_controller.h create mode 100644 src/esp32/logic/power_timer.cpp create mode 100644 src/esp32/logic/power_timer.h create mode 100644 src/esp32/logic/schedule.cpp create mode 100644 src/esp32/logic/schedule.h create mode 100644 src/esp32/logic/status_led.h rename src/esp32/{ => nano_link}/nano_flash_bridge.cpp (97%) rename src/esp32/{ => nano_link}/nano_flash_bridge.h (100%) create mode 100644 src/esp32/nano_link/nano_link.cpp create mode 100644 src/esp32/nano_link/nano_link.h create mode 100644 src/esp32/net/home_assistant.cpp create mode 100644 src/esp32/net/home_assistant.h create mode 100644 src/esp32/net/wifi_ota.cpp create mode 100644 src/esp32/net/wifi_ota.h create mode 100644 test/test_esp32/test_main.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 10856bd..111e420 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,9 @@ See README.md for the overview. ## Layout and build -- `platformio.ini` has the environments `nano`, `esp32` (OTA to 10.1.1.198) and - `esp32_usb` (COM11). The sources are split with `build_src_filter` (`src/nano/`, - `src/esp32/`). +- `platformio.ini` has the environments `nano`, `nano_ota`, `esp32` (OTA to 10.1.1.198), + `esp32_usb` (COM11), and the PC test environments `test_nano` and `test_esp32`. The + 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 `platformio.ini`, not in the header: - `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) talks to `NanoFlashBridge` on the ESP32 (port 2323). The uploader first sends `FLASH \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 - 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 uploader is a Python script. The uploader only uses the Python standard library. Test it against a simulated bootloader before changing it. -- `src/esp32/libs.h` defines globals in the header, so it can only be included from - `main.cpp`. New ESP32 modules must be self-contained (see `nano_flash_bridge.*`). -- After any change, build both targets (`pio run`). After Nano changes, also run - `pio test -e native`. +- After any change, build both targets (`pio run`) and run the tests + (`pio test -e test_nano -e test_esp32`). ## Constraints - **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 data in `PROGMEM`. -- The ESP32 talks to the Nano on `Serial2` (`nanoSerial` in `libs.h`, GPIO 17 TX / GPIO 16 - RX). `Serial` (UART0/USB) is free for debug output. Never write debug text to - `nanoSerial`: the Nano runs lines that start with s/S/F/D/P/B/R/T as commands. +- The ESP32 talks to the Nano on `Serial2` (owned by `NanoLink`, GPIO 17 TX / GPIO 16 RX). + `Serial` (UART0/USB) is free for debug output. Never write debug text to `Serial2`: the + 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. - `src/esp32/secrets.h` holds the real credentials and is gitignored. Never commit it. 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 - 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 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 - powers the Nano through its RX pin. Never send display commands while `relay_on` is - false. +- `NanoLink::off()` ends `Serial2` and sets its TX pin to input. Otherwise the idle-high TX + line powers the Nano through its RX pin. Never send display commands while + `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 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. @@ -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. - 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 - 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 address 2. Values outside 50–2000 µs are rejected. - 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 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 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 new Arduino or driver functions are called. - 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 diff --git a/README.md b/README.md index 96bbbe8..969d650 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ src/nano/ Arduino Nano firmware src/esp32/ ESP32 firmware secrets.example.h template for WiFi / MQTT credentials 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 archive/ old, unused sketches kept for reference 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_usb -t upload # flash the ESP32 over USB 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 @@ -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: - 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 - `src/esp32/libs.h` to that GPIO, for example `4`. The transistor keeps the Nano's 5 V + 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`. @@ -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 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`, `NANO_POWER_OFF_TIME` and - `NANO_RESET_DELAY` in `src/esp32/libs.h`. +- Port, reset pin and timings: `NANO_FLASH_PORT`, `NANO_RESET_PIN`, `NANO_POWER_OFF_TIME` + and `NANO_RESET_DELAY` in `src/esp32/config.h`. **If it fails** @@ -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 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 | | 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 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 - the Nano's `READY` message (at most 3 s), starts NTP and connects to MQTT. -2. **Clock loop (every second).** + 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. diff --git a/platformio.ini b/platformio.ini index 35cb21d..4b22759 100644 --- a/platformio.ini +++ b/platformio.ini @@ -4,13 +4,14 @@ ; nano_ota Same Nano firmware, flashed over WiFi through the ESP32 ; esp32 ESP32 "brain" (WiFi, NTP clock, Home Assistant) - OTA upload ; 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 one: pio run -e nano ; Upload one: pio run -e nano -t upload ; 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 @@ -22,7 +23,7 @@ framework = arduino monitor_speed = 115200 lib_deps = 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 = * ; --------------------------------------------------------------------------- @@ -58,7 +59,7 @@ board = esp32doit-devkit-v1 build_src_filter = + ; 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. -build_flags = -D ESPSERIAL +build_flags = -D ESPSERIAL -I src/esp32 lib_deps = ${env.lib_deps} https://github.com/robjen/GFX_fonts.git @@ -74,15 +75,27 @@ upload_protocol = esptool upload_port = COM11 ; --------------------------------------------------------------------------- -; Native - unit tests for the Nano firmware on the PC (test/test_nano) -; The Arduino core, EEPROM and the display driver are replaced by test/mocks. +; Unit tests on the PC (pio test -e test_nano -e test_esp32) ; --------------------------------------------------------------------------- -[env:native] +[native] platform = native framework = lib_deps = lib_ignore = BROSE9323 test_ignore = 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 = + 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 = + +build_flags = -std=gnu++17 -I src/esp32 diff --git a/src/esp32/README.md b/src/esp32/README.md index 8ee3166..8eba9e4 100644 --- a/src/esp32/README.md +++ b/src/esp32/README.md @@ -1,6 +1,6 @@ # 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 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 | |------|----------| -| `main.cpp` | setup, OTA, clock / power logic, Home Assistant callbacks | -| `libs.h` | includes and global objects (display, MQTT, WiFi, pins, timezone) | -| `bitmaps.h` | 1-bit bitmaps (Pikachu, thought bubble, Nintendo logo, ...) | -| `nano_flash_bridge.h/.cpp` | WiFi to serial bridge for flashing the Nano over WiFi (TCP port 2323) | -| `secrets.example.h` | template for `secrets.h` (WiFi and MQTT credentials, **gitignored**) | +| `main.cpp` | Creates the objects, `setup()` and `loop()` | +| `config.h` | All settings: pins, timings, timezone, Home Assistant device id | +| `credentials.h` | Includes `secrets.h` (WiFi and MQTT credentials, **gitignored**); template: `secrets.example.h` | +| **`logic/`** | **Pure logic without Arduino code, tested on the PC** | +| `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 +- **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 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 - can still be replaced over the air. -- **NTP time** with timezone `CET-1CEST,M3.5.0,M10.5.0/3` (Europe/Amsterdam). -- **Home Assistant** through MQTT (`home-assistant-integration` library) with two switches: - - `FlipDotDisplayClock`: clock on, or the Nintendo logo when off - - `FlipDotDisplayPower`: turns the display off through the relay + handler for 15 seconds before it starts the main code, with the LED blinking fast. A + 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). Until the + time is synced, the display shows "Connecting...". +- **Home Assistant** through MQTT (`home-assistant-integration` library) with two switches. + 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 on. - **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; - the ESP only waits when it actually switches the relay on. - - **Off:** the ESP stops sending and releases its TX pin (`Serial.end()` and the pin set - to input). An idle UART line is high and would otherwise power the Nano through its RX - pin. + (`NANO_BOOT_TIMEOUT`), before it sends frames. + - **Off:** the ESP releases its TX pin (`Serial2.end()` and the pin set 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. While a flash session runs, the clock is paused. See the [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 - off at night or when the display is powered off. +- **Status LED** (built-in): off while the display is off, slow blink with WiFi, fast blink + without WiFi. ## 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 | | 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 | -| 23:31 – 08:00 | Screen cleared, power off | +| 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 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 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 -`display.drawBitmap(x, y, bitmap, w, h, 1)`. The display is 84 × 19 pixels. +horizontal, 1 bit per pixel. Paste the array into `display/bitmaps.h` and draw it in +`display/frames.cpp` with `_display.drawBitmap(x, y, bitmap, w, h, 1)`. The display is +84 × 19 pixels. diff --git a/src/esp32/config.h b/src/esp32/config.h new file mode 100644 index 0000000..43eb1d9 --- /dev/null +++ b/src/esp32/config.h @@ -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 + +// --------------------------------------------------------------------------- +// 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" diff --git a/src/esp32/credentials.h b/src/esp32/credentials.h new file mode 100644 index 0000000..8fd2cbf --- /dev/null +++ b/src/esp32/credentials.h @@ -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 diff --git a/src/esp32/bitmaps.h b/src/esp32/display/bitmaps.h similarity index 100% rename from src/esp32/bitmaps.h rename to src/esp32/display/bitmaps.h diff --git a/src/esp32/display/frames.cpp b/src/esp32/display/frames.cpp new file mode 100644 index 0000000..f3accfd --- /dev/null +++ b/src/esp32/display/frames.cpp @@ -0,0 +1,55 @@ +#include "display/frames.h" + +#include + +#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(); +} diff --git a/src/esp32/display/frames.h b/src/esp32/display/frames.h new file mode 100644 index 0000000..8eb3f67 --- /dev/null +++ b/src/esp32/display/frames.h @@ -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; +}; diff --git a/src/esp32/libs.h b/src/esp32/libs.h deleted file mode 100644 index a98ad93..0000000 --- a/src/esp32/libs.h +++ /dev/null @@ -1,90 +0,0 @@ -#include -#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 -#include -// fonts -#include -#include -// #include - -#include -#include -#include -#include -#include - -// wdt? -#include -#include - -// hassio -#include - -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; diff --git a/src/esp32/logic/display_controller.cpp b/src/esp32/logic/display_controller.cpp new file mode 100644 index 0000000..e2614d1 --- /dev/null +++ b/src/esp32/logic/display_controller.cpp @@ -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; +} diff --git a/src/esp32/logic/display_controller.h b/src/esp32/logic/display_controller.h new file mode 100644 index 0000000..b8685a0 --- /dev/null +++ b/src/esp32/logic/display_controller.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +#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(); +}; diff --git a/src/esp32/logic/power_timer.cpp b/src/esp32/logic/power_timer.cpp new file mode 100644 index 0000000..552eece --- /dev/null +++ b/src/esp32/logic/power_timer.cpp @@ -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; +} diff --git a/src/esp32/logic/power_timer.h b/src/esp32/logic/power_timer.h new file mode 100644 index 0000000..f257500 --- /dev/null +++ b/src/esp32/logic/power_timer.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +// 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; +}; diff --git a/src/esp32/logic/schedule.cpp b/src/esp32/logic/schedule.cpp new file mode 100644 index 0000000..2225f3e --- /dev/null +++ b/src/esp32/logic/schedule.cpp @@ -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; +} diff --git a/src/esp32/logic/schedule.h b/src/esp32/logic/schedule.h new file mode 100644 index 0000000..4d4703a --- /dev/null +++ b/src/esp32/logic/schedule.h @@ -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); diff --git a/src/esp32/logic/status_led.h b/src/esp32/logic/status_led.h new file mode 100644 index 0000000..2a0d30d --- /dev/null +++ b/src/esp32/logic/status_led.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +// 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; +} diff --git a/src/esp32/main.cpp b/src/esp32/main.cpp index be6d943..c75dc1b 100644 --- a/src/esp32/main.cpp +++ b/src/esp32/main.cpp @@ -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 +#include -uint32_t power_off_delay; -int triggered_power_off = false; -int turn_hassio_power_off = false; +#include "BROSE9323.h" +#include "config.h" +#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 -void waitForNano() -{ - char line[16]; - 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); - } -} +NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN); +BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH); +FlipdotFrames frames(display); +DisplayController controller(nano, frames); -void relayOn() -{ - // cancel a pending power off countdown - triggered_power_off = false; - - if (relay_on) - 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; -} +// 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. +NanoFlashBridge flashBridge( + NANO_FLASH_PORT, Serial2, + [](uint32_t baud) { nano.startBootloader(baud); }, + []() { nano.off(); }); void setup() { - otaPanicMode = true; - pinMode(led1, OUTPUT); + Serial.begin(115200); + pinMode(LED_PIN, OUTPUT); + nano.begin(); - Serial.begin(115200); + wifiBegin(); + otaBegin(); + flashBridge.begin(); + otaPanicWindow(LED_PIN); - WiFi.setHostname("FlipDotDisplay"); - wifiMulti.addAP(ssid, password); + nano.on(); + display.begin(&nano.stream()); + display.setTiming(FLIP_TIME); + // Shown until the time is synced + frames.message("Connecting..."); - Serial.println("Booting"); - WiFi.mode(WIFI_STA); - 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(); + configTzTime(TIMEZONE, NTP_SERVER); + homeAssistantBegin(controller); } void loop() { - ArduinoOTA.handle(); - mqtt.loop(); + otaLoop(); + homeAssistantLoop(); - // While the Nano is being flashed, nothing else may talk to it - if (nanoFlashBridge.handle()) - return; + // While the Nano is being flashed, nothing else may talk to it + if (flashBridge.handle()) + 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; - - 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; - } -} \ No newline at end of file + digitalWrite(LED_PIN, statusLedLevel(millis(), wifiConnected(), nano.isOn())); +} diff --git a/src/esp32/nano_flash_bridge.cpp b/src/esp32/nano_link/nano_flash_bridge.cpp similarity index 97% rename from src/esp32/nano_flash_bridge.cpp rename to src/esp32/nano_link/nano_flash_bridge.cpp index e17f53d..08237ba 100644 --- a/src/esp32/nano_flash_bridge.cpp +++ b/src/esp32/nano_link/nano_flash_bridge.cpp @@ -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)()) : _server(port), _serial(serial), _onStart(onStart), _onEnd(onEnd) diff --git a/src/esp32/nano_flash_bridge.h b/src/esp32/nano_link/nano_flash_bridge.h similarity index 100% rename from src/esp32/nano_flash_bridge.h rename to src/esp32/nano_link/nano_flash_bridge.h diff --git a/src/esp32/nano_link/nano_link.cpp b/src/esp32/nano_link/nano_link.cpp new file mode 100644 index 0000000..1a5eafd --- /dev/null +++ b/src/esp32/nano_link/nano_link.cpp @@ -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); + } +} diff --git a/src/esp32/nano_link/nano_link.h b/src/esp32/nano_link/nano_link.h new file mode 100644 index 0000000..9f0684f --- /dev/null +++ b/src/esp32/nano_link/nano_link.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#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(); +}; diff --git a/src/esp32/net/home_assistant.cpp b/src/esp32/net/home_assistant.cpp new file mode 100644 index 0000000..cbe6c15 --- /dev/null +++ b/src/esp32/net/home_assistant.cpp @@ -0,0 +1,55 @@ +#include "net/home_assistant.h" + +#include +#include + +#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(); +} diff --git a/src/esp32/net/home_assistant.h b/src/esp32/net/home_assistant.h new file mode 100644 index 0000000..9a6130d --- /dev/null +++ b/src/esp32/net/home_assistant.h @@ -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(); diff --git a/src/esp32/net/wifi_ota.cpp b/src/esp32/net/wifi_ota.cpp new file mode 100644 index 0000000..2d85258 --- /dev/null +++ b/src/esp32/net/wifi_ota.cpp @@ -0,0 +1,70 @@ +#include "net/wifi_ota.h" + +#include +#include +#include + +#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); +} diff --git a/src/esp32/net/wifi_ota.h b/src/esp32/net/wifi_ota.h new file mode 100644 index 0000000..82de5f2 --- /dev/null +++ b/src/esp32/net/wifi_ota.h @@ -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); diff --git a/src/nano/README.md b/src/nano/README.md index 6f427c2..653c020 100644 --- a/src/nano/README.md +++ b/src/nano/README.md @@ -19,7 +19,7 @@ The display driver itself is in [lib/BROSE9323](../../lib/BROSE9323/README.md). ## 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: 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 diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp new file mode 100644 index 0000000..813f318 --- /dev/null +++ b/test/test_esp32/test_main.cpp @@ -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 + +#include +#include +#include +#include + +#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 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(); +} diff --git a/tools/nano_net_upload.py b/tools/nano_net_upload.py index d920fef..802cf81 100644 --- a/tools/nano_net_upload.py +++ b/tools/nano_net_upload.py @@ -232,7 +232,7 @@ def upload(hex_path, host, port, bauds): raise NoBootloader( 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 " - "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) print("Done. The ESP32 restarts the Nano with the new firmware.") return