264 lines
7.7 KiB
C
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);
|
|
}
|