/* * 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 #include #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