Compare commits

...
1 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
13 changed files with 430 additions and 150 deletions

No files matched your search

+40 -11
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,
@@ -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
+10 -5
View File
@@ -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
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"
+62 -35
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
const uint32_t span_ms = (anchor->ms == newest->ms) ? 1u
: newest->ms - anchor->ms;
const float threshold = threshold_per_hour *
((float)span_ms / (float)HA_TREND_WINDOW_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 "') }}," \
+19
View File
@@ -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,
},
};
+22 -24
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
@@ -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);
+202 -44
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.
@@ -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) {
/* 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++) {
+21 -10
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);
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.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)