Public Access
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:
1 parent
4a5f6a8b5d
commit
1d3b084de1
42 files changed
+1631
-230
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user