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

+170 -22
View File
@@ -2,10 +2,17 @@
#include <ArduinoHA.h>
#include <WiFi.h>
#include <algorithm>
#include <string.h>
#include <strings.h>
#include <utils/HADictionary.h>
#include <utils/HASerializer.h>
#include "app.h"
#include "config.h"
#include "credentials.h"
#include "display/bitmap_store.h"
#include "net/ha_page_select.h"
#include "system/boot_target.h"
static WiFiClient client;
@@ -13,30 +20,153 @@ 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 HAPageSelect modeSelect("FlipDotDisplayMode");
static HAPageSelect offSelect("FlipDotDisplayOffImage");
static HASensor firmwareSensor("FlipDotDisplayFirmware");
static HASensor ipSensor("FlipDotDisplayIP");
static HAButton recoveryButton("FlipDotDisplayRecovery");
static char firmware_status[64] = "";
static bool firmware_status_sent = false;
// Removed entities: their retained discovery config is cleared, so Home Assistant
// deletes them instead of showing them as unavailable
static const char *const REMOVED_SWITCHES[] = {"FlipDotDisplayClock"};
// A text sensor that is only sent when its value changes, and again after a reconnect
// (HASensor doesn't resend its value by itself)
struct TextSensor
{
HASensor &sensor;
char value[64];
bool sent;
void set(const char *new_value)
{
if (strcmp(new_value, value) == 0)
return;
strncpy(value, new_value, sizeof(value) - 1);
value[sizeof(value) - 1] = '\0';
sent = false;
}
void loop()
{
if (!sent && value[0] && mqtt.isConnected())
sent = sensor.setValue(value);
}
};
static TextSensor firmware = {firmwareSensor, "", false};
static TextSensor ip = {ipSensor, "", false};
static DisplayController *displayController = nullptr;
static bool enabled = false;
// 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);
}
// The bitmap names for the dropdowns: built-in ones first, then the uploaded ones
// alphabetically. Rebuilt when bitmaps were added or deleted.
static const std::vector<String> &bitmapNames()
{
static std::vector<String> names;
static bool loaded = false;
static uint32_t revision = 0;
if (loaded && revision == bitmapListRevision())
return names;
loaded = true;
revision = bitmapListRevision();
std::vector<String> uploaded;
names.clear();
for (const BitmapInfo &b : listBitmaps())
(b.builtin ? names : uploaded).push_back(b.name);
std::sort(uploaded.begin(), uploaded.end(),
[](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; });
names.insert(names.end(), uploaded.begin(), uploaded.end());
return names;
}
// A dropdown with one fixed option (the clock, or blank) followed by the bitmaps, for a
// setting that holds either the fixed value or a bitmap name
struct BitmapChoice
{
HAPageSelect &select;
const char *fixed_option; // shown in Home Assistant
const char *fixed_value; // stored in the settings
const char *(*setting)();
const char *(*choose)(const char *value); // returns nullptr or an error
bool options_loaded;
uint32_t options_revision;
bool state_known;
char state_for[PAGE_NAME_SIZE]; // setting the state was published for
std::vector<String> options;
// The option for the setting; the fixed one when the bitmap is gone (the display
// falls back to the clock or blank then too)
const char *current()
{
const char *value = setting();
if (strcmp(value, fixed_value) != 0)
for (const String &option : options)
if (strcasecmp(option.c_str(), value) == 0)
return option.c_str();
return fixed_option;
}
// Rebuilds the options when bitmaps were added or deleted and reports the setting
void update()
{
if (!options_loaded || options_revision != bitmapListRevision())
{
const std::vector<String> &names = bitmapNames();
options_loaded = true;
options_revision = bitmapListRevision();
options.assign(1, fixed_option);
options.insert(options.end(), names.begin(), names.end());
select.setOptions(options);
state_known = false;
}
if (!state_known || strcmp(state_for, setting()) != 0)
{
state_known = true;
strncpy(state_for, setting(), sizeof(state_for) - 1);
state_for[sizeof(state_for) - 1] = '\0';
select.setState(current());
}
}
void command(const char *option)
{
const char *error = choose(strcmp(option, fixed_option) == 0 ? fixed_value : option);
if (error)
Serial.printf("Home Assistant \"%s\": %s\n", option, error);
update(); // report the (new) state back
}
};
// Bitmaps can't be called "clock", and the blank off image is stored as ""
static BitmapChoice mode = {modeSelect, "Clock", PAGE_CLOCK, []() { return appSettings().page; }, appSelectPage};
static BitmapChoice offImage = {offSelect, "Blank", OFF_PAGE_BLANK, []() { return appSettings().off_page; },
appSelectOffPage};
static void clearRemovedEntities()
{
for (const char *id : REMOVED_SWITCHES)
{
uint16_t length = HASerializer::calculateConfigTopicLength(AHATOFSTR(HAComponentSwitch), id);
if (length == 0)
continue;
char topic[length];
if (HASerializer::generateConfigTopic(topic, AHATOFSTR(HAComponentSwitch), id))
mqtt.publish(topic, "", true);
}
}
void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettings &settings)
{
displayController = &controller;
@@ -50,24 +180,36 @@ void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettin
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);
modeSelect.setName("FlipDot Display Mode");
modeSelect.setIcon("mdi:image-multiple");
modeSelect.onCommand([](const char *option) { mode.command(option); });
mode.update();
offSelect.setName("FlipDot Display Off image");
offSelect.setIcon("mdi:image-off-outline");
offSelect.onCommand([](const char *option) { offImage.command(option); });
offImage.update();
recoveryButton.setName("FlipDot Display Recovery");
recoveryButton.setIcon("mdi:lifebuoy");
recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); });
firmwareSensor.setName("FlipDot Display Firmware");
firmwareSensor.setIcon("mdi:chip");
// The sensor doesn't resend its value on reconnect by itself
mqtt.onConnected([]() { firmware_status_sent = false; });
ipSensor.setName("FlipDot Display IP");
ipSensor.setIcon("mdi:ip-network");
mqtt.onConnected([]() {
firmware.sent = false;
ip.sent = false;
clearRemovedEntities();
});
// settings must stay alive: the MQTT library keeps the pointers
if (settings.host[0])
@@ -87,14 +229,20 @@ void homeAssistantLoop()
if (!enabled)
return;
mqtt.loop();
if (!firmware_status_sent && firmware_status[0] && mqtt.isConnected())
firmware_status_sent = firmwareSensor.setValue(firmware_status);
static uint32_t last_ip = 0;
if (WiFi.isConnected() && (uint32_t)WiFi.localIP() != last_ip)
{
last_ip = WiFi.localIP();
ip.set(WiFi.localIP().toString().c_str());
}
ip.loop();
firmware.loop();
mode.update();
offImage.update();
}
void homeAssistantSetFirmwareStatus(const char *status)
{
if (strcmp(status, firmware_status) == 0)
return;
strncpy(firmware_status, status, sizeof(firmware_status) - 1);
firmware_status_sent = false;
firmware.set(status);
}