Show status messages on the display

- Clock firmware: 'Connecting to WiFi...' and 'Getting the time...' at
  boot, 'Crashed!' in the OTA-only modes, and 'No WiFi' with the time
  when WiFi is down for over a minute (new DisplayController screen)
- Recovery app: shows its reason, the setup WiFi name, or its IP address,
  then powers the display off (flipdots keep the image)
- StatusScreen and drawTextLines shared by both apps
- Driver: ESP begin() no longer draws 'ESP Connected'
- Tests: No WiFi screen, short reason texts (70 total)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-13 01:34:04 +02:00
1 parent 4749f9e596
commit c9e0800e36
19 files changed
+332 -48

No files matched your search

+6 -3
View File
@@ -110,9 +110,12 @@ See README.md for the overview.
USB only), ota_0/ota_1 = clock firmware. `esp32_usb` uploads to 0x110000 USB only), ota_0/ota_1 = clock firmware. `esp32_usb` uploads to 0x110000
(`board_upload.offset_address`) so it never overwrites the recovery app. Check with (`board_upload.offset_address`) so it never overwrites the recovery app. Check with
`pio run -e <env> -t envdump` (`ESP32_APP_OFFSET`) after changing upload settings. `pio run -e <env> -t envdump` (`ESP32_APP_OFFSET`) after changing upload settings.
- The recovery app compiles `esp32/logic/boot_policy.cpp`, `esp32/system/system_store.cpp` - The recovery app compiles `esp32/logic/boot_policy.cpp`, `esp32/system/system_store.cpp`,
and `esp32/system/boot_target.cpp` (see its `build_src_filter`). Keep those free of `esp32/system/boot_target.cpp`, `esp32/nano_link/nano_link.cpp`,
clock-firmware dependencies. Both apps share NVS namespace `flipdot`. RTC memory is `esp32/display/status_screen.cpp` and `esp32/display/text.cpp` (see its
`build_src_filter`). Keep those free of clock-firmware dependencies.
- Status messages (`StatusScreen`) are at most 14 characters per line, 2 lines. The
recovery app powers the display off after each message (flipdots keep the image). Both apps share NVS namespace `flipdot`. RTC memory is
not shared between the apps (different layouts). not shared between the apps (different layouts).
- `rebootToRecovery()` selects the factory partition, which erases otadata. The recovery - `rebootToRecovery()` selects the factory partition, which erases otadata. The recovery
app later selects the clock slot again (`startMainFirmware()`), so the clock firmware app later selects the clock slot again (`startMainFirmware()`), so the clock firmware
+21 -3
View File
@@ -163,7 +163,8 @@ even if the uploader is interrupted.
The ESP32 has a small second app, the **recovery app** ([src/recovery](src/recovery)). It The ESP32 has a small second app, the **recovery app** ([src/recovery](src/recovery)). It
has its own place on the flash and is never changed by OTA updates. It contains only WiFi, has its own place on the flash and is never changed by OTA updates. It contains only WiFi,
a setup web page and OTA, so the ESP32 can always be reached, even when the clock firmware a setup web page and OTA, so the ESP32 can always be reached, even when the clock firmware
is broken. The display stays off while it runs, and the built-in LED double-blinks. is broken. It shows what is going on as a short message on the display, and the built-in
LED double-blinks.
``` ```
flash (4 MB): bootloader | nvs | otadata | factory: recovery app | ota_0: clock | ota_1: clock flash (4 MB): bootloader | nvs | otadata | factory: recovery app | ota_0: clock | ota_1: clock
@@ -182,6 +183,22 @@ flash (4 MB): bootloader | nvs | otadata | factory: recovery app | ota_0: clock
The setup page can also start the clock right away, or keep the recovery app running The setup page can also start the clock right away, or keep the recovery app running
("Stay in recovery"). ("Stay in recovery").
**What the display shows**
| Message | Meaning |
|---------|---------|
| `Connecting` / `to WiFi...` | The clock firmware is starting |
| `Getting` / `the time...` | Connected, waiting for the time from the internet (NTP) |
| `No WiFi` / `12:34` | The clock firmware lost WiFi for more than a minute; the time keeps running |
| `Crashed!` / `OTA open 15s` | The clock firmware crashed and waits 15 s for an OTA update before it starts again |
| `Crashed 3x` / `OTA only` | Repeated crashes without the recovery app installed: only OTA for 10 minutes |
| `Recovery` / reason | The recovery app started (`no WiFi`, `crashed`, `requested`, `power cycled`, `no firmware`) |
| `WiFi setup:` / `FlipDot-Setup` | Connect to this WiFi to choose another network, see below |
| `Recovery` / IP address | The recovery app is connected; open this address for the setup page |
The recovery app switches the display off after each message; the dots keep showing it
without power.
**WiFi setup.** The recovery app keeps trying the stored WiFi. If that fails for 20 s, it **WiFi setup.** The recovery app keeps trying the stored WiFi. If that fails for 20 s, it
also opens its own WiFi: **FlipDot-Setup**, with the password `SETUP_AP_PASSWORD` from also opens its own WiFi: **FlipDot-Setup**, with the password `SETUP_AP_PASSWORD` from
`src/esp32/secrets.h`. Connect a phone to it; the setup page opens by itself (otherwise go `src/esp32/secrets.h`. Connect a phone to it; the setup page opens by itself (otherwise go
@@ -209,8 +226,9 @@ anyway.
pio run -e recovery -t erase pio run -e recovery -t erase
pio run -e recovery -t upload pio run -e recovery -t upload
``` ```
The recovery app starts (LED double-blinks), connects to the WiFi from `secrets.h`, and The recovery app starts (LED double-blinks, the display shows `Recovery` /
waits, because there is no clock firmware yet. `no firmware`), connects to the WiFi from `secrets.h` and shows its IP address. It waits
there, because there is no clock firmware yet.
3. Install the clock firmware **over WiFi**. This also tests that the recovery app works: 3. Install the clock firmware **over WiFi**. This also tests that the recovery app works:
```sh ```sh
pio run -e esp32 -t upload pio run -e esp32 -t upload
+1 -4
View File
@@ -18,13 +18,10 @@ BROSE9323::BROSE9323(uint8_t w, uint8_t h, uint8_t pw, uint16_t ft) : Adafruit_G
} }
#ifdef ESPSERIAL #ifdef ESPSERIAL
// Only sets the serial port; the first frame is up to the caller
void BROSE9323::begin(Stream *s) void BROSE9323::begin(Stream *s)
{ {
stream = s; stream = s;
fillScreen(0);
setCursor(0, 4);
print("ESP Connected");
display();
} }
#else #else
void BROSE9323::begin(void) void BROSE9323::begin(void)
+5 -2
View File
@@ -94,8 +94,11 @@ build_src_filter =
+<esp32/logic/boot_policy.cpp> +<esp32/logic/boot_policy.cpp>
+<esp32/system/system_store.cpp> +<esp32/system/system_store.cpp>
+<esp32/system/boot_target.cpp> +<esp32/system/boot_target.cpp>
build_flags = -I src/esp32 +<esp32/nano_link/nano_link.cpp>
lib_deps = +<esp32/display/status_screen.cpp>
+<esp32/display/text.cpp>
; ESPSERIAL: status messages are sent to the Nano like in the clock firmware
build_flags = -D ESPSERIAL -I src/esp32
upload_protocol = esptool upload_protocol = esptool
upload_port = COM11 upload_port = COM11
+8 -1
View File
@@ -25,7 +25,9 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
| `system/boot_target.*` | Switching between the clock firmware and the recovery app | | `system/boot_target.*` | Switching between the clock firmware and the recovery app |
| `system/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count | | `system/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count |
| **`display/`** | **What is drawn** | | **`display/`** | **What is drawn** |
| `display/frames.*` | The screens: clock, logo, blank, text (`FlipdotFrames`) | | `display/frames.*` | The screens of the clock: clock, "No WiFi", logo, blank (`FlipdotFrames`) |
| `display/status_screen.*` | Two-line status messages at boot, after a crash and in the recovery app (`StatusScreen`) |
| `display/text.*` | Centered text lines on the 84 x 19 display |
| `display/bitmaps.h` | 1-bit bitmaps (Pikachu, thought bubble, Nintendo logo, ...) | | `display/bitmaps.h` | 1-bit bitmaps (Pikachu, thought bubble, Nintendo logo, ...) |
| **`nano_link/`** | **Connection to the Nano** | | **`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_link.*` | Relay and serial link: power on (waits for `READY`), power off, bootloader start (`NanoLink`) |
@@ -74,6 +76,11 @@ Rules for changing the code:
## Features ## Features
- **Status on the display:** `Connecting to WiFi...` and `Getting the time...` at boot,
`Crashed!` messages in the OTA-only modes, and `No WiFi` with the time when WiFi is down
for more than a minute. See the table in the
[main README](../../README.md#recovery-app).
- **Frames only when something changes:** a new frame is sent when the minute changes, the - **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 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. quiet in between, which also gives the Nano time for its periodic full redraw.
+5
View File
@@ -36,6 +36,11 @@ const uint32_t NANO_BOOT_TIMEOUT = 3000;
// After a frame is cleared, wait this long before cutting the power, so the dots can flip // 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; const uint32_t CLEAR_BEFORE_OFF_TIME = 1000;
// Status messages (boot, crash, recovery): time for the Nano to flip all dots before the
// power is cut again. The message stays visible without power.
const uint32_t STATUS_FLIP_TIME = 2000;
// WiFi down this long while the clock runs: show "No WiFi" with the time instead
const uint32_t NO_WIFI_SHOW_TIME = 60000;
// Power off this long after the display is no longer needed (night, standby, logo) // Power off this long after the display is no longer needed (night, standby, logo)
const uint32_t POWER_OFF_DELAY = 30000; const uint32_t POWER_OFF_DELAY = 30000;
+9 -10
View File
@@ -3,6 +3,7 @@
#include <stdio.h> #include <stdio.h>
#include "display/bitmaps.h" #include "display/bitmaps.h"
#include "display/text.h"
#include "logic/schedule.h" #include "logic/schedule.h"
// Horizontal offset that centers the 81 pixel wide clock on the 84 pixel display // Horizontal offset that centers the 81 pixel wide clock on the 84 pixel display
@@ -31,6 +32,14 @@ void FlipdotFrames::clock(int hour, int minute)
_display.display(); _display.display();
} }
void FlipdotFrames::noWifi(int hour, int minute)
{
char time[6];
snprintf(time, sizeof(time), "%02d:%02d", hour, minute);
drawTextLines(_display, "No WiFi", time);
_display.display();
}
void FlipdotFrames::logo() void FlipdotFrames::logo()
{ {
_display.fillScreen(0); _display.fillScreen(0);
@@ -43,13 +52,3 @@ void FlipdotFrames::blank()
_display.fillScreen(0); _display.fillScreen(0);
_display.display(); _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();
}
+2 -2
View File
@@ -11,11 +11,11 @@ public:
// Pikachu with a thought bubble showing HH:MM (yawning in the evening) // Pikachu with a thought bubble showing HH:MM (yawning in the evening)
void clock(int hour, int minute) override; void clock(int hour, int minute) override;
// "No WiFi" with the time below it
void noWifi(int hour, int minute) override;
// Nintendo logo // Nintendo logo
void logo() override; void logo() override;
void blank() override; void blank() override;
// A line of text, e.g. while connecting
void message(const char *text);
private: private:
BROSE9323 &_display; BROSE9323 &_display;
+31
View File
@@ -0,0 +1,31 @@
#include "display/status_screen.h"
#include <Arduino.h>
#include "config.h"
#include "display/text.h"
StatusScreen::StatusScreen(BROSE9323 &display, NanoLink &nano) : _display(display), _nano(nano)
{
}
void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
{
Serial.printf("Display: %s / %s\n", line1, line2 ? line2 : "");
_nano.on();
if (!_started)
{
_display.begin(&_nano.stream());
_display.setTiming(FLIP_TIME);
_started = true;
}
drawTextLines(_display, line1, line2);
_display.display();
if (!keep_on)
{
// The first frame after power on flips every dot, give the Nano time for that
delay(STATUS_FLIP_TIME);
_nano.off();
}
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include "BROSE9323.h"
#include "nano_link/nano_link.h"
// Short messages for situations outside the normal clock: booting, crashes, recovery app.
// Used by both the clock firmware and the recovery app.
class StatusScreen
{
public:
StatusScreen(BROSE9323 &display, NanoLink &nano);
// Power the display on, show up to two lines (TEXT_LINE_CHARS each). Unless keep_on,
// power off again once the dots have flipped: flipdots keep their image without power.
void show(const char *line1, const char *line2, bool keep_on = false);
private:
BROSE9323 &_display;
NanoLink &_nano;
bool _started = false;
};
+33
View File
@@ -0,0 +1,33 @@
#include "display/text.h"
#include <string.h>
static const int CHAR_WIDTH = 6;
static void drawCentered(BROSE9323 &display, const char *text, int y)
{
int len = strlen(text);
if (len > TEXT_LINE_CHARS)
len = TEXT_LINE_CHARS;
char line[TEXT_LINE_CHARS + 1];
memcpy(line, text, len);
line[len] = '\0';
display.setCursor((display.width() - len * CHAR_WIDTH) / 2, y);
display.print(line);
}
void drawTextLines(BROSE9323 &display, const char *line1, const char *line2)
{
display.fillScreen(0);
display.setTextSize(1);
display.setTextWrap(false);
display.setTextColor(1);
if (line2 == nullptr || line2[0] == '\0')
{
drawCentered(display, line1, 6); // one line: vertically centered
return;
}
drawCentered(display, line1, 1);
drawCentered(display, line2, 10);
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include "BROSE9323.h"
// Characters per line with the built-in 6x8 font on the 84 pixel wide display
const int TEXT_LINE_CHARS = 14;
// Clear the screen and draw up to two centered lines of text (line2 may be empty).
// Does not call display().
void drawTextLines(BROSE9323 &display, const char *line1, const char *line2);
+18
View File
@@ -54,6 +54,24 @@ const char *recoveryReasonText(RecoveryReason reason)
} }
} }
const char *recoveryReasonShort(RecoveryReason reason)
{
switch (reason)
{
case RecoveryReason::Wifi:
return "no WiFi";
case RecoveryReason::Crash:
return "crashed";
case RecoveryReason::Manual:
return "requested";
case RecoveryReason::PowerCycle:
return "power cycled";
case RecoveryReason::None:
default:
return "no firmware";
}
}
bool powerCyclesRequestRecovery(uint8_t power_on_count) bool powerCyclesRequestRecovery(uint8_t power_on_count)
{ {
return power_on_count >= POWER_CYCLES_FOR_RECOVERY; return power_on_count >= POWER_CYCLES_FOR_RECOVERY;
+2
View File
@@ -59,6 +59,8 @@ enum class RecoveryReason : uint8_t
}; };
const char *recoveryReasonText(RecoveryReason reason); const char *recoveryReasonText(RecoveryReason reason);
// At most 14 characters, for the flipdot display
const char *recoveryReasonShort(RecoveryReason reason);
// Power-on count: each power on within POWER_CYCLE_WINDOW of the previous one counts. // Power-on count: each power on within POWER_CYCLE_WINDOW of the previous one counts.
// True when the next start should be the recovery app. // True when the next start should be the recovery app.
+19 -6
View File
@@ -27,8 +27,17 @@ bool DisplayController::powerEnabled() const
return _power_enabled; return _power_enabled;
} }
void DisplayController::update(uint32_t now, bool time_valid, int hour, int minute) void DisplayController::update(uint32_t now, bool time_valid, int hour, int minute, bool wifi_connected)
{ {
if (wifi_connected)
_wifi_lost = false;
else if (!_wifi_lost)
{
_wifi_lost = true;
_wifi_lost_since = now;
}
bool show_no_wifi = _wifi_lost && now - _wifi_lost_since >= NO_WIFI_SHOW_TIME;
// Switched off elsewhere (power timer, Nano flashing): the Nano lost its frame // Switched off elsewhere (power timer, Nano flashing): the Nano lost its frame
if (!_power.isOn()) if (!_power.isOn())
_screen = Screen::Unknown; _screen = Screen::Unknown;
@@ -81,7 +90,7 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
_power.on(); _power.on();
_screen = Screen::Unknown; _screen = Screen::Unknown;
} }
_showClock(hour, minute); _showClock(hour, minute, show_no_wifi);
break; break;
case ScheduleMode::Standby: case ScheduleMode::Standby:
// Flipdots keep their image without power, so the last time stays visible // Flipdots keep their image without power, so the last time stays visible
@@ -91,12 +100,16 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
} }
} }
void DisplayController::_showClock(int hour, int minute) void DisplayController::_showClock(int hour, int minute, bool no_wifi)
{ {
if (_screen == Screen::Clock && _shown_hour == hour && _shown_minute == minute) Screen screen = no_wifi ? Screen::NoWifi : Screen::Clock;
if (_screen == screen && _shown_hour == hour && _shown_minute == minute)
return; return;
_frames.clock(hour, minute); if (no_wifi)
_screen = Screen::Clock; _frames.noWifi(hour, minute);
else
_frames.clock(hour, minute);
_screen = screen;
_shown_hour = hour; _shown_hour = hour;
_shown_minute = minute; _shown_minute = minute;
} }
+9 -2
View File
@@ -19,6 +19,8 @@ class Frames
{ {
public: public:
virtual void clock(int hour, int minute) = 0; virtual void clock(int hour, int minute) = 0;
// Shown instead of the clock while WiFi is down (the time keeps running without it)
virtual void noWifi(int hour, int minute) = 0;
virtual void logo() = 0; virtual void logo() = 0;
virtual void blank() = 0; virtual void blank() = 0;
}; };
@@ -40,13 +42,15 @@ public:
bool powerEnabled() const; bool powerEnabled() const;
// Call often (every loop). time_valid is false until the time is synced over NTP. // 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); // While WiFi is down for NO_WIFI_SHOW_TIME, "No WiFi" is shown instead of the clock.
void update(uint32_t now, bool time_valid, int hour, int minute, bool wifi_connected);
private: private:
enum class Screen enum class Screen
{ {
Unknown, // nothing sent since the last power on Unknown, // nothing sent since the last power on
Clock, Clock,
NoWifi,
Logo, Logo,
Blank, Blank,
}; };
@@ -62,7 +66,10 @@ private:
int _shown_hour = -1; int _shown_hour = -1;
int _shown_minute = -1; int _shown_minute = -1;
void _showClock(int hour, int minute); bool _wifi_lost = false;
uint32_t _wifi_lost_since = 0;
void _showClock(int hour, int minute, bool no_wifi);
void _showLogo(); void _showLogo();
void _showBlank(); void _showBlank();
}; };
+13 -8
View File
@@ -7,6 +7,7 @@
#include "BROSE9323.h" #include "BROSE9323.h"
#include "config.h" #include "config.h"
#include "display/frames.h" #include "display/frames.h"
#include "display/status_screen.h"
#include "logic/display_controller.h" #include "logic/display_controller.h"
#include "logic/status_led.h" #include "logic/status_led.h"
#include "nano_link/nano_flash_bridge.h" #include "nano_link/nano_flash_bridge.h"
@@ -19,6 +20,7 @@
NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN); NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH); BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
FlipdotFrames frames(display); FlipdotFrames frames(display);
StatusScreen status(display, nano);
DisplayController controller(nano, frames); DisplayController controller(nano, frames);
// Flashing the Nano over WiFi: restart it into its bootloader, switch it off afterwards. // Flashing the Nano over WiFi: restart it into its bootloader, switch it off afterwards.
@@ -32,12 +34,14 @@ void setup()
{ {
Serial.begin(115200); Serial.begin(115200);
pinMode(LED_PIN, OUTPUT); pinMode(LED_PIN, OUTPUT);
nano.begin(); // display off until the main code runs nano.begin(); // display off until something is shown
// Decided first, before anything that could crash // Decided first, before anything that could crash
BootMode boot_mode = firmwareGuardBegin(); BootMode boot_mode = firmwareGuardBegin();
wifiBegin(); if (boot_mode == BootMode::Normal)
status.show("Connecting", "to WiFi...", true);
wifiBegin(); // starts the recovery app when it fails
otaBegin(); otaBegin();
if (boot_mode == BootMode::SafeMode) if (boot_mode == BootMode::SafeMode)
@@ -46,19 +50,20 @@ void setup()
// then try the main code once more // then try the main code once more
firmwareGuardSafeModeEnded(); firmwareGuardSafeModeEnded();
rebootToRecovery(RecoveryReason::Crash); rebootToRecovery(RecoveryReason::Crash);
status.show("Crashed 3x", "OTA only");
otaOnlyMode(SAFE_MODE_TIME, LED_PIN); otaOnlyMode(SAFE_MODE_TIME, LED_PIN);
ESP.restart(); ESP.restart();
} }
if (boot_mode == BootMode::PanicWindow) if (boot_mode == BootMode::PanicWindow)
{
status.show("Crashed!", "OTA open 15s");
otaOnlyMode(OTA_PANIC_WINDOW, LED_PIN); otaOnlyMode(OTA_PANIC_WINDOW, LED_PIN);
}
flashBridge.begin(); flashBridge.begin();
nano.on(); // Shown until the time is synced over NTP
display.begin(&nano.stream()); status.show("Getting", "the time...", true);
display.setTiming(FLIP_TIME);
// Shown until the time is synced
frames.message("Connecting...");
configTzTime(TIMEZONE, NTP_SERVER); configTzTime(TIMEZONE, NTP_SERVER);
homeAssistantBegin(controller); homeAssistantBegin(controller);
@@ -82,7 +87,7 @@ void loop()
struct tm now = {}; struct tm now = {};
bool time_valid = getLocalTime(&now, 0); bool time_valid = getLocalTime(&now, 0);
controller.update(millis(), time_valid, now.tm_hour, now.tm_min); controller.update(millis(), time_valid, now.tm_hour, now.tm_min, wifiConnected());
digitalWrite(LED_PIN, statusLedLevel(millis(), wifiConnected(), nano.isOn())); digitalWrite(LED_PIN, statusLedLevel(millis(), wifiConnected(), nano.isOn()));
} }
+49 -5
View File
@@ -1,16 +1,19 @@
// Recovery app (factory partition): WiFi setup portal and OTA, nothing else. Started when // Recovery app (factory partition): WiFi setup portal and OTA, nothing else. Started when
// the clock firmware can't connect to WiFi, crashes repeatedly, after quick power cycles, // the clock firmware can't connect to WiFi, crashes repeatedly, after quick power cycles,
// from the Home Assistant button, or by the bootloader when no clock firmware works. // from the Home Assistant button, or by the bootloader when no clock firmware works.
// It never drives the display. See README.md "Recovery app". // It only shows short status messages on the display. See README.md "Recovery app".
#include <Arduino.h> #include <Arduino.h>
#include <ArduinoOTA.h> #include <ArduinoOTA.h>
#include <WiFi.h> #include <WiFi.h>
#include <esp_task_wdt.h> #include <esp_task_wdt.h>
#include "BROSE9323.h"
#include "config.h" #include "config.h"
#include "credentials.h" #include "credentials.h"
#include "display/status_screen.h"
#include "logic/boot_policy.h" #include "logic/boot_policy.h"
#include "nano_link/nano_link.h"
#include "recovery_state.h" #include "recovery_state.h"
#include "system/boot_target.h" #include "system/boot_target.h"
#include "system/system_store.h" #include "system/system_store.h"
@@ -21,9 +24,25 @@ static const uint32_t STATION_RETRY_TIME = 30000;
// Close the setup AP this long after the stored WiFi works again // Close the setup AP this long after the stored WiFi works again
static const uint32_t AP_CLOSE_DELAY = 120000; static const uint32_t AP_CLOSE_DELAY = 120000;
static const uint32_t RECOVERY_WATCHDOG_S = 60; static const uint32_t RECOVERY_WATCHDOG_S = 60;
// Show a new status at most this often (each message power cycles the display)
static const uint32_t STATUS_MIN_INTERVAL = 30000;
RecoveryState recovery; RecoveryState recovery;
static NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
static BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
static StatusScreen status(display, nano);
enum class Shown
{
Nothing,
Started, // "Recovery" + reason
SetupWifi, // name of the setup WiFi
Connected, // IP address
};
static Shown shown = Shown::Nothing;
static uint32_t last_status = 0;
static uint32_t station_lost_since = 0; static uint32_t station_lost_since = 0;
static uint32_t station_connected_since = 0; static uint32_t station_connected_since = 0;
static bool watchdog_running = false; static bool watchdog_running = false;
@@ -79,6 +98,32 @@ static void updateWifi()
connectStation(); connectStation();
} }
// Show what is going on, so you know what to do without a computer. The display is
// switched off after each message; the dots keep showing it.
static void updateDisplay()
{
Shown wanted = WiFi.status() == WL_CONNECTED ? Shown::Connected
: recovery.ap_running ? Shown::SetupWifi
: Shown::Started;
if (wanted == shown || (shown != Shown::Nothing && millis() - last_status < STATUS_MIN_INTERVAL))
return;
shown = wanted;
last_status = millis();
switch (wanted)
{
case Shown::Connected:
status.show("Recovery", WiFi.localIP().toString().c_str());
break;
case Shown::SetupWifi:
status.show("WiFi setup:", SETUP_AP_SSID);
break;
default:
status.show("Recovery", recoveryReasonShort(recovery.reason));
break;
}
}
// Double blink: recovery app is running // Double blink: recovery app is running
static void updateLed() static void updateLed()
{ {
@@ -91,11 +136,8 @@ void setup()
Serial.begin(115200); Serial.begin(115200);
Serial.println("FlipDot recovery app"); Serial.println("FlipDot recovery app");
// Display off: relay off, and no high TX line that would power the Nano
digitalWrite(RELAY_PIN, HIGH);
pinMode(RELAY_PIN, OUTPUT);
pinMode(NANO_TX_PIN, INPUT);
pinMode(LED_PIN, OUTPUT); pinMode(LED_PIN, OUTPUT);
nano.begin(); // display off between status messages
recovery.reason = takeRecoveryReason(); recovery.reason = takeRecoveryReason();
main_available = mainFirmwareAvailable(); main_available = mainFirmwareAvailable();
@@ -123,6 +165,7 @@ void setup()
ArduinoOTA.begin(); ArduinoOTA.begin();
portalBegin(); portalBegin();
updateDisplay();
// A hang restarts the ESP32; the bootloader then starts this recovery app again // A hang restarts the ESP32; the bootloader then starts this recovery app again
esp_task_wdt_init(RECOVERY_WATCHDOG_S, true); esp_task_wdt_init(RECOVERY_WATCHDOG_S, true);
@@ -138,6 +181,7 @@ void loop()
ArduinoOTA.handle(); ArduinoOTA.handle();
portalLoop(); portalLoop();
updateWifi(); updateWifi();
updateDisplay();
updateLed(); updateLed();
bool wifi_connected = WiFi.status() == WL_CONNECTED; bool wifi_connected = WiFi.status() == WL_CONNECTED;
+70 -2
View File
@@ -53,6 +53,12 @@ public:
snprintf(s, sizeof(s), "clock %02d:%02d", hour, minute); snprintf(s, sizeof(s), "clock %02d:%02d", hour, minute);
shown.push_back(s); shown.push_back(s);
} }
void noWifi(int hour, int minute) override
{
char s[16];
snprintf(s, sizeof(s), "nowifi %02d:%02d", hour, minute);
shown.push_back(s);
}
void logo() override { shown.push_back("logo"); } void logo() override { shown.push_back("logo"); }
void blank() override { shown.push_back("blank"); } void blank() override { shown.push_back("blank"); }
@@ -63,6 +69,7 @@ static FakePower power;
static FakeFrames frames; static FakeFrames frames;
static DisplayController *controller; static DisplayController *controller;
static uint32_t now; static uint32_t now;
static bool wifi;
void setUp() void setUp()
{ {
@@ -71,6 +78,7 @@ void setUp()
delete controller; delete controller;
controller = new DisplayController(power, frames); controller = new DisplayController(power, frames);
now = 1000; now = 1000;
wifi = true;
} }
void tearDown() {} void tearDown() {}
@@ -81,7 +89,7 @@ static void run(int hour, int minute, uint32_t ms = 0)
uint32_t end = now + ms; uint32_t end = now + ms;
do do
{ {
controller->update(now, true, hour, minute); controller->update(now, true, hour, minute, wifi);
now += 100; now += 100;
} while ((int32_t)(end - now) >= 0); } while ((int32_t)(end - now) >= 0);
} }
@@ -285,7 +293,12 @@ void test_recovery_never_starts_missing_clock_firmware()
void test_every_recovery_reason_has_a_text() void test_every_recovery_reason_has_a_text()
{ {
for (uint8_t r = 0; r <= (uint8_t)RecoveryReason::PowerCycle; r++) for (uint8_t r = 0; r <= (uint8_t)RecoveryReason::PowerCycle; r++)
{
TEST_ASSERT_TRUE(strlen(recoveryReasonText((RecoveryReason)r)) > 0); TEST_ASSERT_TRUE(strlen(recoveryReasonText((RecoveryReason)r)) > 0);
// fits on one line of the display
TEST_ASSERT_TRUE(strlen(recoveryReasonShort((RecoveryReason)r)) > 0);
TEST_ASSERT_TRUE(strlen(recoveryReasonShort((RecoveryReason)r)) <= 14);
}
TEST_ASSERT_TRUE(strcmp(recoveryReasonText(RecoveryReason::Wifi), TEST_ASSERT_TRUE(strcmp(recoveryReasonText(RecoveryReason::Wifi),
recoveryReasonText(RecoveryReason::Crash)) != 0); recoveryReasonText(RecoveryReason::Crash)) != 0);
} }
@@ -318,7 +331,7 @@ void test_led_blinks_slow_with_wifi_and_fast_without()
void test_nothing_happens_until_time_is_synced() void test_nothing_happens_until_time_is_synced()
{ {
power.is_on = true; // showing "Connecting..." power.is_on = true; // showing "Connecting..."
controller->update(now, false, 0, 0); controller->update(now, false, 0, 0, true);
TEST_ASSERT_EQUAL(0, power.off_count); TEST_ASSERT_EQUAL(0, power.off_count);
TEST_ASSERT_EQUAL(0, (int)frames.shown.size()); TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
} }
@@ -452,6 +465,56 @@ void test_redraws_after_power_was_switched_off_elsewhere()
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str()); TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
} }
void test_no_wifi_is_shown_after_a_minute_without_wifi()
{
run(12, 0);
wifi = false;
run(12, 0, NO_WIFI_SHOW_TIME - 200);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
run(12, 1, 300);
TEST_ASSERT_EQUAL_STRING("nowifi 12:01", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on);
}
void test_no_wifi_screen_keeps_the_time_running()
{
wifi = false;
run(12, 0, NO_WIFI_SHOW_TIME + 100);
run(12, 1);
TEST_ASSERT_EQUAL_STRING("nowifi 12:01", frames.last().c_str());
int frames_before = (int)frames.shown.size();
run(12, 1, 5000);
TEST_ASSERT_EQUAL(frames_before, (int)frames.shown.size()); // only on change
}
void test_clock_returns_when_wifi_is_back()
{
wifi = false;
run(12, 0, NO_WIFI_SHOW_TIME + 100);
wifi = true;
run(12, 0);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
void test_short_wifi_drop_does_not_change_the_display()
{
run(12, 0);
wifi = false;
run(12, 0, 10000);
wifi = true;
run(12, 0, NO_WIFI_SHOW_TIME);
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
}
void test_night_without_wifi_still_powers_off()
{
run(23, 30);
wifi = false;
run(23, 31, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
}
int main() int main()
{ {
UNITY_BEGIN(); UNITY_BEGIN();
@@ -501,6 +564,11 @@ int main()
RUN_TEST(test_clock_switch_off_never_powers_on); RUN_TEST(test_clock_switch_off_never_powers_on);
RUN_TEST(test_clock_switch_on_again_shows_clock); RUN_TEST(test_clock_switch_on_again_shows_clock);
RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere); RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere);
RUN_TEST(test_no_wifi_is_shown_after_a_minute_without_wifi);
RUN_TEST(test_no_wifi_screen_keeps_the_time_running);
RUN_TEST(test_clock_returns_when_wifi_is_back);
RUN_TEST(test_short_wifi_drop_does_not_change_the_display);
RUN_TEST(test_night_without_wifi_still_powers_off);
return UNITY_END(); return UNITY_END();
} }