Compare commits

...
Author SHA1 Message Date
mattandClaude ea1b8b8d15 Two indoor sensors, wind compass, and stop framebuffer cache thrashing
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>
2026-08-07 19:58:41 -07:00
mattandClaude 7a58f9838a Stop the constant repaint that made the left-edge artefacts permanent
The artefacts went from occasional to continuous, and the cause was my own
mitigation for them.

lv_obj_set_local_style_prop() does NOT skip identical values -- it calls
lv_obj_refresh_style() unconditionally, which invalidates the object. A
comment in ui_set_stale() claimed the opposite and that assumption spread.
ui_update_thermostat() is called once a second by the control task and was
writing seven style properties every time, so the panel was redrawing
something forever, and glitched lines were being regenerated as fast as
anything could clear them.

Two full-screen invalidates made it worse still: one after every HA poll,
one 600ms after every touch. The premise was that LVGL never reinvalidates
pixels it does not know are wrong, so repainting would clean them up. But
the README already had the answer -- the corruption scales with how much is
written, because LVGL's PSRAM writes compete with the panel's DMA reads out
of the same PSRAM. A full repaint is 768KB of exactly the contention that
causes the problem. Both are removed.

What is left is the thing that actually helps: write less.

  - ui_update_thermostat() returns without taking the lock when nothing
    moved, and otherwise touches only the widgets whose values changed.
  - ui_set_stale() is guarded on the previous value.
  - Condition and trend icons only get a colour write when the glyph itself
    changed.

Also raises the bounce buffer from 16 to 20 scanlines. The comment saying
20 "fails to allocate once WiFi is up" was true when display_init() ran
after wifi_init_sta(); it does not any more. display_alloc() takes the
buffers from a virgin heap with ~200KB free, so the extra 12.8KB is
affordable, and each larger chunk buys the refill ISR more time to fetch the
next one before the LCD needs it. That attacks the mechanism rather than the
symptom. ~24KB internal heap remains once WiFi is up; the "Free internal
heap" log line is the thing to watch if it needs to come back down.

Both gotchas are now in the README so the next person does not reach for a
repaint again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 21:04:23 -07:00
13 changed files with 646 additions and 283 deletions

No files matched your search

