New panel design

This commit is contained in:
matt committed 2026-08-04 11:55:02 -07:00
1 parent face2432e4
commit b5d6b4a897
30 files changed
+4540 -171

No files matched your search

+263
View File
@@ -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);
}
+48
View File
@@ -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
+76
View File
@@ -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
+322
View File
@@ -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];
}
+136
View File
@@ -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
+78
View File
@@ -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