Add HA dropdowns, off image, flip fixes and refresh effects

Home Assistant: remove the Clock switch (its entity is deleted on connect),
add an IP sensor and Mode / Off image dropdowns whose options follow the
saved bitmaps. Remove the built-in "Nintendo border" bitmap.

Off image: a bitmap (or blank) left on the dots when the display switches off.

Nano: pause after each flip pulse and repeat pulses for changed dots, both
stored in EEPROM (G/N commands). Refresh effects (E command): instant,
sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then
reveal, push and random, optionally only for small changes. Effects run in
steps from loop() so serial input keeps flowing.

Web UI: flip settings, refresh effect and test patterns under Settings; the
System tab shows why the ESP32 last restarted. Settings records from
versions 1 and 2 are migrated. Firmware 2.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 01:29:01 +02:00
1 parent 4a5f6a8b5d
commit 1d3b084de1
42 files changed
+1631 -230

No files matched your search

+7
View File
@@ -5,6 +5,7 @@
#include <vector>
#include "Arduino.h"
#include "dot_sequence.h" // the real one, from lib/BROSE9323
struct DisplayCall
{
@@ -17,6 +18,7 @@ class BROSE9323
public:
std::vector<DisplayCall> calls;
bool direct = false;
bool effect_running = false; // what busy() returns
BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t) {}
@@ -26,6 +28,11 @@ public:
void fillScreen(uint16_t color) { log("fill", color); }
void setDirect(bool d) { direct = d; }
void setTiming(uint16_t t) { log("timing", t); }
void setGap(uint16_t t) { log("gap", t); }
void setPulses(uint8_t n) { log("pulses", n); }
void setEffect(uint8_t effect, uint16_t time_ms, uint8_t flags = 0) { log("effect", effect, time_ms, flags); }
bool update() { return effect_running; }
bool busy() const { return effect_running; }
void printBuffer() { log("printBuffer"); }
void setCursor(int16_t x, int16_t y) { log("cursor", x, y); }
void setTextColor(uint16_t color, uint16_t background) { log("textColor", color, background); }
+81 -37
View File
@@ -63,8 +63,7 @@ public:
snprintf(s, sizeof(s), "nowifi %02d:%02d", hour, minute);
shown.push_back(s);
}
void logo() override { shown.push_back("logo"); }
void blank() override { shown.push_back("blank"); }
void off() override { shown.push_back("off"); }
std::string last() const { return shown.empty() ? "" : shown.back(); }
};
@@ -363,7 +362,7 @@ 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_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on);
run(23, 31, 300);
TEST_ASSERT_FALSE(power.is_on);
@@ -382,7 +381,7 @@ 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 evening", frames.last().c_str()); // no blank
TEST_ASSERT_EQUAL_STRING("clock 22:00 evening", frames.last().c_str()); // no off screen
}
void test_quarter_hour_powers_on_again_and_redraws()
@@ -411,7 +410,19 @@ void test_power_switch_off_clears_and_powers_off_quickly()
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_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_FALSE(power.is_on);
}
void test_power_switch_off_waits_for_the_refresh_effect()
{
controller->setEffectTime(3000);
run(12, 0);
controller->setPowerEnabled(false);
run(12, 0, CLEAR_BEFORE_OFF_TIME + 2000);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on); // the effect is still flipping dots
run(12, 0, 1400);
TEST_ASSERT_FALSE(power.is_on);
}
@@ -433,33 +444,6 @@ void test_power_switch_on_again_shows_clock()
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
@@ -518,7 +502,7 @@ void test_night_without_wifi_still_powers_off()
wifi = false;
run(23, 31, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
}
// ---------------------------------------------------------------------------
@@ -545,6 +529,64 @@ void test_settings_times_must_be_in_order()
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_settings_drive_ranges()
{
Settings s;
TEST_ASSERT_NULL(validateSettings(s));
s.drive.flip_gap = 5001;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.drive.pulses = 0;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s.drive.pulses = 4;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.drive.effect = DRIVE_EFFECT_COUNT;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.drive.effect_time = 99;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s.drive.effect_time = 10001;
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_effect_time_is_zero_when_instant()
{
DriveSettings d;
TEST_ASSERT_EQUAL(0, (int)effectTime(d)); // default: instant
d.effect = 1;
d.effect_time = 2500;
TEST_ASSERT_EQUAL(2500, (int)effectTime(d));
d.effect = DRIVE_EFFECT_PUSH; // can take much longer
TEST_ASSERT_EQUAL(2500 + (int)DRIVE_PUSH_EXTRA_TIME, (int)effectTime(d));
d.effect = DRIVE_EFFECT_RANDOM; // can be a push
TEST_ASSERT_EQUAL(2500 + (int)DRIVE_PUSH_EXTRA_TIME, (int)effectTime(d));
}
void test_settings_off_page_blank_or_bitmap_name()
{
Settings s;
TEST_ASSERT_EQUAL_STRING("", s.off_page); // default: blank
TEST_ASSERT_NULL(validateSettings(s));
strcpy(s.off_page, "Nintendo");
TEST_ASSERT_NULL(validateSettings(s));
strcpy(s.off_page, "../x");
TEST_ASSERT_NOT_NULL(validateSettings(s));
memset(s.off_page, 'a', sizeof(s.off_page)); // not terminated
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_changed_off_image_is_redrawn_while_the_display_is_on()
{
run(23, 30);
run(23, 31, 1000);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
controller->pageChanged();
run(23, 31, 1000);
TEST_ASSERT_EQUAL(2, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("off")));
TEST_ASSERT_TRUE(power.is_on);
}
void test_settings_flip_time_and_port_ranges()
{
Settings s;
@@ -719,7 +761,7 @@ void test_bitmap_page_follows_the_schedule()
controller->setPage(Page::Bitmap);
run(23, 30);
run(23, 31, POWER_OFF_DELAY + 200);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_FALSE(power.is_on);
}
@@ -835,11 +877,9 @@ int main()
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_waits_for_the_refresh_effect);
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);
RUN_TEST(test_no_wifi_is_shown_after_a_minute_without_wifi);
RUN_TEST(test_no_wifi_screen_keeps_the_time_running);
@@ -849,6 +889,10 @@ int main()
RUN_TEST(test_default_settings_are_valid);
RUN_TEST(test_settings_times_must_be_in_order);
RUN_TEST(test_settings_drive_ranges);
RUN_TEST(test_effect_time_is_zero_when_instant);
RUN_TEST(test_settings_off_page_blank_or_bitmap_name);
RUN_TEST(test_changed_off_image_is_redrawn_while_the_display_is_on);
RUN_TEST(test_settings_flip_time_and_port_ranges);
RUN_TEST(test_settings_page_must_be_clock_or_bitmap_name);
RUN_TEST(test_time_parse_and_format);
+217
View File
@@ -9,6 +9,9 @@
#include "BROSE9323.h"
#include "EEPROM.h"
#include <cstdlib>
#include <vector>
// From src/nano/main.cpp
extern BROSE9323 display;
extern uint32_t last_refresh;
@@ -233,6 +236,207 @@ void test_timing_out_of_range_is_rejected()
TEST_ASSERT_TRUE(replied("ERR"));
}
// ---------------------------------------------------------------------------
// Flip gap, pulses and refresh effect
// ---------------------------------------------------------------------------
void test_defaults_with_empty_eeprom()
{
EEPROM.erase();
setup();
TEST_ASSERT_EQUAL(200, display.last("gap")->a);
TEST_ASSERT_EQUAL(2, display.last("pulses")->a);
TEST_ASSERT_EQUAL(EFFECT_INSTANT, display.last("effect")->a);
TEST_ASSERT_EQUAL(1500, display.last("effect")->b);
TEST_ASSERT_EQUAL(0, display.last("effect")->c);
}
void test_eeprom_from_before_the_new_settings_keeps_flip_time()
{
// Old firmware only stored the magic and the flip time; the rest is still 0xFF
EEPROM.erase();
send("T300\n");
EEPROM.put(4, (uint16_t)0xFFFF);
EEPROM.put(6, (uint8_t)0xFF);
EEPROM.put(7, (uint8_t)0xFF);
EEPROM.put(8, (uint16_t)0xFFFF);
EEPROM.put(10, (uint8_t)0xFF);
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(300, display.last("timing")->a);
TEST_ASSERT_EQUAL(200, display.last("gap")->a);
TEST_ASSERT_EQUAL(2, display.last("pulses")->a);
TEST_ASSERT_EQUAL(EFFECT_INSTANT, display.last("effect")->a);
}
void test_gap_is_applied_and_stored()
{
send("G350\n");
TEST_ASSERT_EQUAL(350, display.last("gap")->a);
send("G0\n");
TEST_ASSERT_EQUAL(0, display.last("gap")->a);
send("G1200\n");
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(1200, display.last("gap")->a);
}
void test_gap_out_of_range_is_rejected()
{
send("G5001\n");
send("G\n");
TEST_ASSERT_EQUAL(0, display.count("gap"));
TEST_ASSERT_TRUE(replied("ERR"));
}
void test_pulses_are_applied_and_stored()
{
send("N3\n");
TEST_ASSERT_EQUAL(3, display.last("pulses")->a);
send("N1\n");
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(1, display.last("pulses")->a);
}
void test_pulses_out_of_range_are_rejected()
{
send("N0\n");
send("N4\n");
TEST_ASSERT_EQUAL(0, display.count("pulses"));
TEST_ASSERT_TRUE(replied("ERR"));
}
void test_effect_is_applied_and_stored()
{
send("E1,2500\n");
TEST_ASSERT_EQUAL(EFFECT_SPARKLE, display.last("effect")->a);
TEST_ASSERT_EQUAL(2500, display.last("effect")->b);
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(EFFECT_SPARKLE, display.last("effect")->a);
TEST_ASSERT_EQUAL(2500, display.last("effect")->b);
TEST_ASSERT_EQUAL(0, display.last("effect")->c); // no flags given
send("E9,1000,1\n");
TEST_ASSERT_EQUAL(EFFECT_RANDOM, display.last("effect")->a);
TEST_ASSERT_EQUAL(EFFECT_FLAG_SMALL_ONLY, display.last("effect")->c);
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(EFFECT_FLAG_SMALL_ONLY, display.last("effect")->c);
send("E0,1500,0\n");
}
void test_invalid_effect_is_rejected()
{
send("E10,1500\n"); // unknown effect
send("E1,1500,2\n"); // unknown flag
send("E1,50\n"); // too fast
send("E1\n"); // no duration
TEST_ASSERT_EQUAL(0, display.count("effect"));
TEST_ASSERT_TRUE(replied("ERR"));
}
void test_no_full_redraw_while_an_effect_runs()
{
last_refresh = fake_millis;
fake_millis += 120000;
display.effect_running = true;
send("D\n");
TEST_ASSERT_EQUAL(1, display.count("display")); // only the frame itself
display.effect_running = false;
loop();
TEST_ASSERT_EQUAL(2, display.count("display"));
TEST_ASSERT_EQUAL(1, display.last("display")->a);
}
// Every effect order visits each dot exactly once
static void checkSequence(uint8_t effect, uint8_t w, uint8_t h, uint16_t seed)
{
std::vector<int> seen(w * h, 0);
DotSequence sequence;
sequence.begin(effect, w, h, seed);
uint8_t x, y;
int n = 0;
while (sequence.next(x, y))
{
TEST_ASSERT_TRUE(x < w && y < h);
seen[y * w + x]++;
n++;
}
TEST_ASSERT_EQUAL(w * h, n);
TEST_ASSERT_EQUAL(w * h, sequence.total());
for (int count : seen)
TEST_ASSERT_EQUAL(1, count);
}
void test_every_effect_visits_each_dot_once()
{
for (uint8_t effect = 0; effect < EFFECT_COUNT; effect++)
{
checkSequence(effect, 84, 19, 0);
checkSequence(effect, 84, 19, 12345);
checkSequence(effect, 7, 5, 3);
checkSequence(effect, 5, 7, 3);
checkSequence(effect, 1, 1, 0);
checkSequence(effect, 1, 6, 0);
checkSequence(effect, 6, 1, 0);
checkSequence(effect, 2, 2, 0);
}
// Sparkle with every LFSR size up to 4095 dots
for (int n = 2; n < 4096; n = n * 2 + 1)
checkSequence(EFFECT_SPARKLE, n > 255 ? 255 : n, n > 255 ? (n + 254) / 255 : 1, 7);
}
void test_wipe_goes_left_to_right_and_center_starts_in_the_middle()
{
DotSequence sequence;
uint8_t x, y;
sequence.begin(EFFECT_WIPE, 84, 19, 0);
sequence.next(x, y);
TEST_ASSERT_EQUAL(0, x);
sequence.begin(EFFECT_CENTER, 84, 19, 0);
sequence.next(x, y);
TEST_ASSERT_EQUAL(42, x);
sequence.begin(EFFECT_DIAGONAL, 84, 19, 0);
sequence.next(x, y);
TEST_ASSERT_TRUE(x == 0 && y == 0);
sequence.next(x, y);
TEST_ASSERT_TRUE(x == 1 && y == 0);
sequence.next(x, y);
TEST_ASSERT_TRUE(x == 0 && y == 1);
// Spiral: the whole top row, then down the right side
sequence.begin(EFFECT_SPIRAL, 84, 19, 0);
for (int i = 0; i < 84; i++)
{
sequence.next(x, y);
TEST_ASSERT_TRUE(x == i && y == 0);
}
sequence.next(x, y);
TEST_ASSERT_TRUE(x == 83 && y == 1);
sequence.begin(EFFECT_RAIN, 84, 19, 0);
sequence.next(x, y);
sequence.next(x, y);
TEST_ASSERT_EQUAL(1, x);
TEST_ASSERT_EQUAL(0, y);
}
void test_sparkle_order_is_scrambled()
{
// Neighbouring dots should rarely follow each other
DotSequence sequence;
sequence.begin(EFFECT_SPARKLE, 84, 19, 99);
uint8_t x, y, px = 0, py = 0;
int neighbours = 0, n = 0;
while (sequence.next(x, y))
{
if (n++ && abs(x - px) <= 1 && abs(y - py) <= 1)
neighbours++;
px = x;
py = y;
}
TEST_ASSERT_LESS_THAN(n / 20, neighbours);
}
// ---------------------------------------------------------------------------
// Periodic full redraw
// ---------------------------------------------------------------------------
@@ -312,6 +516,19 @@ int main()
RUN_TEST(test_timing_above_255_is_stored_and_restored);
RUN_TEST(test_timing_out_of_range_is_rejected);
RUN_TEST(test_defaults_with_empty_eeprom);
RUN_TEST(test_eeprom_from_before_the_new_settings_keeps_flip_time);
RUN_TEST(test_gap_is_applied_and_stored);
RUN_TEST(test_gap_out_of_range_is_rejected);
RUN_TEST(test_pulses_are_applied_and_stored);
RUN_TEST(test_pulses_out_of_range_are_rejected);
RUN_TEST(test_effect_is_applied_and_stored);
RUN_TEST(test_invalid_effect_is_rejected);
RUN_TEST(test_no_full_redraw_while_an_effect_runs);
RUN_TEST(test_every_effect_visits_each_dot_once);
RUN_TEST(test_wipe_goes_left_to_right_and_center_starts_in_the_middle);
RUN_TEST(test_sparkle_order_is_scrambled);
RUN_TEST(test_refresh_waits_for_end_of_frame);
RUN_TEST(test_refresh_only_once_per_interval);
RUN_TEST(test_refresh_when_serial_is_idle);