Files
thermostat-hw/main/api/ha_client.c
2026-08-04 11:55:02 -07:00

264 lines
7.7 KiB
C

/*
* Home Assistant REST transport. See ha_client.h for the contract and why
* this is plain HTTP.
*/
#include <stdio.h>
#include <string.h>
#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);
}