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

+89
View File
@@ -0,0 +1,89 @@
#include "net/ha_page_select.h"
#include <string.h>
#include <utils/HADictionary.h>
#include <utils/HASerializer.h>
static const size_t MAX_OPTIONS = 255; // HASerializerArray counts with a uint8_t
HAPageSelect::HAPageSelect(const char *uniqueId) : HABaseDeviceType(AHATOFSTR(HAComponentSelect), uniqueId)
{
}
HAPageSelect::~HAPageSelect()
{
delete _array;
}
void HAPageSelect::setOptions(const std::vector<String> &options)
{
if (options == _options)
return;
_options = options;
if (_options.size() > MAX_OPTIONS)
_options.resize(MAX_OPTIONS);
if (mqtt()->isConnected())
{
publishConfig();
_publishState(); // Home Assistant forgets the state when the config changes
}
}
void HAPageSelect::setState(const char *option)
{
if (_state_sent && _state == option)
return;
_state = option;
_publishState();
}
void HAPageSelect::_publishState()
{
_state_sent = mqtt()->isConnected() && !_state.isEmpty() &&
publishOnDataTopic(AHATOFSTR(HAStateTopic), _state.c_str(), true);
}
// Called by publishConfig(), which deletes the serializer again afterwards
void HAPageSelect::buildSerializer()
{
if (_serializer || !uniqueId() || _options.empty())
return;
delete _array;
_array = new HASerializerArray(_options.size(), false);
for (const String &option : _options)
_array->add(option.c_str());
_serializer = new HASerializer(this, 11); // 8 used, room to spare (no overflow check)
_serializer->set(AHATOFSTR(HANameProperty), _name);
_serializer->set(HASerializer::WithUniqueId);
_serializer->set(AHATOFSTR(HAIconProperty), _icon);
_serializer->set(AHATOFSTR(HAOptionsProperty), _array, HASerializer::ArrayPropertyType);
_serializer->set(HASerializer::WithDevice);
_serializer->set(HASerializer::WithAvailability);
_serializer->topic(AHATOFSTR(HAStateTopic));
_serializer->topic(AHATOFSTR(HACommandTopic));
}
void HAPageSelect::onMqttConnected()
{
if (!uniqueId())
return;
publishConfig();
publishAvailability();
_publishState();
subscribeTopic(uniqueId(), AHATOFSTR(HACommandTopic));
}
void HAPageSelect::onMqttMessage(const char *topic, const uint8_t *payload, const uint16_t length)
{
if (!_callback || !HASerializer::compareDataTopics(topic, uniqueId(), AHATOFSTR(HACommandTopic)))
return;
// Exact match (HASelect also accepts a prefix of an option)
for (const String &option : _options)
if (option.length() == length && memcmp(option.c_str(), payload, length) == 0)
{
_callback(option.c_str());
return;
}
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <ArduinoHA.h>
#include <vector>
// A Home Assistant select (dropdown) whose options can change at runtime: ArduinoHA's
// HASelect only accepts the options once, and its index is limited to 127. When the
// options change, the discovery config is published again and Home Assistant updates
// the entity. Options and state are option names, at most 255 options.
class HAPageSelect : public HABaseDeviceType
{
public:
explicit HAPageSelect(const char *uniqueId);
~HAPageSelect();
inline void setIcon(const char *icon) { _icon = icon; }
// Called with the chosen option. Not optimistic: report it back with setState().
inline void onCommand(void (*callback)(const char *option)) { _callback = callback; }
void setOptions(const std::vector<String> &options);
// Publishes the state when it changed, and again after each reconnect
void setState(const char *option);
protected:
void buildSerializer() override;
void onMqttConnected() override;
void onMqttMessage(const char *topic, const uint8_t *payload, const uint16_t length) override;
private:
std::vector<String> _options;
String _state;
bool _state_sent = false;
HASerializerArray *_array = nullptr;
const char *_icon = nullptr;
void (*_callback)(const char *option) = nullptr;
void _publishState();
};
+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);
}
+5 -2
View File
@@ -4,8 +4,11 @@
#include "logic/settings.h"
// Home Assistant integration over MQTT (with discovery):
// FlipDot Display Clock switch: on = clock, off = Nintendo logo
// FlipDot Display Power switch: off = clear the display and switch it off
// FlipDot Display Power switch: off = draw the off image and switch the display off
// FlipDot Display Mode select: the page shown during the day ("Clock" or a bitmap)
// FlipDot Display Off image select: left on the dots when the display switches off
// ("Blank" or a bitmap)
// FlipDot Display IP sensor: the IP address
// FlipDot Display Firmware sensor: version and health, e.g. "2.1.0, ok, 0 crashes, ..."
// FlipDot Display Recovery button: restart into the recovery app (for 10 minutes)
//