New panel design
This commit is contained in:
1 parent
face2432e4
commit
b5d6b4a897
30 files changed
+4540
-171
No files matched your search
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
Reference in new issue
Block a user