Files
Flipdot-display/src/esp32/net/home_assistant.cpp
T
mangavalkandClaude Opus 5.5 93b9bec69a Add a page schedule with rotation, Home Assistant trigger rules and a Stopwatch tab
- Schedule: daily blocks from a start time, each with up to 6 rotating pages
  or bitmaps (own duration), switching with the effect or instantly. A manual
  pick (Show tab, HA Mode) lasts until the next block; Back to schedule ends it.
- Rules: an HA state (=, is not, above, below) shows a page while true, adds it
  to the rotation while true, or shows it for N seconds when it becomes true.
  Each rule is polled at its own interval, with a 60 s back-off on errors.
- PageSelector (pure logic, tested) decides the shown page; schedule stored in
  NVS; HA sensor Shown page and button Back to schedule.
- Web UI: Schedule and Stopwatch tabs, bitmaps used by pages or rules in their
  own subsection, schedule in export/import. Version 2.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:36:56 +02:00

298 lines
9.2 KiB
C++

#include "net/home_assistant.h"
#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 "display/page_resources.h"
#include "display/page_store.h"
#include "net/ha_page_select.h"
#include "system/boot_target.h"
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");
static HAPageSelect modeSelect("FlipDotDisplayMode");
static HAPageSelect offSelect("FlipDotDisplayOffImage");
static HASensor firmwareSensor("FlipDotDisplayFirmware");
static HASensor ipSensor("FlipDotDisplayIP");
static HAButton recoveryButton("FlipDotDisplayRecovery");
static HASwitch stopwatchSwitch("FlipDotDisplayStopwatch");
static HAButton stopwatchReset("FlipDotDisplayStopwatchReset");
static HASensor shownSensor("FlipDotDisplayShownPage");
static HAButton backButton("FlipDotDisplayBackToSchedule");
// 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 TextSensor shownPage = {shownSensor, "", 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 onPowerCommand(bool state, HASwitch *sender)
{
displayController->setPowerEnabled(state);
sender->setState(state);
}
static bool lessName(const String &a, const String &b)
{
return strcasecmp(a.c_str(), b.c_str()) < 0;
}
// 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;
for (const BitmapInfo &b : listBitmaps())
(b.builtin ? names : uploaded).push_back(b.name);
std::sort(uploaded.begin(), uploaded.end(), lessName);
names.insert(names.end(), uploaded.begin(), uploaded.end());
return names;
}
// 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
{
HAPageSelect &select;
const char *fixed_option; // shown in Home Assistant; nullptr: no fixed option
const char *fixed_value; // stored in the settings
const char *fallback; // shown when the setting's name is gone (what the display shows then)
std::vector<String> (*names)();
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();
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;
}
// Rebuilds the options when bitmaps or pages were added or deleted and reports the setting
void update()
{
if (!options_loaded || options_revision != listRevision())
{
options_loaded = true;
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());
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(fixed_option && 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
}
};
// "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};
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;
enabled = settings.enabled;
if (!enabled)
{
Serial.println("Home Assistant disabled in the settings");
return;
}
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);
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();
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); });
recoveryButton.setName("FlipDot Display Recovery");
recoveryButton.setIcon("mdi:lifebuoy");
recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); });
shownSensor.setName("FlipDot Display Shown page");
shownSensor.setIcon("mdi:monitor-eye");
backButton.setName("FlipDot Display Back to schedule");
backButton.setIcon("mdi:calendar-clock");
backButton.onCommand([](HAButton *) { appBackToSchedule(); });
firmwareSensor.setName("FlipDot Display Firmware");
firmwareSensor.setIcon("mdi:chip");
ipSensor.setName("FlipDot Display IP");
ipSensor.setIcon("mdi:ip-network");
mqtt.onConnected([]() {
firmware.sent = false;
shownPage.sent = false;
ip.sent = false;
clearRemovedEntities();
});
// 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();
}
void homeAssistantLoop()
{
if (!enabled)
return;
mqtt.loop();
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();
shownPage.set(appShownPage());
shownPage.loop();
firmware.loop();
mode.update();
offImage.update();
// Started or stopped in the web UI
if (stopwatchSwitch.getCurrentState() != stopwatch.running())
stopwatchSwitch.setState(stopwatch.running());
}
void homeAssistantSetFirmwareStatus(const char *status)
{
firmware.set(status);
}