#include "net/home_assistant.h" #include #include #include #include #include #include #include #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 bitmapNames() { std::vector 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 pageNames() { std::vector 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 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 (*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 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 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); }