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"
|
|
|
|
|
#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-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 01:29:01 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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-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-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
|
|
|
}
|