/* * Home Assistant REST transport. See ha_client.h for the contract and why * this is plain HTTP. */ #include #include #include "cJSON.h" #include "esp_http_client.h" #include "esp_log.h" #include "app_config.h" #include "ha_client.h" #include "ha_template.h" static const char *TAG = "ha_client"; #define HA_URL_MAX 128 #define HA_BODY_MAX 1024 /* * The forecast reply is the big one: six days of conditions and temperatures, * measured at ~1.1KB. The state template is ~400 bytes. 4KB leaves room for * HA to grow a field without silently truncating. */ #define HA_RESP_MAX 4096 /* Local network, no TLS handshake, so these can be far tighter than the * cloud client's. A LAN request that has not answered in 5s is not going to. */ #define HA_TIMEOUT_MS 5000 static char resp_buf[HA_RESP_MAX]; static size_t resp_len; static bool resp_truncated; /* * Kept alive between polls. Re-creating the client each time re-opens the TCP * connection, which is cheap on a LAN but not free, and this runs inside the * window where rendering is paused. */ static esp_http_client_handle_t client; /* * Long-lived HA tokens are JWTs -- ~180-200 characters, not the 30-40 an * "API key" suggests. 320 leaves room for "Bearer " plus a token noticeably * longer than the ones HA issues today. */ static char auth_header[320]; static esp_err_t http_event_handler(esp_http_client_event_t *evt) { switch (evt->event_id) { case HTTP_EVENT_ON_DATA: /* Responses are chunked, so Content-Length is often -1; accumulate * into a fixed buffer rather than sizing an allocation from it. */ if (resp_len + evt->data_len < sizeof(resp_buf)) { memcpy(resp_buf + resp_len, evt->data, evt->data_len); resp_len += evt->data_len; } else if (!resp_truncated) { resp_truncated = true; /* once per response, not per chunk */ ESP_LOGW(TAG, "Response exceeds %d bytes, truncating", HA_RESP_MAX); } break; default: break; } return ESP_OK; } static void client_drop(void) { if (client != NULL) { esp_http_client_cleanup(client); client = NULL; } } esp_err_t ha_client_init(void) { if (HA_HOST[0] == '\0' || HA_TOKEN[0] == '\0') { ESP_LOGW(TAG, "HA_HOST or HA_TOKEN not set in main/app_config.h -- " "Home Assistant will not be polled"); return ESP_ERR_INVALID_STATE; } const int n = snprintf(auth_header, sizeof(auth_header), "Bearer %s", HA_TOKEN); if (n < 0 || n >= (int)sizeof(auth_header)) { ESP_LOGE(TAG, "Token too long for the auth header buffer"); return ESP_ERR_INVALID_SIZE; } return ESP_OK; } /* * One POST. `path` and `body` are borrowed; the reply lands in resp_buf. * * The client is rebuilt whenever the URL changes, because the three endpoints * this talks to (template, service call, forecast) are different paths on the * same host and esp_http_client keeps the connection per-handle. */ static esp_err_t ha_post(const char *path, const char *body) { char url[HA_URL_MAX]; const int n = snprintf(url, sizeof(url), "http://%s:%d%s", HA_HOST, HA_PORT, path); if (n < 0 || n >= (int)sizeof(url)) { ESP_LOGE(TAG, "URL truncated for path %s", path); return ESP_ERR_INVALID_SIZE; } if (client == NULL) { const esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_POST, .timeout_ms = HA_TIMEOUT_MS, .event_handler = http_event_handler, .keep_alive_enable = true, }; client = esp_http_client_init(&config); if (client == NULL) { return ESP_ERR_NO_MEM; } esp_http_client_set_header(client, "Authorization", auth_header); esp_http_client_set_header(client, "Content-Type", "application/json"); } else if (esp_http_client_set_url(client, url) != ESP_OK) { client_drop(); return ESP_FAIL; } esp_http_client_set_method(client, HTTP_METHOD_POST); esp_http_client_set_post_field(client, body, (int)strlen(body)); resp_len = 0; resp_truncated = false; esp_err_t err = esp_http_client_perform(client); if (err != ESP_OK) { ESP_LOGE(TAG, "POST %s failed: %s", path, esp_err_to_name(err)); client_drop(); /* a half-open connection would fail every later poll */ return err; } const int status = esp_http_client_get_status_code(client); if (status != 200) { ESP_LOGE(TAG, "HTTP %d from %s", status, path); if (status == 401) { ESP_LOGE(TAG, "Token rejected -- check HA_TOKEN in app_config.h"); } client_drop(); return ESP_FAIL; } if (resp_len == 0) { /* Service calls legitimately return an empty body. */ resp_buf[0] = '\0'; return ESP_OK; } resp_buf[resp_len] = '\0'; return ESP_OK; } esp_err_t ha_fetch_state(ha_data_t *data) { if (data == NULL) { return ESP_ERR_INVALID_ARG; } /* * The template is JSON-escaped by cJSON rather than by hand: it is full of * quotes and braces, and getting the escaping subtly wrong yields a 400 * that looks like a template bug. */ cJSON *req = cJSON_CreateObject(); if (req == NULL) { return ESP_ERR_NO_MEM; } if (cJSON_AddStringToObject(req, "template", HA_STATE_TEMPLATE) == NULL) { cJSON_Delete(req); return ESP_ERR_NO_MEM; } char *body = cJSON_PrintUnformatted(req); cJSON_Delete(req); if (body == NULL) { return ESP_ERR_NO_MEM; } const esp_err_t err = ha_post("/api/template", body); cJSON_free(body); if (err != ESP_OK) { return err; } if (!ha_parse_json(resp_buf, data)) { ESP_LOGE(TAG, "State response was not valid JSON"); return ESP_FAIL; } return ESP_OK; } esp_err_t ha_fetch_forecast(ha_data_t *data) { if (data == NULL) { return ESP_ERR_INVALID_ARG; } /* return_response is required: without it HA runs the service and returns * nothing, which looks like an empty forecast rather than a mistake. */ const esp_err_t err = ha_post( "/api/services/weather/get_forecasts?return_response", HA_FORECAST_BODY); if (err != ESP_OK) { return err; } if (!ha_parse_forecast_json(resp_buf, data)) { ESP_LOGE(TAG, "Forecast response could not be parsed"); return ESP_FAIL; } return ESP_OK; } esp_err_t ha_light_command(int index, bool on, int16_t brightness) { if (index < 0 || index >= HA_LIGHT_COUNT) { return ESP_ERR_INVALID_ARG; } const ha_light_cfg_t *cfg = &HA_LIGHTS[index]; const char *domain = ha_service_domain(cfg->entity_id); char path[HA_URL_MAX]; int n = snprintf(path, sizeof(path), "/api/services/%s/turn_%s", domain, on ? "on" : "off"); if (n < 0 || n >= (int)sizeof(path)) { return ESP_ERR_INVALID_SIZE; } char body[HA_BODY_MAX]; if (on && brightness >= 0 && cfg->dimmable) { n = snprintf(body, sizeof(body), "{\"entity_id\":\"%s\",\"brightness\":%d}", cfg->entity_id, brightness); } else { /* No brightness key: turning on without one makes HA restore the * light's previous level, which is what a user expects from a tap. */ n = snprintf(body, sizeof(body), "{\"entity_id\":\"%s\"}", cfg->entity_id); } if (n < 0 || n >= (int)sizeof(body)) { return ESP_ERR_INVALID_SIZE; } ESP_LOGI(TAG, "%s -> %s%s", cfg->entity_id, on ? "on" : "off", (on && brightness >= 0 && cfg->dimmable) ? " (with brightness)" : ""); return ha_post(path, body); }