Compare commits
1
Commits
7a58f9838a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea1b8b8d15 |
No files matched your search
@@ -140,19 +140,37 @@ The forecast is a second, slower request (every 30th poll). It cannot be part
|
||||
of the template: HA removed the `forecast` attribute from weather entities in
|
||||
2024.4, and templates cannot call services.
|
||||
|
||||
### The wind sensors are dead
|
||||
### Wind, and the compass
|
||||
|
||||
The station's anemometer has been offline since **2026-07-27**: `wind_speed`,
|
||||
`wind_gust`, `wind_direction` and `feels_like` all read `unavailable`, and the
|
||||
WIND tile shows `--` by design. It is deliberately *not* backfilled from the
|
||||
met.no forecast — regional modelled wind under a heading that means "my yard"
|
||||
is worse than showing nothing.
|
||||
The station's anemometer was offline 2026-07-27 to 2026-08-07 and is live
|
||||
again. It has its own column now: a dial whose needle points at the direction
|
||||
the wind is coming **from** (weather-vane convention, matching the cardinal
|
||||
label beneath it), the speed in mph, and an arrow for the 5-minute trend.
|
||||
|
||||
`sensor.moonlight_beach_max_gust` is a trap: it is **not** unavailable. It
|
||||
still reports its last value from 2026-07-28 and will render as a plausible
|
||||
live number. Anything that uses it needs a staleness check first.
|
||||
Three things worth knowing:
|
||||
|
||||
### Fallback
|
||||
- **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. It snaps once a minute and the dirty area is just the dial.
|
||||
- **Below 0.5 mph it shows "Calm" and draws no needle.** A vane that is not
|
||||
being pushed reports whatever it last settled on — on this station that is a
|
||||
steady 0°, which would otherwise render as a confident needle pointing due
|
||||
north.
|
||||
- **The trend is computed locally, not taken from HA's 10-minute average.**
|
||||
`HA_TREND_CH_WIND` uses 1-minute buckets over a 5-minute window, which is as
|
||||
fine as a 60-second poll can support. The threshold is 1.5 mph — wind is
|
||||
noisy and a twitchy arrow is worse than none.
|
||||
|
||||
### Two indoor sensors
|
||||
|
||||
`temperature_4` is **downstairs** and `inside_temperature` is **upstairs** —
|
||||
the names come from the Ambient Weather console, not the house, so they read
|
||||
backwards. 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.
|
||||
`HA_ENT_CONTROL_TEMP` in `ha_config.h` is the single place that choice is
|
||||
made; the big card in `ui_manager.c` should move with it.
|
||||
|
||||
### Fallback### Fallback
|
||||
|
||||
When HA cannot be reached, `ambient_weather.c` fills in outdoor temperature,
|
||||
humidity and wind from the cloud API and the status line says so. Indoor, sun,
|
||||
@@ -232,7 +250,18 @@ own reads, and the resulting corrupt pixels persist indefinitely because LVGL
|
||||
does not know they are wrong and never reinvalidates them.
|
||||
|
||||
A **20**-scanline bounce buffer stages each chunk in internal RAM (64KB across
|
||||
two buffers). This was 16 for a long time because 20 "fails to allocate once
|
||||
two buffers), with `bb_invalidate_cache` enabled.
|
||||
|
||||
That flag matters more than it looks. 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. That is
|
||||
the real reason this board is so sensitive to WiFi activity 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 ever draws into the other one. This was 16 for a long time because 20 "fails to allocate once
|
||||
WiFi is up" — which was true when `display_init()` ran after
|
||||
`wifi_init_sta()`. The two-phase split changed that: `display_alloc()` now
|
||||
takes these buffers from a virgin heap with ~200KB free, long before WiFi has
|
||||
|
||||
@@ -85,12 +85,17 @@ touch-to-wake is a natural small feature. Note the STC8H1K28 shares the I2C
|
||||
bus with the GT911, so brightness writes must stay in task context (never an
|
||||
ISR) and will contend with touch polls on the same bus.
|
||||
|
||||
### 4. The wind sensors are dead (hardware, not software)
|
||||
### 4. Left-edge artefacts, still open
|
||||
|
||||
The station anemometer has read `unavailable` since 2026-07-27 — wind speed,
|
||||
gust, direction and feels-like. The WIND tile shows `--` by design and will
|
||||
keep doing so until the hardware is fixed. Nothing to change in the firmware;
|
||||
noted so it is not re-diagnosed as a parsing bug. See README "Home Assistant".
|
||||
Three things have been tried. Two "repaint to clean up" mitigations made it
|
||||
worse and are gone (README Gotchas). The 1Hz restyle that made them permanent
|
||||
is fixed. Now: bounce buffer at 20 scanlines and `bb_invalidate_cache` on, so
|
||||
framebuffer streaming no longer thrashes the DCache.
|
||||
|
||||
If they persist after that, the mechanism is the raw PSRAM ceiling and the
|
||||
honest remaining levers are unattractive: 120MHz PSRAM (needs a newer IDF and
|
||||
a DC-aware bootloader), or a lower pixel clock (below ~15MHz the panel stops
|
||||
locking and the image rolls). Do not reach for another repaint.
|
||||
|
||||
### 5. The actual thermostat
|
||||
|
||||
|
||||
+25
-8
@@ -14,21 +14,38 @@
|
||||
|
||||
#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"
|
||||
|
||||
/*
|
||||
* Two indoor sensors.
|
||||
*
|
||||
* DOWN is the console's "4" channel and UP is the one the station calls
|
||||
* "inside" -- the names come from the Ambient Weather hardware, not from the
|
||||
* house, so they read backwards. Downstairs is the primary: it gets the big
|
||||
* card AND it is what the thermostat controls on, because it is where people
|
||||
* are. Heat rises, so upstairs typically reads several degrees warmer;
|
||||
* controlling on it would under-heat the living space.
|
||||
*
|
||||
* To swap which one drives the furnace, change HA_ENT_CONTROL_TEMP below --
|
||||
* that is the only place the choice is made.
|
||||
*/
|
||||
#define HA_ENT_INDOOR_DOWN_TEMP "sensor.moonlight_beach_temperature_4"
|
||||
#define HA_ENT_INDOOR_DOWN_HUMIDITY "sensor.moonlight_beach_humidity_4"
|
||||
#define HA_ENT_INDOOR_UP_TEMP "sensor.moonlight_beach_inside_temperature"
|
||||
#define HA_ENT_INDOOR_UP_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.
|
||||
* The anemometer was offline from 2026-07-27 and came back 2026-08-07. All
|
||||
* three read live again. The UI still treats them as optional -- ha_value_t
|
||||
* carries its own validity and the compass hides itself when direction is
|
||||
* missing -- because this sensor has now failed once and will again.
|
||||
*
|
||||
* 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.
|
||||
* The 10-minute averages are the station's own smoothing. The 5-minute trend
|
||||
* shown on the compass is computed locally from the instantaneous speed
|
||||
* instead, so the trend window is ours to choose; see HA_TREND_CH_WIND.
|
||||
*/
|
||||
#define HA_ENT_WIND_SPEED "sensor.moonlight_beach_wind_speed"
|
||||
#define HA_ENT_WIND_GUST "sensor.moonlight_beach_wind_gust"
|
||||
|
||||
+63
-36
@@ -127,8 +127,10 @@ bool ha_parse_json(const char *json, ha_data_t *out)
|
||||
|
||||
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, "dn_t", &out->indoor_down_temp_f);
|
||||
take_number(root, "dn_h", &out->indoor_down_humidity);
|
||||
take_number(root, "up_t", &out->indoor_up_temp_f);
|
||||
take_number(root, "up_h", &out->indoor_up_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);
|
||||
@@ -308,22 +310,42 @@ const char *ha_condition_short(const char *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.
|
||||
* 20 slots per channel. How much history that covers depends on the bucket
|
||||
* size, which is per-channel: a room temperature is judged over an hour, but
|
||||
* wind over an hour is meaningless -- it needs minutes.
|
||||
*
|
||||
* 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.
|
||||
* buffers come from, with only a few KB of slack (README, memory notes). Keep
|
||||
* this structure small; if it ever needs to grow, move it to PSRAM instead.
|
||||
*
|
||||
* Static per channel; a reboot forgets history, and the display shows no
|
||||
* arrow until enough accumulates again (see ha_data.h).
|
||||
* arrow until enough accumulates again.
|
||||
*/
|
||||
#define TREND_SLOTS 20
|
||||
#define TREND_BUCKET_MS (4u * 60u * 1000u)
|
||||
|
||||
typedef struct {
|
||||
uint32_t bucket_ms; /* minimum spacing between retained samples */
|
||||
uint32_t window_ms; /* how far back "the trend" looks */
|
||||
uint32_t min_age_ms; /* refuse to judge on less history than this */
|
||||
float threshold; /* change over a full window that counts as movement */
|
||||
} trend_cfg_t;
|
||||
|
||||
/*
|
||||
* Thresholds are the difference between a useful arrow and a flickering one.
|
||||
* A thermostat's indoor reading wobbles a couple of tenths continuously, and
|
||||
* wind is noisier still -- hence 1.5 mph over five minutes rather than
|
||||
* anything tighter.
|
||||
*/
|
||||
static const trend_cfg_t TREND_CFG[HA_TREND_CHANNELS] = {
|
||||
[HA_TREND_CH_INDOOR_DOWN] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.5f },
|
||||
[HA_TREND_CH_INDOOR_UP] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.5f },
|
||||
[HA_TREND_CH_OUTDOOR] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.5f },
|
||||
[HA_TREND_CH_PRESSURE] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.02f },
|
||||
/* Wind: 1-minute buckets over a 5-minute window. At a 60s poll that is
|
||||
* five samples, which is the most the poll rate can support. */
|
||||
[HA_TREND_CH_WIND] = { 1u * 60000u, 5u * 60000u, 2u * 60000u, 1.5f },
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
float value;
|
||||
@@ -334,6 +356,12 @@ typedef struct {
|
||||
static trend_sample_t trend_ring[HA_TREND_CHANNELS][TREND_SLOTS];
|
||||
static int trend_head[HA_TREND_CHANNELS]; /* slot most recently written */
|
||||
|
||||
/* Wrap-safe: unsigned subtraction is correct across the 32-bit rollover. */
|
||||
static uint32_t trend_elapsed(uint32_t now_ms, uint32_t then_ms)
|
||||
{
|
||||
return now_ms - then_ms;
|
||||
}
|
||||
|
||||
void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
||||
{
|
||||
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
|
||||
@@ -344,7 +372,8 @@ void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
||||
|
||||
/* 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) {
|
||||
if (newest->used &&
|
||||
trend_elapsed(now_ms, newest->ms) < TREND_CFG[channel].bucket_ms) {
|
||||
newest->value = value;
|
||||
return;
|
||||
}
|
||||
@@ -355,41 +384,40 @@ void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
||||
trend_head[channel] = next;
|
||||
}
|
||||
|
||||
ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_ms)
|
||||
ha_trend_t ha_trend_compute(int channel, 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_cfg_t *cfg = &TREND_CFG[channel];
|
||||
|
||||
/* Newest usable sample, and the one whose age is closest to the window. */
|
||||
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) {
|
||||
const trend_sample_t *sm = &trend_ring[channel][i];
|
||||
if (!sm->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 */
|
||||
const uint32_t age = trend_elapsed(now_ms, sm->ms);
|
||||
if (age > cfg->window_ms + cfg->min_age_ms) {
|
||||
continue; /* too old even for a sparse ring */
|
||||
}
|
||||
|
||||
if (newest == NULL || age < (now_ms - newest->ms)) {
|
||||
newest = s;
|
||||
if (newest == NULL || age < trend_elapsed(now_ms, newest->ms)) {
|
||||
newest = sm;
|
||||
}
|
||||
|
||||
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 (age >= cfg->min_age_ms) {
|
||||
const uint32_t dist = (age > cfg->window_ms) ? age - cfg->window_ms
|
||||
: cfg->window_ms - age;
|
||||
if (dist < best_dist) {
|
||||
best_dist = dist;
|
||||
anchor = s;
|
||||
anchor = sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,15 +427,14 @@ ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_
|
||||
}
|
||||
|
||||
/*
|
||||
* 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.
|
||||
* Scale the threshold to the actual span, so a half-window-old anchor
|
||||
* uses a proportionally smaller bar. Without this the period right 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 uint32_t span_ms = (anchor->ms == newest->ms) ? 1u
|
||||
: newest->ms - anchor->ms;
|
||||
const float threshold = cfg->threshold *
|
||||
((float)span_ms / (float)cfg->window_ms);
|
||||
|
||||
const float delta = newest->value - anchor->value;
|
||||
if (delta > threshold) {
|
||||
|
||||
+12
-8
@@ -71,9 +71,11 @@ typedef enum {
|
||||
} ha_trend_t;
|
||||
|
||||
enum {
|
||||
HA_TREND_CH_INDOOR = 0,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -91,8 +93,11 @@ 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;
|
||||
/* 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;
|
||||
@@ -121,9 +126,11 @@ typedef struct {
|
||||
* 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_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;
|
||||
|
||||
/*
|
||||
@@ -173,10 +180,7 @@ const char *ha_wind_cardinal(float degrees);
|
||||
* 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);
|
||||
ha_trend_t ha_trend_compute(int channel, uint32_t now_ms);
|
||||
|
||||
#endif
|
||||
@@ -40,8 +40,10 @@
|
||||
"{" \
|
||||
"\"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 "') }}," \
|
||||
"\"dn_t\":" "{{ num('" HA_ENT_INDOOR_DOWN_TEMP "') }}," \
|
||||
"\"dn_h\":" "{{ num('" HA_ENT_INDOOR_DOWN_HUMIDITY "') }}," \
|
||||
"\"up_t\":" "{{ num('" HA_ENT_INDOOR_UP_TEMP "') }}," \
|
||||
"\"up_h\":" "{{ num('" HA_ENT_INDOOR_UP_HUMIDITY "') }}," \
|
||||
"\"dew\":" "{{ num('" HA_ENT_DEW_POINT "') }}," \
|
||||
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
|
||||
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
|
||||
|
||||
@@ -193,6 +193,25 @@ esp_err_t display_alloc(void)
|
||||
},
|
||||
.flags = {
|
||||
.fb_in_psram = true,
|
||||
/*
|
||||
* Free the cache lines the bounce refill just consumed.
|
||||
*
|
||||
* 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. That is the real reason this
|
||||
* board is so sensitive to WiFi activity and to any extra
|
||||
* rendering.
|
||||
*
|
||||
* This flag invalidates each chunk after it has been copied, so
|
||||
* framebuffer traffic stops polluting the cache. The header warns
|
||||
* it "can be dangerous if data is written from other core(s)" --
|
||||
* that does not apply here: it invalidates the buffer being
|
||||
* scanned, and by design LVGL only ever draws into the other one.
|
||||
*/
|
||||
.bb_invalidate_cache = true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+23
-25
@@ -41,14 +41,6 @@ static const char *TAG = "main";
|
||||
/* Consecutive failed polls before the displayed reading is treated as stale. */
|
||||
#define STALE_AFTER_POLLS 2
|
||||
|
||||
/*
|
||||
* Trend arrow thresholds, per hour. Below these a reading renders as flat:
|
||||
* a thermostat's indoor temp wobbles a couple of tenths continuously, and an
|
||||
* arrow that flickers on noise is worse than none.
|
||||
*/
|
||||
#define TREND_TEMP_PER_HOUR_F 0.5f
|
||||
#define TREND_PRESS_PER_HOUR_INHG 0.02f
|
||||
|
||||
/* Depth 8: a burst of taps while the network is slow should queue rather than
|
||||
* be dropped, but a user cannot meaningfully get more than a few ahead. */
|
||||
#define CMD_QUEUE_DEPTH 8
|
||||
@@ -317,25 +309,31 @@ static void ha_task(void *arg)
|
||||
* lost on reboot; arrows reappear ~5 minutes later.
|
||||
*/
|
||||
const uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
||||
if (ha_data.indoor_temp_f.valid) {
|
||||
ha_trend_feed(HA_TREND_CH_INDOOR, ha_data.indoor_temp_f.value, now_ms);
|
||||
struct { int ch; const ha_value_t *v; } feeds[] = {
|
||||
{ HA_TREND_CH_INDOOR_DOWN, &ha_data.indoor_down_temp_f },
|
||||
{ HA_TREND_CH_INDOOR_UP, &ha_data.indoor_up_temp_f },
|
||||
{ HA_TREND_CH_OUTDOOR, &ha_data.outdoor_temp_f },
|
||||
{ HA_TREND_CH_PRESSURE, &ha_data.pressure_inhg },
|
||||
{ HA_TREND_CH_WIND, &ha_data.wind_mph },
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(feeds) / sizeof(feeds[0]); i++) {
|
||||
if (feeds[i].v->valid) {
|
||||
ha_trend_feed(feeds[i].ch, feeds[i].v->value, now_ms);
|
||||
}
|
||||
}
|
||||
if (ha_data.outdoor_temp_f.valid) {
|
||||
ha_trend_feed(HA_TREND_CH_OUTDOOR, ha_data.outdoor_temp_f.value, now_ms);
|
||||
}
|
||||
if (ha_data.pressure_inhg.valid) {
|
||||
ha_trend_feed(HA_TREND_CH_PRESSURE, ha_data.pressure_inhg.value, now_ms);
|
||||
}
|
||||
ha_data.trend_indoor =
|
||||
ha_trend_compute(HA_TREND_CH_INDOOR, TREND_TEMP_PER_HOUR_F, now_ms);
|
||||
ha_data.trend_outdoor =
|
||||
ha_trend_compute(HA_TREND_CH_OUTDOOR, TREND_TEMP_PER_HOUR_F, now_ms);
|
||||
ha_data.trend_pressure =
|
||||
ha_trend_compute(HA_TREND_CH_PRESSURE, TREND_PRESS_PER_HOUR_INHG, now_ms);
|
||||
ha_data.trend_indoor_down = ha_trend_compute(HA_TREND_CH_INDOOR_DOWN, now_ms);
|
||||
ha_data.trend_indoor_up = ha_trend_compute(HA_TREND_CH_INDOOR_UP, now_ms);
|
||||
ha_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, now_ms);
|
||||
ha_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, now_ms);
|
||||
ha_data.trend_wind = ha_trend_compute(HA_TREND_CH_WIND, now_ms);
|
||||
|
||||
/* Hand the reading to the control loop, which runs on its own
|
||||
* task and does not care where it came from. */
|
||||
publish_indoor_temp(&ha_data.indoor_temp_f);
|
||||
/*
|
||||
* Downstairs drives the furnace -- it is where people are, and
|
||||
* heat rises, so controlling on upstairs would under-heat the
|
||||
* living space. Change this line and the big card in ui_manager
|
||||
* together if that ever needs to swap.
|
||||
*/
|
||||
publish_indoor_temp(&ha_data.indoor_down_temp_f);
|
||||
|
||||
ui_update(&ha_data);
|
||||
ui_set_stale(false);
|
||||
|
||||
+203
-45
@@ -94,6 +94,9 @@ static const char *TAG = "ui_manager";
|
||||
*/
|
||||
#define NO_VALUE "--"
|
||||
|
||||
/* Compass dial diameter. Sized to the 120px column with its 8px padding. */
|
||||
#define COMPASS_SIZE 96
|
||||
|
||||
/* Icon fonts, one per size in use. Created in ui_init from the embedded
|
||||
* subset TTF; NULL if tiny_ttf fails, in which case icons render empty
|
||||
* rather than as tofu. */
|
||||
@@ -106,10 +109,25 @@ static lv_obj_t *date_label;
|
||||
static lv_obj_t *cond_label;
|
||||
static lv_obj_t *cond_icon;
|
||||
|
||||
static lv_obj_t *temp_label;
|
||||
static lv_obj_t *temp_label; /* downstairs, the primary reading */
|
||||
static lv_obj_t *temp_unit_label;
|
||||
static lv_obj_t *indoor_detail_label;
|
||||
static lv_obj_t *trend_indoor_icon;
|
||||
static lv_obj_t *upstairs_value_label; /* secondary, deliberately small */
|
||||
static lv_obj_t *upstairs_sub_label;
|
||||
static lv_obj_t *trend_upstairs_icon;
|
||||
|
||||
/* Wind compass. The needle is an lv_line whose two points are recomputed on
|
||||
* each update -- no image asset, no rotation transform, and the dirty area is
|
||||
* just the dial. */
|
||||
static lv_obj_t *compass_dial;
|
||||
static lv_obj_t *compass_needle;
|
||||
static lv_obj_t *compass_north;
|
||||
static lv_obj_t *wind_speed_label;
|
||||
static lv_obj_t *wind_unit_label;
|
||||
static lv_obj_t *wind_dir_label;
|
||||
static lv_obj_t *wind_trend_icon;
|
||||
static lv_point_precise_t compass_pts[2];
|
||||
|
||||
/*
|
||||
* 2x2 reading grid.
|
||||
@@ -767,10 +785,10 @@ static void build_body(lv_obj_t *scr)
|
||||
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
/* --- thermostat card: current temperature and what the furnace is doing --- */
|
||||
lv_obj_t *card = panel_create(row, 300, LV_PCT(100), COL_PANEL);
|
||||
lv_obj_t *card = panel_create(row, 296, LV_PCT(100), COL_PANEL);
|
||||
lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t *heading = label_create(card, "INDOOR", &lv_font_montserrat_14, COL_TEXT_DIM);
|
||||
lv_obj_t *heading = label_create(card, "DOWNSTAIRS", &lv_font_montserrat_14, COL_TEXT_DIM);
|
||||
lv_obj_set_style_text_letter_space(heading, 2, LV_PART_MAIN);
|
||||
lv_obj_align(heading, LV_ALIGN_TOP_LEFT, 0, 0);
|
||||
|
||||
@@ -793,6 +811,25 @@ static void build_body(lv_obj_t *scr)
|
||||
indoor_detail_label = label_create(card, "--", &lv_font_montserrat_14, COL_TEXT_DIM);
|
||||
lv_obj_align(indoor_detail_label, LV_ALIGN_TOP_LEFT, 0, 78);
|
||||
|
||||
/*
|
||||
* Upstairs sits beside the big number rather than under it: same card,
|
||||
* obviously subordinate by size, and it costs no vertical space in a
|
||||
* layout that has none to spare.
|
||||
*/
|
||||
lv_obj_t *up_cap = label_create(card, "UPSTAIRS", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||
lv_obj_set_style_text_letter_space(up_cap, 1, LV_PART_MAIN);
|
||||
lv_obj_align(up_cap, LV_ALIGN_TOP_RIGHT, 0, 26);
|
||||
|
||||
upstairs_value_label = label_create(card, NO_VALUE, &lv_font_montserrat_24, COL_TEXT_DIM);
|
||||
lv_obj_align(upstairs_value_label, LV_ALIGN_TOP_RIGHT, 0, 42);
|
||||
|
||||
trend_upstairs_icon = icon_create(card, "", icon_font_16, COL_TEXT_DIM);
|
||||
lv_obj_align_to(trend_upstairs_icon, upstairs_value_label,
|
||||
LV_ALIGN_OUT_LEFT_MID, -4, 0);
|
||||
|
||||
upstairs_sub_label = label_create(card, "", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||
lv_obj_align(upstairs_sub_label, LV_ALIGN_TOP_RIGHT, 0, 70);
|
||||
|
||||
/* Status dot plus text: the dot carries the state as colour so it reads
|
||||
* from across the room without parsing a word. */
|
||||
therm_state_dot = lv_obj_create(card);
|
||||
@@ -808,18 +845,18 @@ static void build_body(lv_obj_t *scr)
|
||||
|
||||
/* Two buttons across the bottom of the card. 134px each is a comfortable
|
||||
* finger target; the row fills the card's 276px inner width. */
|
||||
mode_btn = button_create(card, 134, 40, "Heat", &mode_btn_label);
|
||||
mode_btn = button_create(card, 132, 40, "Heat", &mode_btn_label);
|
||||
lv_obj_align(mode_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
||||
lv_obj_add_event_cb(mode_btn, therm_button_clicked, LV_EVENT_CLICKED,
|
||||
(void *)(intptr_t)UI_THERM_MODE_TOGGLE);
|
||||
|
||||
fan_btn = button_create(card, 134, 40, "Fan auto", &fan_btn_label);
|
||||
fan_btn = button_create(card, 132, 40, "Fan auto", &fan_btn_label);
|
||||
lv_obj_align(fan_btn, LV_ALIGN_BOTTOM_RIGHT, 0, 0);
|
||||
lv_obj_add_event_cb(fan_btn, therm_button_clicked, LV_EVENT_CLICKED,
|
||||
(void *)(intptr_t)UI_THERM_FAN_TOGGLE);
|
||||
|
||||
/* --- setpoint column: up, value, down --- */
|
||||
lv_obj_t *setcol = panel_create(row, 120, LV_PCT(100), COL_PANEL);
|
||||
lv_obj_t *setcol = panel_create(row, 110, LV_PCT(100), COL_PANEL);
|
||||
lv_obj_set_style_pad_all(setcol, 8, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t *up_btn = button_create(setcol, LV_PCT(100), 52, "+", NULL);
|
||||
@@ -839,6 +876,48 @@ static void build_body(lv_obj_t *scr)
|
||||
lv_obj_add_event_cb(down_btn, therm_button_clicked, LV_EVENT_CLICKED,
|
||||
(void *)(intptr_t)UI_THERM_SETPOINT_DOWN);
|
||||
|
||||
/* --- wind compass --- */
|
||||
lv_obj_t *windcol = panel_create(row, 120, LV_PCT(100), COL_PANEL);
|
||||
lv_obj_set_style_pad_all(windcol, 8, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t *wind_cap = label_create(windcol, "WIND", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||
lv_obj_set_style_text_letter_space(wind_cap, 1, LV_PART_MAIN);
|
||||
lv_obj_align(wind_cap, LV_ALIGN_TOP_LEFT, 0, 0);
|
||||
|
||||
/* The dial is just a bordered circle; the needle is drawn over it. */
|
||||
compass_dial = lv_obj_create(windcol);
|
||||
lv_obj_set_size(compass_dial, COMPASS_SIZE, COMPASS_SIZE);
|
||||
lv_obj_set_style_radius(compass_dial, COMPASS_SIZE / 2, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(compass_dial, lv_color_hex(COL_TRACK), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(compass_dial, 2, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_color(compass_dial, lv_color_hex(COL_PANEL_HI), LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(compass_dial, 0, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(compass_dial, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_align(compass_dial, LV_ALIGN_TOP_MID, 0, 18);
|
||||
|
||||
compass_north = label_create(compass_dial, "N", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||
lv_obj_align(compass_north, LV_ALIGN_TOP_MID, 0, 2);
|
||||
|
||||
compass_needle = lv_line_create(compass_dial);
|
||||
lv_obj_set_style_line_width(compass_needle, 3, LV_PART_MAIN);
|
||||
lv_obj_set_style_line_color(compass_needle, lv_color_hex(COL_ACCENT), LV_PART_MAIN);
|
||||
lv_obj_set_style_line_rounded(compass_needle, true, LV_PART_MAIN);
|
||||
compass_pts[0] = (lv_point_precise_t){ COMPASS_SIZE / 2, COMPASS_SIZE / 2 };
|
||||
compass_pts[1] = (lv_point_precise_t){ COMPASS_SIZE / 2, COMPASS_SIZE / 2 };
|
||||
lv_line_set_points(compass_needle, compass_pts, 2);
|
||||
|
||||
wind_dir_label = label_create(windcol, NO_VALUE, &lv_font_montserrat_14, COL_TEXT_DIM);
|
||||
lv_obj_align(wind_dir_label, LV_ALIGN_TOP_MID, 0, 18 + COMPASS_SIZE + 4);
|
||||
|
||||
wind_speed_label = label_create(windcol, NO_VALUE, &lv_font_montserrat_20, COL_TEXT);
|
||||
lv_obj_align(wind_speed_label, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
||||
|
||||
wind_unit_label = label_create(windcol, "mph", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||
lv_obj_align_to(wind_unit_label, wind_speed_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 3, -3);
|
||||
|
||||
wind_trend_icon = icon_create(windcol, "", icon_font_16, COL_TEXT_DIM);
|
||||
lv_obj_align(wind_trend_icon, LV_ALIGN_BOTTOM_RIGHT, 0, -2);
|
||||
|
||||
/* --- 2x2 reading grid --- */
|
||||
lv_obj_t *grid = lv_obj_create(row);
|
||||
lv_obj_set_size(grid, LV_GRID_CONTENT, LV_PCT(100));
|
||||
@@ -851,7 +930,7 @@ static void build_body(lv_obj_t *scr)
|
||||
lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
static const char *const captions[CELL_COUNT] = {
|
||||
"OUTDOOR", "RAIN TODAY", "SUN", "PRESSURE",
|
||||
"OUTDOOR", "RAIN", "SUN", "PRESSURE",
|
||||
};
|
||||
static const char *const cell_icons[CELL_COUNT] = {
|
||||
ICON_THERMOMETER, ICON_WATER, ICON_SUNSET, ICON_GAUGE,
|
||||
@@ -862,8 +941,8 @@ static void build_body(lv_obj_t *scr)
|
||||
const int32_t cell_h = (BAND_BODY_H - BAND_GAP) / 2;
|
||||
|
||||
for (int i = 0; i < CELL_COUNT; i++) {
|
||||
lv_obj_t *cell = panel_create(grid, 172, cell_h, COL_PANEL);
|
||||
lv_obj_set_style_pad_all(cell, 10, LV_PART_MAIN);
|
||||
lv_obj_t *cell = panel_create(grid, 117, cell_h, COL_PANEL);
|
||||
lv_obj_set_style_pad_all(cell, 8, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t *cap = label_create(cell, captions[i], &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||
lv_obj_set_style_text_letter_space(cap, 1, LV_PART_MAIN);
|
||||
@@ -872,7 +951,7 @@ static void build_body(lv_obj_t *scr)
|
||||
lv_obj_t *ic = icon_create(cell, cell_icons[i], icon_font_16, COL_TEXT_FAINT);
|
||||
lv_obj_align(ic, LV_ALIGN_TOP_RIGHT, 0, -1);
|
||||
|
||||
cell_value[i] = label_create(cell, NO_VALUE, &lv_font_montserrat_24, COL_TEXT);
|
||||
cell_value[i] = label_create(cell, NO_VALUE, &lv_font_montserrat_20, COL_TEXT);
|
||||
lv_obj_align(cell_value[i], LV_ALIGN_LEFT_MID, 0, 4);
|
||||
|
||||
/* Only readings with a meaningful hourly drift get an arrow slot. */
|
||||
@@ -941,26 +1020,45 @@ static void update_body(const ha_data_t *d)
|
||||
char buf[48];
|
||||
char sub[48];
|
||||
|
||||
/* Indoor card. Rounded with lroundf, not truncation: below 0 degF a cast
|
||||
* rounds toward zero and turns -10.6 into -10. */
|
||||
if (d->indoor_temp_f.valid) {
|
||||
snprintf(buf, sizeof(buf), "%.1f", d->indoor_temp_f.value);
|
||||
/* Downstairs -- the primary reading, and what the furnace controls on. */
|
||||
if (d->indoor_down_temp_f.valid) {
|
||||
snprintf(buf, sizeof(buf), "%.1f", d->indoor_down_temp_f.value);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||
}
|
||||
label_set_if_changed(temp_label, buf);
|
||||
/* Both hang off the number, whose width just changed with its digits. */
|
||||
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
|
||||
trend_render(trend_indoor_icon, d->trend_indoor);
|
||||
trend_render(trend_indoor_icon, d->trend_indoor_down);
|
||||
lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4);
|
||||
|
||||
if (d->indoor_humidity.valid) {
|
||||
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->indoor_humidity.value);
|
||||
if (d->indoor_down_humidity.valid) {
|
||||
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->indoor_down_humidity.value);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||
}
|
||||
label_set_if_changed(indoor_detail_label, buf);
|
||||
|
||||
/* Upstairs -- same card, smaller, right-aligned. */
|
||||
if (d->indoor_up_temp_f.valid) {
|
||||
snprintf(buf, sizeof(buf), "%.1f°", d->indoor_up_temp_f.value);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||
}
|
||||
label_set_if_changed(upstairs_value_label, buf);
|
||||
lv_obj_align(upstairs_value_label, LV_ALIGN_TOP_RIGHT, 0, 42);
|
||||
trend_render(trend_upstairs_icon, d->trend_indoor_up);
|
||||
lv_obj_align_to(trend_upstairs_icon, upstairs_value_label,
|
||||
LV_ALIGN_OUT_LEFT_MID, -4, 0);
|
||||
|
||||
if (d->indoor_up_humidity.valid) {
|
||||
snprintf(sub, sizeof(sub), "%.0f%% RH", d->indoor_up_humidity.value);
|
||||
} else {
|
||||
sub[0] = '\0';
|
||||
}
|
||||
label_set_if_changed(upstairs_sub_label, sub);
|
||||
lv_obj_align(upstairs_sub_label, LV_ALIGN_TOP_RIGHT, 0, 70);
|
||||
|
||||
/* Outdoor cell */
|
||||
if (d->outdoor_temp_f.valid) {
|
||||
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(d->outdoor_temp_f.value));
|
||||
@@ -972,32 +1070,13 @@ static void update_body(const ha_data_t *d)
|
||||
lv_obj_align_to(cell_trend[CELL_OUTDOOR], cell_value[CELL_OUTDOOR],
|
||||
LV_ALIGN_OUT_RIGHT_MID, 6, 0);
|
||||
|
||||
/*
|
||||
* Wind lives on the outdoor cell's sub-line now that it has no cell of
|
||||
* its own -- the station anemometer has been offline since 2026-07-27 and
|
||||
* a dedicated cell showing "--" was not worth the space the thermostat
|
||||
* controls needed. It is still deliberately NOT backfilled from the
|
||||
* met.no forecast: regional modelled wind under a heading that means "my
|
||||
* yard" is worse than showing nothing.
|
||||
*
|
||||
* When the sensor comes back it displaces the dew point here, which is
|
||||
* both a useful reading and a visible sign the hardware is alive again.
|
||||
*/
|
||||
if (d->wind_mph.valid) {
|
||||
if (d->wind_dir_deg.valid) {
|
||||
snprintf(sub, sizeof(sub), "%.0f%% %.0f mph %s",
|
||||
d->outdoor_humidity.valid ? d->outdoor_humidity.value : 0.0f,
|
||||
d->wind_mph.value, ha_wind_cardinal(d->wind_dir_deg.value));
|
||||
} else {
|
||||
snprintf(sub, sizeof(sub), "%.0f%% %.0f mph",
|
||||
d->outdoor_humidity.valid ? d->outdoor_humidity.value : 0.0f,
|
||||
d->wind_mph.value);
|
||||
}
|
||||
} else if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
|
||||
snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°",
|
||||
/* Wind has its own compass column now, so the outdoor sub-line goes back
|
||||
* to humidity and dew point. */
|
||||
if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
|
||||
snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°",
|
||||
d->outdoor_humidity.value, d->dew_point_f.value);
|
||||
} else if (d->outdoor_humidity.valid) {
|
||||
snprintf(sub, sizeof(sub), "%.0f%% humidity", d->outdoor_humidity.value);
|
||||
snprintf(sub, sizeof(sub), "%.0f%% RH", d->outdoor_humidity.value);
|
||||
} else {
|
||||
sub[0] = '\0';
|
||||
}
|
||||
@@ -1023,13 +1102,11 @@ static void update_body(const ha_data_t *d)
|
||||
if (d->sun_up) {
|
||||
label_set_if_changed(cell_value[CELL_SUN],
|
||||
d->sunset[0] ? d->sunset : NO_VALUE);
|
||||
snprintf(sub, sizeof(sub), "sets - rose %s",
|
||||
d->sunrise[0] ? d->sunrise : NO_VALUE);
|
||||
snprintf(sub, sizeof(sub), "sunset");
|
||||
} else {
|
||||
label_set_if_changed(cell_value[CELL_SUN],
|
||||
d->sunrise[0] ? d->sunrise : NO_VALUE);
|
||||
snprintf(sub, sizeof(sub), "rises - set %s",
|
||||
d->sunset[0] ? d->sunset : NO_VALUE);
|
||||
snprintf(sub, sizeof(sub), "sunrise");
|
||||
}
|
||||
label_set_if_changed(cell_sub[CELL_SUN], sub);
|
||||
|
||||
@@ -1042,6 +1119,86 @@ static void update_body(const ha_data_t *d)
|
||||
label_set_if_changed(cell_sub[CELL_PRESSURE], "inHg");
|
||||
}
|
||||
|
||||
/*
|
||||
* Point the needle at the direction the wind is coming FROM, which is what a
|
||||
* weather vane does and what the cardinal label says. 0 degrees is north, at
|
||||
* the top of the dial, increasing clockwise -- so screen x uses sin and y uses
|
||||
* -cos, because screen y grows downward.
|
||||
*
|
||||
* The needle deliberately does not animate. A spin would look better, but
|
||||
* every animated frame is a redraw, and redrawing is what starves the bounce
|
||||
* refill ISR (README, PSRAM bandwidth). Once a minute it snaps, and the dirty
|
||||
* area is just the dial.
|
||||
*/
|
||||
static void compass_render(const ha_data_t *d)
|
||||
{
|
||||
if (compass_needle == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int32_t c = COMPASS_SIZE / 2;
|
||||
|
||||
/*
|
||||
* Below this, treat it as calm and draw no needle.
|
||||
*
|
||||
* A vane that is not being pushed reports whatever it last settled on --
|
||||
* with the station becalmed that is a steady 0 degrees, which renders as a
|
||||
* confident needle pointing due north. "Calm" is the honest reading; an
|
||||
* arrow would be inventing information the sensor did not supply.
|
||||
*/
|
||||
const bool calm = d->wind_mph.valid && d->wind_mph.value < 0.5f;
|
||||
|
||||
if (!d->wind_dir_deg.valid || calm) {
|
||||
/* Degenerate zero-length line: nothing drawn, dial stays as a dial. */
|
||||
if (compass_pts[1].x != c || compass_pts[1].y != c) {
|
||||
compass_pts[0] = (lv_point_precise_t){ c, c };
|
||||
compass_pts[1] = (lv_point_precise_t){ c, c };
|
||||
lv_line_set_points(compass_needle, compass_pts, 2);
|
||||
}
|
||||
label_set_if_changed(wind_dir_label, calm ? "Calm" : NO_VALUE);
|
||||
return;
|
||||
}
|
||||
|
||||
const float rad = d->wind_dir_deg.value * (float)M_PI / 180.0f;
|
||||
const float head_r = (float)c - 8.0f; /* stop inside the border */
|
||||
const float tail_r = (float)c * 0.35f; /* short tail through centre */
|
||||
|
||||
const int32_t hx = c + (int32_t)lroundf(head_r * sinf(rad));
|
||||
const int32_t hy = c - (int32_t)lroundf(head_r * cosf(rad));
|
||||
const int32_t tx = c - (int32_t)lroundf(tail_r * sinf(rad));
|
||||
const int32_t ty = c + (int32_t)lroundf(tail_r * cosf(rad));
|
||||
|
||||
/* Only rewrite the points when the needle actually moved --
|
||||
* lv_line_set_points() invalidates unconditionally. */
|
||||
if (compass_pts[0].x != tx || compass_pts[0].y != ty ||
|
||||
compass_pts[1].x != hx || compass_pts[1].y != hy) {
|
||||
compass_pts[0] = (lv_point_precise_t){ tx, ty };
|
||||
compass_pts[1] = (lv_point_precise_t){ hx, hy };
|
||||
lv_line_set_points(compass_needle, compass_pts, 2);
|
||||
}
|
||||
|
||||
label_set_if_changed(wind_dir_label, ha_wind_cardinal(d->wind_dir_deg.value));
|
||||
}
|
||||
|
||||
static void update_wind(const ha_data_t *d)
|
||||
{
|
||||
char buf[24];
|
||||
|
||||
compass_render(d);
|
||||
|
||||
if (d->wind_mph.valid) {
|
||||
snprintf(buf, sizeof(buf), "%.1f", d->wind_mph.value);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||
}
|
||||
label_set_if_changed(wind_speed_label, buf);
|
||||
lv_obj_align_to(wind_unit_label, wind_speed_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 3, -3);
|
||||
|
||||
/* The arrow is the 5-minute trend in average speed, not the instantaneous
|
||||
* reading -- see HA_TREND_CH_WIND. */
|
||||
trend_render(wind_trend_icon, d->trend_wind);
|
||||
}
|
||||
|
||||
static void update_forecast(const ha_data_t *d)
|
||||
{
|
||||
char buf[32];
|
||||
@@ -1108,6 +1265,7 @@ void ui_update(const ha_data_t *data)
|
||||
lv_obj_align_to(cond_label, cond_icon, LV_ALIGN_OUT_LEFT_MID, -8, 0);
|
||||
|
||||
update_body(data);
|
||||
update_wind(data);
|
||||
update_forecast(data);
|
||||
|
||||
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
|
||||
|
||||
+23
-12
@@ -212,13 +212,17 @@ static void apply_mock_data(void)
|
||||
|
||||
sim_data.outdoor_temp_f = (ha_value_t){ 73.0f, true };
|
||||
sim_data.outdoor_humidity = (ha_value_t){ 80.0f, true };
|
||||
sim_data.indoor_temp_f = (ha_value_t){ 78.1f, true };
|
||||
sim_data.indoor_humidity = (ha_value_t){ 70.0f, true };
|
||||
sim_data.indoor_down_temp_f = (ha_value_t){ 71.4f, true };
|
||||
sim_data.indoor_down_humidity = (ha_value_t){ 46.0f, true };
|
||||
sim_data.indoor_up_temp_f = (ha_value_t){ 74.8f, true };
|
||||
sim_data.indoor_up_humidity = (ha_value_t){ 43.0f, true };
|
||||
sim_data.dew_point_f = (ha_value_t){ 66.0f, true };
|
||||
sim_data.pressure_inhg = (ha_value_t){ 29.89f, true };
|
||||
sim_data.rain_today_in = (ha_value_t){ 0.0f, true };
|
||||
sim_data.rain_rate_inhr = (ha_value_t){ 0.0f, true };
|
||||
/* wind/gust/wdir left invalid on purpose -- that is the real state */
|
||||
sim_data.wind_mph = (ha_value_t){ 6.4f, true };
|
||||
sim_data.gust_mph = (ha_value_t){ 11.2f, true };
|
||||
sim_data.wind_dir_deg = (ha_value_t){ 292.0f, true }; /* WNW */
|
||||
|
||||
snprintf(sim_data.condition, sizeof(sim_data.condition), "cloudy");
|
||||
snprintf(sim_data.sunrise, sizeof(sim_data.sunrise), "6:05 AM");
|
||||
@@ -243,15 +247,22 @@ static void apply_mock_data(void)
|
||||
*/
|
||||
const uint32_t now = lv_tick_get();
|
||||
const uint32_t hour_ago = now - 55u * 60u * 1000u;
|
||||
ha_trend_feed(HA_TREND_CH_INDOOR, 77.1f, hour_ago); /* -> rising */
|
||||
ha_trend_feed(HA_TREND_CH_INDOOR, 78.1f, now);
|
||||
ha_trend_feed(HA_TREND_CH_OUTDOOR, 74.6f, hour_ago); /* -> falling */
|
||||
const uint32_t min_ago = now - 4u * 60u * 1000u;
|
||||
ha_trend_feed(HA_TREND_CH_INDOOR_DOWN, 70.6f, hour_ago); /* -> rising */
|
||||
ha_trend_feed(HA_TREND_CH_INDOOR_DOWN, 71.4f, now);
|
||||
ha_trend_feed(HA_TREND_CH_INDOOR_UP, 75.5f, hour_ago); /* -> falling */
|
||||
ha_trend_feed(HA_TREND_CH_INDOOR_UP, 74.8f, now);
|
||||
ha_trend_feed(HA_TREND_CH_OUTDOOR, 74.6f, hour_ago); /* -> falling */
|
||||
ha_trend_feed(HA_TREND_CH_OUTDOOR, 73.0f, now);
|
||||
ha_trend_feed(HA_TREND_CH_PRESSURE, 29.95f, hour_ago); /* -> falling */
|
||||
ha_trend_feed(HA_TREND_CH_PRESSURE, 29.95f, hour_ago); /* -> falling */
|
||||
ha_trend_feed(HA_TREND_CH_PRESSURE, 29.89f, now);
|
||||
sim_data.trend_indoor = ha_trend_compute(HA_TREND_CH_INDOOR, 0.5f, now);
|
||||
sim_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, 0.5f, now);
|
||||
sim_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, 0.02f, now);
|
||||
ha_trend_feed(HA_TREND_CH_WIND, 3.9f, min_ago); /* -> rising */
|
||||
ha_trend_feed(HA_TREND_CH_WIND, 6.4f, now);
|
||||
sim_data.trend_indoor_down = ha_trend_compute(HA_TREND_CH_INDOOR_DOWN, now);
|
||||
sim_data.trend_indoor_up = ha_trend_compute(HA_TREND_CH_INDOOR_UP, now);
|
||||
sim_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, now);
|
||||
sim_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, now);
|
||||
sim_data.trend_wind = ha_trend_compute(HA_TREND_CH_WIND, now);
|
||||
|
||||
static const char *const days[HA_FORECAST_DAYS] = { "Tue", "Wed", "Thu", "Fri", "Sat" };
|
||||
static const char *const cond[HA_FORECAST_DAYS] = {
|
||||
@@ -286,8 +297,8 @@ static void sim_thermostat_tick(void)
|
||||
{
|
||||
therm_status_t st;
|
||||
|
||||
const bool have_temp = sim_data.indoor_temp_f.valid;
|
||||
thermostat_tick(have_temp ? sim_data.indoor_temp_f.value : 0.0f,
|
||||
const bool have_temp = sim_data.indoor_down_temp_f.valid;
|
||||
thermostat_tick(have_temp ? sim_data.indoor_down_temp_f.value : 0.0f,
|
||||
have_temp, lv_tick_get());
|
||||
thermostat_get_status(&st);
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"changed_states":[],"service_response":{"weather.forecast_home":{"forecast":[{"condition":"partlycloudy","datetime":"2026-08-04T19:00:00+00:00","wind_bearing":248.4,"uv_index":10.5,"temperature":80,"templow":70,"wind_speed":9.63,"precipitation":0.0,"humidity":72},{"condition":"sunny","datetime":"2026-08-05T19:00:00+00:00","wind_bearing":263.9,"uv_index":10.0,"temperature":80,"templow":67,"wind_speed":10.94,"precipitation":0.0,"humidity":70},{"condition":"sunny","datetime":"2026-08-06T19:00:00+00:00","wind_bearing":278.8,"uv_index":9.9,"temperature":82,"templow":68,"wind_speed":10.31,"precipitation":0.0,"humidity":73},{"condition":"clear-night","datetime":"2026-08-07T19:00:00+00:00","wind_bearing":285.6,"temperature":82,"templow":69,"wind_speed":6.28,"precipitation":0.0,"humidity":71},{"condition":"clear-night","datetime":"2026-08-08T19:00:00+00:00","wind_bearing":268.9,"temperature":82,"templow":68,"wind_speed":7.15,"precipitation":0.0,"humidity":72},{"condition":"clear-night","datetime":"2026-08-09T19:00:00+00:00","wind_bearing":212.3,"temperature":81,"templow":69,"wind_speed":8.08,"precipitation":0.0,"humidity":74}]}}}
|
||||
{"changed_states":[],"service_response":{"weather.forecast_home":{"forecast":[{"condition":"sunny","datetime":"2026-08-07T19:00:00+00:00","wind_bearing":264.8,"uv_index":0.1,"temperature":75,"templow":72,"wind_speed":7.15,"precipitation":0.0,"humidity":78},{"condition":"sunny","datetime":"2026-08-08T19:00:00+00:00","wind_bearing":286.5,"uv_index":9.8,"temperature":82,"templow":69,"wind_speed":11.43,"precipitation":0.0,"humidity":74},{"condition":"sunny","datetime":"2026-08-09T19:00:00+00:00","wind_bearing":278.2,"uv_index":9.4,"temperature":82,"templow":69,"wind_speed":10.75,"precipitation":0.0,"humidity":73},{"condition":"clear-night","datetime":"2026-08-10T19:00:00+00:00","wind_bearing":236.1,"uv_index":7.3,"temperature":80,"templow":69,"wind_speed":9.2,"precipitation":0.0,"humidity":74},{"condition":"partlycloudy","datetime":"2026-08-11T19:00:00+00:00","wind_bearing":262.0,"temperature":80,"templow":68,"wind_speed":6.28,"precipitation":0.0,"humidity":71},{"condition":"clear-night","datetime":"2026-08-12T19:00:00+00:00","wind_bearing":235.5,"temperature":78,"templow":68,"wind_speed":8.08,"precipitation":0.0,"humidity":70}]}}}
|
||||
@@ -1 +1 @@
|
||||
{"out_t":76.6,"out_h":73.0,"in_t":78.6,"in_h":69.0,"dew":67.28,"press":29.899,"rain_d":0.0,"rain_r":0.0,"wind":null,"gust":null,"wdir":null,"cond":"partlycloudy","sunrise":"6:05 AM","sunset":"7:45 PM","sun_up":true,"clock":"11:32 AM","date":"Tue Aug 4","lights":[{"s":"off","b":null},{"s":"off","b":null},{"s":"off","b":null},{"s":"off","b":null},{"s":"unavailable","b":null},{"s":"off","b":null}]}
|
||||
{"out_t":74.3,"out_h":80.0,"dn_t":82.2,"dn_h":61.0,"up_t":85.6,"up_h":61.0,"dew":67.72,"press":29.988,"rain_d":0.0,"rain_r":0.0,"wind":0.0,"gust":2.91,"wdir":0.0,"cond":"sunny","sunrise":"6:07 AM","sunset":"7:41 PM","sun_up":false,"clock":"7:51 PM","date":"Fri Aug 7","lights":[{"s":"on","b":127},{"s":"on","b":255},{"s":"on","b":255},{"s":"on","b":255},{"s":"on","b":255},{"s":"on","b":null}]}
|
||||
+11
-1
@@ -64,11 +64,21 @@ def main() -> int:
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--host", default="192.168.1.134")
|
||||
ap.add_argument("--name", default="live", help="snapshot basename")
|
||||
ap.add_argument("--token-file",
|
||||
default=str(Path.home() / ".config/thermostat-hw/ha_token"),
|
||||
help="file holding the long-lived token; read directly, never printed")
|
||||
args = ap.parse_args()
|
||||
|
||||
# Read the token from its file by default. Passing it through the
|
||||
# environment means it appears on a command line, and command lines end up
|
||||
# in shell history and terminal transcripts; reading the path here keeps
|
||||
# the value inside the process that consumes it.
|
||||
token = os.environ.get("HA_TOKEN")
|
||||
if not token:
|
||||
sys.exit("HA_TOKEN is not set. See tools/ha_discover.py for how to make one.")
|
||||
try:
|
||||
token = Path(args.token_file).read_text().strip()
|
||||
except OSError:
|
||||
sys.exit(f"No token: set HA_TOKEN or create {args.token_file}")
|
||||
|
||||
SNAP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
Reference in new issue
Block a user