Downstairs and upstairs are now separate readings. The station names them
backwards -- "temperature_4" is downstairs and "inside_temperature" is
upstairs -- so ha_config.h says which is which. Downstairs is the primary:
big number on the card, and it is what the furnace controls on, because it
is where people are and heat rises. Upstairs sits beside it, obviously
subordinate by size, and costs no vertical space in a layout that has none.
The anemometer came back on 2026-08-07, so wind gets its own column: a dial
whose needle points at the direction the wind is coming FROM (weather-vane
convention, matching the cardinal below it), the speed, and a 5-minute trend
arrow.
- The needle does not animate. A spin would look better, but every animated
frame is a redraw, and redrawing is what starves the bounce refill ISR.
- Below 0.5 mph it shows "Calm" and draws no needle. A vane that is not
being pushed reports whatever it last settled on, which on this station
is a steady 0 degrees -- a confident needle pointing due north at zero
wind. The live snapshot caught exactly that.
- The trend is local, not HA's 10-minute average, so the window is ours to
pick: 1-minute buckets over 5 minutes, threshold 1.5 mph.
That last point needed the trend engine to stop assuming one hour. Window,
bucket size, minimum age and threshold are now per-channel in a table, which
also removes the threshold arguments main.c was passing in.
Grid drops to 117px cells to make room, so the sun sub-line is just which
event it is and "RAIN TODAY" is "RAIN" -- both were overrunning.
On the artefacts: bounce mode is NOT a DMA read of the framebuffer. The
refill is a CPU memcpy in an ISR, which is why the driver skips the usual
write-back in draw_bitmap ("the data gets read by the CPU as well"). So the
panel streams 750KB per frame at ~44Hz through the DCache, evicting the
working set of everything else on the chip, continuously -- which is the real
reason this board is so sensitive to WiFi and to any extra rendering.
bb_invalidate_cache frees each chunk after it is copied. The header warns it
"can be dangerous if data is written from other core(s)"; that does not apply
here, because it invalidates the buffer being scanned and by design LVGL only
draws into the other one. Whether it is enough is for the panel to say.
Also gives tools/ha_snapshot.py a --token-file so the token no longer has to
travel through a shell command line to reach it.
Co-Authored-By: Claude <noreply@anthropic.com>
187 lines
6.1 KiB
C
187 lines
6.1 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_DOWN = 0,
|
|
HA_TREND_CH_INDOOR_UP,
|
|
HA_TREND_CH_OUTDOOR,
|
|
HA_TREND_CH_PRESSURE,
|
|
HA_TREND_CH_WIND,
|
|
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;
|
|
/* Downstairs is the primary reading and drives the control loop. */
|
|
ha_value_t indoor_down_temp_f;
|
|
ha_value_t indoor_down_humidity;
|
|
ha_value_t indoor_up_temp_f;
|
|
ha_value_t indoor_up_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_down;
|
|
ha_trend_t trend_indoor_up;
|
|
ha_trend_t trend_outdoor;
|
|
ha_trend_t trend_pressure;
|
|
ha_trend_t trend_wind;
|
|
} 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.
|
|
*/
|
|
void ha_trend_feed(int channel, float value, uint32_t now_ms);
|
|
ha_trend_t ha_trend_compute(int channel, uint32_t now_ms);
|
|
|
|
#endif
|