From 02816345963714865845fc599a93b58bfcdd9665 Mon Sep 17 00:00:00 2001 From: Oliver Barnwell Date: Mon, 16 Jun 2025 15:12:44 +0100 Subject: [PATCH 1/7] Change UK esp32-s3 supplier link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fd942e8..978584d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ DeskHog packs an Adafruit ESP32-S3 Reverse TFT Feather in a custom-made 3D print A [plug-and-play DeskHog hardware kit](https://posthog.com/merch) is coming soon (so you can get everything in one box). In the meantime, here’s what you’ll need if you want to make DeskHog yourself right now. -- ESP32-S3 Reverse TFT Feather – [`Adafruit 5691`](https://learn.adafruit.com/esp32-s3-reverse-tft-feather) – buy: [Adafruit](https://www.adafruit.com/product/5691), [DigiKey](https://www.digikey.com/en/products/detail/adafruit-industries-llc/5691/18627502?s=N4IgjCBcoLQBxVAYygMwIYBsDOBTANCAPZQDa4ArAEwIC6AvvYVWSBQGwCcEDQA), [Mouser](https://www.mouser.com/ProductDetail/Adafruit/5691?qs=mELouGlnn3eeALy2e3r3sw%3D%3D), [Botland (EU)](https://botland.store/arduino-compatible-boards-adafruit/22891-feather-esp32-s3-reverse-with-tft-display-wifi-module-4mb-flash-2mb-psram-compatible-with-arduino-adafruit-5691.html), [Cool Components (UK)](https://coolcomponents.co.uk/products/esp32-s2-reverse-tft-feather?_pos=1&_psq=reverse+tft+fea&_ss=e&_v=1.0), [Electromaker](https://www.electromaker.io/shop/product/adafruit-esp32-s3-reverse-tft-feather-4mb-flash-2mb-psram-stemma-qt?srsltid=AfmBOorFfshQCLVi9EDKfmKFKMNC_cE3Ww0NaY0U0evm5ZU2OEM2Yn_B), [BerryBase (EU)](https://www.berrybase.de/en/adafruit-esp32-s3-reverse-tft-feather) +- ESP32-S3 Reverse TFT Feather – [`Adafruit 5691`](https://learn.adafruit.com/esp32-s3-reverse-tft-feather) – buy: [Adafruit](https://www.adafruit.com/product/5691), [DigiKey](https://www.digikey.com/en/products/detail/adafruit-industries-llc/5691/18627502?s=N4IgjCBcoLQBxVAYygMwIYBsDOBTANCAPZQDa4ArAEwIC6AvvYVWSBQGwCcEDQA), [Mouser](https://www.mouser.com/ProductDetail/Adafruit/5691?qs=mELouGlnn3eeALy2e3r3sw%3D%3D), [Botland (EU)](https://botland.store/arduino-compatible-boards-adafruit/22891-feather-esp32-s3-reverse-with-tft-display-wifi-module-4mb-flash-2mb-psram-compatible-with-arduino-adafruit-5691.html), [The Pi Hut (UK)](https://thepihut.com/products/adafruit-esp32-s3-reverse-tft-feather-4mb-flash-2mb-psram-stemma-qt), [Electromaker](https://www.electromaker.io/shop/product/adafruit-esp32-s3-reverse-tft-feather-4mb-flash-2mb-psram-stemma-qt?srsltid=AfmBOorFfshQCLVi9EDKfmKFKMNC_cE3Ww0NaY0U0evm5ZU2OEM2Yn_B), [BerryBase (EU)](https://www.berrybase.de/en/adafruit-esp32-s3-reverse-tft-feather) - Optional: PKCell 552035 350mAh 3.7V LiPoly battery – buy: [Adafruit](https://www.adafruit.com/product/2750), [Tinytronics (EU)](https://www.tinytronics.nl/en/power/batteries/li-po/pkcell-li-po-battery-3.7v-350mah-jst-ph-lp552035), [BerryBase (EU)](https://www.berrybase.de/en/lp-552035-lithium-polymer-lipo-akku-3-7v-350mah-mit-2-pin-jst-stecker) - 3D printed enclosure – print: [3mf file](3d-printing) From a54220fa251111675c6bc3f8bb025d57c373f3f0 Mon Sep 17 00:00:00 2001 From: Danilo Campos Date: Tue, 17 Jun 2025 11:19:06 -0400 Subject: [PATCH 2/7] Rename Enclosure*.step to Enclosure.step --- 3d-printing/cad/{Enclosure*.step => Enclosure.step} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename 3d-printing/cad/{Enclosure*.step => Enclosure.step} (100%) diff --git a/3d-printing/cad/Enclosure*.step b/3d-printing/cad/Enclosure.step similarity index 100% rename from 3d-printing/cad/Enclosure*.step rename to 3d-printing/cad/Enclosure.step From ab0e38f9c842aa0eb1cf676af48331a06bea57d5 Mon Sep 17 00:00:00 2001 From: Tim Broddin Date: Thu, 19 Jun 2025 09:05:15 +0200 Subject: [PATCH 3/7] feat: Enhance Home Assistant integration with new card type and configuration options - Increased maximum API key length for Home Assistant from 64 to 256 characters. - Added new card type for Home Assistant entities in CardConfig and CardController. - Implemented Home Assistant client processing in the main application loop. - Created configuration endpoints for Home Assistant URL and API key in CaptivePortal. - Removed example files for Home Assistant client and card due to redundancy. - Improved logging for Home Assistant card events and updates. - Updated HomeAssistantCard to handle entity state updates and display appropriately. --- include/EventQueue.h | 4 +- src/ConfigManager.cpp | 45 +++ src/ConfigManager.h | 40 ++ src/homeassistant/HomeAssistantClient.cpp | 249 +++++++++++++ src/homeassistant/HomeAssistantClient.h | 152 ++++++++ .../HomeAssistantClient_example.cpp | 100 +++++ src/homeassistant/HomeAssistantParser.cpp | 192 ++++++++++ src/homeassistant/HomeAssistantParser.h | 137 +++++++ src/homeassistant/README.md | 348 ++++++++++++++++++ src/main.cpp | 1 + src/ui/CardController.cpp | 53 ++- src/ui/CardController.h | 27 +- src/ui/HomeAssistantCard.cpp | 297 +++++++++++++++ src/ui/HomeAssistantCard.h | 142 +++++++ src/ui/HomeAssistantCard_example.cpp | 129 +++++++ 15 files changed, 1909 insertions(+), 7 deletions(-) create mode 100644 src/homeassistant/HomeAssistantClient.cpp create mode 100644 src/homeassistant/HomeAssistantClient.h create mode 100644 src/homeassistant/HomeAssistantClient_example.cpp create mode 100644 src/homeassistant/HomeAssistantParser.cpp create mode 100644 src/homeassistant/HomeAssistantParser.h create mode 100644 src/homeassistant/README.md create mode 100644 src/ui/HomeAssistantCard.cpp create mode 100644 src/ui/HomeAssistantCard.h create mode 100644 src/ui/HomeAssistantCard_example.cpp diff --git a/include/EventQueue.h b/include/EventQueue.h index 818f753..fe05115 100644 --- a/include/EventQueue.h +++ b/include/EventQueue.h @@ -17,6 +17,8 @@ enum class EventType { INSIGHT_ADDED, INSIGHT_DELETED, INSIGHT_DATA_RECEIVED, + HA_ENTITY_STATE_RECEIVED, + HA_SERVICE_CALLED, WIFI_CREDENTIALS_FOUND, NEED_WIFI_CREDENTIALS, WIFI_CONNECTING, @@ -138,4 +140,4 @@ class EventQueue { * @brief Stop the event processing task */ void end(); -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/ConfigManager.cpp b/src/ConfigManager.cpp index de7f78c..c06510f 100644 --- a/src/ConfigManager.cpp +++ b/src/ConfigManager.cpp @@ -324,4 +324,49 @@ bool ConfigManager::saveCardConfigs(const std::vector& configs) { } return true; +} + +// Home Assistant Configuration Methods +bool ConfigManager::setHomeAssistantUrl(const String& url) { + if (url.length() > MAX_HA_URL_LENGTH) { + Serial.printf("Home Assistant URL too long: %u > %u\n", url.length(), MAX_HA_URL_LENGTH); + return false; + } + + _preferences.putString(_haUrlKey, url); + + // Commit changes + commit(); + + return true; +} + +String ConfigManager::getHomeAssistantUrl() { + return _preferences.getString(_haUrlKey, ""); +} + +bool ConfigManager::setHomeAssistantApiKey(const String& apiKey) { + if (apiKey.length() > MAX_HA_API_KEY_LENGTH) { + Serial.printf("Home Assistant API key too long: %u > %u\n", apiKey.length(), MAX_HA_API_KEY_LENGTH); + return false; + } + + _preferences.putString(_haApiKeyKey, apiKey); + + // Commit changes + commit(); + + return true; +} + +String ConfigManager::getHomeAssistantApiKey() { + return _preferences.getString(_haApiKeyKey, ""); +} + +void ConfigManager::clearHomeAssistantConfig() { + _preferences.remove(_haUrlKey); + _preferences.remove(_haApiKeyKey); + + // Commit changes + commit(); } \ No newline at end of file diff --git a/src/ConfigManager.h b/src/ConfigManager.h index 31e3c83..07d7892 100644 --- a/src/ConfigManager.h +++ b/src/ConfigManager.h @@ -114,6 +114,38 @@ class ConfigManager { */ void clearApiKey(); + // Home Assistant Configuration + /** + * @brief Store Home Assistant URL + * @param url The Home Assistant URL to store + * @return true if saved successfully, false otherwise + */ + bool setHomeAssistantUrl(const String& url); + + /** + * @brief Retrieve stored Home Assistant URL + * @return The Home Assistant URL or empty string if not set + */ + String getHomeAssistantUrl(); + + /** + * @brief Store Home Assistant API key + * @param apiKey The Home Assistant API key to store + * @return true if saved successfully, false otherwise + */ + bool setHomeAssistantApiKey(const String& apiKey); + + /** + * @brief Retrieve stored Home Assistant API key + * @return The Home Assistant API key or empty string if not set + */ + String getHomeAssistantApiKey(); + + /** + * @brief Remove stored Home Assistant configuration + */ + void clearHomeAssistantConfig(); + /** * @brief Store insight configuration * @param id Unique insight identifier @@ -205,6 +237,10 @@ class ConfigManager { const char* _teamIdKey = "team_id"; ///< Key for stored team ID const char* _apiKeyKey = "api_key"; ///< Key for stored API key + // Storage keys for Home Assistant configuration + const char* _haUrlKey = "ha_url"; ///< Key for stored Home Assistant URL + const char* _haApiKeyKey = "ha_api_key"; ///< Key for stored Home Assistant API key + // Storage size limits /** @brief Maximum length for WiFi SSID (per IEEE 802.11 spec) */ static const size_t MAX_SSID_LENGTH = 32; @@ -214,6 +250,10 @@ class ConfigManager { static const size_t MAX_INSIGHT_LENGTH = 1024; /** @brief Maximum length for API key */ static const size_t MAX_API_KEY_LENGTH = 64; + /** @brief Maximum length for Home Assistant URL */ + static const size_t MAX_HA_URL_LENGTH = 256; + /** @brief Maximum length for Home Assistant API key */ + static const size_t MAX_HA_API_KEY_LENGTH = 64; /** @brief Maximum length for insight identifier */ static const size_t MAX_INSIGHT_ID_LENGTH = 64; diff --git a/src/homeassistant/HomeAssistantClient.cpp b/src/homeassistant/HomeAssistantClient.cpp new file mode 100644 index 0000000..aec9df4 --- /dev/null +++ b/src/homeassistant/HomeAssistantClient.cpp @@ -0,0 +1,249 @@ +#include "HomeAssistantClient.h" +#include "../ConfigManager.h" + +HomeAssistantClient::HomeAssistantClient(ConfigManager& config, EventQueue& eventQueue) + : _config(config) + , _eventQueue(eventQueue) + , has_active_request(false) + , last_refresh_check(0) { + _http.setReuse(true); +} + +void HomeAssistantClient::requestEntityState(const String& entity_id) { + // Add to queue for immediate fetch + QueuedRequest request = { + .entity_id = entity_id, + .retry_count = 0 + }; + request_queue.push(request); + + // Add to our set of known entities for future refreshes + requested_entities.insert(entity_id); +} + +bool HomeAssistantClient::callService(const String& domain, const String& service, const String& entity_id, const String& data) { + if (!isReady() || WiFi.status() != WL_CONNECTED) { + return false; + } + + String url = buildServiceUrl(domain, service); + String payload = "{\"entity_id\":\"" + entity_id + "\""; + + if (!data.isEmpty()) { + // Remove the closing brace and add the data + payload = payload.substring(0, payload.length() - 1); + payload += "," + data + "}"; + } else { + payload += "}"; + } + + _http.begin(url); + _http.addHeader("Content-Type", "application/json"); + _http.addHeader("Authorization", "Bearer " + _config.getHomeAssistantApiKey()); + + int httpCode = _http.POST(payload); + bool success = (httpCode == HTTP_CODE_OK || httpCode == 200); + + if (!success) { + Serial.printf("Service call failed: %s.%s for %s, HTTP: %d\n", + domain.c_str(), service.c_str(), entity_id.c_str(), httpCode); + } else { + Serial.printf("Service call successful: %s.%s for %s\n", + domain.c_str(), service.c_str(), entity_id.c_str()); + } + + _http.end(); + return success; +} + +bool HomeAssistantClient::isReady() const { + return SystemController::isSystemFullyReady() && + !_config.getHomeAssistantUrl().isEmpty() && + !_config.getHomeAssistantApiKey().isEmpty(); +} + +void HomeAssistantClient::process() { + if (!isReady()) { + return; + } + + // Process any queued requests + if (!has_active_request) { + processQueue(); + } + + // Check for needed refreshes + if (!has_active_request) { + unsigned long now = millis(); + if (now - last_refresh_check >= REFRESH_INTERVAL) { + last_refresh_check = now; + checkRefreshes(); + } + } +} + +void HomeAssistantClient::onSystemStateChange(SystemState state) { + if (!SystemController::isSystemFullyReady() == false) { + // Clear any active request when system becomes not ready + has_active_request = false; + } +} + +void HomeAssistantClient::processQueue() { + if (request_queue.empty()) { + return; + } + + QueuedRequest request = request_queue.front(); + String response; + + if (fetchEntityState(request.entity_id, response)) { + // Publish to the event system + publishEntityStateEvent(request.entity_id, response); + request_queue.pop(); + } else { + // Handle failure - retry if under max attempts + if (request.retry_count < MAX_RETRIES) { + // Update retry count and push back to end of queue + request.retry_count++; + Serial.printf("Request for entity %s failed, retrying (%d/%d)...\n", + request.entity_id.c_str(), request.retry_count, MAX_RETRIES); + + // Remove from front and add to back with incremented retry count + request_queue.pop(); + request_queue.push(request); + + // Add delay before next attempt + delay(RETRY_DELAY); + } else { + // Max retries reached, drop request + Serial.printf("Max retries reached for entity %s, dropping request\n", + request.entity_id.c_str()); + request_queue.pop(); + } + } +} + +void HomeAssistantClient::checkRefreshes() { + if (requested_entities.empty()) { + return; + } + + // Pick one entity to refresh + String refresh_id; + + // This cycles through entities in a round-robin fashion since sets are ordered + static auto it = requested_entities.begin(); + if (it == requested_entities.end()) { + it = requested_entities.begin(); + } + + if (it != requested_entities.end()) { + refresh_id = *it; + ++it; + } else { + // Reset if we're at the end + it = requested_entities.begin(); + if (it != requested_entities.end()) { + refresh_id = *it; + ++it; + } + } + + if (!refresh_id.isEmpty()) { + String response; + if (fetchEntityState(refresh_id, response)) { + // Publish to the event system + publishEntityStateEvent(refresh_id, response); + } + } +} + +String HomeAssistantClient::buildEntityStateUrl(const String& entity_id) const { + String url = _config.getHomeAssistantUrl(); + if (!url.endsWith("/")) { + url += "/"; + } + url += "api/states/"; + url += entity_id; + return url; +} + +String HomeAssistantClient::buildServiceUrl(const String& domain, const String& service) const { + String url = _config.getHomeAssistantUrl(); + if (!url.endsWith("/")) { + url += "/"; + } + url += "api/services/"; + url += domain; + url += "/"; + url += service; + return url; +} + +bool HomeAssistantClient::fetchEntityState(const String& entity_id, String& response) { + if (!isReady() || WiFi.status() != WL_CONNECTED) { + return false; + } + + unsigned long start_time = millis(); + has_active_request = true; + + String url = buildEntityStateUrl(entity_id); + + _http.begin(url); + _http.addHeader("Authorization", "Bearer " + _config.getHomeAssistantApiKey()); + _http.addHeader("Content-Type", "application/json"); + + int httpCode = _http.GET(); + + bool success = false; + + if (httpCode == HTTP_CODE_OK) { + unsigned long network_time = millis() - start_time; + Serial.printf("Network fetch time for %s: %lu ms\n", entity_id.c_str(), network_time); + + start_time = millis(); + + // Get content length for allocation + size_t contentLength = _http.getSize(); + + // Pre-allocate in PSRAM if content is large + if (contentLength > 8192) { // 8KB threshold + // Force allocation in PSRAM for large responses + response = String(); + response.reserve(contentLength); + Serial.printf("Pre-allocated %u bytes in PSRAM for large response\n", contentLength); + } + + response = _http.getString(); + unsigned long string_time = millis() - start_time; + Serial.printf("Response processing time: %lu ms (size: %u bytes)\n", string_time, response.length()); + + success = true; + } else { + // Handle HTTP errors + Serial.print("HTTP GET failed for entity "); + Serial.print(entity_id); + Serial.print(", error: "); + Serial.println(httpCode); + } + + _http.end(); + has_active_request = false; + return success; +} + +void HomeAssistantClient::publishEntityStateEvent(const String& entity_id, const String& response) { + // Check if response is empty or invalid + if (response.length() == 0) { + Serial.printf("Empty response for entity %s\n", entity_id.c_str()); + return; + } + + // Publish the event with the raw JSON response + _eventQueue.publishEvent(EventType::HA_ENTITY_STATE_RECEIVED, entity_id, response); + + // Log for debugging + Serial.printf("Published entity state data for %s\n", entity_id.c_str()); +} diff --git a/src/homeassistant/HomeAssistantClient.h b/src/homeassistant/HomeAssistantClient.h new file mode 100644 index 0000000..bb7a8fe --- /dev/null +++ b/src/homeassistant/HomeAssistantClient.h @@ -0,0 +1,152 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "../ConfigManager.h" +#include "SystemController.h" +#include "EventQueue.h" + +/** + * @class HomeAssistantClient + * @brief Client for fetching Home Assistant data + * + * Features: + * - Queued entity state requests with retry logic + * - Automatic refresh of entity states + * - Thread-safe operation with event queue + * - Configurable retry and refresh intervals + * - Support for multiple entity types + */ +class HomeAssistantClient { +public: + /** + * @brief Constructor + * + * @param config Reference to configuration manager + * @param eventQueue Reference to event system + */ + explicit HomeAssistantClient(ConfigManager& config, EventQueue& eventQueue); + + // Delete copy constructor and assignment operator + HomeAssistantClient(const HomeAssistantClient&) = delete; + void operator=(const HomeAssistantClient&) = delete; + + /** + * @brief Queue an entity for immediate state fetch + * + * @param entity_id ID of entity to fetch (e.g., "sensor.temperature") + * + * Adds entity to request queue with retry count of 0. + * Will be processed in FIFO order. + */ + void requestEntityState(const String& entity_id); + + /** + * @brief Call a Home Assistant service + * + * @param domain Service domain (e.g., "light", "switch") + * @param service Service name (e.g., "turn_on", "turn_off") + * @param entity_id Entity to call service on + * @param data Optional service data (JSON) + * @return true if service call was successful + */ + bool callService(const String& domain, const String& service, const String& entity_id, const String& data = ""); + + /** + * @brief Check if client is ready for operation + * + * @return true if configured and connected + */ + bool isReady() const; + + /** + * @brief Process queued requests and refreshes + * + * Should be called regularly in main loop. + * Handles: + * - Processing queued requests + * - Retrying failed requests + * - Refreshing existing entity states + */ + void process(); + +private: + /** + * @struct QueuedRequest + * @brief Tracks a queued entity state request + */ + struct QueuedRequest { + String entity_id; ///< ID of entity to fetch + uint8_t retry_count; ///< Number of retry attempts + }; + + // Configuration + ConfigManager& _config; ///< Configuration storage + EventQueue& _eventQueue; ///< Event system + + // Request tracking + std::set requested_entities; ///< All known entity IDs + std::queue request_queue; ///< Queue of pending requests + bool has_active_request; ///< Request in progress flag + HTTPClient _http; ///< HTTP client instance + unsigned long last_refresh_check; ///< Last refresh timestamp + + // Constants + static const unsigned long REFRESH_INTERVAL = 30000; ///< Refresh every 30s + static const uint8_t MAX_RETRIES = 3; ///< Max retry attempts + static const unsigned long RETRY_DELAY = 1000; ///< Delay between retries + + /** + * @brief Handle system state changes + * @param state New system state + */ + void onSystemStateChange(SystemState state); + + /** + * @brief Process pending requests in queue + * + * Handles retry logic and request timeouts. + */ + void processQueue(); + + /** + * @brief Check if entities need refreshing + * + * Queues refresh requests for entities older than REFRESH_INTERVAL. + */ + void checkRefreshes(); + + /** + * @brief Fetch entity state from Home Assistant + * + * @param entity_id ID of entity to fetch + * @param response String to store response + * @return true if fetch was successful + */ + bool fetchEntityState(const String& entity_id, String& response); + + /** + * @brief Build entity state API URL + * + * @param entity_id ID of entity + * @return Complete API URL + */ + String buildEntityStateUrl(const String& entity_id) const; + + /** + * @brief Build service call API URL + * + * @param domain Service domain + * @param service Service name + * @return Complete API URL + */ + String buildServiceUrl(const String& domain, const String& service) const; + + // Event-related methods + void publishEntityStateEvent(const String& entity_id, const String& response); +}; diff --git a/src/homeassistant/HomeAssistantClient_example.cpp b/src/homeassistant/HomeAssistantClient_example.cpp new file mode 100644 index 0000000..cb47676 --- /dev/null +++ b/src/homeassistant/HomeAssistantClient_example.cpp @@ -0,0 +1,100 @@ +/** + * HomeAssistantClient Usage Example + * ================================= + * + * This example demonstrates how to use the HomeAssistantClient to: + * 1. Fetch entity states from Home Assistant + * 2. Call Home Assistant services + * 3. Handle configuration + * + * To integrate into main.cpp, add similar code patterns. + */ + +#include "homeassistant/HomeAssistantClient.h" +#include "ConfigManager.h" +#include "EventQueue.h" + +// Global instances (similar to PostHogClient pattern in main.cpp) +HomeAssistantClient* homeAssistantClient; +ConfigManager* configManager; +EventQueue* eventQueue; + +void setupHomeAssistant() { + // Configure Home Assistant connection + configManager->setHomeAssistantUrl("http://homeassistant.local:8123"); + configManager->setHomeAssistantApiKey("your_long_lived_access_token_here"); + + // Create the client instance + homeAssistantClient = new HomeAssistantClient(*configManager, *eventQueue); +} + +void homeAssistantLoop() { + // Call this in your main loop (similar to posthogClient->process()) + if (homeAssistantClient) { + homeAssistantClient->process(); + } +} + +void requestSensorData() { + // Request entity states + homeAssistantClient->requestEntityState("sensor.outdoor_temperature"); + homeAssistantClient->requestEntityState("sensor.indoor_humidity"); + homeAssistantClient->requestEntityState("light.living_room"); + homeAssistantClient->requestEntityState("switch.desk_lamp"); +} + +void controlDevices() { + // Turn on a light + homeAssistantClient->callService("light", "turn_on", "light.living_room", + "\"brightness\":255,\"color_name\":\"blue\""); + + // Turn off a switch + homeAssistantClient->callService("switch", "turn_off", "switch.desk_lamp"); + + // Set thermostat temperature + homeAssistantClient->callService("climate", "set_temperature", "climate.living_room", + "\"temperature\":22.5"); +} + +// Event handler example (add to your event handling system) +void handleHomeAssistantEvent(const Event& event) { + if (event.type == EventType::HA_ENTITY_STATE_RECEIVED) { + Serial.printf("Received state for entity %s: %s\n", + event.insightId.c_str(), event.jsonData.c_str()); + + // Parse the JSON response to extract state information + // Example JSON response: + // { + // "entity_id": "sensor.outdoor_temperature", + // "state": "23.5", + // "attributes": { + // "unit_of_measurement": "°C", + // "friendly_name": "Outdoor Temperature" + // } + // } + } +} + +/** + * Integration into main.cpp: + * + * 1. Add include: #include "homeassistant/HomeAssistantClient.h" + * + * 2. Add global variable: HomeAssistantClient* homeAssistantClient; + * + * 3. In setup(), add: + * homeAssistantClient = new HomeAssistantClient(*configManager, *eventQueue); + * + * 4. In the main loop task, add: + * homeAssistantClient->process(); + * + * 5. Configure through web portal or programmatically: + * configManager->setHomeAssistantUrl("http://your-ha-instance:8123"); + * configManager->setHomeAssistantApiKey("your_token"); + * + * 6. Request entity states: + * homeAssistantClient->requestEntityState("sensor.temperature"); + * + * 7. Call services: + * homeAssistantClient->callService("light", "turn_on", "light.bedroom"); + */ diff --git a/src/homeassistant/HomeAssistantParser.cpp b/src/homeassistant/HomeAssistantParser.cpp new file mode 100644 index 0000000..c4ed9f0 --- /dev/null +++ b/src/homeassistant/HomeAssistantParser.cpp @@ -0,0 +1,192 @@ +#include "HomeAssistantParser.h" +#include +#include + +HomeAssistantParser::HomeAssistantParser(const char* json) + : _doc(1024) // Start with 1KB, will auto-expand if needed + , _valid(false) { + + if (!json || strlen(json) == 0) { + Serial.println("[HomeAssistantParser] Empty or null JSON provided"); + return; + } + + DeserializationError error = deserializeJson(_doc, json); + if (error) { + Serial.printf("[HomeAssistantParser] JSON parse error: %s\n", error.c_str()); + return; + } + + // Basic validation - check for required fields + if (!_doc["entity_id"].is() || !_doc["state"]) { + Serial.println("[HomeAssistantParser] Missing required fields: entity_id or state"); + return; + } + + _valid = true; +} + +bool HomeAssistantParser::isValid() const { + return _valid; +} + +HomeAssistantParser::EntityType HomeAssistantParser::getEntityType() const { + if (!_valid) return EntityType::ENTITY_NOT_SUPPORTED; + + const char* entity_id = _doc["entity_id"]; + if (!entity_id) return EntityType::ENTITY_NOT_SUPPORTED; + + // Determine type based on entity_id prefix + if (strncmp(entity_id, "sensor.", 7) == 0) { + // Check if it's a binary sensor by looking at device_class or state + const char* device_class = _doc["attributes"]["device_class"]; + const char* state = _doc["state"]; + + if (state && (strcmp(state, "on") == 0 || strcmp(state, "off") == 0 || + strcmp(state, "true") == 0 || strcmp(state, "false") == 0)) { + return EntityType::BINARY_SENSOR; + } + + if (device_class && (strcmp(device_class, "motion") == 0 || + strcmp(device_class, "door") == 0 || + strcmp(device_class, "window") == 0)) { + return EntityType::BINARY_SENSOR; + } + + return EntityType::NUMERIC_SENSOR; + } + else if (strncmp(entity_id, "binary_sensor.", 14) == 0) { + return EntityType::BINARY_SENSOR; + } + else if (strncmp(entity_id, "switch.", 7) == 0) { + return EntityType::SWITCH; + } + else if (strncmp(entity_id, "light.", 6) == 0) { + return EntityType::LIGHT; + } + + return EntityType::ENTITY_NOT_SUPPORTED; +} + +bool HomeAssistantParser::getEntityId(char* buffer, size_t bufferSize) const { + if (!_valid || !buffer || bufferSize == 0) return false; + + const char* entity_id = _doc["entity_id"]; + if (!entity_id) return false; + + return copyJsonString(entity_id, buffer, bufferSize); +} + +bool HomeAssistantParser::getFriendlyName(char* buffer, size_t bufferSize) const { + if (!_valid || !buffer || bufferSize == 0) return false; + + const char* friendly_name = _doc["attributes"]["friendly_name"]; + if (!friendly_name) { + // Fallback to entity_id if no friendly name + return getEntityId(buffer, bufferSize); + } + + return copyJsonString(friendly_name, buffer, bufferSize); +} + +double HomeAssistantParser::getNumericState() const { + if (!_valid) return 0.0; + + // Try to get as number first + if (_doc["state"].is()) { + return _doc["state"].as(); + } + + // If it's a string, try to parse it + const char* state_str = _doc["state"]; + if (state_str) { + char* endptr; + double value = strtod(state_str, &endptr); + + // Check if the entire string was parsed (no trailing characters) + if (*endptr == '\0' || *endptr == ' ') { + return value; + } + } + + return 0.0; +} + +bool HomeAssistantParser::getStateString(char* buffer, size_t bufferSize) const { + if (!_valid || !buffer || bufferSize == 0) return false; + + const char* state = _doc["state"]; + if (!state) return false; + + return copyJsonString(state, buffer, bufferSize); +} + +bool HomeAssistantParser::getUnitOfMeasurement(char* buffer, size_t bufferSize) const { + if (!_valid || !buffer || bufferSize == 0) return false; + + const char* unit = _doc["attributes"]["unit_of_measurement"]; + if (!unit) { + buffer[0] = '\0'; // Empty string if no unit + return true; + } + + return copyJsonString(unit, buffer, bufferSize); +} + +bool HomeAssistantParser::getDeviceClass(char* buffer, size_t bufferSize) const { + if (!_valid || !buffer || bufferSize == 0) return false; + + const char* device_class = _doc["attributes"]["device_class"]; + if (!device_class) { + buffer[0] = '\0'; // Empty string if no device class + return true; + } + + return copyJsonString(device_class, buffer, bufferSize); +} + +bool HomeAssistantParser::isNumericState() const { + if (!_valid) return false; + + // Check if state is directly a number + if (_doc["state"].is()) { + return true; + } + + // Check if state string can be parsed as a number + const char* state_str = _doc["state"]; + if (state_str) { + char* endptr; + strtod(state_str, &endptr); + + // Return true if the entire string was parsed (no trailing non-numeric characters) + return (*endptr == '\0' || *endptr == ' '); + } + + return false; +} + +bool HomeAssistantParser::isAvailable() const { + if (!_valid) return false; + + const char* state = _doc["state"]; + if (!state) return false; + + return (strcmp(state, "unavailable") != 0 && + strcmp(state, "unknown") != 0); +} + +bool HomeAssistantParser::copyJsonString(const char* value, char* buffer, size_t bufferSize) const { + if (!value || !buffer || bufferSize == 0) return false; + + size_t len = strlen(value); + if (len >= bufferSize) { + // String too long, truncate + strncpy(buffer, value, bufferSize - 1); + buffer[bufferSize - 1] = '\0'; + return false; // Indicate truncation + } + + strcpy(buffer, value); + return true; +} diff --git a/src/homeassistant/HomeAssistantParser.h b/src/homeassistant/HomeAssistantParser.h new file mode 100644 index 0000000..f2f107d --- /dev/null +++ b/src/homeassistant/HomeAssistantParser.h @@ -0,0 +1,137 @@ +#pragma once + +#define ARDUINOJSON_DEFAULT_NESTING_LIMIT 20 +#include +#include + +/** + * @class HomeAssistantParser + * @brief Parser for Home Assistant entity state data + * + * Handles parsing and data extraction from Home Assistant entity state JSON responses. + * Supports numeric sensor data with unit extraction and state information. + * + * Example JSON structure: + * { + * "entity_id": "sensor.outdoor_temperature", + * "state": "23.5", + * "attributes": { + * "unit_of_measurement": "°C", + * "friendly_name": "Outdoor Temperature", + * "device_class": "temperature" + * }, + * "last_changed": "2025-06-19T10:30:00.000000+00:00", + * "last_updated": "2025-06-19T10:30:00.000000+00:00" + * } + */ +class HomeAssistantParser { +public: + /** + * @enum EntityType + * @brief Supported entity types for visualization + */ + enum class EntityType { + NUMERIC_SENSOR, ///< Numeric sensor (temperature, humidity, etc.) + BINARY_SENSOR, ///< Binary sensor (on/off, true/false) + SWITCH, ///< Switch entity (on/off state) + LIGHT, ///< Light entity (on/off with brightness) + ENTITY_NOT_SUPPORTED ///< Unsupported or unrecognized entity type + }; + + /** + * @brief Constructor - parses JSON data + * @param json Raw JSON string to parse + * + * Initializes parser with JSON data and attempts to parse entity state. + * Uses isValid() to check if parsing was successful. + */ + HomeAssistantParser(const char* json); + + /** + * @brief Destructor + */ + ~HomeAssistantParser() = default; + + /** + * @brief Check if JSON was parsed successfully + * @return true if valid, false otherwise + */ + bool isValid() const; + + /** + * @brief Get entity type based on entity_id and attributes + * @return EntityType enum value + */ + EntityType getEntityType() const; + + /** + * @brief Get entity ID + * @param buffer Buffer to store entity ID + * @param bufferSize Size of the buffer + * @return true if successful, false otherwise + */ + bool getEntityId(char* buffer, size_t bufferSize) const; + + /** + * @brief Get friendly name from attributes + * @param buffer Buffer to store friendly name + * @param bufferSize Size of the buffer + * @return true if successful, false otherwise + */ + bool getFriendlyName(char* buffer, size_t bufferSize) const; + + /** + * @brief Get numeric state value + * @return Numeric value if parseable, 0.0 if not numeric + */ + double getNumericState() const; + + /** + * @brief Get state as string + * @param buffer Buffer to store state string + * @param bufferSize Size of the buffer + * @return true if successful, false otherwise + */ + bool getStateString(char* buffer, size_t bufferSize) const; + + /** + * @brief Get unit of measurement from attributes + * @param buffer Buffer to store unit + * @param bufferSize Size of the buffer + * @return true if successful, false otherwise + */ + bool getUnitOfMeasurement(char* buffer, size_t bufferSize) const; + + /** + * @brief Get device class from attributes + * @param buffer Buffer to store device class + * @param bufferSize Size of the buffer + * @return true if successful, false otherwise + */ + bool getDeviceClass(char* buffer, size_t bufferSize) const; + + /** + * @brief Check if entity state is numeric + * @return true if state can be parsed as a number + */ + bool isNumericState() const; + + /** + * @brief Check if entity is available/online + * @return true if state is not "unavailable" or "unknown" + */ + bool isAvailable() const; + +private: + DynamicJsonDocument _doc; ///< JSON document for parsing + bool _valid; ///< Parsing success flag + + /** + * @brief Helper to safely copy string from JSON to buffer + * @param value JSON string value + * @param buffer Destination buffer + * @param bufferSize Size of destination buffer + * @return true if successful, false if truncated or failed + */ + bool copyJsonString(const char* value, char* buffer, size_t bufferSize) const; +}; diff --git a/src/homeassistant/README.md b/src/homeassistant/README.md new file mode 100644 index 0000000..8ba6af7 --- /dev/null +++ b/src/homeassistant/README.md @@ -0,0 +1,348 @@ +# Home Assistant Integration for DeskHog + +This integration allows DeskHog to display Home Assistant entity data alongside PostHog analytics, creating a unified dashboard for both data analytics and smart home monitoring. + +## 📁 Files Created + +### Core Components +- **`HomeAssistantClient.h/cpp`** - HTTP client for Home Assistant API integration +- **`HomeAssistantParser.h/cpp`** - JSON parser for Home Assistant entity state data +- **`HomeAssistantCard.h/cpp`** - UI card component for displaying entity data +- **`ConfigManager.h/cpp`** (extended) - Added Home Assistant URL and API key storage + +### Examples and Documentation +- **`HomeAssistantClient_example.cpp`** - Usage examples for the client +- **`HomeAssistantCard_example.cpp`** - Card creation and integration examples + +## 🏠 Supported Entity Types + +### Numeric Sensors +- **Temperature** sensors (°C, °F) +- **Humidity** sensors (%) +- **Power consumption** (W, kW) +- **Pressure** sensors (hPa, bar) +- **Any numeric sensor** with unit display + +### Binary Sensors +- **Motion** detectors (ON/OFF) +- **Door/Window** sensors (OPEN/CLOSED) +- **Occupancy** sensors (DETECTED/CLEAR) + +### Switches & Lights +- **Switch** entities (ON/OFF) +- **Light** entities (ON/OFF) + +## 🔧 Configuration + +### 1. Home Assistant Setup +```cpp +// Configure connection (typically done through web interface) +configManager->setHomeAssistantUrl("http://homeassistant.local:8123"); +configManager->setHomeAssistantApiKey("your_long_lived_access_token_here"); +``` + +### 2. Create Client Instance +```cpp +// In main.cpp or CardController setup +HomeAssistantClient* haClient = new HomeAssistantClient(*configManager, *eventQueue); +``` + +### 3. Create Entity Cards +```cpp +// Create cards for different entity types +cardController->createHomeAssistantCard("sensor.outdoor_temperature"); +cardController->createHomeAssistantCard("binary_sensor.front_door"); +cardController->createHomeAssistantCard("switch.desk_lamp"); +``` + +## 📊 Card Display Examples + +### Temperature Sensor +``` +┌─────────────────────────┐ +│ Outdoor Temperature │ +│ │ +│ 23.5 │ +│ °C │ +└─────────────────────────┘ +``` + +### Binary Sensor +``` +┌─────────────────────────┐ +│ Front Door │ +│ │ +│ CLOSED │ +│ │ +└─────────────────────────┘ +``` + +### Switch/Light +``` +┌─────────────────────────┐ +│ Desk Lamp │ +│ │ +│ ON │ +│ │ +└─────────────────────────┘ +``` + +## 🔄 Data Flow + +1. **Request Phase**: `homeAssistantClient->requestEntityState("sensor.temperature")` +2. **HTTP Request**: Client sends GET request to `/api/states/sensor.temperature` +3. **Response Parsing**: HomeAssistantParser extracts state, unit, friendly name +4. **Event Publishing**: `HA_ENTITY_STATE_RECEIVED` event published +5. **UI Update**: HomeAssistantCard receives event and updates display +6. **Auto Refresh**: Client automatically refreshes data every 30 seconds + +## 🎛️ API Integration + +### Entity State Endpoint +``` +GET /api/states/{entity_id} +Authorization: Bearer {long_lived_access_token} +``` + +**Response Example:** +```json +{ + "entity_id": "sensor.outdoor_temperature", + "state": "23.5", + "attributes": { + "unit_of_measurement": "°C", + "friendly_name": "Outdoor Temperature", + "device_class": "temperature" + }, + "last_changed": "2025-06-19T10:30:00.000000+00:00" +} +``` + +### Service Call Endpoint (Future Enhancement) +``` +POST /api/services/{domain}/{service} +Authorization: Bearer {long_lived_access_token} +Content-Type: application/json + +{"entity_id": "light.bedroom", "brightness": 255} +``` + +## 🔒 Authentication + +Uses Home Assistant **Long-Lived Access Tokens**: + +1. Go to Home Assistant → Profile → Long-Lived Access Tokens +2. Click "Create Token" +3. Copy the token and configure in DeskHog +4. Token provides read/write access to all entities + +## 🧵 Thread Safety + +- **UI Updates**: All LVGL operations dispatched to UI thread via `globalUIDispatch` +- **HTTP Requests**: Non-blocking with queue management +- **Memory Management**: PSRAM allocation for large responses +- **State Management**: Thread-safe event publishing and subscription + +## 🔧 Error Handling + +### Connection Issues +- Automatic retry logic with exponential backoff +- Queue management for failed requests +- Graceful degradation when Home Assistant unavailable + +### Entity States +- **Unavailable entities**: Display "Unavailable" message +- **Unknown states**: Display "Unknown" message +- **Parse errors**: Display "Data Error" message +- **Non-numeric sensors**: Display raw state string + +## 🚀 Integration with DeskHog + +### Event System +- Uses existing `EventQueue` for state change notifications +- New event types: `HA_ENTITY_STATE_RECEIVED`, `HA_SERVICE_CALLED` +- Compatible with existing event handling architecture + +### Card Management +- Managed by `CardController` alongside PostHog insight cards +- Same navigation system (button inputs for card switching) +- Unified styling using `Style.h` fonts and colors + +### Configuration Storage +- Persistent storage in ESP32 NVS (Non-Volatile Storage) +- Configuration via web interface (same as PostHog setup) +- Validation and size limits for stored values + +## 🎯 Usage Scenarios + +### Home Office Dashboard +- **Temperature/Humidity**: Monitor office environment +- **Power Consumption**: Track energy usage +- **Security**: Door/window sensors, motion detection +- **Lighting**: Quick status of office lighting + +### Smart Home Monitor +- **HVAC Status**: Temperature, humidity, thermostat states +- **Security System**: Armed/disarmed, sensor states +- **Energy Monitoring**: Solar production, grid consumption +- **Device Status**: Critical switches and lights + +## 🔮 Future Enhancements + +### Service Control +- Touch interface for controlling switches/lights +- Brightness/color controls for lights +- Thermostat temperature adjustment + +### Advanced Visualizations +- Historical data graphs (like PostHog insights) +- Multi-entity dashboards +- Custom aggregations + +### Configuration UI +- Web interface for entity selection +- Card layout customization +- Refresh interval configuration + +## 🏃‍♂️ Quick Start + +1. **Configure Home Assistant connection**: + ```cpp + configManager->setHomeAssistantUrl("http://192.168.1.100:8123"); + configManager->setHomeAssistantApiKey("your_token_here"); + ``` + +2. **Create Home Assistant client**: + ```cpp + homeAssistantClient = new HomeAssistantClient(*configManager, *eventQueue); + ``` + +3. **Add to main loop**: + ```cpp + homeAssistantClient->process(); + ``` + +4. **Create cards for your entities**: + ```cpp + cardController->createHomeAssistantCard("sensor.outdoor_temp"); + cardController->createHomeAssistantCard("binary_sensor.front_door"); + ``` + +5. **Navigate using existing button controls** to view Home Assistant data alongside PostHog analytics! + +The Home Assistant integration seamlessly extends DeskHog's capabilities, providing a unified view of both business analytics and smart home data on a single, elegant desk display. +- **Service Calls**: Execute Home Assistant services to control devices +- **Queue Management**: Automatic retry logic and background refresh of entity states +- **Event System**: Integration with the device's event queue for real-time updates + +## Configuration + +The client requires two configuration parameters: + +- **URL**: Your Home Assistant instance URL (e.g., `http://homeassistant.local:8123`) +- **API Key**: A long-lived access token from Home Assistant + +### Setting up the API Key + +1. In Home Assistant, go to Profile → Long-Lived Access Tokens +2. Click "Create Token" +3. Give it a descriptive name (e.g., "DeskHog Device") +4. Copy the generated token + +### Configuration Methods + +```cpp +// Set configuration programmatically +configManager->setHomeAssistantUrl("http://homeassistant.local:8123"); +configManager->setHomeAssistantApiKey("your_long_lived_access_token_here"); + +// Get configuration +String url = configManager->getHomeAssistantUrl(); +String apiKey = configManager->getHomeAssistantApiKey(); + +// Clear configuration +configManager->clearHomeAssistantConfig(); +``` + +## Usage + +### Basic Setup + +```cpp +#include "homeassistant/HomeAssistantClient.h" + +// Create client instance +HomeAssistantClient* haClient = new HomeAssistantClient(*configManager, *eventQueue); + +// In your main loop +haClient->process(); +``` + +### Requesting Entity States + +```cpp +// Request current state of entities +haClient->requestEntityState("sensor.outdoor_temperature"); +haClient->requestEntityState("light.living_room"); +haClient->requestEntityState("switch.desk_lamp"); +``` + +### Calling Services + +```cpp +// Turn on a light with specific settings +haClient->callService("light", "turn_on", "light.living_room", + "\"brightness\":255,\"color_name\":\"blue\""); + +// Turn off a switch +haClient->callService("switch", "turn_off", "switch.desk_lamp"); + +// Set thermostat temperature +haClient->callService("climate", "set_temperature", "climate.living_room", + "\"temperature\":22.5"); +``` + +## Events + +The client publishes events to the event queue: + +- `HA_ENTITY_STATE_RECEIVED`: When an entity state is successfully fetched +- `HA_SERVICE_CALLED`: When a service call is completed (future enhancement) + +### Handling Events + +```cpp +void handleEvent(const Event& event) { + if (event.type == EventType::HA_ENTITY_STATE_RECEIVED) { + Serial.printf("Entity %s state: %s\n", + event.insightId.c_str(), event.jsonData.c_str()); + + // Parse JSON response to extract state and attributes + // event.jsonData contains the full entity state JSON + } +} +``` + +## API Endpoints Used + +- **Entity States**: `GET /api/states/{entity_id}` +- **Service Calls**: `POST /api/services/{domain}/{service}` + +## Features + +- **Automatic Retry**: Failed requests are retried up to 3 times +- **Background Refresh**: Entity states are refreshed every 30 seconds +- **Memory Optimization**: Large responses are allocated in PSRAM +- **Queue Management**: Non-blocking operation with request queuing +- **Error Handling**: Comprehensive error logging and recovery + +## Integration with DeskHog + +The HomeAssistantClient follows the same patterns as PostHogClient: + +1. Created in `setup()` with ConfigManager and EventQueue references +2. Called in the main loop via `process()` +3. Configured through the web portal or programmatically +4. Publishes events for UI updates + +This allows seamless integration with the existing DeskHog architecture for displaying Home Assistant data alongside PostHog analytics. diff --git a/src/main.cpp b/src/main.cpp index 4858e5a..73ebf9d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -28,6 +28,7 @@ #include "ui/InsightCard.h" #include "hardware/Input.h" #include "posthog/PostHogClient.h" +#include "homeassistant/HomeAssistantClient.h" #include "Style.h" #include "esp_heap_caps.h" // For PSRAM management #include "ui/CardController.h" diff --git a/src/ui/CardController.cpp b/src/ui/CardController.cpp index 2fce128..42dca44 100644 --- a/src/ui/CardController.cpp +++ b/src/ui/CardController.cpp @@ -13,6 +13,7 @@ CardController::CardController( ConfigManager& configManager, WiFiInterface& wifiInterface, PostHogClient& posthogClient, + HomeAssistantClient& homeAssistantClient, EventQueue& eventQueue ) : screen(screen), screenWidth(screenWidth), @@ -20,6 +21,7 @@ CardController::CardController( configManager(configManager), wifiInterface(wifiInterface), posthogClient(posthogClient), + homeAssistantClient(homeAssistantClient), eventQueue(eventQueue), cardStack(nullptr), provisioningCard(nullptr), @@ -208,6 +210,55 @@ void CardController::createInsightCard(const String& insightId) { }); } +// Create a Home Assistant card and add it to the UI +void CardController::createHomeAssistantCard(const String& entityId) { + // Log current task and core + Serial.printf("[CardCtrl-DEBUG] createHomeAssistantCard called from Core: %d, Task: %s\n", + xPortGetCoreID(), + pcTaskGetTaskName(NULL)); + + // Dispatch the actual card creation and LVGL work to the LVGL task + dispatchToLVGLTask([this, entityId]() { + Serial.printf("[CardCtrl-DEBUG] LVGL Task creating card for entity: %s from Core: %d, Task: %s\n", + entityId.c_str(), xPortGetCoreID(), pcTaskGetTaskName(NULL)); + + if (!displayInterface || !displayInterface->takeMutex(portMAX_DELAY)) { + Serial.println("[CardCtrl-ERROR] Failed to take mutex in LVGL task for HA card creation."); + return; + } + + // Create new Home Assistant card using full screen dimensions + HomeAssistantCard* newCard = new HomeAssistantCard( + screen, // LVGL parent object + configManager, // Dependencies + eventQueue, + entityId, + screenWidth, // Dimensions + screenHeight + ); + + if (!newCard || !newCard->getCardObject()) { + Serial.printf("[CardCtrl-ERROR] Failed to create HomeAssistantCard or its LVGL object for ID: %s\n", entityId.c_str()); + displayInterface->giveMutex(); + delete newCard; // Clean up if partially created + return; + } + + // Add to navigation stack + cardStack->addCard(newCard->getCardObject()); + + // Add to our list of Home Assistant cards + homeAssistantCards.push_back(newCard); + + Serial.printf("[CardCtrl-DEBUG] HomeAssistantCard for ID: %s created and added to stack.\n", entityId.c_str()); + + displayInterface->giveMutex(); + + // Request immediate data for this entity now that it's set up + homeAssistantClient.requestEntityState(entityId); + }); +} + // Handle insight events void CardController::handleInsightEvent(const Event& event) { if (event.type == EventType::INSIGHT_ADDED) { @@ -484,4 +535,4 @@ void CardController::handleCardTitleUpdated(const Event& event) { break; } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/ui/CardController.h b/src/ui/CardController.h index 0e00359..90cca93 100644 --- a/src/ui/CardController.h +++ b/src/ui/CardController.h @@ -7,9 +7,11 @@ #include "ConfigManager.h" #include "hardware/WifiInterface.h" #include "posthog/PostHogClient.h" +#include "homeassistant/HomeAssistantClient.h" #include "ui/CardNavigationStack.h" #include "ui/ProvisioningCard.h" #include "ui/InsightCard.h" +#include "ui/HomeAssistantCard.h" #include "ui/FriendCard.h" #include "hardware/DisplayInterface.h" #include "EventQueue.h" @@ -44,6 +46,7 @@ class CardController { ConfigManager& configManager, WiFiInterface& wifiInterface, PostHogClient& posthogClient, + HomeAssistantClient& homeAssistantClient, EventQueue& eventQueue ); @@ -70,6 +73,12 @@ class CardController { */ void createInsightCard(const String& insightId); + /** + * @brief Create and add a new Home Assistant card to the UI + * @param entityId Home Assistant entity ID (e.g., "sensor.temperature") + */ + void createHomeAssistantCard(const String& entityId); + /** * @brief Get the card navigation stack * @return Pointer to card navigation stack @@ -94,6 +103,12 @@ class CardController { */ std::vector& getInsightCards() { return insightCards; } + /** + * @brief Get all Home Assistant cards + * @return Reference to vector of Home Assistant card pointers + */ + std::vector& getHomeAssistantCards() { return homeAssistantCards; } + /** * @brief Get the display interface * @return Pointer to display interface @@ -152,16 +167,18 @@ class CardController { uint16_t screenHeight; ///< Screen height in pixels // System components - ConfigManager& configManager; ///< Configuration manager reference - WiFiInterface& wifiInterface; ///< WiFi interface reference - PostHogClient& posthogClient; ///< PostHog client reference - EventQueue& eventQueue; ///< Event queue reference + ConfigManager& configManager; ///< Configuration manager reference + WiFiInterface& wifiInterface; ///< WiFi interface reference + PostHogClient& posthogClient; ///< PostHog client reference + HomeAssistantClient& homeAssistantClient; ///< Home Assistant client reference + EventQueue& eventQueue; ///< Event queue reference // UI Components CardNavigationStack* cardStack; ///< Navigation stack for cards ProvisioningCard* provisioningCard; ///< Card for device provisioning FriendCard* animationCard; ///< Card for animations std::vector insightCards; ///< Collection of insight cards + std::vector homeAssistantCards; ///< Collection of Home Assistant cards // Display interface for thread safety DisplayInterface* displayInterface; ///< Thread-safe display interface @@ -208,4 +225,4 @@ class CardController { * @param newConfigs New card configuration from storage */ void reconcileCards(const std::vector& newConfigs); -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/ui/HomeAssistantCard.cpp b/src/ui/HomeAssistantCard.cpp new file mode 100644 index 0000000..6ca331f --- /dev/null +++ b/src/ui/HomeAssistantCard.cpp @@ -0,0 +1,297 @@ +#include "HomeAssistantCard.h" +#include "Style.h" +#include "NumberFormat.h" +#include + +HomeAssistantCard::HomeAssistantCard(lv_obj_t* parent, ConfigManager& config, EventQueue& eventQueue, + const String& entityId, uint16_t width, uint16_t height) + : _config(config) + , _event_queue(eventQueue) + , _entity_id(entityId) + , _current_title("") + , _card(nullptr) + , _title_label(nullptr) + , _value_label(nullptr) + , _unit_label(nullptr) + , _content_container(nullptr) + , _current_type(HomeAssistantParser::EntityType::ENTITY_NOT_SUPPORTED) { + + _card = lv_obj_create(parent); + if (!_card) { + Serial.printf("[HomeAssistantCard-%s] CRITICAL: Failed to create card base object!\n", _entity_id.c_str()); + return; + } + lv_obj_set_size(_card, width, height); + lv_obj_set_style_bg_color(_card, Style::backgroundColor(), 0); + lv_obj_set_style_pad_all(_card, 0, 0); + lv_obj_set_style_border_width(_card, 0, 0); + lv_obj_set_style_radius(_card, 0, 0); + + lv_obj_t* flex_col = lv_obj_create(_card); + if (!flex_col) { + Serial.printf("[HomeAssistantCard-%s] CRITICAL: Failed to create flex_col!\n", _entity_id.c_str()); + return; + } + lv_obj_set_size(flex_col, lv_pct(100), lv_pct(100)); + lv_obj_set_style_pad_all(flex_col, 5, 0); + lv_obj_set_style_pad_row(flex_col, 5, 0); + lv_obj_set_flex_flow(flex_col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(flex_col, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(flex_col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(flex_col, LV_OPA_0, 0); + lv_obj_set_style_border_width(flex_col, 0, 0); + + _title_label = lv_label_create(flex_col); + if (!_title_label) { + Serial.printf("[HomeAssistantCard-%s] CRITICAL: Failed to create _title_label!\n", _entity_id.c_str()); + return; + } + lv_obj_set_width(_title_label, lv_pct(100)); + lv_obj_set_style_text_color(_title_label, Style::labelColor(), 0); + lv_obj_set_style_text_font(_title_label, Style::labelFont(), 0); + lv_label_set_long_mode(_title_label, LV_LABEL_LONG_DOT); + lv_label_set_text(_title_label, "Loading..."); + + _content_container = lv_obj_create(flex_col); + if (!_content_container) { + Serial.printf("[HomeAssistantCard-%s] CRITICAL: Failed to create _content_container!\n", _entity_id.c_str()); + return; + } + lv_obj_set_width(_content_container, lv_pct(100)); + lv_obj_set_flex_grow(_content_container, 1); + lv_obj_set_style_bg_opa(_content_container, LV_OPA_0, 0); + lv_obj_set_style_border_width(_content_container, 0, 0); + lv_obj_set_style_pad_all(_content_container, 0, 0); + lv_obj_set_flex_flow(_content_container, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(_content_container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + // Create value and unit labels + _value_label = lv_label_create(_content_container); + if (!_value_label) { + Serial.printf("[HomeAssistantCard-%s] CRITICAL: Failed to create _value_label!\n", _entity_id.c_str()); + return; + } + lv_obj_set_style_text_color(_value_label, Style::valueColor(), 0); + lv_obj_set_style_text_font(_value_label, Style::valueFont(), 0); + lv_obj_set_style_text_align(_value_label, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(_value_label, "--"); + + _unit_label = lv_label_create(_content_container); + if (!_unit_label) { + Serial.printf("[HomeAssistantCard-%s] CRITICAL: Failed to create _unit_label!\n", _entity_id.c_str()); + return; + } + lv_obj_set_style_text_color(_unit_label, Style::labelColor(), 0); + lv_obj_set_style_text_font(_unit_label, Style::labelFont(), 0); + lv_obj_set_style_text_align(_unit_label, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(_unit_label, ""); + + // Subscribe to Home Assistant entity state events + _event_queue.subscribe([this](const Event& event) { + if (event.type == EventType::HA_ENTITY_STATE_RECEIVED && event.insightId == _entity_id) { + this->onEvent(event); + } + }); +} + +HomeAssistantCard::~HomeAssistantCard() { + if (globalUIDispatch) { + globalUIDispatch([card_obj = _card]() { + if (card_obj && lv_obj_is_valid(card_obj)) { + lv_obj_del_async(card_obj); + } + }, true); + } +} + +void HomeAssistantCard::onEvent(const Event& event) { + std::shared_ptr parser = nullptr; + if (event.jsonData.length() > 0) { + parser = std::make_shared(event.jsonData.c_str()); + } else { + Serial.printf("[HomeAssistantCard-%s] Event received with no JSON data.\n", _entity_id.c_str()); + handleParsedData(nullptr); + return; + } + handleParsedData(parser); +} + +void HomeAssistantCard::handleParsedData(std::shared_ptr parser) { + if (!parser || !parser->isValid()) { + Serial.printf("[HomeAssistantCard-%s] Invalid data or parse error.\n", _entity_id.c_str()); + if (globalUIDispatch) { + globalUIDispatch([this]() { + if(isValidObject(_title_label)) lv_label_set_text(_title_label, "Data Error"); + if(isValidObject(_value_label)) lv_label_set_text(_value_label, "--"); + if(isValidObject(_unit_label)) lv_label_set_text(_unit_label, ""); + _current_type = HomeAssistantParser::EntityType::ENTITY_NOT_SUPPORTED; + }, true); + } + return; + } + + if (!parser->isAvailable()) { + Serial.printf("[HomeAssistantCard-%s] Entity is unavailable.\n", _entity_id.c_str()); + if (globalUIDispatch) { + globalUIDispatch([this]() { + if(isValidObject(_title_label)) lv_label_set_text(_title_label, "Unavailable"); + if(isValidObject(_value_label)) lv_label_set_text(_value_label, "--"); + if(isValidObject(_unit_label)) lv_label_set_text(_unit_label, ""); + }, true); + } + return; + } + + HomeAssistantParser::EntityType new_entity_type = parser->getEntityType(); + char friendly_name_buffer[64]; + if (!parser->getFriendlyName(friendly_name_buffer, sizeof(friendly_name_buffer))) { + strcpy(friendly_name_buffer, _entity_id.c_str()); + } + String new_title(friendly_name_buffer); + + // Only dispatch title update event if the title has actually changed + if (_current_title != new_title) { + _current_title = new_title; + _event_queue.publishEvent(Event::createTitleUpdateEvent(_entity_id, new_title)); + Serial.printf("[HomeAssistantCard-%s] Title updated to: %s\n", _entity_id.c_str(), new_title.c_str()); + } + + if (globalUIDispatch) { + globalUIDispatch([this, new_entity_type, new_title, parser, id = _entity_id]() mutable { + if (isValidObject(_title_label)) { + lv_label_set_text(_title_label, new_title.c_str()); + } + + _current_type = new_entity_type; + + switch (new_entity_type) { + case HomeAssistantParser::EntityType::NUMERIC_SENSOR: + updateNumericDisplay(*parser, new_title); + break; + case HomeAssistantParser::EntityType::BINARY_SENSOR: + updateBinaryDisplay(*parser, new_title); + break; + case HomeAssistantParser::EntityType::SWITCH: + case HomeAssistantParser::EntityType::LIGHT: + updateSwitchDisplay(*parser, new_title); + break; + default: + Serial.printf("[HomeAssistantCard-%s] Unsupported entity type %d.\n", + id.c_str(), (int)new_entity_type); + if(isValidObject(_value_label)) lv_label_set_text(_value_label, "Unsupported"); + if(isValidObject(_unit_label)) lv_label_set_text(_unit_label, ""); + break; + } + + }, true); + } +} + +void HomeAssistantCard::updateNumericDisplay(const HomeAssistantParser& parser, const String& friendly_name) { + if (parser.isNumericState()) { + double value = parser.getNumericState(); + char value_buffer[32]; + formatNumericValue(value, value_buffer, sizeof(value_buffer)); + + char unit_buffer[16]; + parser.getUnitOfMeasurement(unit_buffer, sizeof(unit_buffer)); + + if (isValidObject(_value_label)) { + lv_label_set_text(_value_label, value_buffer); + } + if (isValidObject(_unit_label)) { + lv_label_set_text(_unit_label, unit_buffer); + } + + Serial.printf("[HomeAssistantCard-%s] Updated numeric: %s %s\n", + _entity_id.c_str(), value_buffer, unit_buffer); + } else { + char state_buffer[32]; + parser.getStateString(state_buffer, sizeof(state_buffer)); + + if (isValidObject(_value_label)) { + lv_label_set_text(_value_label, state_buffer); + } + if (isValidObject(_unit_label)) { + lv_label_set_text(_unit_label, ""); + } + } +} + +void HomeAssistantCard::updateBinaryDisplay(const HomeAssistantParser& parser, const String& friendly_name) { + char state_buffer[32]; + parser.getStateString(state_buffer, sizeof(state_buffer)); + + // Convert common binary states to more user-friendly display + String display_state = String(state_buffer); + if (display_state == "on" || display_state == "true") { + display_state = "ON"; + } else if (display_state == "off" || display_state == "false") { + display_state = "OFF"; + } else if (display_state == "open") { + display_state = "OPEN"; + } else if (display_state == "closed") { + display_state = "CLOSED"; + } + + if (isValidObject(_value_label)) { + lv_label_set_text(_value_label, display_state.c_str()); + } + if (isValidObject(_unit_label)) { + lv_label_set_text(_unit_label, ""); + } + + Serial.printf("[HomeAssistantCard-%s] Updated binary: %s\n", + _entity_id.c_str(), display_state.c_str()); +} + +void HomeAssistantCard::updateSwitchDisplay(const HomeAssistantParser& parser, const String& friendly_name) { + char state_buffer[32]; + parser.getStateString(state_buffer, sizeof(state_buffer)); + + // Convert switch/light states to user-friendly display + String display_state = String(state_buffer); + if (display_state == "on") { + display_state = "ON"; + } else if (display_state == "off") { + display_state = "OFF"; + } + + if (isValidObject(_value_label)) { + lv_label_set_text(_value_label, display_state.c_str()); + } + if (isValidObject(_unit_label)) { + lv_label_set_text(_unit_label, ""); + } + + Serial.printf("[HomeAssistantCard-%s] Updated switch/light: %s\n", + _entity_id.c_str(), display_state.c_str()); +} + +void HomeAssistantCard::formatNumericValue(double value, char* buffer, size_t bufferSize) { + // Use the existing NumberFormat utility if available, otherwise basic formatting + if (value == (int)value && value < 1000) { + // Integer values under 1000, no decimal places + snprintf(buffer, bufferSize, "%.0f", value); + } else if (abs(value) >= 1000) { + // Large numbers, use K/M formatting if NumberFormat is available + if (abs(value) >= 1000000) { + snprintf(buffer, bufferSize, "%.1fM", value / 1000000.0); + } else { + snprintf(buffer, bufferSize, "%.1fK", value / 1000.0); + } + } else { + // Decimal values, 1 decimal place + snprintf(buffer, bufferSize, "%.1f", value); + } +} + +void HomeAssistantCard::clearContentContainer() { + if (isValidObject(_content_container)) { + lv_obj_clean(_content_container); + } +} + +bool HomeAssistantCard::isValidObject(lv_obj_t* obj) const { + return obj && lv_obj_is_valid(obj); +} diff --git a/src/ui/HomeAssistantCard.h b/src/ui/HomeAssistantCard.h new file mode 100644 index 0000000..f9040ae --- /dev/null +++ b/src/ui/HomeAssistantCard.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include +#include "ConfigManager.h" +#include "EventQueue.h" +#include "homeassistant/HomeAssistantParser.h" +#include "UICallback.h" + +/** + * @class HomeAssistantCard + * @brief UI component for displaying Home Assistant entity data + * + * Provides a card-based UI component that displays Home Assistant entity states: + * - Numeric sensor values (temperature, humidity, power, etc.) + * - Binary sensor states (motion, door, window states) + * - Switch and light states + * + * Features: + * - Thread-safe UI updates via queue system + * - Automatic entity type detection and UI adaptation + * - Memory-safe LVGL object management + * - Unit display and formatting + * - State availability indication + */ +class HomeAssistantCard { +public: + /** + * @brief Constructor + * + * @param parent LVGL parent object to attach this card to + * @param config Configuration manager for persistent storage + * @param eventQueue Event queue for receiving data updates + * @param entityId Home Assistant entity ID (e.g., "sensor.temperature") + * @param width Card width in pixels + * @param height Card height in pixels + * + * Creates a card with a vertical flex layout containing: + * - Title label showing friendly name with ellipsis for overflow + * - Value label for numeric display + * - Unit label for unit of measurement + * Subscribes to HA_ENTITY_STATE_RECEIVED events for the specified entityId. + */ + HomeAssistantCard(lv_obj_t* parent, ConfigManager& config, EventQueue& eventQueue, + const String& entityId, uint16_t width, uint16_t height); + + /** + * @brief Destructor - cleans up LVGL objects safely + */ + ~HomeAssistantCard(); + + /** + * @brief Get the entity ID for this card + * @return Entity ID string + */ + const String& getEntityId() const { return _entity_id; } + + /** + * @brief Get the current friendly name/title + * @return Current title string + */ + const String& getCurrentTitle() const { return _current_title; } + + /** + * @brief Get the main card object + * @return Pointer to LVGL card object + */ + lv_obj_t* getCardObject() const { return _card; } + +private: + // Configuration and events + ConfigManager& _config; + EventQueue& _event_queue; + + // Entity identification + String _entity_id; + String _current_title; + + // LVGL UI objects + lv_obj_t* _card; ///< Main card container + lv_obj_t* _title_label; ///< Entity friendly name + lv_obj_t* _value_label; ///< Main value display + lv_obj_t* _unit_label; ///< Unit of measurement + lv_obj_t* _content_container; ///< Container for value and unit + + // State tracking + HomeAssistantParser::EntityType _current_type; + + /** + * @brief Handle incoming Home Assistant entity events + * @param event Event containing entity state data + */ + void onEvent(const Event& event); + + /** + * @brief Process parsed entity data and update UI + * @param parser Shared pointer to parsed entity data + */ + void handleParsedData(std::shared_ptr parser); + + /** + * @brief Update numeric sensor display + * @param parser Parser containing entity data + * @param friendly_name Entity friendly name + */ + void updateNumericDisplay(const HomeAssistantParser& parser, const String& friendly_name); + + /** + * @brief Update binary sensor display + * @param parser Parser containing entity data + * @param friendly_name Entity friendly name + */ + void updateBinaryDisplay(const HomeAssistantParser& parser, const String& friendly_name); + + /** + * @brief Update switch/light display + * @param parser Parser containing entity data + * @param friendly_name Entity friendly name + */ + void updateSwitchDisplay(const HomeAssistantParser& parser, const String& friendly_name); + + /** + * @brief Format numeric value with appropriate precision + * @param value Numeric value to format + * @param buffer Buffer to store formatted string + * @param bufferSize Size of the buffer + */ + void formatNumericValue(double value, char* buffer, size_t bufferSize); + + /** + * @brief Clear content container + */ + void clearContentContainer(); + + /** + * @brief Check if LVGL object is valid + * @param obj Object to check + * @return true if valid, false otherwise + */ + bool isValidObject(lv_obj_t* obj) const; +}; diff --git a/src/ui/HomeAssistantCard_example.cpp b/src/ui/HomeAssistantCard_example.cpp new file mode 100644 index 0000000..3c58a7f --- /dev/null +++ b/src/ui/HomeAssistantCard_example.cpp @@ -0,0 +1,129 @@ +/** + * HomeAssistantCard Usage Example + * =============================== + * + * This example demonstrates how to use the HomeAssistantCard to display + * Home Assistant entity data on the DeskHog display. + */ + +#include "ui/CardController.h" +#include "homeassistant/HomeAssistantClient.h" + +// Example: Creating Home Assistant cards in your main application + +void setupHomeAssistantCards(CardController& cardController, HomeAssistantClient& haClient) { + // Configure Home Assistant connection (do this once during setup) + // This would typically be done through the web interface or stored configuration + + // Create cards for various Home Assistant entities + + // Temperature sensor + cardController.createHomeAssistantCard("sensor.outdoor_temperature"); + + // Humidity sensor + cardController.createHomeAssistantCard("sensor.indoor_humidity"); + + // Power consumption sensor + cardController.createHomeAssistantCard("sensor.power_consumption"); + + // Binary sensors + cardController.createHomeAssistantCard("binary_sensor.front_door"); + cardController.createHomeAssistantCard("binary_sensor.motion_living_room"); + + // Switch states + cardController.createHomeAssistantCard("switch.desk_lamp"); + cardController.createHomeAssistantCard("light.bedroom_light"); + + // The cards will automatically: + // 1. Request entity state data from Home Assistant + // 2. Parse the JSON response using HomeAssistantParser + // 3. Display the appropriate UI based on entity type: + // - Numeric sensors: Show value with unit (e.g., "23.5 °C") + // - Binary sensors: Show ON/OFF, OPEN/CLOSED + // - Switches/Lights: Show ON/OFF state + // 4. Refresh data automatically every 30 seconds + // 5. Handle unavailable/unknown states gracefully +} + +/** + * Example JSON responses that HomeAssistantCard can handle: + * + * Numeric Sensor: + * { + * "entity_id": "sensor.outdoor_temperature", + * "state": "23.5", + * "attributes": { + * "unit_of_measurement": "°C", + * "friendly_name": "Outdoor Temperature", + * "device_class": "temperature" + * } + * } + * + * Binary Sensor: + * { + * "entity_id": "binary_sensor.front_door", + * "state": "off", + * "attributes": { + * "friendly_name": "Front Door", + * "device_class": "door" + * } + * } + * + * Switch: + * { + * "entity_id": "switch.desk_lamp", + * "state": "on", + * "attributes": { + * "friendly_name": "Desk Lamp" + * } + * } + */ + +/** + * Card Display Examples: + * + * Temperature Sensor Card: + * ┌─────────────────────────┐ + * │ Outdoor Temperature │ + * │ │ + * │ 23.5 │ + * │ °C │ + * └─────────────────────────┘ + * + * Binary Sensor Card: + * ┌─────────────────────────┐ + * │ Front Door │ + * │ │ + * │ CLOSED │ + * │ │ + * └─────────────────────────┘ + * + * Switch Card: + * ┌─────────────────────────┐ + * │ Desk Lamp │ + * │ │ + * │ ON │ + * │ │ + * └─────────────────────────┘ + */ + +/** + * Integration with existing DeskHog workflow: + * + * 1. The HomeAssistantCard uses the same event system as InsightCard + * 2. Cards are managed by CardController alongside PostHog insight cards + * 3. Navigation between Home Assistant and PostHog cards works seamlessly + * 4. All cards share the same LVGL styling from Style.h + * 5. Thread-safe UI updates are handled automatically + * 6. Cards respond to the same button inputs for navigation + */ + +/** + * Configuration requirements: + * + * Before creating Home Assistant cards, ensure: + * 1. Home Assistant URL is configured: configManager.setHomeAssistantUrl("http://homeassistant.local:8123") + * 2. API key is configured: configManager.setHomeAssistantApiKey("your_long_lived_access_token") + * 3. WiFi is connected and HomeAssistantClient.isReady() returns true + * 4. The specified entities exist in your Home Assistant instance + */ From 97aa3e1fd625833ba92b4c01c59b2f135da00c32 Mon Sep 17 00:00:00 2001 From: Tim Broddin Date: Thu, 19 Jun 2025 10:00:52 +0200 Subject: [PATCH 4/7] feat: Enhance Home Assistant integration with new card type and configuration options - Increased maximum API key length for Home Assistant from 64 to 256 characters. - Added new card type for Home Assistant entities in CardConfig and CardController. - Implemented Home Assistant client processing in the main application loop. - Created configuration endpoints for Home Assistant URL and API key in CaptivePortal. - Removed example files for Home Assistant client and card due to redundancy. - Improved logging for Home Assistant card events and updates. - Updated HomeAssistantCard to handle entity state updates and display appropriately. --- html/portal.css | 2 +- html/portal.html | 22 ++ html/portal.js | 83 ++++- include/html_portal.h | 107 +++++- src/ConfigManager.h | 2 +- src/config/CardConfig.h | 7 +- src/homeassistant/HomeAssistantClient.cpp | 1 + .../HomeAssistantClient_example.cpp | 100 ----- src/homeassistant/README.md | 348 ------------------ src/main.cpp | 25 ++ src/ui/CaptivePortal.cpp | 51 +++ src/ui/CaptivePortal.h | 12 + src/ui/CardController.cpp | 58 ++- src/ui/HomeAssistantCard.cpp | 77 +++- src/ui/HomeAssistantCard_example.cpp | 129 ------- 15 files changed, 431 insertions(+), 593 deletions(-) delete mode 100644 src/homeassistant/HomeAssistantClient_example.cpp delete mode 100644 src/homeassistant/README.md delete mode 100644 src/ui/HomeAssistantCard_example.cpp diff --git a/html/portal.css b/html/portal.css index ec62e45..044fbcc 100644 --- a/html/portal.css +++ b/html/portal.css @@ -61,7 +61,7 @@ label { margin-bottom: 0.5rem; font-weight: 600; } -input[type='text'], input[type='password'] { +input[type='text'], input[type='password'], input[type='url'] { width: 100%; padding: 8px; border: 1px solid #ddd; diff --git a/html/portal.html b/html/portal.html index 50b03bb..8e02691 100644 --- a/html/portal.html +++ b/html/portal.html @@ -62,6 +62,28 @@

API configuration

+

Home Assistant

+ +
+
+
+ + +
+ +
+ + +
+ +

Get your token from Home Assistant → Profile → Long-Lived Access Tokens

+ +
+ +
+
+
+

Card management

diff --git a/html/portal.js b/html/portal.js index 5027856..8b6e222 100644 --- a/html/portal.js +++ b/html/portal.js @@ -135,6 +135,68 @@ function saveDeviceConfig() { return false; } +// Handle Home Assistant config form submission +function saveHomeAssistantConfig() { + const form = document.getElementById('ha-form'); + const formData = new FormData(form); + const globalActionStatusEl = document.getElementById('global-action-status'); + + fetch('/save-ha-config', { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + if (data && data.success) { + console.log("Home Assistant config saved successfully"); + if (globalActionStatusEl) { + globalActionStatusEl.textContent = "Home Assistant configuration saved successfully."; + globalActionStatusEl.className = 'status-message success'; + globalActionStatusEl.style.display = 'block'; + setTimeout(() => { + if (globalActionStatusEl.textContent === "Home Assistant configuration saved successfully.") { + globalActionStatusEl.style.display = 'none'; + globalActionStatusEl.textContent = ''; + globalActionStatusEl.className = 'status-message'; + } + }, 5000); + } + } else { + const errorMessage = (data && data.message) ? data.message : "Failed to save Home Assistant configuration."; + console.error("Failed to save Home Assistant config:", errorMessage); + if (globalActionStatusEl) { + globalActionStatusEl.textContent = errorMessage; + globalActionStatusEl.className = 'status-message error'; + globalActionStatusEl.style.display = 'block'; + setTimeout(() => { + if (globalActionStatusEl.className.includes('error')) { + globalActionStatusEl.style.display = 'none'; + globalActionStatusEl.textContent = ''; + globalActionStatusEl.className = 'status-message'; + } + }, 7000); + } + } + }) + .catch(() => { + console.error("Communication error saving Home Assistant config."); + if (globalActionStatusEl) { + globalActionStatusEl.textContent = "Communication error saving Home Assistant config."; + globalActionStatusEl.className = 'status-message error'; + globalActionStatusEl.style.display = 'block'; + setTimeout(() => { + if (globalActionStatusEl.className.includes('error')) { + globalActionStatusEl.style.display = 'none'; + globalActionStatusEl.textContent = ''; + globalActionStatusEl.className = 'status-message'; + } + }, 7000); + } + }); + + return false; +} + // Toggle API key visibility function toggleApiKeyVisibility() { const apiKeyInput = document.getElementById('apiKey'); @@ -775,6 +837,11 @@ function pollApiStatus() { _updateDeviceConfigUI(data.device_config); } + // 3a. Update Home Assistant Config Info + if (data.ha_config) { + _updateHomeAssistantConfigUI(data.ha_config); + } + // 4. Update Insights List (legacy - remove if cards are working) if (data.insights) { _updateInsightsListUI(data.insights); @@ -819,6 +886,20 @@ function _updateDeviceConfigUI(config) { } } +// Load current Home Assistant configuration - UI update part will be in pollApiStatus +let initialHaConfigLoaded = false; +function _updateHomeAssistantConfigUI(config) { + if (!initialHaConfigLoaded) { + if (config.ha_url !== undefined) { + document.getElementById('haUrl').value = config.ha_url; + } + if (config.ha_api_key_display !== undefined) { + document.getElementById('haApiKey').value = config.ha_api_key_display; + } + initialHaConfigLoaded = true; + } +} + // Initialize page document.addEventListener('DOMContentLoaded', function() { const hash = window.location.hash.substr(1); @@ -852,7 +933,7 @@ document.addEventListener('DOMContentLoaded', function() { setTimeout(() => { loadConfiguredCards(); }, 500); - }, 1000); + }, 10000); }); // Enum for OtaManager::UpdateStatus::State (mirror from C++) diff --git a/include/html_portal.h b/include/html_portal.h index 8fb69af..1a6d7cb 100644 --- a/include/html_portal.h +++ b/include/html_portal.h @@ -72,7 +72,7 @@ static const char PORTAL_HTML[] PROGMEM = "\n" " margin-bottom: 0.5rem; \n" " font-weight: 600;\n" "}\n" -"input[type='text'], input[type='password'] { \n" +"input[type='text'], input[type='password'], input[type='url'] { \n" " width: 100%; \n" " padding: 8px; \n" " border: 1px solid #ddd; \n" @@ -508,6 +508,68 @@ static const char PORTAL_HTML[] PROGMEM = "\n" " return false;\n" "}\n" "\n" +"// Handle Home Assistant config form submission\n" +"function saveHomeAssistantConfig() {\n" +" const form = document.getElementById('ha-form');\n" +" const formData = new FormData(form);\n" +" const globalActionStatusEl = document.getElementById('global-action-status');\n" +" \n" +" fetch('/save-ha-config', { \n" +" method: 'POST',\n" +" body: formData\n" +" })\n" +" .then(response => response.json())\n" +" .then(data => {\n" +" if (data && data.success) {\n" +" console.log(\"Home Assistant config saved successfully\");\n" +" if (globalActionStatusEl) {\n" +" globalActionStatusEl.textContent = \"Home Assistant configuration saved successfully.\";\n" +" globalActionStatusEl.className = 'status-message success';\n" +" globalActionStatusEl.style.display = 'block';\n" +" setTimeout(() => {\n" +" if (globalActionStatusEl.textContent === \"Home Assistant configuration saved successfully.\") {\n" +" globalActionStatusEl.style.display = 'none';\n" +" globalActionStatusEl.textContent = '';\n" +" globalActionStatusEl.className = 'status-message';\n" +" }\n" +" }, 5000);\n" +" }\n" +" } else {\n" +" const errorMessage = (data && data.message) ? data.message : \"Failed to save Home Assistant configuration.\";\n" +" console.error(\"Failed to save Home Assistant config:\", errorMessage);\n" +" if (globalActionStatusEl) {\n" +" globalActionStatusEl.textContent = errorMessage;\n" +" globalActionStatusEl.className = 'status-message error';\n" +" globalActionStatusEl.style.display = 'block';\n" +" setTimeout(() => {\n" +" if (globalActionStatusEl.className.includes('error')) {\n" +" globalActionStatusEl.style.display = 'none';\n" +" globalActionStatusEl.textContent = '';\n" +" globalActionStatusEl.className = 'status-message';\n" +" }\n" +" }, 7000);\n" +" }\n" +" }\n" +" })\n" +" .catch(() => {\n" +" console.error(\"Communication error saving Home Assistant config.\");\n" +" if (globalActionStatusEl) {\n" +" globalActionStatusEl.textContent = \"Communication error saving Home Assistant config.\";\n" +" globalActionStatusEl.className = 'status-message error';\n" +" globalActionStatusEl.style.display = 'block';\n" +" setTimeout(() => {\n" +" if (globalActionStatusEl.className.includes('error')) {\n" +" globalActionStatusEl.style.display = 'none';\n" +" globalActionStatusEl.textContent = '';\n" +" globalActionStatusEl.className = 'status-message';\n" +" }\n" +" }, 7000);\n" +" }\n" +" });\n" +" \n" +" return false;\n" +"}\n" +"\n" "// Toggle API key visibility\n" "function toggleApiKeyVisibility() {\n" " const apiKeyInput = document.getElementById('apiKey');\n" @@ -1148,6 +1210,11 @@ static const char PORTAL_HTML[] PROGMEM = "\n" " _updateDeviceConfigUI(data.device_config);\n" " }\n" "\n" +" // 3a. Update Home Assistant Config Info\n" +" if (data.ha_config) {\n" +" _updateHomeAssistantConfigUI(data.ha_config);\n" +" }\n" +"\n" " // 4. Update Insights List (legacy - remove if cards are working)\n" " if (data.insights) {\n" " _updateInsightsListUI(data.insights);\n" @@ -1192,6 +1259,20 @@ static const char PORTAL_HTML[] PROGMEM = "\n" " }\n" "}\n" "\n" +"// Load current Home Assistant configuration - UI update part will be in pollApiStatus\n" +"let initialHaConfigLoaded = false;\n" +"function _updateHomeAssistantConfigUI(config) {\n" +" if (!initialHaConfigLoaded) {\n" +" if (config.ha_url !== undefined) {\n" +" document.getElementById('haUrl').value = config.ha_url;\n" +" }\n" +" if (config.ha_api_key_display !== undefined) { \n" +" document.getElementById('haApiKey').value = config.ha_api_key_display;\n" +" }\n" +" initialHaConfigLoaded = true;\n" +" }\n" +"}\n" +"\n" "// Initialize page\n" "document.addEventListener('DOMContentLoaded', function() {\n" " const hash = window.location.hash.substr(1);\n" @@ -1225,7 +1306,7 @@ static const char PORTAL_HTML[] PROGMEM = "\n" " setTimeout(() => {\n" " loadConfiguredCards();\n" " }, 500);\n" -" }, 1000);\n" +" }, 10000);\n" "});\n" "\n" "// Enum for OtaManager::UpdateStatus::State (mirror from C++)\n" @@ -1444,6 +1525,28 @@ static const char PORTAL_HTML[] PROGMEM = "\n" " \n" "
\n" "\n" +"

Home Assistant

\n" +"\n" +"
\n" +"
\n" +"
\n" +" \n" +" \n" +"
\n" +" \n" +"
\n" +" \n" +" \n" +"
\n" +" \n" +"

Get your token from Home Assistant → Profile → Long-Lived Access Tokens

\n" +" \n" +"
\n" +" \n" +"
\n" +"
\n" +"
\n" +"\n" "

Card management

\n" "\n" "
\n" diff --git a/src/ConfigManager.h b/src/ConfigManager.h index 07d7892..5d32ae9 100644 --- a/src/ConfigManager.h +++ b/src/ConfigManager.h @@ -253,7 +253,7 @@ class ConfigManager { /** @brief Maximum length for Home Assistant URL */ static const size_t MAX_HA_URL_LENGTH = 256; /** @brief Maximum length for Home Assistant API key */ - static const size_t MAX_HA_API_KEY_LENGTH = 64; + static const size_t MAX_HA_API_KEY_LENGTH = 256; /** @brief Maximum length for insight identifier */ static const size_t MAX_INSIGHT_ID_LENGTH = 64; diff --git a/src/config/CardConfig.h b/src/config/CardConfig.h index 54b4b92..3c46e0d 100644 --- a/src/config/CardConfig.h +++ b/src/config/CardConfig.h @@ -8,8 +8,9 @@ * @brief Enum to uniquely identify each type of card available in the system */ enum class CardType { - INSIGHT, ///< PostHog insight visualization card - FRIEND ///< Walking animation/encouragement card + INSIGHT, ///< PostHog insight visualization card + FRIEND, ///< Walking animation/encouragement card + HOME_ASSISTANT ///< Home Assistant entity monitoring card // New card types can be added here }; @@ -77,6 +78,7 @@ inline String cardTypeToString(CardType type) { switch (type) { case CardType::INSIGHT: return "INSIGHT"; case CardType::FRIEND: return "FRIEND"; + case CardType::HOME_ASSISTANT: return "HOME_ASSISTANT"; default: return "UNKNOWN"; } } @@ -89,5 +91,6 @@ inline String cardTypeToString(CardType type) { inline CardType stringToCardType(const String& str) { if (str == "INSIGHT") return CardType::INSIGHT; if (str == "FRIEND") return CardType::FRIEND; + if (str == "HOME_ASSISTANT") return CardType::HOME_ASSISTANT; return CardType::INSIGHT; // Default fallback } \ No newline at end of file diff --git a/src/homeassistant/HomeAssistantClient.cpp b/src/homeassistant/HomeAssistantClient.cpp index aec9df4..34e2094 100644 --- a/src/homeassistant/HomeAssistantClient.cpp +++ b/src/homeassistant/HomeAssistantClient.cpp @@ -190,6 +190,7 @@ bool HomeAssistantClient::fetchEntityState(const String& entity_id, String& resp has_active_request = true; String url = buildEntityStateUrl(entity_id); + Serial.printf("Fetching entity state for %s from %s\n", entity_id.c_str(), url.c_str()); _http.begin(url); _http.addHeader("Authorization", "Bearer " + _config.getHomeAssistantApiKey()); diff --git a/src/homeassistant/HomeAssistantClient_example.cpp b/src/homeassistant/HomeAssistantClient_example.cpp deleted file mode 100644 index cb47676..0000000 --- a/src/homeassistant/HomeAssistantClient_example.cpp +++ /dev/null @@ -1,100 +0,0 @@ -/** - * HomeAssistantClient Usage Example - * ================================= - * - * This example demonstrates how to use the HomeAssistantClient to: - * 1. Fetch entity states from Home Assistant - * 2. Call Home Assistant services - * 3. Handle configuration - * - * To integrate into main.cpp, add similar code patterns. - */ - -#include "homeassistant/HomeAssistantClient.h" -#include "ConfigManager.h" -#include "EventQueue.h" - -// Global instances (similar to PostHogClient pattern in main.cpp) -HomeAssistantClient* homeAssistantClient; -ConfigManager* configManager; -EventQueue* eventQueue; - -void setupHomeAssistant() { - // Configure Home Assistant connection - configManager->setHomeAssistantUrl("http://homeassistant.local:8123"); - configManager->setHomeAssistantApiKey("your_long_lived_access_token_here"); - - // Create the client instance - homeAssistantClient = new HomeAssistantClient(*configManager, *eventQueue); -} - -void homeAssistantLoop() { - // Call this in your main loop (similar to posthogClient->process()) - if (homeAssistantClient) { - homeAssistantClient->process(); - } -} - -void requestSensorData() { - // Request entity states - homeAssistantClient->requestEntityState("sensor.outdoor_temperature"); - homeAssistantClient->requestEntityState("sensor.indoor_humidity"); - homeAssistantClient->requestEntityState("light.living_room"); - homeAssistantClient->requestEntityState("switch.desk_lamp"); -} - -void controlDevices() { - // Turn on a light - homeAssistantClient->callService("light", "turn_on", "light.living_room", - "\"brightness\":255,\"color_name\":\"blue\""); - - // Turn off a switch - homeAssistantClient->callService("switch", "turn_off", "switch.desk_lamp"); - - // Set thermostat temperature - homeAssistantClient->callService("climate", "set_temperature", "climate.living_room", - "\"temperature\":22.5"); -} - -// Event handler example (add to your event handling system) -void handleHomeAssistantEvent(const Event& event) { - if (event.type == EventType::HA_ENTITY_STATE_RECEIVED) { - Serial.printf("Received state for entity %s: %s\n", - event.insightId.c_str(), event.jsonData.c_str()); - - // Parse the JSON response to extract state information - // Example JSON response: - // { - // "entity_id": "sensor.outdoor_temperature", - // "state": "23.5", - // "attributes": { - // "unit_of_measurement": "°C", - // "friendly_name": "Outdoor Temperature" - // } - // } - } -} - -/** - * Integration into main.cpp: - * - * 1. Add include: #include "homeassistant/HomeAssistantClient.h" - * - * 2. Add global variable: HomeAssistantClient* homeAssistantClient; - * - * 3. In setup(), add: - * homeAssistantClient = new HomeAssistantClient(*configManager, *eventQueue); - * - * 4. In the main loop task, add: - * homeAssistantClient->process(); - * - * 5. Configure through web portal or programmatically: - * configManager->setHomeAssistantUrl("http://your-ha-instance:8123"); - * configManager->setHomeAssistantApiKey("your_token"); - * - * 6. Request entity states: - * homeAssistantClient->requestEntityState("sensor.temperature"); - * - * 7. Call services: - * homeAssistantClient->callService("light", "turn_on", "light.bedroom"); - */ diff --git a/src/homeassistant/README.md b/src/homeassistant/README.md deleted file mode 100644 index 8ba6af7..0000000 --- a/src/homeassistant/README.md +++ /dev/null @@ -1,348 +0,0 @@ -# Home Assistant Integration for DeskHog - -This integration allows DeskHog to display Home Assistant entity data alongside PostHog analytics, creating a unified dashboard for both data analytics and smart home monitoring. - -## 📁 Files Created - -### Core Components -- **`HomeAssistantClient.h/cpp`** - HTTP client for Home Assistant API integration -- **`HomeAssistantParser.h/cpp`** - JSON parser for Home Assistant entity state data -- **`HomeAssistantCard.h/cpp`** - UI card component for displaying entity data -- **`ConfigManager.h/cpp`** (extended) - Added Home Assistant URL and API key storage - -### Examples and Documentation -- **`HomeAssistantClient_example.cpp`** - Usage examples for the client -- **`HomeAssistantCard_example.cpp`** - Card creation and integration examples - -## 🏠 Supported Entity Types - -### Numeric Sensors -- **Temperature** sensors (°C, °F) -- **Humidity** sensors (%) -- **Power consumption** (W, kW) -- **Pressure** sensors (hPa, bar) -- **Any numeric sensor** with unit display - -### Binary Sensors -- **Motion** detectors (ON/OFF) -- **Door/Window** sensors (OPEN/CLOSED) -- **Occupancy** sensors (DETECTED/CLEAR) - -### Switches & Lights -- **Switch** entities (ON/OFF) -- **Light** entities (ON/OFF) - -## 🔧 Configuration - -### 1. Home Assistant Setup -```cpp -// Configure connection (typically done through web interface) -configManager->setHomeAssistantUrl("http://homeassistant.local:8123"); -configManager->setHomeAssistantApiKey("your_long_lived_access_token_here"); -``` - -### 2. Create Client Instance -```cpp -// In main.cpp or CardController setup -HomeAssistantClient* haClient = new HomeAssistantClient(*configManager, *eventQueue); -``` - -### 3. Create Entity Cards -```cpp -// Create cards for different entity types -cardController->createHomeAssistantCard("sensor.outdoor_temperature"); -cardController->createHomeAssistantCard("binary_sensor.front_door"); -cardController->createHomeAssistantCard("switch.desk_lamp"); -``` - -## 📊 Card Display Examples - -### Temperature Sensor -``` -┌─────────────────────────┐ -│ Outdoor Temperature │ -│ │ -│ 23.5 │ -│ °C │ -└─────────────────────────┘ -``` - -### Binary Sensor -``` -┌─────────────────────────┐ -│ Front Door │ -│ │ -│ CLOSED │ -│ │ -└─────────────────────────┘ -``` - -### Switch/Light -``` -┌─────────────────────────┐ -│ Desk Lamp │ -│ │ -│ ON │ -│ │ -└─────────────────────────┘ -``` - -## 🔄 Data Flow - -1. **Request Phase**: `homeAssistantClient->requestEntityState("sensor.temperature")` -2. **HTTP Request**: Client sends GET request to `/api/states/sensor.temperature` -3. **Response Parsing**: HomeAssistantParser extracts state, unit, friendly name -4. **Event Publishing**: `HA_ENTITY_STATE_RECEIVED` event published -5. **UI Update**: HomeAssistantCard receives event and updates display -6. **Auto Refresh**: Client automatically refreshes data every 30 seconds - -## 🎛️ API Integration - -### Entity State Endpoint -``` -GET /api/states/{entity_id} -Authorization: Bearer {long_lived_access_token} -``` - -**Response Example:** -```json -{ - "entity_id": "sensor.outdoor_temperature", - "state": "23.5", - "attributes": { - "unit_of_measurement": "°C", - "friendly_name": "Outdoor Temperature", - "device_class": "temperature" - }, - "last_changed": "2025-06-19T10:30:00.000000+00:00" -} -``` - -### Service Call Endpoint (Future Enhancement) -``` -POST /api/services/{domain}/{service} -Authorization: Bearer {long_lived_access_token} -Content-Type: application/json - -{"entity_id": "light.bedroom", "brightness": 255} -``` - -## 🔒 Authentication - -Uses Home Assistant **Long-Lived Access Tokens**: - -1. Go to Home Assistant → Profile → Long-Lived Access Tokens -2. Click "Create Token" -3. Copy the token and configure in DeskHog -4. Token provides read/write access to all entities - -## 🧵 Thread Safety - -- **UI Updates**: All LVGL operations dispatched to UI thread via `globalUIDispatch` -- **HTTP Requests**: Non-blocking with queue management -- **Memory Management**: PSRAM allocation for large responses -- **State Management**: Thread-safe event publishing and subscription - -## 🔧 Error Handling - -### Connection Issues -- Automatic retry logic with exponential backoff -- Queue management for failed requests -- Graceful degradation when Home Assistant unavailable - -### Entity States -- **Unavailable entities**: Display "Unavailable" message -- **Unknown states**: Display "Unknown" message -- **Parse errors**: Display "Data Error" message -- **Non-numeric sensors**: Display raw state string - -## 🚀 Integration with DeskHog - -### Event System -- Uses existing `EventQueue` for state change notifications -- New event types: `HA_ENTITY_STATE_RECEIVED`, `HA_SERVICE_CALLED` -- Compatible with existing event handling architecture - -### Card Management -- Managed by `CardController` alongside PostHog insight cards -- Same navigation system (button inputs for card switching) -- Unified styling using `Style.h` fonts and colors - -### Configuration Storage -- Persistent storage in ESP32 NVS (Non-Volatile Storage) -- Configuration via web interface (same as PostHog setup) -- Validation and size limits for stored values - -## 🎯 Usage Scenarios - -### Home Office Dashboard -- **Temperature/Humidity**: Monitor office environment -- **Power Consumption**: Track energy usage -- **Security**: Door/window sensors, motion detection -- **Lighting**: Quick status of office lighting - -### Smart Home Monitor -- **HVAC Status**: Temperature, humidity, thermostat states -- **Security System**: Armed/disarmed, sensor states -- **Energy Monitoring**: Solar production, grid consumption -- **Device Status**: Critical switches and lights - -## 🔮 Future Enhancements - -### Service Control -- Touch interface for controlling switches/lights -- Brightness/color controls for lights -- Thermostat temperature adjustment - -### Advanced Visualizations -- Historical data graphs (like PostHog insights) -- Multi-entity dashboards -- Custom aggregations - -### Configuration UI -- Web interface for entity selection -- Card layout customization -- Refresh interval configuration - -## 🏃‍♂️ Quick Start - -1. **Configure Home Assistant connection**: - ```cpp - configManager->setHomeAssistantUrl("http://192.168.1.100:8123"); - configManager->setHomeAssistantApiKey("your_token_here"); - ``` - -2. **Create Home Assistant client**: - ```cpp - homeAssistantClient = new HomeAssistantClient(*configManager, *eventQueue); - ``` - -3. **Add to main loop**: - ```cpp - homeAssistantClient->process(); - ``` - -4. **Create cards for your entities**: - ```cpp - cardController->createHomeAssistantCard("sensor.outdoor_temp"); - cardController->createHomeAssistantCard("binary_sensor.front_door"); - ``` - -5. **Navigate using existing button controls** to view Home Assistant data alongside PostHog analytics! - -The Home Assistant integration seamlessly extends DeskHog's capabilities, providing a unified view of both business analytics and smart home data on a single, elegant desk display. -- **Service Calls**: Execute Home Assistant services to control devices -- **Queue Management**: Automatic retry logic and background refresh of entity states -- **Event System**: Integration with the device's event queue for real-time updates - -## Configuration - -The client requires two configuration parameters: - -- **URL**: Your Home Assistant instance URL (e.g., `http://homeassistant.local:8123`) -- **API Key**: A long-lived access token from Home Assistant - -### Setting up the API Key - -1. In Home Assistant, go to Profile → Long-Lived Access Tokens -2. Click "Create Token" -3. Give it a descriptive name (e.g., "DeskHog Device") -4. Copy the generated token - -### Configuration Methods - -```cpp -// Set configuration programmatically -configManager->setHomeAssistantUrl("http://homeassistant.local:8123"); -configManager->setHomeAssistantApiKey("your_long_lived_access_token_here"); - -// Get configuration -String url = configManager->getHomeAssistantUrl(); -String apiKey = configManager->getHomeAssistantApiKey(); - -// Clear configuration -configManager->clearHomeAssistantConfig(); -``` - -## Usage - -### Basic Setup - -```cpp -#include "homeassistant/HomeAssistantClient.h" - -// Create client instance -HomeAssistantClient* haClient = new HomeAssistantClient(*configManager, *eventQueue); - -// In your main loop -haClient->process(); -``` - -### Requesting Entity States - -```cpp -// Request current state of entities -haClient->requestEntityState("sensor.outdoor_temperature"); -haClient->requestEntityState("light.living_room"); -haClient->requestEntityState("switch.desk_lamp"); -``` - -### Calling Services - -```cpp -// Turn on a light with specific settings -haClient->callService("light", "turn_on", "light.living_room", - "\"brightness\":255,\"color_name\":\"blue\""); - -// Turn off a switch -haClient->callService("switch", "turn_off", "switch.desk_lamp"); - -// Set thermostat temperature -haClient->callService("climate", "set_temperature", "climate.living_room", - "\"temperature\":22.5"); -``` - -## Events - -The client publishes events to the event queue: - -- `HA_ENTITY_STATE_RECEIVED`: When an entity state is successfully fetched -- `HA_SERVICE_CALLED`: When a service call is completed (future enhancement) - -### Handling Events - -```cpp -void handleEvent(const Event& event) { - if (event.type == EventType::HA_ENTITY_STATE_RECEIVED) { - Serial.printf("Entity %s state: %s\n", - event.insightId.c_str(), event.jsonData.c_str()); - - // Parse JSON response to extract state and attributes - // event.jsonData contains the full entity state JSON - } -} -``` - -## API Endpoints Used - -- **Entity States**: `GET /api/states/{entity_id}` -- **Service Calls**: `POST /api/services/{domain}/{service}` - -## Features - -- **Automatic Retry**: Failed requests are retried up to 3 times -- **Background Refresh**: Entity states are refreshed every 30 seconds -- **Memory Optimization**: Large responses are allocated in PSRAM -- **Queue Management**: Non-blocking operation with request queuing -- **Error Handling**: Comprehensive error logging and recovery - -## Integration with DeskHog - -The HomeAssistantClient follows the same patterns as PostHogClient: - -1. Created in `setup()` with ConfigManager and EventQueue references -2. Called in the main loop via `process()` -3. Configured through the web portal or programmatically -4. Publishes events for UI updates - -This allows seamless integration with the existing DeskHog architecture for displaying Home Assistant data alongside PostHog analytics. diff --git a/src/main.cpp b/src/main.cpp index 73ebf9d..c2c3844 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -62,6 +62,7 @@ WiFiInterface* wifiInterface; CaptivePortal* captivePortal; CardController* cardController; // Replace individual card objects with controller PostHogClient* posthogClient; +HomeAssistantClient* homeAssistantClient; EventQueue* eventQueue; // Add global EventQueue NeoPixelController* neoPixelController; // Renamed from neoPixelManager OtaManager* otaManager; @@ -70,6 +71,7 @@ OtaManager* otaManager; TaskHandle_t wifiTask; TaskHandle_t portalTask; TaskHandle_t insightTask; +TaskHandle_t homeAssistantTask; TaskHandle_t neoPixelTask; // WiFi connection timeout in milliseconds @@ -103,6 +105,14 @@ void insightTaskFunction(void* parameter) { } } +// HomeAssistant processing task +void homeAssistantTaskFunction(void* parameter) { + while (1) { + homeAssistantClient->process(); // Use the global instance + vTaskDelay(pdMS_TO_TICKS(100)); // Check every 100ms + } +} + // LVGL handler task that includes button polling - added here to consolidate UI operations void lvglHandlerTask(void* parameter) { TickType_t lastButtonCheck = xTaskGetTickCount(); @@ -239,6 +249,9 @@ void setup() { // Initialize PostHog client with event queue posthogClient = new PostHogClient(*configManager, *eventQueue); + + // Initialize Home Assistant client + homeAssistantClient = new HomeAssistantClient(*configManager, *eventQueue); // Initialize display manager displayInterface = new DisplayInterface( @@ -262,6 +275,7 @@ void setup() { *configManager, *wifiInterface, *posthogClient, + *homeAssistantClient, *eventQueue ); @@ -307,6 +321,17 @@ void setup() { &insightTask, 0 ); + + // Create task for Home Assistant processing + xTaskCreatePinnedToCore( + homeAssistantTaskFunction, + "homeAssistantTask", + 8192, + NULL, + 1, + &homeAssistantTask, + 0 + ); // Create LVGL tick task xTaskCreatePinnedToCore( diff --git a/src/ui/CaptivePortal.cpp b/src/ui/CaptivePortal.cpp index 2dc9fff..4954576 100644 --- a/src/ui/CaptivePortal.cpp +++ b/src/ui/CaptivePortal.cpp @@ -63,6 +63,7 @@ void CaptivePortal::begin() { // Needs to be defined before the actual GET/POST handlers for the same paths. _server.on("/save-wifi", HTTP_OPTIONS, std::bind(&CaptivePortal::handleCorsPreflight, this, std::placeholders::_1)); _server.on("/save-device-config", HTTP_OPTIONS, std::bind(&CaptivePortal::handleCorsPreflight, this, std::placeholders::_1)); + _server.on("/save-ha-config", HTTP_OPTIONS, std::bind(&CaptivePortal::handleCorsPreflight, this, std::placeholders::_1)); _server.on("/save-insight", HTTP_OPTIONS, std::bind(&CaptivePortal::handleCorsPreflight, this, std::placeholders::_1)); _server.on("/delete-insight", HTTP_OPTIONS, std::bind(&CaptivePortal::handleCorsPreflight, this, std::placeholders::_1)); _server.on("/start-update", HTTP_OPTIONS, std::bind(&CaptivePortal::handleCorsPreflight, this, std::placeholders::_1)); @@ -93,6 +94,10 @@ void CaptivePortal::begin() { // Device config actions _server.on("/get-device-config", HTTP_GET, std::bind(&CaptivePortal::handleGetDeviceConfig, this, std::placeholders::_1)); + // Home Assistant config actions + _server.on("/get-ha-config", HTTP_GET, std::bind(&CaptivePortal::handleGetHomeAssistantConfig, this, std::placeholders::_1)); + _server.on("/save-ha-config", HTTP_POST, std::bind(&CaptivePortal::handleSaveHomeAssistantConfig, this, std::placeholders::_1)); + // Insight actions _server.on("/get-insights", HTTP_GET, std::bind(&CaptivePortal::handleGetInsights, this, std::placeholders::_1)); @@ -261,6 +266,45 @@ void CaptivePortal::handleSaveDeviceConfig(AsyncWebServerRequest *request) { request->send(response); } +void CaptivePortal::handleGetHomeAssistantConfig(AsyncWebServerRequest *request) { + DynamicJsonDocument doc(512); + String haUrl = _configManager.getHomeAssistantUrl(); + String haApiKey = _configManager.getHomeAssistantApiKey(); + + doc["haUrl"] = haUrl; + // Send a truncated or placeholder API key for security if it's set + doc["haApiKey"] = haApiKey.length() > 0 ? "********" + haApiKey.substring(haApiKey.length() - 4) : ""; + + String responseJson; + serializeJson(doc, responseJson); + AsyncWebServerResponse *response = request->beginResponse(200, "application/json", responseJson); + response->addHeader("Access-Control-Allow-Origin", "*"); + request->send(response); +} + +void CaptivePortal::handleSaveHomeAssistantConfig(AsyncWebServerRequest *request) { + bool success = false; + if (request->hasParam("haUrl", true) && request->hasParam("haApiKey", true)) { + String haUrl = request->getParam("haUrl", true)->value(); + String haApiKey = request->getParam("haApiKey", true)->value(); + + _configManager.setHomeAssistantUrl(haUrl); + // Only update API key if it's not the placeholder + if (haApiKey.indexOf("********") == -1) { + _configManager.setHomeAssistantApiKey(haApiKey); + } + success = true; + } + + DynamicJsonDocument doc(256); + doc["success"] = success; + String responseJson; + serializeJson(doc, responseJson); + AsyncWebServerResponse *response = request->beginResponse(200, "application/json", responseJson); + response->addHeader("Access-Control-Allow-Origin", "*"); + request->send(response); +} + void CaptivePortal::handleGetInsights(AsyncWebServerRequest *request) { DynamicJsonDocument doc(1024); // Switched to DynamicJsonDocument, larger for potential list JsonArray insightsArray = doc.createNestedArray("insights"); @@ -669,6 +713,13 @@ void CaptivePortal::handleApiStatus(AsyncWebServerRequest *request) { deviceConfigObj["api_key_display"] = apiKey.length() > 0 ? "********" + apiKey.substring(apiKey.length() - 4) : ""; deviceConfigObj["api_key_present"] = apiKey.length() > 0; + JsonObject haConfigObj = doc.createNestedObject("ha_config"); + String haUrl = _configManager.getHomeAssistantUrl(); + String haApiKey = _configManager.getHomeAssistantApiKey(); + haConfigObj["ha_url"] = haUrl; + haConfigObj["ha_api_key_display"] = haApiKey.length() > 0 ? "********" + haApiKey.substring(haApiKey.length() - 4) : ""; + haConfigObj["ha_api_key_present"] = haApiKey.length() > 0; + JsonArray insightsArray = doc.createNestedArray("insights"); std::vector insightIds = _configManager.getAllInsightIds(); for (const String& id : insightIds) { diff --git a/src/ui/CaptivePortal.h b/src/ui/CaptivePortal.h index 6f3efaf..4913ae4 100644 --- a/src/ui/CaptivePortal.h +++ b/src/ui/CaptivePortal.h @@ -133,6 +133,18 @@ class CaptivePortal { */ void handleSaveDeviceConfig(AsyncWebServerRequest *request); + /** + * @brief Return current Home Assistant configuration + * Returns Home Assistant URL and truncated API key + */ + void handleGetHomeAssistantConfig(AsyncWebServerRequest *request); + + /** + * @brief Handle Home Assistant configuration updates + * Accepts Home Assistant URL and API key updates + */ + void handleSaveHomeAssistantConfig(AsyncWebServerRequest *request); + /** * @brief Return list of configured insights * Returns JSON array of insight IDs and titles diff --git a/src/ui/CardController.cpp b/src/ui/CardController.cpp index 42dca44..0a2ecff 100644 --- a/src/ui/CardController.cpp +++ b/src/ui/CardController.cpp @@ -52,6 +52,12 @@ CardController::~CardController() { } insightCards.clear(); + // Clean up Home Assistant cards + for (auto* card : homeAssistantCards) { + delete card; + } + homeAssistantCards.clear(); + // Release mutex if we took it if (displayInterface && displayInterface->getMutexPtr()) { xSemaphoreGive(*(displayInterface->getMutexPtr())); @@ -385,6 +391,43 @@ void CardController::initializeCardTypes() { return nullptr; }; registerCardType(friendDef); + + // Register HOME_ASSISTANT card type + CardDefinition homeAssistantDef; + homeAssistantDef.type = CardType::HOME_ASSISTANT; + homeAssistantDef.name = "Home Assistant entity"; + homeAssistantDef.allowMultiple = true; + homeAssistantDef.needsConfigInput = true; + homeAssistantDef.configInputLabel = "Entity ID"; + homeAssistantDef.uiDescription = "Monitor Home Assistant sensors, switches, and devices"; + homeAssistantDef.factory = [this](const String& configValue) -> lv_obj_t* { + // Create new Home Assistant card using the entity ID + HomeAssistantCard* newCard = new HomeAssistantCard( + screen, + configManager, + eventQueue, + configValue, + screenWidth, + screenHeight + ); + + if (newCard && newCard->getCardObject()) { + // Add to our list of cards + homeAssistantCards.push_back(newCard); + + // Request data for this entity immediately + homeAssistantClient.requestEntityState(configValue); + Serial.printf("Requested Home Assistant entity data for: %s\n", configValue.c_str()); + + return newCard->getCardObject(); + } + + delete newCard; + return nullptr; + }; + registerCardType(homeAssistantDef); + + } void CardController::handleCardConfigChanged() { @@ -424,6 +467,15 @@ void CardController::reconcileCards(const std::vector& newConfigs) { } insightCards.clear(); + // Remove existing Home Assistant cards + for (auto* card : homeAssistantCards) { + if (card && card->getCardObject()) { + cardStack->removeCard(card->getCardObject()); + } + delete card; + } + homeAssistantCards.clear(); + // Remove existing animation card if (animationCard && animationCard->getCard()) { cardStack->removeCard(animationCard->getCard()); @@ -521,7 +573,8 @@ void CardController::dispatchToLVGLTask(std::function update_func, bool void CardController::handleCardTitleUpdated(const Event& event) { // Find and update the card configuration with the new title for (auto& cardConfig : currentCardConfigs) { - if (cardConfig.type == CardType::INSIGHT && cardConfig.config == event.insightId) { + if ((cardConfig.type == CardType::INSIGHT && cardConfig.config == event.insightId) || + (cardConfig.type == CardType::HOME_ASSISTANT && cardConfig.config == event.insightId)) { // Update the name with the new title if (cardConfig.name != event.title) { cardConfig.name = event.title; @@ -529,7 +582,8 @@ void CardController::handleCardTitleUpdated(const Event& event) { // Save the updated configuration to persistent storage configManager.saveCardConfigs(currentCardConfigs); - Serial.printf("Updated card title for insight %s to: %s\n", + Serial.printf("Updated card title for %s %s to: %s\n", + cardTypeToString(cardConfig.type).c_str(), event.insightId.c_str(), event.title.c_str()); } break; diff --git a/src/ui/HomeAssistantCard.cpp b/src/ui/HomeAssistantCard.cpp index 6ca331f..e4bb257 100644 --- a/src/ui/HomeAssistantCard.cpp +++ b/src/ui/HomeAssistantCard.cpp @@ -16,6 +16,9 @@ HomeAssistantCard::HomeAssistantCard(lv_obj_t* parent, ConfigManager& config, Ev , _content_container(nullptr) , _current_type(HomeAssistantParser::EntityType::ENTITY_NOT_SUPPORTED) { + Serial.printf("[HomeAssistantCard-%s] DEBUG: Creating HomeAssistant card with dimensions %dx%d\n", + _entity_id.c_str(), width, height); + _card = lv_obj_create(parent); if (!_card) { Serial.printf("[HomeAssistantCard-%s] CRITICAL: Failed to create card base object!\n", _entity_id.c_str()); @@ -92,6 +95,9 @@ HomeAssistantCard::HomeAssistantCard(lv_obj_t* parent, ConfigManager& config, Ev this->onEvent(event); } }); + + Serial.printf("[HomeAssistantCard-%s] DEBUG: Card created successfully, subscribed to HA_ENTITY_STATE_RECEIVED events\n", + _entity_id.c_str()); } HomeAssistantCard::~HomeAssistantCard() { @@ -105,11 +111,17 @@ HomeAssistantCard::~HomeAssistantCard() { } void HomeAssistantCard::onEvent(const Event& event) { + Serial.printf("[HomeAssistantCard-%s] DEBUG: Event received - type: %d, entityId: %s, dataLength: %d\n", + _entity_id.c_str(), (int)event.type, event.insightId.c_str(), event.jsonData.length()); + std::shared_ptr parser = nullptr; if (event.jsonData.length() > 0) { + Serial.printf("[HomeAssistantCard-%s] DEBUG: JSON data preview: %.100s%s\n", + _entity_id.c_str(), event.jsonData.c_str(), + event.jsonData.length() > 100 ? "..." : ""); parser = std::make_shared(event.jsonData.c_str()); } else { - Serial.printf("[HomeAssistantCard-%s] Event received with no JSON data.\n", _entity_id.c_str()); + Serial.printf("[HomeAssistantCard-%s] ERROR: Event received with no JSON data.\n", _entity_id.c_str()); handleParsedData(nullptr); return; } @@ -117,8 +129,13 @@ void HomeAssistantCard::onEvent(const Event& event) { } void HomeAssistantCard::handleParsedData(std::shared_ptr parser) { + Serial.printf("[HomeAssistantCard-%s] DEBUG: handleParsedData called\n", _entity_id.c_str()); + if (!parser || !parser->isValid()) { - Serial.printf("[HomeAssistantCard-%s] Invalid data or parse error.\n", _entity_id.c_str()); + Serial.printf("[HomeAssistantCard-%s] ERROR: Invalid data or parse error. Parser: %s, Valid: %s\n", + _entity_id.c_str(), + parser ? "exists" : "null", + parser ? (parser->isValid() ? "true" : "false") : "N/A"); if (globalUIDispatch) { globalUIDispatch([this]() { if(isValidObject(_title_label)) lv_label_set_text(_title_label, "Data Error"); @@ -131,7 +148,7 @@ void HomeAssistantCard::handleParsedData(std::shared_ptr pa } if (!parser->isAvailable()) { - Serial.printf("[HomeAssistantCard-%s] Entity is unavailable.\n", _entity_id.c_str()); + Serial.printf("[HomeAssistantCard-%s] WARNING: Entity is unavailable.\n", _entity_id.c_str()); if (globalUIDispatch) { globalUIDispatch([this]() { if(isValidObject(_title_label)) lv_label_set_text(_title_label, "Unavailable"); @@ -143,9 +160,14 @@ void HomeAssistantCard::handleParsedData(std::shared_ptr pa } HomeAssistantParser::EntityType new_entity_type = parser->getEntityType(); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Entity type detected: %d\n", _entity_id.c_str(), (int)new_entity_type); + char friendly_name_buffer[64]; if (!parser->getFriendlyName(friendly_name_buffer, sizeof(friendly_name_buffer))) { strcpy(friendly_name_buffer, _entity_id.c_str()); + Serial.printf("[HomeAssistantCard-%s] DEBUG: No friendly name found, using entity ID\n", _entity_id.c_str()); + } else { + Serial.printf("[HomeAssistantCard-%s] DEBUG: Friendly name: %s\n", _entity_id.c_str(), friendly_name_buffer); } String new_title(friendly_name_buffer); @@ -153,30 +175,43 @@ void HomeAssistantCard::handleParsedData(std::shared_ptr pa if (_current_title != new_title) { _current_title = new_title; _event_queue.publishEvent(Event::createTitleUpdateEvent(_entity_id, new_title)); - Serial.printf("[HomeAssistantCard-%s] Title updated to: %s\n", _entity_id.c_str(), new_title.c_str()); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Title updated to: %s\n", _entity_id.c_str(), new_title.c_str()); + } else { + Serial.printf("[HomeAssistantCard-%s] DEBUG: Title unchanged: %s\n", _entity_id.c_str(), new_title.c_str()); } + Serial.printf("[HomeAssistantCard-%s] DEBUG: Dispatching UI update for entity type %d\n", + _entity_id.c_str(), (int)new_entity_type); + if (globalUIDispatch) { globalUIDispatch([this, new_entity_type, new_title, parser, id = _entity_id]() mutable { + Serial.printf("[HomeAssistantCard-%s] DEBUG: UI update lambda executing\n", id.c_str()); + if (isValidObject(_title_label)) { lv_label_set_text(_title_label, new_title.c_str()); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Title label updated\n", id.c_str()); + } else { + Serial.printf("[HomeAssistantCard-%s] ERROR: Title label is invalid!\n", id.c_str()); } _current_type = new_entity_type; switch (new_entity_type) { case HomeAssistantParser::EntityType::NUMERIC_SENSOR: + Serial.printf("[HomeAssistantCard-%s] DEBUG: Updating numeric display\n", id.c_str()); updateNumericDisplay(*parser, new_title); break; case HomeAssistantParser::EntityType::BINARY_SENSOR: + Serial.printf("[HomeAssistantCard-%s] DEBUG: Updating binary display\n", id.c_str()); updateBinaryDisplay(*parser, new_title); break; case HomeAssistantParser::EntityType::SWITCH: case HomeAssistantParser::EntityType::LIGHT: + Serial.printf("[HomeAssistantCard-%s] DEBUG: Updating switch/light display\n", id.c_str()); updateSwitchDisplay(*parser, new_title); break; default: - Serial.printf("[HomeAssistantCard-%s] Unsupported entity type %d.\n", + Serial.printf("[HomeAssistantCard-%s] ERROR: Unsupported entity type %d.\n", id.c_str(), (int)new_entity_type); if(isValidObject(_value_label)) lv_label_set_text(_value_label, "Unsupported"); if(isValidObject(_unit_label)) lv_label_set_text(_unit_label, ""); @@ -184,31 +219,50 @@ void HomeAssistantCard::handleParsedData(std::shared_ptr pa } }, true); + } else { + Serial.printf("[HomeAssistantCard-%s] ERROR: globalUIDispatch is null!\n", _entity_id.c_str()); } } void HomeAssistantCard::updateNumericDisplay(const HomeAssistantParser& parser, const String& friendly_name) { + Serial.printf("[HomeAssistantCard-%s] DEBUG: updateNumericDisplay called\n", _entity_id.c_str()); + if (parser.isNumericState()) { double value = parser.getNumericState(); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Numeric value: %f\n", _entity_id.c_str(), value); + char value_buffer[32]; formatNumericValue(value, value_buffer, sizeof(value_buffer)); char unit_buffer[16]; parser.getUnitOfMeasurement(unit_buffer, sizeof(unit_buffer)); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Formatted value: '%s', unit: '%s'\n", + _entity_id.c_str(), value_buffer, unit_buffer); + if (isValidObject(_value_label)) { lv_label_set_text(_value_label, value_buffer); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Value label updated\n", _entity_id.c_str()); + } else { + Serial.printf("[HomeAssistantCard-%s] ERROR: Value label is invalid!\n", _entity_id.c_str()); } if (isValidObject(_unit_label)) { lv_label_set_text(_unit_label, unit_buffer); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Unit label updated\n", _entity_id.c_str()); + } else { + Serial.printf("[HomeAssistantCard-%s] ERROR: Unit label is invalid!\n", _entity_id.c_str()); } - Serial.printf("[HomeAssistantCard-%s] Updated numeric: %s %s\n", + Serial.printf("[HomeAssistantCard-%s] INFO: Updated numeric: %s %s\n", _entity_id.c_str(), value_buffer, unit_buffer); } else { + Serial.printf("[HomeAssistantCard-%s] DEBUG: Non-numeric state, getting state string\n", _entity_id.c_str()); + char state_buffer[32]; parser.getStateString(state_buffer, sizeof(state_buffer)); + Serial.printf("[HomeAssistantCard-%s] DEBUG: State string: '%s'\n", _entity_id.c_str(), state_buffer); + if (isValidObject(_value_label)) { lv_label_set_text(_value_label, state_buffer); } @@ -219,9 +273,13 @@ void HomeAssistantCard::updateNumericDisplay(const HomeAssistantParser& parser, } void HomeAssistantCard::updateBinaryDisplay(const HomeAssistantParser& parser, const String& friendly_name) { + Serial.printf("[HomeAssistantCard-%s] DEBUG: updateBinaryDisplay called\n", _entity_id.c_str()); + char state_buffer[32]; parser.getStateString(state_buffer, sizeof(state_buffer)); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Raw binary state: '%s'\n", _entity_id.c_str(), state_buffer); + // Convert common binary states to more user-friendly display String display_state = String(state_buffer); if (display_state == "on" || display_state == "true") { @@ -234,14 +292,19 @@ void HomeAssistantCard::updateBinaryDisplay(const HomeAssistantParser& parser, c display_state = "CLOSED"; } + Serial.printf("[HomeAssistantCard-%s] DEBUG: Display state: '%s'\n", _entity_id.c_str(), display_state.c_str()); + if (isValidObject(_value_label)) { lv_label_set_text(_value_label, display_state.c_str()); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Binary value label updated\n", _entity_id.c_str()); + } else { + Serial.printf("[HomeAssistantCard-%s] ERROR: Binary value label is invalid!\n", _entity_id.c_str()); } if (isValidObject(_unit_label)) { lv_label_set_text(_unit_label, ""); } - Serial.printf("[HomeAssistantCard-%s] Updated binary: %s\n", + Serial.printf("[HomeAssistantCard-%s] INFO: Updated binary: %s\n", _entity_id.c_str(), display_state.c_str()); } diff --git a/src/ui/HomeAssistantCard_example.cpp b/src/ui/HomeAssistantCard_example.cpp deleted file mode 100644 index 3c58a7f..0000000 --- a/src/ui/HomeAssistantCard_example.cpp +++ /dev/null @@ -1,129 +0,0 @@ -/** - * HomeAssistantCard Usage Example - * =============================== - * - * This example demonstrates how to use the HomeAssistantCard to display - * Home Assistant entity data on the DeskHog display. - */ - -#include "ui/CardController.h" -#include "homeassistant/HomeAssistantClient.h" - -// Example: Creating Home Assistant cards in your main application - -void setupHomeAssistantCards(CardController& cardController, HomeAssistantClient& haClient) { - // Configure Home Assistant connection (do this once during setup) - // This would typically be done through the web interface or stored configuration - - // Create cards for various Home Assistant entities - - // Temperature sensor - cardController.createHomeAssistantCard("sensor.outdoor_temperature"); - - // Humidity sensor - cardController.createHomeAssistantCard("sensor.indoor_humidity"); - - // Power consumption sensor - cardController.createHomeAssistantCard("sensor.power_consumption"); - - // Binary sensors - cardController.createHomeAssistantCard("binary_sensor.front_door"); - cardController.createHomeAssistantCard("binary_sensor.motion_living_room"); - - // Switch states - cardController.createHomeAssistantCard("switch.desk_lamp"); - cardController.createHomeAssistantCard("light.bedroom_light"); - - // The cards will automatically: - // 1. Request entity state data from Home Assistant - // 2. Parse the JSON response using HomeAssistantParser - // 3. Display the appropriate UI based on entity type: - // - Numeric sensors: Show value with unit (e.g., "23.5 °C") - // - Binary sensors: Show ON/OFF, OPEN/CLOSED - // - Switches/Lights: Show ON/OFF state - // 4. Refresh data automatically every 30 seconds - // 5. Handle unavailable/unknown states gracefully -} - -/** - * Example JSON responses that HomeAssistantCard can handle: - * - * Numeric Sensor: - * { - * "entity_id": "sensor.outdoor_temperature", - * "state": "23.5", - * "attributes": { - * "unit_of_measurement": "°C", - * "friendly_name": "Outdoor Temperature", - * "device_class": "temperature" - * } - * } - * - * Binary Sensor: - * { - * "entity_id": "binary_sensor.front_door", - * "state": "off", - * "attributes": { - * "friendly_name": "Front Door", - * "device_class": "door" - * } - * } - * - * Switch: - * { - * "entity_id": "switch.desk_lamp", - * "state": "on", - * "attributes": { - * "friendly_name": "Desk Lamp" - * } - * } - */ - -/** - * Card Display Examples: - * - * Temperature Sensor Card: - * ┌─────────────────────────┐ - * │ Outdoor Temperature │ - * │ │ - * │ 23.5 │ - * │ °C │ - * └─────────────────────────┘ - * - * Binary Sensor Card: - * ┌─────────────────────────┐ - * │ Front Door │ - * │ │ - * │ CLOSED │ - * │ │ - * └─────────────────────────┘ - * - * Switch Card: - * ┌─────────────────────────┐ - * │ Desk Lamp │ - * │ │ - * │ ON │ - * │ │ - * └─────────────────────────┘ - */ - -/** - * Integration with existing DeskHog workflow: - * - * 1. The HomeAssistantCard uses the same event system as InsightCard - * 2. Cards are managed by CardController alongside PostHog insight cards - * 3. Navigation between Home Assistant and PostHog cards works seamlessly - * 4. All cards share the same LVGL styling from Style.h - * 5. Thread-safe UI updates are handled automatically - * 6. Cards respond to the same button inputs for navigation - */ - -/** - * Configuration requirements: - * - * Before creating Home Assistant cards, ensure: - * 1. Home Assistant URL is configured: configManager.setHomeAssistantUrl("http://homeassistant.local:8123") - * 2. API key is configured: configManager.setHomeAssistantApiKey("your_long_lived_access_token") - * 3. WiFi is connected and HomeAssistantClient.isReady() returns true - * 4. The specified entities exist in your Home Assistant instance - */ From d050de939ea662d1305bfe89597d6852ca4a2704 Mon Sep 17 00:00:00 2001 From: Tim Broddin Date: Fri, 20 Jun 2025 21:41:25 +0200 Subject: [PATCH 5/7] Oops broke the HA config --- html/portal.html | 21 ++++++++++++++++++++ html/portal.js | 4 ++-- include/html_portal.h | 24 +++++++++++------------ src/homeassistant/HomeAssistantClient.cpp | 2 +- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/html/portal.html b/html/portal.html index bb41660..96679ac 100644 --- a/html/portal.html +++ b/html/portal.html @@ -69,6 +69,27 @@

PostHog API configuration

+

Home Assistant

+ +
+
+
Home Assistant URL + +

This is the URL of your Home Assistant instance.

+
+
Long-lived access token + +

Create a long-lived access token in your Home Assistant user profile.

+
+
+ +
+
+
+ +

Add cards

diff --git a/html/portal.js b/html/portal.js index 09ea6d5..d045003 100644 --- a/html/portal.js +++ b/html/portal.js @@ -137,7 +137,7 @@ function saveDeviceConfig() { // Handle Home Assistant config form submission function saveHomeAssistantConfig() { - const form = document.getElementById('ha-form'); + const form = document.getElementById('home-assistant-form'); const formData = new FormData(form); const globalActionStatusEl = document.getElementById('global-action-status'); @@ -873,7 +873,7 @@ function pollApiStatus() { } // 3a. Update Home Assistant Config Info - if (data.ha_config) { + if (data.ha_config && document.getElementById('haUrl').value === '') { _updateHomeAssistantConfigUI(data.ha_config); } diff --git a/include/html_portal.h b/include/html_portal.h index 850a139..2259d44 100644 --- a/include/html_portal.h +++ b/include/html_portal.h @@ -518,7 +518,7 @@ static const char PORTAL_HTML[] PROGMEM = "\n" "\n" "// Handle Home Assistant config form submission\n" "function saveHomeAssistantConfig() {\n" -" const form = document.getElementById('ha-form');\n" +" const form = document.getElementById('home-assistant-form');\n" " const formData = new FormData(form);\n" " const globalActionStatusEl = document.getElementById('global-action-status');\n" " \n" @@ -1254,7 +1254,7 @@ static const char PORTAL_HTML[] PROGMEM = "\n" " }\n" "\n" " // 3a. Update Home Assistant Config Info\n" -" if (data.ha_config) {\n" +" if (data.ha_config && document.getElementById('haUrl').value === '') {\n" " _updateHomeAssistantConfigUI(data.ha_config);\n" " }\n" "\n" @@ -1586,28 +1586,28 @@ static const char PORTAL_HTML[] PROGMEM = "\n" "

Home Assistant

\n" "\n" "
\n" -"
\n" -"
\n" +" \n" +"
Home Assistant URL\n" -" \n" +" \n" +"

This is the URL of your Home Assistant instance.

\n" "
\n" -" \n" -"
\n" -" \n" -" \n" +"
Long-lived access token\n" +" \n" +"

Create a long-lived access token in your Home Assistant user profile.

\n" "
\n" -" \n" -"

Get your token from Home Assistant → Profile → Long-Lived Access Tokens

\n" -" \n" "
\n" " \n" "
\n" " \n" "
\n" "\n" +"\n" "

Add cards

\n" "\n" "
\n" +"

Available cards

\n" "
\n" " \n" "

Loading available card types...

\n" diff --git a/src/homeassistant/HomeAssistantClient.cpp b/src/homeassistant/HomeAssistantClient.cpp index 34e2094..70ccf7e 100644 --- a/src/homeassistant/HomeAssistantClient.cpp +++ b/src/homeassistant/HomeAssistantClient.cpp @@ -57,7 +57,7 @@ bool HomeAssistantClient::callService(const String& domain, const String& servic } bool HomeAssistantClient::isReady() const { - return SystemController::isSystemFullyReady() && + return SystemController::getWifiState() == WifiState::CONNECTED && !_config.getHomeAssistantUrl().isEmpty() && !_config.getHomeAssistantApiKey().isEmpty(); } From 427e8468538623280ff7ebeaf0a292cbfdcdbf55 Mon Sep 17 00:00:00 2001 From: Tim Broddin Date: Fri, 20 Jun 2025 21:51:51 +0200 Subject: [PATCH 6/7] Add support for cover entities in Home Assistant integration - Extend HomeAssistantParser to recognize cover entities and retrieve their position and state. - Update HomeAssistantCard to display cover states and positions. - Enhance documentation to include cover entity support. --- src/homeassistant/HomeAssistantParser.cpp | 63 +++++++++++++++++++++++ src/homeassistant/HomeAssistantParser.h | 40 +++++++++++++- src/ui/HomeAssistantCard.cpp | 51 ++++++++++++++++++ src/ui/HomeAssistantCard.h | 8 +++ 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/src/homeassistant/HomeAssistantParser.cpp b/src/homeassistant/HomeAssistantParser.cpp index c4ed9f0..cb4d3ce 100644 --- a/src/homeassistant/HomeAssistantParser.cpp +++ b/src/homeassistant/HomeAssistantParser.cpp @@ -64,6 +64,9 @@ HomeAssistantParser::EntityType HomeAssistantParser::getEntityType() const { else if (strncmp(entity_id, "light.", 6) == 0) { return EntityType::LIGHT; } + else if (strncmp(entity_id, "cover.", 6) == 0) { + return EntityType::COVER; + } return EntityType::ENTITY_NOT_SUPPORTED; } @@ -190,3 +193,63 @@ bool HomeAssistantParser::copyJsonString(const char* value, char* buffer, size_t strcpy(buffer, value); return true; } + +int HomeAssistantParser::getCoverPosition() const { + if (!_valid || getEntityType() != EntityType::COVER) return -1; + + // Check for current_position attribute + if (_doc["attributes"]["current_position"].is()) { + return _doc["attributes"]["current_position"].as(); + } + + // Some covers use position instead of current_position + if (_doc["attributes"]["position"].is()) { + return _doc["attributes"]["position"].as(); + } + + return -1; +} + +int HomeAssistantParser::getCoverTiltPosition() const { + if (!_valid || getEntityType() != EntityType::COVER) return -1; + + // Check for current_tilt_position attribute + if (_doc["attributes"]["current_tilt_position"].is()) { + return _doc["attributes"]["current_tilt_position"].as(); + } + + // Some covers use tilt_position instead of current_tilt_position + if (_doc["attributes"]["tilt_position"].is()) { + return _doc["attributes"]["tilt_position"].as(); + } + + return -1; +} + +bool HomeAssistantParser::isCoverOpening() const { + if (!_valid || getEntityType() != EntityType::COVER) return false; + + const char* state = _doc["state"]; + return state && strcmp(state, "opening") == 0; +} + +bool HomeAssistantParser::isCoverClosing() const { + if (!_valid || getEntityType() != EntityType::COVER) return false; + + const char* state = _doc["state"]; + return state && strcmp(state, "closing") == 0; +} + +bool HomeAssistantParser::isCoverOpen() const { + if (!_valid || getEntityType() != EntityType::COVER) return false; + + const char* state = _doc["state"]; + return state && strcmp(state, "open") == 0; +} + +bool HomeAssistantParser::isCoverClosed() const { + if (!_valid || getEntityType() != EntityType::COVER) return false; + + const char* state = _doc["state"]; + return state && strcmp(state, "closed") == 0; +} diff --git a/src/homeassistant/HomeAssistantParser.h b/src/homeassistant/HomeAssistantParser.h index f2f107d..04f3667 100644 --- a/src/homeassistant/HomeAssistantParser.h +++ b/src/homeassistant/HomeAssistantParser.h @@ -9,7 +9,8 @@ * @brief Parser for Home Assistant entity state data * * Handles parsing and data extraction from Home Assistant entity state JSON responses. - * Supports numeric sensor data with unit extraction and state information. + * Supports various entity types including numeric sensors, binary sensors, switches, + * lights, and cover entities with unit extraction and state information. * * Example JSON structure: * { @@ -35,6 +36,7 @@ class HomeAssistantParser { BINARY_SENSOR, ///< Binary sensor (on/off, true/false) SWITCH, ///< Switch entity (on/off state) LIGHT, ///< Light entity (on/off with brightness) + COVER, ///< Cover entity (blinds, garage doors, curtains, etc.) ENTITY_NOT_SUPPORTED ///< Unsupported or unrecognized entity type }; @@ -122,6 +124,42 @@ class HomeAssistantParser { */ bool isAvailable() const; + /** + * @brief Get cover position (0-100 percentage) + * @return Position percentage, -1 if not available or not a cover + */ + int getCoverPosition() const; + + /** + * @brief Get cover tilt position (0-100 percentage) + * @return Tilt position percentage, -1 if not available or not a cover + */ + int getCoverTiltPosition() const; + + /** + * @brief Check if cover is opening + * @return true if cover state is "opening" + */ + bool isCoverOpening() const; + + /** + * @brief Check if cover is closing + * @return true if cover state is "closing" + */ + bool isCoverClosing() const; + + /** + * @brief Check if cover is open + * @return true if cover state is "open" + */ + bool isCoverOpen() const; + + /** + * @brief Check if cover is closed + * @return true if cover state is "closed" + */ + bool isCoverClosed() const; + private: DynamicJsonDocument _doc; ///< JSON document for parsing bool _valid; ///< Parsing success flag diff --git a/src/ui/HomeAssistantCard.cpp b/src/ui/HomeAssistantCard.cpp index e4bb257..c72a102 100644 --- a/src/ui/HomeAssistantCard.cpp +++ b/src/ui/HomeAssistantCard.cpp @@ -210,6 +210,10 @@ void HomeAssistantCard::handleParsedData(std::shared_ptr pa Serial.printf("[HomeAssistantCard-%s] DEBUG: Updating switch/light display\n", id.c_str()); updateSwitchDisplay(*parser, new_title); break; + case HomeAssistantParser::EntityType::COVER: + Serial.printf("[HomeAssistantCard-%s] DEBUG: Updating cover display\n", id.c_str()); + updateCoverDisplay(*parser, new_title); + break; default: Serial.printf("[HomeAssistantCard-%s] ERROR: Unsupported entity type %d.\n", id.c_str(), (int)new_entity_type); @@ -331,6 +335,53 @@ void HomeAssistantCard::updateSwitchDisplay(const HomeAssistantParser& parser, c _entity_id.c_str(), display_state.c_str()); } +void HomeAssistantCard::updateCoverDisplay(const HomeAssistantParser& parser, const String& friendly_name) { + Serial.printf("[HomeAssistantCard-%s] DEBUG: updateCoverDisplay called\n", _entity_id.c_str()); + + char state_buffer[32]; + parser.getStateString(state_buffer, sizeof(state_buffer)); + + Serial.printf("[HomeAssistantCard-%s] DEBUG: Raw cover state: '%s'\n", _entity_id.c_str(), state_buffer); + + // Check if we have position information + int position = parser.getCoverPosition(); + String display_value; + + if (position >= 0) { + // Show position percentage if available + display_value = String(position) + "%"; + Serial.printf("[HomeAssistantCard-%s] DEBUG: Cover position: %d%%\n", _entity_id.c_str(), position); + } else { + // Fall back to state display + String display_state = String(state_buffer); + if (display_state == "open") { + display_state = "OPEN"; + } else if (display_state == "closed") { + display_state = "CLOSED"; + } else if (display_state == "opening") { + display_state = "OPENING"; + } else if (display_state == "closing") { + display_state = "CLOSING"; + } + display_value = display_state; + Serial.printf("[HomeAssistantCard-%s] DEBUG: Cover state: '%s'\n", _entity_id.c_str(), display_state.c_str()); + } + + if (isValidObject(_value_label)) { + lv_label_set_text(_value_label, display_value.c_str()); + Serial.printf("[HomeAssistantCard-%s] DEBUG: Cover value label updated\n", _entity_id.c_str()); + } else { + Serial.printf("[HomeAssistantCard-%s] ERROR: Cover value label is invalid!\n", _entity_id.c_str()); + } + + if (isValidObject(_unit_label)) { + lv_label_set_text(_unit_label, ""); + } + + Serial.printf("[HomeAssistantCard-%s] INFO: Updated cover: %s\n", + _entity_id.c_str(), display_value.c_str()); +} + void HomeAssistantCard::formatNumericValue(double value, char* buffer, size_t bufferSize) { // Use the existing NumberFormat utility if available, otherwise basic formatting if (value == (int)value && value < 1000) { diff --git a/src/ui/HomeAssistantCard.h b/src/ui/HomeAssistantCard.h index f9040ae..ec63db3 100644 --- a/src/ui/HomeAssistantCard.h +++ b/src/ui/HomeAssistantCard.h @@ -16,6 +16,7 @@ * - Numeric sensor values (temperature, humidity, power, etc.) * - Binary sensor states (motion, door, window states) * - Switch and light states + * - Cover states (blinds, curtains, garage doors with position display) * * Features: * - Thread-safe UI updates via queue system @@ -120,6 +121,13 @@ class HomeAssistantCard { */ void updateSwitchDisplay(const HomeAssistantParser& parser, const String& friendly_name); + /** + * @brief Update cover display (blinds, curtains, garage doors) + * @param parser Parser containing entity data + * @param friendly_name Entity friendly name + */ + void updateCoverDisplay(const HomeAssistantParser& parser, const String& friendly_name); + /** * @brief Format numeric value with appropriate precision * @param value Numeric value to format From d23d3a2bb9829f39e7b170301766d23808c2d8b8 Mon Sep 17 00:00:00 2001 From: Tim Broddin Date: Fri, 20 Jun 2025 22:06:24 +0200 Subject: [PATCH 7/7] Add Home Assistant client integration and button handling for cover, switch, and light entities --- src/ui/CardController.cpp | 8 ++++++ src/ui/HomeAssistantCard.cpp | 52 +++++++++++++++++++++++++++++++++++- src/ui/HomeAssistantCard.h | 21 +++++++++++++-- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/ui/CardController.cpp b/src/ui/CardController.cpp index ab738ad..643f58d 100644 --- a/src/ui/CardController.cpp +++ b/src/ui/CardController.cpp @@ -238,6 +238,7 @@ void CardController::createHomeAssistantCard(const String& entityId) { screen, // LVGL parent object configManager, // Dependencies eventQueue, + homeAssistantClient, // Home Assistant client reference entityId, screenWidth, // Dimensions screenHeight @@ -253,6 +254,9 @@ void CardController::createHomeAssistantCard(const String& entityId) { // Add to navigation stack cardStack->addCard(newCard->getCardObject()); + // Register as input handler for button interactions + cardStack->registerInputHandler(newCard->getCardObject(), newCard); + // Add to our list of Home Assistant cards homeAssistantCards.push_back(newCard); @@ -406,12 +410,16 @@ void CardController::initializeCardTypes() { screen, configManager, eventQueue, + homeAssistantClient, // Add the HomeAssistant client reference configValue, screenWidth, screenHeight ); if (newCard && newCard->getCardObject()) { + // Register as input handler for button interactions + cardStack->registerInputHandler(newCard->getCardObject(), newCard); + // Add to our list of cards homeAssistantCards.push_back(newCard); diff --git a/src/ui/HomeAssistantCard.cpp b/src/ui/HomeAssistantCard.cpp index c72a102..eb7c558 100644 --- a/src/ui/HomeAssistantCard.cpp +++ b/src/ui/HomeAssistantCard.cpp @@ -1,12 +1,15 @@ #include "HomeAssistantCard.h" #include "Style.h" #include "NumberFormat.h" +#include "hardware/Input.h" #include HomeAssistantCard::HomeAssistantCard(lv_obj_t* parent, ConfigManager& config, EventQueue& eventQueue, - const String& entityId, uint16_t width, uint16_t height) + HomeAssistantClient& homeAssistantClient, const String& entityId, + uint16_t width, uint16_t height) : _config(config) , _event_queue(eventQueue) + , _home_assistant_client(homeAssistantClient) , _entity_id(entityId) , _current_title("") , _card(nullptr) @@ -406,6 +409,53 @@ void HomeAssistantCard::clearContentContainer() { } } +bool HomeAssistantCard::handleButtonPress(uint8_t button_index) { + // Only handle center button press + if (button_index != Input::BUTTON_CENTER) { + return false; + } + + // Only handle cover and light entity types + if (_current_type != HomeAssistantParser::EntityType::COVER && + _current_type != HomeAssistantParser::EntityType::LIGHT && + _current_type != HomeAssistantParser::EntityType::SWITCH) { + return false; + } + + Serial.printf("[HomeAssistantCard-%s] DEBUG: Button press handled for entity type %d\n", + _entity_id.c_str(), (int)_current_type); + + // Determine the service domain and action based on entity type + String domain, service; + if (_current_type == HomeAssistantParser::EntityType::COVER) { + domain = "cover"; + // For covers, we'll use toggle service which opens if closed, closes if open + service = "toggle"; + } else if (_current_type == HomeAssistantParser::EntityType::LIGHT) { + domain = "light"; + service = "toggle"; + } else if (_current_type == HomeAssistantParser::EntityType::SWITCH) { + domain = "switch"; + service = "toggle"; + } + + // Call the service + bool success = _home_assistant_client.callService(domain, service, _entity_id); + + if (success) { + Serial.printf("[HomeAssistantCard-%s] Service call successful: %s.%s\n", + _entity_id.c_str(), domain.c_str(), service.c_str()); + + // Request updated state immediately after service call + _home_assistant_client.requestEntityState(_entity_id); + } else { + Serial.printf("[HomeAssistantCard-%s] Service call failed: %s.%s\n", + _entity_id.c_str(), domain.c_str(), service.c_str()); + } + + return true; // We handled this button press +} + bool HomeAssistantCard::isValidObject(lv_obj_t* obj) const { return obj && lv_obj_is_valid(obj); } diff --git a/src/ui/HomeAssistantCard.h b/src/ui/HomeAssistantCard.h index ec63db3..7246924 100644 --- a/src/ui/HomeAssistantCard.h +++ b/src/ui/HomeAssistantCard.h @@ -6,6 +6,8 @@ #include "ConfigManager.h" #include "EventQueue.h" #include "homeassistant/HomeAssistantParser.h" +#include "homeassistant/HomeAssistantClient.h" +#include "ui/InputHandler.h" #include "UICallback.h" /** @@ -24,8 +26,9 @@ * - Memory-safe LVGL object management * - Unit display and formatting * - State availability indication + * - Button interaction for cover and light control */ -class HomeAssistantCard { +class HomeAssistantCard : public InputHandler { public: /** * @brief Constructor @@ -33,6 +36,7 @@ class HomeAssistantCard { * @param parent LVGL parent object to attach this card to * @param config Configuration manager for persistent storage * @param eventQueue Event queue for receiving data updates + * @param homeAssistantClient Home Assistant client for service calls * @param entityId Home Assistant entity ID (e.g., "sensor.temperature") * @param width Card width in pixels * @param height Card height in pixels @@ -44,7 +48,8 @@ class HomeAssistantCard { * Subscribes to HA_ENTITY_STATE_RECEIVED events for the specified entityId. */ HomeAssistantCard(lv_obj_t* parent, ConfigManager& config, EventQueue& eventQueue, - const String& entityId, uint16_t width, uint16_t height); + HomeAssistantClient& homeAssistantClient, const String& entityId, + uint16_t width, uint16_t height); /** * @brief Destructor - cleans up LVGL objects safely @@ -69,10 +74,22 @@ class HomeAssistantCard { */ lv_obj_t* getCardObject() const { return _card; } + /** + * @brief Handle button press events + * @param button_index The button that was pressed + * @return true if the event was handled, false otherwise + * + * For cover entities: toggles open/close + * For light entities: toggles on/off + * For switch entities: toggles on/off + */ + bool handleButtonPress(uint8_t button_index) override; + private: // Configuration and events ConfigManager& _config; EventQueue& _event_queue; + HomeAssistantClient& _home_assistant_client; // Entity identification String _entity_id;