+64 -13
View File
@@ -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,
@@ -231,9 +249,28 @@ the buffer being displayed — copying into the live framebuffer races the LCD's
own reads, and the resulting corrupt pixels persist indefinitely because LVGL
does not know they are wrong and never reinvalidates them.
A 16-scanline bounce buffer stages each chunk in internal RAM. 20 scanlines
would be better but needs 64KB of internal RAM and fails to allocate once
WiFi is up.
A **20**-scanline bounce buffer stages each chunk in internal RAM (64KB across
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
fragmented it. Bigger chunks give the refill ISR more time to fetch the next
one before the LCD needs it, which attacks the artefact mechanism directly.
That leaves ~24KB of internal heap once everything is up. Watch the
"Free internal heap" log line — if it approaches single digits, come back
down to 18 or 16 scanlines.
### Known limitation: occasional left-edge artefacts
@@ -283,6 +320,20 @@ about PSRAM bandwidth or panel timing.
## Gotchas
- **Setting a style property always invalidates, even to an identical value.**
`lv_obj_set_local_style_prop()` calls `lv_obj_refresh_style()`
unconditionally — there is no equality short-circuit, unlike the one you
might assume by analogy with `label_set_if_changed()`. A periodic update
that restyles widgets "harmlessly" is a periodic full repaint of them.
This cost a round of debugging: `ui_update_thermostat()` ran at 1Hz writing
seven styles, which turned the left-edge artefacts from occasional into
permanent. Every update path here is now change-guarded.
- **Do not repaint to fix artefacts.** It is tempting, because LVGL never
reinvalidates pixels it does not know are wrong. But the corruption scales
with how much is written, so a full-screen invalidate is 768KB of exactly
the PSRAM contention that causes it. Two attempts at this (post-poll and a
post-interaction "repair" timer) both made it visibly worse and were
removed. Redraw *less*, not more.
- **LVGL does not bubble events to parents.** An event handler on the screen
object never sees taps that land on a child widget, which looks exactly like
dead touch zones. Light tiles therefore carry their own handlers, and the
+16 -12
View File
@@ -27,13 +27,12 @@ no errors), but eyes and fingers haven't confirmed:
2. Trend arrows appear ~5 minutes after boot (indoor big card, outdoor and
pressure cells) once the local ring has history. They are local-history
based and reset on reboot — that is by design, not a bug.
3. Artefact lifetime: interactions schedule a one-shot full repaint 600ms
after the last touch, and every poll repaints too. Artefacts should now
visibly self-clean in under a second after touching, and within a poll
otherwise. If they *persist* beyond that, the repaint isn't reaching
them; if they merely *recur* during interaction, that is the PSRAM
bandwidth ceiling (README) and the honest remaining levers are fewer
redraws or 120MHz PSRAM on a newer IDF.
3. Artefact lifetime. Two "repaint to clean up" mitigations were tried and
both made it worse -- see README Gotchas. They are gone, the 1Hz restyle
that made the artefacts permanent is fixed, and the bounce buffer is up to
20 scanlines. If lines still come and go, the remaining honest levers are
fewer redraws still, less WiFi interrupt load during rendering, or 120MHz
PSRAM on a newer IDF.
## Done
@@ -86,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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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 "') }}," \
+35 -3
View File
@@ -80,9 +80,22 @@ static const char *TAG = "display_rgb";
* displays whatever was left in the buffer, showing up as short stale lines
* at the left edge where each chunk begins.
*/
/* 16 is the practical ceiling: two buffers at 20 lines need 64KB of internal
* RAM and the allocation fails once WiFi is up. */
#define BOUNCE_BUFFER_LINES 16
/*
* 20 lines: two buffers at 800px x 2 bytes = 64KB of internal RAM.
*
* This used to be 16 because 20 "fails to allocate once WiFi is up" -- true
* when display_init() ran after wifi_init_sta(). It no longer does: the
* two-phase split means display_alloc() takes these buffers from a virgin
* heap before WiFi has touched it (~200KB free at that point), so the extra
* 12.8KB is affordable now.
*
* Bigger directly attacks the artefact mechanism: each chunk buys the refill
* ISR that much more time to fetch the next one out of PSRAM before the LCD
* needs it. Going further eats the internal RAM WiFi wants at runtime --
* watch the "Free internal heap" line, and if it drops near single digits,
* come back down.
*/
#define BOUNCE_BUFFER_LINES 20
static esp_lcd_panel_handle_t panel = NULL;
static lv_display_t *disp = NULL;
@@ -180,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,
},
};
+34 -37
View File
@@ -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
@@ -289,20 +281,19 @@ static void ha_task(void *arg)
lvgl_port_resume();
/*
* Repaint everything after every fetch window.
* No full-screen invalidate here, deliberately.
*
* The known left-edge artefacts (see README) happen when WiFi
* interrupt load starves the bounce-refill ISR, and once bad pixels
* are on screen LVGL has no idea they are wrong -- it never
* reinvalidates them, so they sit there indefinitely. A full
* invalidate after each poll bounds their lifetime at one poll
* interval. Mitigation, not cure: it cannot prevent the glitch,
* only clean up after it.
* There used to be one, on the theory that repainting would clean up
* glitched pixels LVGL does not know are wrong. It made things
* materially worse, and the README already said why: the corruption
* scales with how much is written, because LVGL's writes into PSRAM
* compete with the panel's own DMA reads out of it. A full repaint is
* 768KB of exactly that contention -- the largest possible dose of
* the cause, applied as a cure.
*
* Redraw as little as possible is the actual lever. See ui_update*(),
* which now write nothing when nothing changed.
*/
if (lvgl_port_lock(100)) {
lv_obj_invalidate(lv_screen_active());
lvgl_port_unlock();
}
poll_count++;
@@ -318,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);
+357 -149
View File
@@ -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.
@@ -195,33 +213,20 @@ static bool slider_touched[HA_LIGHT_COUNT];
static int32_t slider_sent_pct[HA_LIGHT_COUNT];
/*
* One-shot "repair" repaint, rescheduled by every interaction to fire 600ms
* after the last one.
* There is deliberately no "repair" repaint here any more.
*
* Redrawing is itself what glitches the panel: the flush's PSRAM writes
* contend with the LCD's scan-out, and a burst of interactive redraws is the
* most likely moment for the bounce ISR to lose (README, PSRAM bandwidth).
* The glitched lines then persist because LVGL believes those pixels are
* fine. One full repaint after the burst settles cleans them up within
* ~0.6s instead of leaving them until the next poll's repaint, up to 60s
* away. It runs once per burst, not periodically -- a constant repaint loop
* would be a constant supply of fresh glitch opportunities.
* An earlier version scheduled a full-screen invalidate 600ms after each
* interaction, on the theory that repainting would clear glitched pixels
* LVGL does not know are wrong. It made the artefacts worse and constant
* rather than occasional, for the reason the README already gave: the
* corruption scales with how much is written, because LVGL's PSRAM writes
* compete with the panel's DMA reads out of the same PSRAM. Repainting to
* fix corruption caused by repainting does not converge.
*
* The lever that actually works is writing less. Everything below is
* change-guarded: if a value has not moved, no style is written and nothing
* is invalidated.
*/
static lv_timer_t *repair_timer;
static void repair_timer_cb(lv_timer_t *t)
{
lv_obj_invalidate(lv_screen_active());
lv_timer_pause(t);
}
static void schedule_repair(void)
{
if (repair_timer != NULL) {
lv_timer_reset(repair_timer);
lv_timer_resume(repair_timer);
}
}
/* --------------------------------------------------------- condition icons */
@@ -348,18 +353,24 @@ static void trend_render(lv_obj_t *icon, ha_trend_t t)
return;
}
switch (t) {
case HA_TREND_RISING:
label_set_if_changed(icon, ICON_TREND_UP);
/*
* The colour write is skipped when the glyph did not change. Style writes
* invalidate unconditionally, so an unguarded one here would repaint
* three arrows on every poll to redraw the same pixels.
*/
const char *glyph = (t == HA_TREND_RISING) ? ICON_TREND_UP
: (t == HA_TREND_FALLING) ? ICON_TREND_DOWN
: "";
const char *current = lv_label_get_text(icon);
if (current != NULL && strcmp(current, glyph) == 0) {
return;
}
lv_label_set_text(icon, glyph);
if (t == HA_TREND_RISING) {
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT_WARM), LV_PART_MAIN);
break;
case HA_TREND_FALLING:
label_set_if_changed(icon, ICON_TREND_DOWN);
} else if (t == HA_TREND_FALLING) {
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT), LV_PART_MAIN);
break;
default:
label_set_if_changed(icon, "");
break;
}
}
@@ -409,7 +420,6 @@ static void therm_button_clicked(lv_event_t *e)
if (therm_cb != NULL) {
therm_cb(cmd);
}
schedule_repair();
}
/* ------------------------------------------------------------ light tiles */
@@ -469,7 +479,6 @@ static void light_tile_clicked(lv_event_t *e)
: -1,
};
light_apply_optimistic(idx, expect);
schedule_repair();
/* -1: a tap is on/off only. Brightness is the slider's job, and sending
* the current brightness here would fight a user who dimmed the light
@@ -525,7 +534,6 @@ static void light_slider_changed(lv_event_t *e)
lv_color_hex(pct > 0 ? COL_ON : COL_TEXT_DIM),
LV_PART_MAIN);
schedule_repair();
if (light_cb == NULL || !lv_obj_has_state(slider, LV_STATE_PRESSED)) {
return;
@@ -552,7 +560,6 @@ static void light_slider_released(lv_event_t *e)
const bool touched = slider_touched[idx];
slider_touched[idx] = false;
schedule_repair();
if (touched && pct == slider_sent_pct[idx]) {
return; /* tap without drag: the press command already said this */
@@ -778,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);
@@ -804,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);
@@ -819,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);
@@ -850,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));
@@ -862,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,
@@ -873,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);
@@ -883,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. */
@@ -952,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));
@@ -983,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';
}
@@ -1034,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);
@@ -1053,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];
@@ -1070,9 +1216,12 @@ static void update_forecast(const ha_data_t *d)
if (icon_font_26 != NULL) {
const cond_icon_t *ci = cond_icon_lookup(f->condition);
label_set_if_changed(fc_icon[i], ci->icon);
lv_obj_set_style_text_color(fc_icon[i], lv_color_hex(ci->colour),
LV_PART_MAIN);
const char *current = lv_label_get_text(fc_icon[i]);
if (current == NULL || strcmp(current, ci->icon) != 0) {
lv_label_set_text(fc_icon[i], ci->icon);
lv_obj_set_style_text_color(fc_icon[i], lv_color_hex(ci->colour),
LV_PART_MAIN);
}
}
if (f->high_f.valid && f->low_f.valid) {
@@ -1105,13 +1254,18 @@ void ui_update(const ha_data_t *data)
if (icon_font_26 != NULL) {
const cond_icon_t *ci = cond_icon_lookup(
data->condition[0] ? data->condition : NULL);
label_set_if_changed(cond_icon, ci->icon);
lv_obj_set_style_text_color(cond_icon, lv_color_hex(ci->colour), LV_PART_MAIN);
/* Colour only when the glyph moved -- see trend_render(). */
const char *current = lv_label_get_text(cond_icon);
if (current == NULL || strcmp(current, ci->icon) != 0) {
lv_label_set_text(cond_icon, ci->icon);
lv_obj_set_style_text_color(cond_icon, lv_color_hex(ci->colour), LV_PART_MAIN);
}
}
/* The label's width changed with its words; re-hang it off the icon. */
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++) {
@@ -1138,19 +1292,34 @@ void ui_update(const ha_data_t *data)
void ui_set_stale(bool stale)
{
/*
* Guarded, because setting an identical style value is NOT free --
* lv_obj_set_local_style_prop() calls lv_obj_refresh_style()
* unconditionally and invalidates the object. An earlier comment here
* claimed the opposite and was wrong; called every poll, it repainted the
* temperature for no reason.
*/
static bool last_stale;
static bool last_stale_valid;
if (last_stale_valid && last_stale == stale) {
return;
}
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out");
return;
}
if (temp_label != NULL) {
/* Unlike lv_label_set_text(), setting an identical style value does
* not invalidate, so this is safe to call on every poll. */
lv_obj_set_style_text_color(temp_label,
lv_color_hex(stale ? COL_STALE : COL_TEXT),
LV_PART_MAIN);
}
last_stale = stale;
last_stale_valid = true;
lvgl_port_unlock();
}
@@ -1180,12 +1349,37 @@ void ui_set_therm_cb(ui_therm_cmd_cb_t cb)
therm_cb = cb;
}
/*
* Previous state pushed to the thermostat card. This is not an optimisation,
* it is the fix for a real bug: this function is called once a second by the
* control task, and lv_obj_set_local_style_prop() does NOT skip identical
* values -- it calls lv_obj_refresh_style() unconditionally, which
* invalidates. Writing seven styles per second was seven redraws per second
* forever, which is what turned the left-edge artefacts from occasional into
* permanent.
*/
static therm_status_t last_therm;
static bool last_therm_valid;
void ui_update_thermostat(const therm_status_t *st)
{
if (st == NULL) {
return;
}
const bool first = !last_therm_valid;
/* The common case by far: nothing has moved, so touch nothing and do not
* even take the lock. */
if (!first &&
st->state == last_therm.state &&
st->mode == last_therm.mode &&
st->fan == last_therm.fan &&
st->lockout_remaining_s == last_therm.lockout_remaining_s &&
(int)lroundf(st->setpoint_f) == (int)lroundf(last_therm.setpoint_f)) {
return;
}
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped thermostat update: LVGL lock timed out");
return;
@@ -1193,61 +1387,81 @@ void ui_update_thermostat(const therm_status_t *st)
char buf[32];
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(st->setpoint_f));
label_set_if_changed(setpoint_label, buf);
thermostat_state_text(st, buf, sizeof(buf));
label_set_if_changed(therm_state_label, buf);
/*
* The dot colour is the at-a-glance channel: amber means the burner is
* lit, red means something is wrong and no heat is coming, dim means
* everything is fine and there is nothing to do.
*/
uint32_t dot = COL_TEXT_FAINT;
uint32_t text = COL_TEXT_DIM;
switch (st->state) {
case THERM_STATE_HEATING:
dot = COL_ON;
text = COL_ON;
break;
case THERM_STATE_LOCKOUT:
dot = COL_ACCENT_WARM;
text = COL_ACCENT_WARM;
break;
case THERM_STATE_NO_SENSOR:
dot = 0xE74C3C;
text = 0xE74C3C;
break;
default:
break;
}
lv_obj_set_style_bg_color(therm_state_dot, lv_color_hex(dot), LV_PART_MAIN);
lv_obj_set_style_text_color(therm_state_label, lv_color_hex(text), LV_PART_MAIN);
const bool heat_mode = (st->mode == THERM_MODE_HEAT);
label_set_if_changed(mode_btn_label, heat_mode ? "Heat" : "Off");
lv_obj_set_style_bg_color(mode_btn,
lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_PANEL_HI),
LV_PART_MAIN);
lv_obj_set_style_text_color(mode_btn_label,
lv_color_hex(heat_mode ? 0x2A1A05 : COL_TEXT_DIM),
LV_PART_MAIN);
const bool mode_changed = first || st->mode != last_therm.mode;
const bool fan_on = (st->fan == THERM_FAN_ON);
label_set_if_changed(fan_btn_label, fan_on ? "Fan on" : "Fan auto");
lv_obj_set_style_bg_color(fan_btn,
lv_color_hex(fan_on ? COL_ACCENT : COL_PANEL_HI),
LV_PART_MAIN);
lv_obj_set_style_text_color(fan_btn_label,
lv_color_hex(fan_on ? 0x05131F : COL_TEXT),
LV_PART_MAIN);
if (first || mode_changed ||
(int)lroundf(st->setpoint_f) != (int)lroundf(last_therm.setpoint_f)) {
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(st->setpoint_f));
label_set_if_changed(setpoint_label, buf);
/* Setpoint greys out when the mode is off -- it still adjusts, it just
* is not controlling anything. */
lv_obj_set_style_text_color(setpoint_label,
lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_TEXT_FAINT),
LV_PART_MAIN);
/* Greys out when the mode is off -- it still adjusts, it just is not
* controlling anything. */
lv_obj_set_style_text_color(setpoint_label,
lv_color_hex(heat_mode ? COL_ACCENT_WARM
: COL_TEXT_FAINT),
LV_PART_MAIN);
}
/* Text changes every second while a lockout counts down, but it is one
* small label rather than the whole card. */
if (first || st->state != last_therm.state ||
st->lockout_remaining_s != last_therm.lockout_remaining_s) {
thermostat_state_text(st, buf, sizeof(buf));
label_set_if_changed(therm_state_label, buf);
}
if (first || st->state != last_therm.state) {
/*
* The dot colour is the at-a-glance channel: amber means the burner
* is lit, warm orange means it wants to run but a protection timer
* says not yet, red means no heat is coming at all.
*/
uint32_t dot = COL_TEXT_FAINT;
uint32_t text = COL_TEXT_DIM;
switch (st->state) {
case THERM_STATE_HEATING:
dot = COL_ON;
text = COL_ON;
break;
case THERM_STATE_LOCKOUT:
dot = COL_ACCENT_WARM;
text = COL_ACCENT_WARM;
break;
case THERM_STATE_NO_SENSOR:
dot = 0xE74C3C;
text = 0xE74C3C;
break;
default:
break;
}
lv_obj_set_style_bg_color(therm_state_dot, lv_color_hex(dot), LV_PART_MAIN);
lv_obj_set_style_text_color(therm_state_label, lv_color_hex(text), LV_PART_MAIN);
}
if (mode_changed) {
label_set_if_changed(mode_btn_label, heat_mode ? "Heat" : "Off");
lv_obj_set_style_bg_color(mode_btn,
lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_PANEL_HI),
LV_PART_MAIN);
lv_obj_set_style_text_color(mode_btn_label,
lv_color_hex(heat_mode ? 0x2A1A05 : COL_TEXT_DIM),
LV_PART_MAIN);
}
if (first || st->fan != last_therm.fan) {
const bool fan_on = (st->fan == THERM_FAN_ON);
label_set_if_changed(fan_btn_label, fan_on ? "Fan on" : "Fan auto");
lv_obj_set_style_bg_color(fan_btn,
lv_color_hex(fan_on ? COL_ACCENT : COL_PANEL_HI),
LV_PART_MAIN);
lv_obj_set_style_text_color(fan_btn_label,
lv_color_hex(fan_on ? 0x05131F : COL_TEXT),
LV_PART_MAIN);
}
last_therm = *st;
last_therm_valid = true;
lvgl_port_unlock();
}
@@ -1280,12 +1494,6 @@ void ui_init(void)
lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
/* Created paused; interactions reset-and-resume it. See schedule_repair. */
repair_timer = lv_timer_create(repair_timer_cb, 600, NULL);
if (repair_timer != NULL) {
lv_timer_pause(repair_timer);
}
build_header(scr);
build_body(scr);
build_lights(scr);
+23 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)