Files
thermostat-hw/main/api/ha_data.c
T

438 lines
15 KiB
C

/*
* 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.
* The icon argument belongs to the UI layer and is ignored here. */
#define HA_LIGHT_ENTRY_X(id, label, dimmable, icon) { 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 : "--");
}
/* ------------------------------------------------------------------ trends */
/*
* 20 slots, one kept per ~4 minutes, covers ~80 minutes. Feeding faster than
* that overwrites the newest slot instead of consuming a new one, so a 60s
* poll cadence still fills the ring at 4-minute spacing.
*
* Size matters here more than it looks: this array is .bss, which lives in
* internal RAM on the S3 -- the same internal RAM the RGB panel's bounce
* buffers come from, with only a few KB of slack (README, memory notes). A
* naive 72-slot-per-poll ring cost 2.6KB and was enough to tip display_init
* into "no mem for bounce buffer" on an unlucky boot. Keep this structure
* small; if it ever needs to grow, it must move to PSRAM instead.
*
* Static per channel; a reboot forgets history, and the display shows no
* arrow until enough accumulates again (see ha_data.h).
*/
#define TREND_SLOTS 20
#define TREND_BUCKET_MS (4u * 60u * 1000u)
typedef struct {
float value;
uint32_t ms;
bool used;
} trend_sample_t;
static trend_sample_t trend_ring[HA_TREND_CHANNELS][TREND_SLOTS];
static int trend_head[HA_TREND_CHANNELS]; /* slot most recently written */
void ha_trend_feed(int channel, float value, uint32_t now_ms)
{
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
return;
}
trend_sample_t *newest = &trend_ring[channel][trend_head[channel]];
/* Inside the current bucket: refresh the newest sample in place, so
* compute always sees the latest reading without spending a slot. */
if (newest->used && (now_ms - newest->ms) < TREND_BUCKET_MS) {
newest->value = value;
return;
}
const int next = newest->used ? (trend_head[channel] + 1) % TREND_SLOTS
: trend_head[channel];
trend_ring[channel][next] = (trend_sample_t){ value, now_ms, true };
trend_head[channel] = next;
}
ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_ms)
{
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
return HA_TREND_UNKNOWN;
}
/* The newest sample is the one just fed; find the usable sample whose
* age is closest to the window. Unsigned subtraction keeps this correct
* across tick wrap. */
const trend_sample_t *newest = NULL;
const trend_sample_t *anchor = NULL;
uint32_t best_dist = UINT32_MAX;
for (int i = 0; i < TREND_SLOTS; i++) {
const trend_sample_t *s = &trend_ring[channel][i];
if (!s->used) {
continue;
}
const uint32_t age = now_ms - s->ms;
if (age > HA_TREND_WINDOW_MS + HA_TREND_MIN_AGE_MS) {
continue; /* too old even for a sparse ring; ignore */
}
if (newest == NULL || age < (now_ms - newest->ms)) {
newest = s;
}
if (age >= HA_TREND_MIN_AGE_MS) {
const uint32_t dist = (age > HA_TREND_WINDOW_MS)
? age - HA_TREND_WINDOW_MS
: HA_TREND_WINDOW_MS - age;
if (dist < best_dist) {
best_dist = dist;
anchor = s;
}
}
}
if (newest == NULL || anchor == NULL || newest == anchor) {
return HA_TREND_UNKNOWN;
}
/*
* Scale the threshold to the actual span so a 20-minute-old anchor uses
* a proportionally smaller bar than a full hour would. Without this the
* first hour after boot under-reports every trend.
*/
const uint32_t span_ms = anchor->ms == newest->ms
? 1
: newest->ms - anchor->ms;
const float threshold = threshold_per_hour *
((float)span_ms / (float)HA_TREND_WINDOW_MS);
const float delta = newest->value - anchor->value;
if (delta > threshold) {
return HA_TREND_RISING;
}
if (delta < -threshold) {
return HA_TREND_FALLING;
}
return HA_TREND_FLAT;
}
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];
}