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);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Home Assistant REST transport (device only).
|
||||
*
|
||||
* Everything ESP-IDF-specific about talking to HA lives here; parsing and
|
||||
* presentation are in ha_data.c, which the desktop simulator also compiles.
|
||||
*
|
||||
* Plain HTTP on the LAN, deliberately. The Ambient Weather cloud client needs
|
||||
* TLS and pays a multi-second handshake for it; this talks to a box on the
|
||||
* same switch, and the token is not worth a handshake inside the window where
|
||||
* the UI is frozen. Do not point this at an https:// HA instance without
|
||||
* revisiting that trade-off.
|
||||
*/
|
||||
#ifndef HA_CLIENT_H
|
||||
#define HA_CLIENT_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#include "ha_data.h"
|
||||
|
||||
/* Validates configuration. Does not open a connection. */
|
||||
esp_err_t ha_client_init(void);
|
||||
|
||||
/*
|
||||
* One request for every sensor, light and sun time (~400 bytes). Leaves
|
||||
* data->forecast alone -- see ha_fetch_forecast().
|
||||
*
|
||||
* Blocks for the round trip, so callers pause LVGL around it.
|
||||
*/
|
||||
esp_err_t ha_fetch_state(ha_data_t *data);
|
||||
|
||||
/*
|
||||
* Fills data->forecast. A separate, slower poll: the forecast is a service
|
||||
* call rather than a template render, and it changes hourly at most.
|
||||
*/
|
||||
esp_err_t ha_fetch_forecast(ha_data_t *data);
|
||||
|
||||
/*
|
||||
* Turn a light on or off. `index` indexes HA_LIGHTS; `brightness` is 0-255,
|
||||
* or -1 to leave the level alone (and always for switch-domain entities).
|
||||
*
|
||||
* Blocks. Must not be called from the LVGL task -- see ui_set_light_cb().
|
||||
*/
|
||||
esp_err_t ha_light_command(int index, bool on, int16_t brightness);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Home Assistant entity IDs for this installation.
|
||||
*
|
||||
* Entity IDs are not secrets, so unlike the token in app_config.h they are
|
||||
* tracked. They are also not stable: renaming a device or reinstalling an
|
||||
* integration changes them, and a wrong ID fails silently as "unavailable"
|
||||
* rather than as an error. Re-check with tools/ha_discover.py after touching
|
||||
* anything in HA.
|
||||
*/
|
||||
#ifndef HA_CONFIG_H
|
||||
#define HA_CONFIG_H
|
||||
|
||||
/* --- Weather station (Ambient Weather, exposed locally through HA) ------- */
|
||||
|
||||
#define HA_ENT_OUTDOOR_TEMP "sensor.moonlight_beach_temperature"
|
||||
#define HA_ENT_OUTDOOR_HUMIDITY "sensor.moonlight_beach_humidity"
|
||||
#define HA_ENT_INDOOR_TEMP "sensor.moonlight_beach_inside_temperature"
|
||||
#define HA_ENT_INDOOR_HUMIDITY "sensor.moonlight_beach_humidity_indoor"
|
||||
#define HA_ENT_DEW_POINT "sensor.moonlight_beach_dew_point"
|
||||
#define HA_ENT_PRESSURE "sensor.moonlight_beach_relative_pressure"
|
||||
#define HA_ENT_RAIN_TODAY "sensor.moonlight_beach_daily_rain"
|
||||
#define HA_ENT_RAIN_RATE "sensor.moonlight_beach_precipitation_intensity"
|
||||
|
||||
/*
|
||||
* The anemometer has been offline since 2026-07-27: wind speed, gust and
|
||||
* direction all read "unavailable". The display shows blanks for these by
|
||||
* design -- no substituting forecast wind for station wind.
|
||||
*
|
||||
* Do not be tempted by sensor.moonlight_beach_max_gust. It is NOT unavailable;
|
||||
* it still reports its last value from 2026-07-28 and would render as a
|
||||
* plausible live number. Anything using it needs a staleness check first.
|
||||
*/
|
||||
#define HA_ENT_WIND_SPEED "sensor.moonlight_beach_wind_speed"
|
||||
#define HA_ENT_WIND_GUST "sensor.moonlight_beach_wind_gust"
|
||||
#define HA_ENT_WIND_DIR "sensor.moonlight_beach_wind_direction"
|
||||
|
||||
/* --- Forecast and sun ---------------------------------------------------- */
|
||||
|
||||
/* met.no via HA. Supplies the textual condition and the daily forecast. */
|
||||
#define HA_ENT_WEATHER "weather.forecast_home"
|
||||
#define HA_ENT_SUN "sun.sun"
|
||||
|
||||
/* --- Lights -------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* The X-macro is load-bearing, not decoration.
|
||||
*
|
||||
* The JSON template asks HA for these entities as an ordered array, and the
|
||||
* firmware reads the reply back by index. Two hand-maintained lists would
|
||||
* drift the first time one is reordered, and the failure mode is silent: the
|
||||
* Dining Room tile would just start controlling the Blue Lamp. Generating the
|
||||
* template's entity list and the C table from one source makes that
|
||||
* impossible.
|
||||
*
|
||||
* X(entity_id, label, dimmable)
|
||||
*
|
||||
* `dimmable` is false for the patio, which is a TP-Link smart plug in the
|
||||
* switch domain -- on/off only, and it needs switch.turn_on rather than
|
||||
* light.turn_on. See ha_service_domain().
|
||||
*
|
||||
* Note light.main_room is a GROUP containing blue_lamp, brown_lamp and
|
||||
* landing_chandelier. Toggling it changes three of the other tiles; that is
|
||||
* expected, and the UI reflects it on the next poll.
|
||||
*/
|
||||
/* Labels must fit one line of Montserrat 16 in a ~125px tile -- roughly 11
|
||||
* characters. Longer ones are truncated with an ellipsis, which reads as a
|
||||
* mistake. "Dining Room" is one character too wide, hence "Dining". */
|
||||
#define HA_LIGHT_LIST(X) \
|
||||
X("light.main_room", "Main Room", true) \
|
||||
X("light.dining_room", "Dining", true) \
|
||||
X("light.blue_lamp", "Blue Lamp", true) \
|
||||
X("light.brown_lamp", "Brown Lamp", true) \
|
||||
X("light.landing_chandelier", "Chandelier", true) \
|
||||
X("switch.tp_link_smart_plug_f048_outdoor_hanging_lights", "Patio", false)
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* Parsing and presentation helpers for Home Assistant data.
|
||||
*
|
||||
* Deliberately free of ESP-IDF and network dependencies: this file is
|
||||
* compiled into the desktop simulator as well as the firmware, so the layout
|
||||
* work in sim/ exercises the same parser the device runs. Keep it that way --
|
||||
* anything needing esp_* belongs in ha_client.c.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
|
||||
#include "ha_data.h"
|
||||
|
||||
/* Build the config table from the same list the JSON template is built from. */
|
||||
#define HA_LIGHT_ENTRY_X(id, label, dimmable) { id, label, dimmable },
|
||||
const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT] = {
|
||||
HA_LIGHT_LIST(HA_LIGHT_ENTRY_X)
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ values */
|
||||
|
||||
/*
|
||||
* A JSON null (or a missing key) means the sensor is unavailable and the
|
||||
* field stays invalid. Note cJSON_IsNumber() is false for null, so this
|
||||
* handles both cases without a separate null check.
|
||||
*/
|
||||
static void take_number(const cJSON *root, const char *key, ha_value_t *out)
|
||||
{
|
||||
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
||||
if (cJSON_IsNumber(item)) {
|
||||
out->value = (float)item->valuedouble;
|
||||
out->valid = true;
|
||||
}
|
||||
}
|
||||
|
||||
static void take_string(const cJSON *root, const char *key, char *out, size_t len)
|
||||
{
|
||||
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
||||
if (cJSON_IsString(item) && item->valuestring != NULL) {
|
||||
snprintf(out, len, "%s", item->valuestring);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ lights */
|
||||
|
||||
static ha_light_state_t light_state_from_string(const char *s)
|
||||
{
|
||||
if (s == NULL) {
|
||||
return HA_LIGHT_UNAVAILABLE;
|
||||
}
|
||||
if (strcmp(s, "on") == 0) {
|
||||
return HA_LIGHT_ON;
|
||||
}
|
||||
if (strcmp(s, "off") == 0) {
|
||||
return HA_LIGHT_OFF;
|
||||
}
|
||||
/* "unavailable", "unknown", or anything unexpected. Treated the same:
|
||||
* the tile greys out rather than claiming the light is off. */
|
||||
return HA_LIGHT_UNAVAILABLE;
|
||||
}
|
||||
|
||||
static void parse_lights(const cJSON *root, ha_data_t *out)
|
||||
{
|
||||
const cJSON *lights = cJSON_GetObjectItemCaseSensitive(root, "lights");
|
||||
if (!cJSON_IsArray(lights)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Index-aligned with HA_LIGHTS because the template iterates the same
|
||||
* X-macro list. If HA returns a different count, something is out of sync
|
||||
* between the template and the table -- take what lines up rather than
|
||||
* reading past either array.
|
||||
*/
|
||||
const int n = cJSON_GetArraySize(lights);
|
||||
const int count = (n < HA_LIGHT_COUNT) ? n : HA_LIGHT_COUNT;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
const cJSON *entry = cJSON_GetArrayItem(lights, i);
|
||||
if (!cJSON_IsObject(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cJSON *state = cJSON_GetObjectItemCaseSensitive(entry, "s");
|
||||
out->lights[i].state = light_state_from_string(
|
||||
cJSON_IsString(state) ? state->valuestring : NULL);
|
||||
|
||||
const cJSON *bright = cJSON_GetObjectItemCaseSensitive(entry, "b");
|
||||
out->lights[i].brightness = cJSON_IsNumber(bright)
|
||||
? (int16_t)bright->valuedouble
|
||||
: -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- state JSON */
|
||||
|
||||
bool ha_parse_json(const char *json, ha_data_t *out)
|
||||
{
|
||||
if (json == NULL || out == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_Parse(json);
|
||||
if (root == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Preserve the forecast across the wipe. It arrives from a different,
|
||||
* slower request; zeroing the whole struct here would blank the forecast
|
||||
* strip on every sensor poll.
|
||||
*/
|
||||
ha_forecast_day_t saved_forecast[HA_FORECAST_DAYS];
|
||||
memcpy(saved_forecast, out->forecast, sizeof(saved_forecast));
|
||||
const uint8_t saved_count = out->forecast_count;
|
||||
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
memcpy(out->forecast, saved_forecast, sizeof(saved_forecast));
|
||||
out->forecast_count = saved_count;
|
||||
|
||||
take_number(root, "out_t", &out->outdoor_temp_f);
|
||||
take_number(root, "out_h", &out->outdoor_humidity);
|
||||
take_number(root, "in_t", &out->indoor_temp_f);
|
||||
take_number(root, "in_h", &out->indoor_humidity);
|
||||
take_number(root, "dew", &out->dew_point_f);
|
||||
take_number(root, "press", &out->pressure_inhg);
|
||||
take_number(root, "rain_d", &out->rain_today_in);
|
||||
take_number(root, "rain_r", &out->rain_rate_inhr);
|
||||
take_number(root, "wind", &out->wind_mph);
|
||||
take_number(root, "gust", &out->gust_mph);
|
||||
take_number(root, "wdir", &out->wind_dir_deg);
|
||||
|
||||
take_string(root, "cond", out->condition, sizeof(out->condition));
|
||||
take_string(root, "sunrise", out->sunrise, sizeof(out->sunrise));
|
||||
take_string(root, "sunset", out->sunset, sizeof(out->sunset));
|
||||
take_string(root, "clock", out->clock, sizeof(out->clock));
|
||||
take_string(root, "date", out->date, sizeof(out->date));
|
||||
|
||||
out->sun_up = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "sun_up"));
|
||||
|
||||
parse_lights(root, out);
|
||||
|
||||
cJSON_Delete(root);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- forecast JSON */
|
||||
|
||||
/*
|
||||
* Weekday from a "YYYY-MM-DD..." prefix, by Zeller's congruence.
|
||||
*
|
||||
* The device has no clock, so the day name cannot come from localtime(). The
|
||||
* forecast timestamps are UTC; HA returns them at 19:00Z, which is midday
|
||||
* locally, so the UTC date and the local date agree. That would stop being
|
||||
* true for a timestamp near midnight UTC -- if HA ever changes the hour, this
|
||||
* needs the offset applied first.
|
||||
*/
|
||||
static void weekday_name(const char *iso, char *out, size_t len)
|
||||
{
|
||||
int y = 0, m = 0, d = 0;
|
||||
if (iso == NULL || sscanf(iso, "%4d-%2d-%2d", &y, &m, &d) != 3) {
|
||||
snprintf(out, len, "--");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Zeller treats January and February as months 13 and 14 of the year
|
||||
* before. */
|
||||
if (m < 3) {
|
||||
m += 12;
|
||||
y -= 1;
|
||||
}
|
||||
|
||||
const int K = y % 100;
|
||||
const int J = y / 100;
|
||||
const int h = (d + (13 * (m + 1)) / 5 + K + K / 4 + J / 4 + 5 * J) % 7;
|
||||
|
||||
/* Zeller's h: 0 = Saturday. */
|
||||
static const char *const names[7] = {
|
||||
"Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"
|
||||
};
|
||||
snprintf(out, len, "%s", names[h]);
|
||||
}
|
||||
|
||||
bool ha_parse_forecast_json(const char *json, ha_data_t *out)
|
||||
{
|
||||
if (json == NULL || out == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_Parse(json);
|
||||
if (root == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
|
||||
/* { "service_response": { "<entity>": { "forecast": [ ... ] } } } */
|
||||
const cJSON *resp = cJSON_GetObjectItemCaseSensitive(root, "service_response");
|
||||
const cJSON *entity = cJSON_IsObject(resp) ? resp->child : NULL;
|
||||
const cJSON *list = cJSON_IsObject(entity)
|
||||
? cJSON_GetObjectItemCaseSensitive(entity, "forecast")
|
||||
: NULL;
|
||||
|
||||
if (cJSON_IsArray(list)) {
|
||||
const int n = cJSON_GetArraySize(list);
|
||||
const int count = (n < HA_FORECAST_DAYS) ? n : HA_FORECAST_DAYS;
|
||||
|
||||
memset(out->forecast, 0, sizeof(out->forecast));
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
const cJSON *day = cJSON_GetArrayItem(list, i);
|
||||
if (!cJSON_IsObject(day)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ha_forecast_day_t *slot = &out->forecast[i];
|
||||
|
||||
const cJSON *dt = cJSON_GetObjectItemCaseSensitive(day, "datetime");
|
||||
weekday_name(cJSON_IsString(dt) ? dt->valuestring : NULL,
|
||||
slot->day, sizeof(slot->day));
|
||||
|
||||
take_string(day, "condition", slot->condition, sizeof(slot->condition));
|
||||
take_number(day, "temperature", &slot->high_f);
|
||||
take_number(day, "templow", &slot->low_f);
|
||||
}
|
||||
|
||||
out->forecast_count = (uint8_t)count;
|
||||
ok = true;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- presentation */
|
||||
|
||||
const char *ha_service_domain(const char *entity_id)
|
||||
{
|
||||
/* The patio plug lives in the switch domain; light.turn_on on a switch
|
||||
* entity is accepted by HA and silently does nothing. */
|
||||
if (entity_id != NULL && strncmp(entity_id, "switch.", 7) == 0) {
|
||||
return "switch";
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *slug;
|
||||
const char *label;
|
||||
const char *shrt;
|
||||
} condition_name_t;
|
||||
|
||||
/* met.no condition slugs as HA reports them. `shrt` is for the forecast
|
||||
* strip, where a column is about 150px wide. */
|
||||
static const condition_name_t CONDITIONS[] = {
|
||||
{ "clear-night", "Clear", "Clear" },
|
||||
{ "cloudy", "Cloudy", "Cloudy" },
|
||||
{ "exceptional", "Exceptional", "Severe" },
|
||||
{ "fog", "Fog", "Fog" },
|
||||
{ "hail", "Hail", "Hail" },
|
||||
{ "lightning", "Thunderstorms", "Storm" },
|
||||
{ "lightning-rainy", "Thunderstorms", "Storm" },
|
||||
{ "partlycloudy", "Partly Cloudy", "Partly" },
|
||||
{ "pouring", "Heavy Rain", "Heavy" },
|
||||
{ "rainy", "Rain", "Rain" },
|
||||
{ "snowy", "Snow", "Snow" },
|
||||
{ "snowy-rainy", "Wintry Mix", "Wintry" },
|
||||
{ "sunny", "Sunny", "Sunny" },
|
||||
{ "windy", "Windy", "Windy" },
|
||||
{ "windy-variant", "Windy", "Windy" },
|
||||
};
|
||||
|
||||
static const condition_name_t *condition_lookup(const char *slug)
|
||||
{
|
||||
if (slug == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
for (size_t i = 0; i < sizeof(CONDITIONS) / sizeof(CONDITIONS[0]); i++) {
|
||||
if (strcmp(CONDITIONS[i].slug, slug) == 0) {
|
||||
return &CONDITIONS[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char *ha_condition_label(const char *slug)
|
||||
{
|
||||
const condition_name_t *c = condition_lookup(slug);
|
||||
/* Unrecognised slugs pass through rather than becoming "Unknown" -- if
|
||||
* met.no adds a condition, seeing the raw slug on screen says what to
|
||||
* add here. */
|
||||
return (c != NULL) ? c->label : (slug != NULL ? slug : "--");
|
||||
}
|
||||
|
||||
const char *ha_condition_short(const char *slug)
|
||||
{
|
||||
const condition_name_t *c = condition_lookup(slug);
|
||||
return (c != NULL) ? c->shrt : (slug != NULL ? slug : "--");
|
||||
}
|
||||
|
||||
const char *ha_wind_cardinal(float degrees)
|
||||
{
|
||||
static const char *const points[16] = {
|
||||
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
|
||||
};
|
||||
|
||||
if (!isfinite(degrees)) {
|
||||
return "--";
|
||||
}
|
||||
|
||||
/* fmodf twice: a negative bearing would otherwise index out of bounds. */
|
||||
float d = fmodf(fmodf(degrees, 360.0f) + 360.0f, 360.0f);
|
||||
const int idx = (int)((d + 11.25f) / 22.5f) % 16;
|
||||
return points[idx];
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* The data contract between "fetching" and "drawing".
|
||||
*
|
||||
* ha_data_t is plain data with no ESP-IDF or network dependency, and
|
||||
* ha_parse_json() is a pure function. That is what lets the desktop simulator
|
||||
* run the real UI: sim/ supplies a JSON snapshot or a LAN fetch, the firmware
|
||||
* supplies esp_http_client, and both hand the identical struct to the
|
||||
* identical drawing code.
|
||||
*
|
||||
* The UI layer must never see HTTP, cJSON or an entity ID -- only this.
|
||||
*/
|
||||
#ifndef HA_DATA_H
|
||||
#define HA_DATA_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "ha_config.h"
|
||||
|
||||
/* Five days across the bottom strip. HA returns six; the sixth is dropped. */
|
||||
#define HA_FORECAST_DAYS 5
|
||||
|
||||
/* Derived from HA_LIGHT_LIST so the two can never disagree. */
|
||||
#define HA_LIGHT_COUNT_X(a, b, c) +1
|
||||
#define HA_LIGHT_COUNT (0 HA_LIGHT_LIST(HA_LIGHT_COUNT_X))
|
||||
|
||||
/*
|
||||
* A reading that may not exist.
|
||||
*
|
||||
* Sensors go unavailable routinely -- a dead anemometer, a flat battery, an
|
||||
* integration reloading -- and a float alone cannot say so. Every consumer has
|
||||
* to check `valid` before touching `value`; there is deliberately no sentinel
|
||||
* to accidentally render as a real number.
|
||||
*/
|
||||
typedef struct {
|
||||
float value;
|
||||
bool valid;
|
||||
} ha_value_t;
|
||||
|
||||
typedef enum {
|
||||
HA_LIGHT_OFF = 0,
|
||||
HA_LIGHT_ON,
|
||||
HA_LIGHT_UNAVAILABLE, /* entity exists but HA cannot reach the device */
|
||||
} ha_light_state_t;
|
||||
|
||||
typedef struct {
|
||||
ha_light_state_t state;
|
||||
/* 0-255 as HA reports it. Only meaningful when state is HA_LIGHT_ON and
|
||||
* the entity is dimmable; -1 otherwise. */
|
||||
int16_t brightness;
|
||||
} ha_light_status_t;
|
||||
|
||||
typedef struct {
|
||||
const char *entity_id;
|
||||
const char *label;
|
||||
bool dimmable;
|
||||
} ha_light_cfg_t;
|
||||
|
||||
/* Generated from HA_LIGHT_LIST; index-aligned with ha_data_t.lights. */
|
||||
extern const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT];
|
||||
|
||||
typedef struct {
|
||||
char day[4]; /* "Mon" -- derived from the ISO date, see below */
|
||||
char condition[24]; /* met.no condition slug, e.g. "partlycloudy" */
|
||||
ha_value_t high_f;
|
||||
ha_value_t low_f;
|
||||
} ha_forecast_day_t;
|
||||
|
||||
typedef struct {
|
||||
/* Weather station */
|
||||
ha_value_t outdoor_temp_f;
|
||||
ha_value_t outdoor_humidity;
|
||||
ha_value_t indoor_temp_f;
|
||||
ha_value_t indoor_humidity;
|
||||
ha_value_t dew_point_f;
|
||||
ha_value_t pressure_inhg;
|
||||
ha_value_t rain_today_in;
|
||||
ha_value_t rain_rate_inhr;
|
||||
ha_value_t wind_mph;
|
||||
ha_value_t gust_mph;
|
||||
ha_value_t wind_dir_deg;
|
||||
|
||||
/* Forecast / sun. These strings arrive pre-formatted in local time --
|
||||
* HA owns the timezone, so the device needs no clock and no SNTP. */
|
||||
char condition[24];
|
||||
char sunrise[12]; /* "6:05 AM" */
|
||||
char sunset[12]; /* "7:45 PM" */
|
||||
char clock[12]; /* "10:21 AM" */
|
||||
char date[16]; /* "Tue Aug 4" */
|
||||
bool sun_up;
|
||||
|
||||
ha_light_status_t lights[HA_LIGHT_COUNT];
|
||||
|
||||
ha_forecast_day_t forecast[HA_FORECAST_DAYS];
|
||||
uint8_t forecast_count; /* 0 when no forecast has been fetched yet */
|
||||
} ha_data_t;
|
||||
|
||||
/*
|
||||
* Parse the reply to the state template into `out`.
|
||||
*
|
||||
* Zeroes `out` first, so a field missing from the response reads as invalid
|
||||
* rather than keeping a previous poll's value. Returns false only when the
|
||||
* body is not parseable JSON -- individual missing or unavailable sensors are
|
||||
* normal and leave their ha_value_t invalid.
|
||||
*
|
||||
* Does not touch the forecast fields; those come from ha_parse_forecast_json()
|
||||
* on a slower poll and would otherwise be wiped every minute.
|
||||
*/
|
||||
bool ha_parse_json(const char *json, ha_data_t *out);
|
||||
|
||||
/*
|
||||
* Parse a weather/get_forecasts service response into out->forecast.
|
||||
* Fills at most HA_FORECAST_DAYS entries and sets forecast_count.
|
||||
*/
|
||||
bool ha_parse_forecast_json(const char *json, ha_data_t *out);
|
||||
|
||||
/*
|
||||
* "light" or "switch" for entity_id -- the HA service domain to call. The
|
||||
* patio plug is a switch, and calling light.turn_on on it silently does
|
||||
* nothing.
|
||||
*/
|
||||
const char *ha_service_domain(const char *entity_id);
|
||||
|
||||
/* Human-readable form of a met.no condition slug ("partlycloudy" ->
|
||||
* "Partly Cloudy"). Returns the slug unchanged if unrecognised, so an
|
||||
* unhandled condition shows up on screen as the thing to go add. */
|
||||
const char *ha_condition_label(const char *slug);
|
||||
|
||||
/* As above, abbreviated for the forecast strip's narrow columns
|
||||
* ("partlycloudy" -> "Partly"). */
|
||||
const char *ha_condition_short(const char *slug);
|
||||
|
||||
/* Compass point for a bearing in degrees ("NNE"). */
|
||||
const char *ha_wind_cardinal(float degrees);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* The Jinja template POSTed to Home Assistant's /api/template.
|
||||
*
|
||||
* Why a template rather than plain REST calls:
|
||||
*
|
||||
* - One request instead of a dozen. /api/states returns all ~204 entities
|
||||
* (far too much to buffer); fetching entities one at a time would mean a
|
||||
* dozen round-trips inside the window where the UI is frozen. This is a
|
||||
* single ~430 byte reply, measured at ~85ms on the LAN.
|
||||
* - HA formats the times. sunrise/sunset/clock/date come back as local-time
|
||||
* strings, so the device needs neither SNTP nor a timezone database, and
|
||||
* DST is somebody else's problem. The board's clock starts at the epoch
|
||||
* and that no longer matters.
|
||||
* - Unavailable sensors arrive as JSON null, not a sentinel number. A
|
||||
* sentinel would be indistinguishable from a real reading -- -1 mph of
|
||||
* wind is impossible, but -1 degF of dew point is not.
|
||||
*
|
||||
* The reply is compact JSON; see ha_parse_json().
|
||||
*/
|
||||
#ifndef HA_TEMPLATE_H
|
||||
#define HA_TEMPLATE_H
|
||||
|
||||
#include "ha_config.h"
|
||||
|
||||
/* Emits the entity list for the lights loop, in HA_LIGHT_LIST order. The
|
||||
* trailing comma is deliberate -- Jinja accepts it, and it avoids a
|
||||
* last-element special case. */
|
||||
#define HA_TMPL_LIGHT_X(id, label, dimmable) "'" id "',"
|
||||
|
||||
/*
|
||||
* num() -- a state as a number, or null if it is not one ("unavailable",
|
||||
* "unknown", or a non-numeric state).
|
||||
* attrnum() -- ditto for an attribute; brightness is None when a light is off.
|
||||
*/
|
||||
#define HA_STATE_TEMPLATE \
|
||||
"{%- macro num(e) -%}{%- set v = states(e) -%}" \
|
||||
"{{ v | float if is_number(v) else 'null' }}{%- endmacro -%}" \
|
||||
"{%- macro attrnum(e, a) -%}{%- set v = state_attr(e, a) -%}" \
|
||||
"{{ v if v is number else 'null' }}{%- endmacro -%}" \
|
||||
"{" \
|
||||
"\"out_t\":" "{{ num('" HA_ENT_OUTDOOR_TEMP "') }}," \
|
||||
"\"out_h\":" "{{ num('" HA_ENT_OUTDOOR_HUMIDITY "') }}," \
|
||||
"\"in_t\":" "{{ num('" HA_ENT_INDOOR_TEMP "') }}," \
|
||||
"\"in_h\":" "{{ num('" HA_ENT_INDOOR_HUMIDITY "') }}," \
|
||||
"\"dew\":" "{{ num('" HA_ENT_DEW_POINT "') }}," \
|
||||
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
|
||||
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
|
||||
"\"rain_r\":" "{{ num('" HA_ENT_RAIN_RATE "') }}," \
|
||||
"\"wind\":" "{{ num('" HA_ENT_WIND_SPEED "') }}," \
|
||||
"\"gust\":" "{{ num('" HA_ENT_WIND_GUST "') }}," \
|
||||
"\"wdir\":" "{{ num('" HA_ENT_WIND_DIR "') }}," \
|
||||
"\"cond\":\"" "{{ states('" HA_ENT_WEATHER "') }}\"," \
|
||||
"\"sunrise\":\"{{ (state_attr('" HA_ENT_SUN "','next_rising')" \
|
||||
" | as_datetime | as_local).strftime('%-I:%M %p') }}\"," \
|
||||
"\"sunset\":\"{{ (state_attr('" HA_ENT_SUN "','next_setting')" \
|
||||
" | as_datetime | as_local).strftime('%-I:%M %p') }}\"," \
|
||||
"\"sun_up\":{{ (states('" HA_ENT_SUN "') == 'above_horizon') | lower }}," \
|
||||
"\"clock\":\"{{ now().strftime('%-I:%M %p') }}\"," \
|
||||
"\"date\":\"{{ now().strftime('%a %b %-d') }}\"," \
|
||||
"\"lights\":[" \
|
||||
"{%- for e in [" HA_LIGHT_LIST(HA_TMPL_LIGHT_X) "] -%}" \
|
||||
"{\"s\":\"{{ states(e) }}\",\"b\":{{ attrnum(e,'brightness') }}}" \
|
||||
"{{ \",\" if not loop.last }}" \
|
||||
"{%- endfor -%}" \
|
||||
"]}"
|
||||
|
||||
/*
|
||||
* Forecast body for POST /api/services/weather/get_forecasts?return_response.
|
||||
*
|
||||
* The forecast is deliberately NOT part of the template above: HA removed the
|
||||
* `forecast` attribute from weather entities in 2024.4, and Jinja templates
|
||||
* cannot call services. It also changes far more slowly than the sensors, so
|
||||
* it is polled on its own longer interval.
|
||||
*/
|
||||
#define HA_FORECAST_BODY \
|
||||
"{\"entity_id\":\"" HA_ENT_WEATHER "\",\"type\":\"daily\"}"
|
||||
|
||||
#endif
|
||||
Reference in new issue
Block a user