Restructure ESP32 firmware into modules, fix bugs, add tests

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

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

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

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

No files matched your search

+371
View File
@@ -0,0 +1,371 @@
// Tests for the ESP32 logic (src/esp32/logic), run on the PC: pio test -e test_esp32
//
// The display controller is tested with a fake power switch and fake frames that record
// what the controller asks for.
#include <unity.h>
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#include "config.h"
#include "logic/display_controller.h"
#include "logic/power_timer.h"
#include "logic/schedule.h"
#include "logic/status_led.h"
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
class FakePower : public DisplayPower
{
public:
bool is_on = false;
int on_count = 0;
int off_count = 0;
bool isOn() override { return is_on; }
void on() override
{
is_on = true;
on_count++;
}
void off() override
{
is_on = false;
off_count++;
}
};
class FakeFrames : public Frames
{
public:
std::vector<std::string> shown;
void clock(int hour, int minute) override
{
char s[16];
snprintf(s, sizeof(s), "clock %02d:%02d", hour, minute);
shown.push_back(s);
}
void logo() override { shown.push_back("logo"); }
void blank() override { shown.push_back("blank"); }
std::string last() const { return shown.empty() ? "" : shown.back(); }
};
static FakePower power;
static FakeFrames frames;
static DisplayController *controller;
static uint32_t now;
void setUp()
{
power = FakePower();
frames = FakeFrames();
delete controller;
controller = new DisplayController(power, frames);
now = 1000;
}
void tearDown() {}
// Run the controller at a time of day; `ms` passes in steps like the real loop
static void run(int hour, int minute, uint32_t ms = 0)
{
uint32_t end = now + ms;
do
{
controller->update(now, true, hour, minute);
now += 100;
} while ((int32_t)(end - now) >= 0);
}
// ---------------------------------------------------------------------------
// Schedule
// ---------------------------------------------------------------------------
void test_schedule_night_until_8()
{
TEST_ASSERT_TRUE(scheduleFor(0, 0) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(7, 0) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(7, 59) == ScheduleMode::Night);
}
void test_schedule_clock_from_8_to_22()
{
TEST_ASSERT_TRUE(scheduleFor(8, 0) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(12, 34) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(21, 59) == ScheduleMode::Clock);
}
void test_schedule_quarter_hours_until_23_30()
{
TEST_ASSERT_TRUE(scheduleFor(22, 0) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(22, 1) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(22, 14) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(22, 15) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(22, 45) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(23, 0) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(23, 29) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(23, 30) == ScheduleMode::Clock);
}
void test_schedule_night_from_23_31()
{
TEST_ASSERT_TRUE(scheduleFor(23, 31) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(23, 45) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(23, 59) == ScheduleMode::Night);
}
void test_evening_from_17()
{
TEST_ASSERT_FALSE(isEvening(16));
TEST_ASSERT_TRUE(isEvening(17));
TEST_ASSERT_TRUE(isEvening(23));
}
// ---------------------------------------------------------------------------
// Power timer
// ---------------------------------------------------------------------------
void test_timer_expires_once_after_delay()
{
PowerTimer t;
t.request(1000, 500);
TEST_ASSERT_FALSE(t.expired(1499));
TEST_ASSERT_TRUE(t.expired(1500));
TEST_ASSERT_FALSE(t.expired(1600));
}
void test_timer_keeps_earlier_deadline()
{
PowerTimer t;
t.request(0, 1000);
t.request(500, 1000); // later deadline: ignored
TEST_ASSERT_TRUE(t.expired(1000));
t.request(0, 30000);
t.request(100, 1000); // earlier deadline: used
TEST_ASSERT_TRUE(t.expired(1100));
}
void test_timer_cancel()
{
PowerTimer t;
t.request(0, 100);
t.cancel();
TEST_ASSERT_FALSE(t.pending());
TEST_ASSERT_FALSE(t.expired(1000));
}
void test_timer_across_millis_overflow()
{
PowerTimer t;
t.request(0xFFFFFF00u, 0x200); // deadline wraps past 0
TEST_ASSERT_FALSE(t.expired(0xFFFFFFFFu));
TEST_ASSERT_FALSE(t.expired(0x00000050u));
TEST_ASSERT_TRUE(t.expired(0x00000100u));
}
// ---------------------------------------------------------------------------
// Status LED
// ---------------------------------------------------------------------------
void test_led_off_while_display_off()
{
for (uint32_t t = 0; t < 2000; t += 50)
TEST_ASSERT_FALSE(statusLedLevel(t, true, false));
}
void test_led_blinks_slow_with_wifi_and_fast_without()
{
TEST_ASSERT_TRUE(statusLedLevel(0, true, true));
TEST_ASSERT_TRUE(statusLedLevel(499, true, true));
TEST_ASSERT_FALSE(statusLedLevel(500, true, true));
TEST_ASSERT_TRUE(statusLedLevel(0, false, true));
TEST_ASSERT_FALSE(statusLedLevel(100, false, true));
TEST_ASSERT_TRUE(statusLedLevel(200, false, true));
}
// ---------------------------------------------------------------------------
// Display controller
// ---------------------------------------------------------------------------
void test_nothing_happens_until_time_is_synced()
{
power.is_on = true; // showing "Connecting..."
controller->update(now, false, 0, 0);
TEST_ASSERT_EQUAL(0, power.off_count);
TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
}
void test_daytime_powers_on_and_shows_clock()
{
run(12, 34);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 12:34", frames.last().c_str());
}
void test_clock_is_only_sent_when_the_minute_changes()
{
run(12, 34, 5000);
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
run(12, 35);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
TEST_ASSERT_EQUAL_STRING("clock 12:35", frames.last().c_str());
TEST_ASSERT_EQUAL(1, power.on_count);
}
void test_night_clears_then_powers_off_after_delay()
{
run(23, 30);
run(23, 31, POWER_OFF_DELAY - 200);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on);
run(23, 31, 300);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL(1, power.off_count);
}
void test_night_does_not_power_on()
{
run(3, 0, 60000);
TEST_ASSERT_EQUAL(0, power.on_count);
TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
}
void test_standby_keeps_the_last_time_and_powers_off()
{
run(22, 0);
run(22, 1, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 22:00", frames.last().c_str()); // no blank
}
void test_quarter_hour_powers_on_again_and_redraws()
{
run(22, 1, POWER_OFF_DELAY + 200);
run(22, 14);
TEST_ASSERT_FALSE(power.is_on);
run(22, 15);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 22:15", frames.last().c_str());
}
void test_clock_cancels_pending_power_off()
{
run(22, 0);
run(22, 14, 10000); // power off countdown running
TEST_ASSERT_TRUE(power.is_on);
run(22, 15, POWER_OFF_DELAY);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL(0, power.off_count);
}
void test_power_switch_off_clears_and_powers_off_quickly()
{
run(12, 0);
run(22, 14, 1000); // 30 s countdown already running
controller->setPowerEnabled(false);
run(22, 14, CLEAR_BEFORE_OFF_TIME + 200);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
TEST_ASSERT_FALSE(power.is_on);
}
void test_power_switch_off_keeps_display_off_during_the_day()
{
controller->setPowerEnabled(false);
run(12, 0, 60000);
TEST_ASSERT_EQUAL(0, power.on_count);
}
void test_power_switch_on_again_shows_clock()
{
run(12, 0);
controller->setPowerEnabled(false);
run(12, 0, CLEAR_BEFORE_OFF_TIME + 200);
controller->setPowerEnabled(true);
run(12, 1);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 12:01", frames.last().c_str());
}
void test_clock_switch_off_shows_logo_then_powers_off()
{
run(12, 0);
controller->setClockEnabled(false);
run(12, 0, POWER_OFF_DELAY + 200);
TEST_ASSERT_EQUAL_STRING("logo", frames.last().c_str());
TEST_ASSERT_EQUAL(1, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("logo")));
TEST_ASSERT_FALSE(power.is_on);
}
void test_clock_switch_off_never_powers_on()
{
controller->setClockEnabled(false);
run(12, 0, 60000);
TEST_ASSERT_EQUAL(0, power.on_count);
}
void test_clock_switch_on_again_shows_clock()
{
run(12, 0);
controller->setClockEnabled(false);
run(12, 0);
controller->setClockEnabled(true);
run(12, 0);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
void test_redraws_after_power_was_switched_off_elsewhere()
{
// e.g. after flashing the Nano: the Nano restarts without a frame
run(12, 0);
power.off();
run(12, 0);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
int main()
{
UNITY_BEGIN();
RUN_TEST(test_schedule_night_until_8);
RUN_TEST(test_schedule_clock_from_8_to_22);
RUN_TEST(test_schedule_quarter_hours_until_23_30);
RUN_TEST(test_schedule_night_from_23_31);
RUN_TEST(test_evening_from_17);
RUN_TEST(test_timer_expires_once_after_delay);
RUN_TEST(test_timer_keeps_earlier_deadline);
RUN_TEST(test_timer_cancel);
RUN_TEST(test_timer_across_millis_overflow);
RUN_TEST(test_led_off_while_display_off);
RUN_TEST(test_led_blinks_slow_with_wifi_and_fast_without);
RUN_TEST(test_nothing_happens_until_time_is_synced);
RUN_TEST(test_daytime_powers_on_and_shows_clock);
RUN_TEST(test_clock_is_only_sent_when_the_minute_changes);
RUN_TEST(test_night_clears_then_powers_off_after_delay);
RUN_TEST(test_night_does_not_power_on);
RUN_TEST(test_standby_keeps_the_last_time_and_powers_off);
RUN_TEST(test_quarter_hour_powers_on_again_and_redraws);
RUN_TEST(test_clock_cancels_pending_power_off);
RUN_TEST(test_power_switch_off_clears_and_powers_off_quickly);
RUN_TEST(test_power_switch_off_keeps_display_off_during_the_day);
RUN_TEST(test_power_switch_on_again_shows_clock);
RUN_TEST(test_clock_switch_off_shows_logo_then_powers_off);
RUN_TEST(test_clock_switch_off_never_powers_on);
RUN_TEST(test_clock_switch_on_again_shows_clock);
RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere);
return UNITY_END();
}