Files
thermostat-hw/main/api/ha_data.h

183 lines
5.9 KiB
C

/*
* 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, d) +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;
/*
* Which way a reading has moved over the last hour.
*
* UNKNOWN is deliberately 0 so a freshly wiped ha_data_t renders no arrow --
* after boot there is no history, and "no arrow" is the honest display until
* at least HA_TREND_MIN_AGE_MS of samples exist.
*/
typedef enum {
HA_TREND_UNKNOWN = 0,
HA_TREND_FLAT,
HA_TREND_RISING,
HA_TREND_FALLING,
} ha_trend_t;
enum {
HA_TREND_CH_INDOOR = 0,
HA_TREND_CH_OUTDOOR,
HA_TREND_CH_PRESSURE,
HA_TREND_CHANNELS,
};
/* 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 */
/*
* Filled by the CALLER after parsing, from ha_trend_feed/compute --
* the parser wipes them to UNKNOWN. Local ring-buffer history, not an HA
* query: an /api/history call returns hundreds of points per entity,
* which is silly when the device already sees every reading each poll.
*/
ha_trend_t trend_indoor;
ha_trend_t trend_outdoor;
ha_trend_t trend_pressure;
} 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);
/*
* Trend tracking. Feed one sample per poll; compute compares the newest
* sample against the one closest to an hour old. Timestamps come from the
* caller (lv_tick_get() or xTaskGetTickCount-derived ms) so this file stays
* free of LVGL and FreeRTOS. Wrap-safe on uint32 ms.
*
* compute returns UNKNOWN until the oldest usable sample is at least
* HA_TREND_MIN_AGE_MS old -- "trending" over 90 seconds is noise.
*/
#define HA_TREND_WINDOW_MS (60u * 60u * 1000u)
#define HA_TREND_MIN_AGE_MS (5u * 60u * 1000u)
void ha_trend_feed(int channel, float value, uint32_t now_ms);
ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_ms);
#endif