2026-10-13 00:56:32 +02:00
|
|
|
#include "net/home_assistant.h"
|
|
|
|
|
|
|
|
|
|
#include <ArduinoHA.h>
|
|
|
|
|
#include <WiFi.h>
|
2026-10-15 01:29:01 +02:00
|
|
|
#include <algorithm>
|
2026-10-13 01:05:06 +02:00
|
|
|
#include <string.h>
|
2026-10-15 01:29:01 +02:00
|
|
|
#include <strings.h>
|
|
|
|
|
#include <utils/HADictionary.h>
|
|
|
|
|
#include <utils/HASerializer.h>
|
2026-10-13 00:56:32 +02:00
|
|
|
|
2026-10-15 01:29:01 +02:00
|
|
|
#include "app.h"
|
2026-10-13 00:56:32 +02:00
|
|
|
#include "config.h"
|
|
|
|
|
#include "credentials.h"
|
2026-10-15 01:29:01 +02:00
|
|
|
#include "display/bitmap_store.h"
|
2026-10-15 21:01:14 +02:00
|
|
|
#include "display/page_resources.h"
|
2026-10-15 19:43:59 +02:00
|
|
|
#include "display/page_store.h"
|
2026-10-15 01:29:01 +02:00
|
|
|
#include "net/ha_page_select.h"
|
2026-10-13 01:21:04 +02:00
|
|
|
#include "system/boot_target.h"
|
2026-10-13 00:56:32 +02:00
|
|
|
|
|
|
|
|
static WiFiClient client;
|
|
|
|
|
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 powerSwitch("FlipDotDisplayPower");
|
2026-10-15 01:29:01 +02:00
|
|
|
static HAPageSelect modeSelect("FlipDotDisplayMode");
|
|
|
|
|
static HAPageSelect offSelect("FlipDotDisplayOffImage");
|
2026-10-13 01:05:06 +02:00
|
|
|
static HASensor firmwareSensor("FlipDotDisplayFirmware");
|
2026-10-15 01:29:01 +02:00
|
|
|
static HASensor ipSensor("FlipDotDisplayIP");
|
2026-10-13 01:21:04 +02:00
|
|
|
static HAButton recoveryButton("FlipDotDisplayRecovery");
|
2026-10-15 21:01:14 +02:00
|
|
|
static HASwitch stopwatchSwitch("FlipDotDisplayStopwatch");
|
|
|
|
|
static HAButton stopwatchReset("FlipDotDisplayStopwatchReset");
|
2026-10-13 01:05:06 +02:00
|
|
|
|
2026-10-15 01:29:01 +02:00
|
|
|
// 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};
|
2026-10-13 00:56:32 +02:00
|
|
|
|
|
|
|
|
static DisplayController *displayController = nullptr;
|
2026-10-13 01:52:34 +02:00
|
|
|
static bool enabled = false;
|
2026-10-13 00:56:32 +02:00
|
|
|
|
|
|
|
|
// The switches are not optimistic: the new state must be reported back to Home Assistant
|
|
|
|
|
static void onPowerCommand(bool state, HASwitch *sender)
|
|
|
|
|
{
|
|
|
|
|
displayController->setPowerEnabled(state);
|
|
|
|
|
sender->setState(state);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
static bool lessName(const String &a, const String &b)
|
2026-10-15 01:29:01 +02:00
|
|
|
{
|
2026-10-15 19:43:59 +02:00
|
|
|
return strcasecmp(a.c_str(), b.c_str()) < 0;
|
|
|
|
|
}
|
2026-10-15 01:29:01 +02:00
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
// The bitmap names for the dropdowns: built-in ones first, then the uploaded ones
|
|
|
|
|
// alphabetically
|
|
|
|
|
static std::vector<String> bitmapNames()
|
|
|
|
|
{
|
|
|
|
|
std::vector<String> names, uploaded;
|
2026-10-15 01:29:01 +02:00
|
|
|
for (const BitmapInfo &b : listBitmaps())
|
|
|
|
|
(b.builtin ? names : uploaded).push_back(b.name);
|
2026-10-15 19:43:59 +02:00
|
|
|
std::sort(uploaded.begin(), uploaded.end(), lessName);
|
2026-10-15 01:29:01 +02:00
|
|
|
names.insert(names.end(), uploaded.begin(), uploaded.end());
|
|
|
|
|
return names;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
// The dynamic pages the same way, followed by the bitmaps
|
|
|
|
|
static std::vector<String> pageNames()
|
|
|
|
|
{
|
|
|
|
|
std::vector<String> names, stored;
|
|
|
|
|
for (const PageInfo &p : listPages())
|
|
|
|
|
(p.builtin ? names : stored).push_back(p.name);
|
|
|
|
|
std::sort(stored.begin(), stored.end(), lessName);
|
|
|
|
|
names.insert(names.end(), stored.begin(), stored.end());
|
|
|
|
|
std::vector<String> bitmaps = bitmapNames();
|
|
|
|
|
names.insert(names.end(), bitmaps.begin(), bitmaps.end());
|
|
|
|
|
return names;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Changes when bitmaps or pages were added or deleted
|
|
|
|
|
static uint32_t listRevision()
|
|
|
|
|
{
|
|
|
|
|
return bitmapListRevision() + pageListRevision();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A dropdown for a setting that holds a name from the list, or optionally a fixed value
|
|
|
|
|
// shown as an extra first option (the blank off image)
|
|
|
|
|
struct NameChoice
|
2026-10-15 01:29:01 +02:00
|
|
|
{
|
|
|
|
|
HAPageSelect &select;
|
2026-10-15 19:43:59 +02:00
|
|
|
const char *fixed_option; // shown in Home Assistant; nullptr: no fixed option
|
2026-10-15 01:29:01 +02:00
|
|
|
const char *fixed_value; // stored in the settings
|
2026-10-15 19:43:59 +02:00
|
|
|
const char *fallback; // shown when the setting's name is gone (what the display shows then)
|
|
|
|
|
std::vector<String> (*names)();
|
2026-10-15 01:29:01 +02:00
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
const char *current()
|
|
|
|
|
{
|
|
|
|
|
const char *value = setting();
|
2026-10-15 19:43:59 +02:00
|
|
|
if (fixed_option && strcmp(value, fixed_value) == 0)
|
|
|
|
|
return fixed_option;
|
|
|
|
|
for (const String &option : options)
|
|
|
|
|
if (strcasecmp(option.c_str(), value) == 0)
|
|
|
|
|
return option.c_str();
|
|
|
|
|
return fallback;
|
2026-10-15 01:29:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
// Rebuilds the options when bitmaps or pages were added or deleted and reports the setting
|
2026-10-15 01:29:01 +02:00
|
|
|
void update()
|
|
|
|
|
{
|
2026-10-15 19:43:59 +02:00
|
|
|
if (!options_loaded || options_revision != listRevision())
|
2026-10-15 01:29:01 +02:00
|
|
|
{
|
|
|
|
|
options_loaded = true;
|
2026-10-15 19:43:59 +02:00
|
|
|
options_revision = listRevision();
|
|
|
|
|
options.clear();
|
|
|
|
|
if (fixed_option)
|
|
|
|
|
options.push_back(fixed_option);
|
|
|
|
|
std::vector<String> list = names();
|
|
|
|
|
options.insert(options.end(), list.begin(), list.end());
|
2026-10-15 01:29:01 +02:00
|
|
|
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)
|
|
|
|
|
{
|
2026-10-15 19:43:59 +02:00
|
|
|
const char *error = choose(fixed_option && strcmp(option, fixed_option) == 0 ? fixed_value : option);
|
2026-10-15 01:29:01 +02:00
|
|
|
if (error)
|
|
|
|
|
Serial.printf("Home Assistant \"%s\": %s\n", option, error);
|
|
|
|
|
update(); // report the (new) state back
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
// "Clock" is the built-in page (it was a fixed option before, so automations keep working)
|
|
|
|
|
static NameChoice mode = {modeSelect, nullptr, nullptr, DEFAULT_PAGE, pageNames,
|
|
|
|
|
[]() { return appSettings().page; }, appSelectPage};
|
|
|
|
|
// The blank off image is stored as ""
|
|
|
|
|
static NameChoice offImage = {offSelect, "Blank", OFF_PAGE_BLANK, "Blank", bitmapNames,
|
|
|
|
|
[]() { return appSettings().off_page; }, appSelectOffPage};
|
2026-10-15 01:29:01 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 01:52:34 +02:00
|
|
|
void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettings &settings)
|
2026-10-13 00:56:32 +02:00
|
|
|
{
|
|
|
|
|
displayController = &controller;
|
2026-10-13 01:52:34 +02:00
|
|
|
enabled = settings.enabled;
|
|
|
|
|
if (!enabled)
|
|
|
|
|
{
|
|
|
|
|
Serial.println("Home Assistant disabled in the settings");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-13 00:56:32 +02:00
|
|
|
|
|
|
|
|
device.setName(HA_DEVICE_NAME);
|
|
|
|
|
device.setSoftwareVersion(HA_SOFTWARE_VERSION);
|
|
|
|
|
|
|
|
|
|
powerSwitch.setName("FlipDot Display Power");
|
|
|
|
|
powerSwitch.setIcon("mdi:lightbulb");
|
|
|
|
|
powerSwitch.setCurrentState(controller.powerEnabled());
|
|
|
|
|
powerSwitch.onCommand(onPowerCommand);
|
|
|
|
|
|
2026-10-15 01:29:01 +02:00
|
|
|
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();
|
|
|
|
|
|
2026-10-15 21:01:14 +02:00
|
|
|
stopwatchSwitch.setName("FlipDot Display Stopwatch");
|
|
|
|
|
stopwatchSwitch.setIcon("mdi:timer-outline");
|
|
|
|
|
stopwatchSwitch.setCurrentState(stopwatch.running());
|
|
|
|
|
stopwatchSwitch.onCommand([](bool state, HASwitch *sender) {
|
|
|
|
|
appStopwatch(state ? StopwatchAction::Start : StopwatchAction::Stop);
|
|
|
|
|
sender->setState(stopwatch.running());
|
|
|
|
|
});
|
|
|
|
|
stopwatchReset.setName("FlipDot Display Stopwatch reset");
|
|
|
|
|
stopwatchReset.setIcon("mdi:timer-refresh-outline");
|
|
|
|
|
stopwatchReset.onCommand([](HAButton *) { appStopwatch(StopwatchAction::Reset); });
|
|
|
|
|
|
2026-10-13 01:21:04 +02:00
|
|
|
recoveryButton.setName("FlipDot Display Recovery");
|
|
|
|
|
recoveryButton.setIcon("mdi:lifebuoy");
|
|
|
|
|
recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); });
|
|
|
|
|
|
2026-10-13 01:05:06 +02:00
|
|
|
firmwareSensor.setName("FlipDot Display Firmware");
|
|
|
|
|
firmwareSensor.setIcon("mdi:chip");
|
2026-10-15 01:29:01 +02:00
|
|
|
|
|
|
|
|
ipSensor.setName("FlipDot Display IP");
|
|
|
|
|
ipSensor.setIcon("mdi:ip-network");
|
|
|
|
|
|
|
|
|
|
mqtt.onConnected([]() {
|
|
|
|
|
firmware.sent = false;
|
|
|
|
|
ip.sent = false;
|
|
|
|
|
clearRemovedEntities();
|
|
|
|
|
});
|
2026-10-13 01:05:06 +02:00
|
|
|
|
2026-10-13 01:52:34 +02:00
|
|
|
// settings must stay alive: the MQTT library keeps the pointers
|
|
|
|
|
if (settings.host[0])
|
|
|
|
|
mqtt.begin(settings.host, settings.port, settings.user[0] ? settings.user : nullptr,
|
|
|
|
|
settings.password[0] ? settings.password : nullptr);
|
|
|
|
|
else
|
|
|
|
|
mqtt.begin(BROKER_ADDR, settings.port, MQTT_USER, MQTT_PASSWORD); // secrets.h
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool homeAssistantConnected()
|
|
|
|
|
{
|
|
|
|
|
return enabled && mqtt.isConnected();
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void homeAssistantLoop()
|
|
|
|
|
{
|
2026-10-13 01:52:34 +02:00
|
|
|
if (!enabled)
|
|
|
|
|
return;
|
2026-10-13 00:56:32 +02:00
|
|
|
mqtt.loop();
|
2026-10-15 01:29:01 +02:00
|
|
|
|
|
|
|
|
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();
|
2026-10-15 21:01:14 +02:00
|
|
|
// Started or stopped in the web UI
|
|
|
|
|
if (stopwatchSwitch.getCurrentState() != stopwatch.running())
|
|
|
|
|
stopwatchSwitch.setState(stopwatch.running());
|
2026-10-13 01:05:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void homeAssistantSetFirmwareStatus(const char *status)
|
|
|
|
|
{
|
2026-10-15 01:29:01 +02:00
|
|
|
firmware.set(status);
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|