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

+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/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count |
| **`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, ...) |
| **`nano_link/`** | **Connection to the Nano** |
| `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
- **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
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.
+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
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)
const uint32_t POWER_OFF_DELAY = 30000;
+9 -10
View File
@@ -3,6 +3,7 @@
#include <stdio.h>
#include "display/bitmaps.h"
#include "display/text.h"
#include "logic/schedule.h"
// 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();
}
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()
{
_display.fillScreen(0);
@@ -43,13 +52,3 @@ 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();
}
+2 -2
View File
@@ -11,11 +11,11 @@ public:
// Pikachu with a thought bubble showing HH:MM (yawning in the evening)
void clock(int hour, int minute) override;
// "No WiFi" with the time below it
void noWifi(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;
+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)
{
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);
// 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.
// 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;
}
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
if (!_power.isOn())
_screen = Screen::Unknown;
@@ -81,7 +90,7 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
_power.on();
_screen = Screen::Unknown;
}
_showClock(hour, minute);
_showClock(hour, minute, show_no_wifi);
break;
case ScheduleMode::Standby:
// 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;
_frames.clock(hour, minute);
_screen = Screen::Clock;
if (no_wifi)
_frames.noWifi(hour, minute);
else
_frames.clock(hour, minute);
_screen = screen;
_shown_hour = hour;
_shown_minute = minute;
}
+9 -2
View File
@@ -19,6 +19,8 @@ class Frames
{
public:
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 blank() = 0;
};
@@ -40,13 +42,15 @@ public:
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);
// 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:
enum class Screen
{
Unknown, // nothing sent since the last power on
Clock,
NoWifi,
Logo,
Blank,
};
@@ -62,7 +66,10 @@ private:
int _shown_hour = -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 _showBlank();
};
+13 -8
View File
@@ -7,6 +7,7 @@
#include "BROSE9323.h"
#include "config.h"
#include "display/frames.h"
#include "display/status_screen.h"
#include "logic/display_controller.h"
#include "logic/status_led.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);
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
FlipdotFrames frames(display);
StatusScreen status(display, nano);
DisplayController controller(nano, frames);
// Flashing the Nano over WiFi: restart it into its bootloader, switch it off afterwards.
@@ -32,12 +34,14 @@ void setup()
{
Serial.begin(115200);
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
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();
if (boot_mode == BootMode::SafeMode)
@@ -46,19 +50,20 @@ void setup()
// then try the main code once more
firmwareGuardSafeModeEnded();
rebootToRecovery(RecoveryReason::Crash);
status.show("Crashed 3x", "OTA only");
otaOnlyMode(SAFE_MODE_TIME, LED_PIN);
ESP.restart();
}
if (boot_mode == BootMode::PanicWindow)
{
status.show("Crashed!", "OTA open 15s");
otaOnlyMode(OTA_PANIC_WINDOW, LED_PIN);
}
flashBridge.begin();
nano.on();
display.begin(&nano.stream());
display.setTiming(FLIP_TIME);
// Shown until the time is synced
frames.message("Connecting...");
// Shown until the time is synced over NTP
status.show("Getting", "the time...", true);
configTzTime(TIMEZONE, NTP_SERVER);
homeAssistantBegin(controller);
@@ -82,7 +87,7 @@ void loop()
struct tm now = {};
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()));
}
+49 -5
View File
@@ -1,16 +1,19 @@
// 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,
// 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 <ArduinoOTA.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include "BROSE9323.h"
#include "config.h"
#include "credentials.h"
#include "display/status_screen.h"
#include "logic/boot_policy.h"
#include "nano_link/nano_link.h"
#include "recovery_state.h"
#include "system/boot_target.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
static const uint32_t AP_CLOSE_DELAY = 120000;
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;
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_connected_since = 0;
static bool watchdog_running = false;
@@ -79,6 +98,32 @@ static void updateWifi()
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
static void updateLed()
{
@@ -91,11 +136,8 @@ void setup()
Serial.begin(115200);
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);
nano.begin(); // display off between status messages
recovery.reason = takeRecoveryReason();
main_available = mainFirmwareAvailable();
@@ -123,6 +165,7 @@ void setup()
ArduinoOTA.begin();
portalBegin();
updateDisplay();
// A hang restarts the ESP32; the bootloader then starts this recovery app again
esp_task_wdt_init(RECOVERY_WATCHDOG_S, true);
@@ -138,6 +181,7 @@ void loop()
ArduinoOTA.handle();
portalLoop();
updateWifi();
updateDisplay();
updateLed();
bool wifi_connected = WiFi.status() == WL_CONNECTED;