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>
This commit is contained in:
mattandClaude committed 2026-08-07 19:58:41 -07:00
1 parent 7a58f9838a
commit ea1b8b8d15
13 files changed
+435 -155

No files matched your search

+203 -45
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) {
snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°",
/* Wind has its own compass column now, so the outdoor sub-line goes back
* to humidity and dew point. */
if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°",
d->outdoor_humidity.value, d->dew_point_f.value);
} else if (d->outdoor_humidity.valid) {
snprintf(sub, sizeof(sub), "%.0f%% humidity", d->outdoor_humidity.value);
snprintf(sub, sizeof(sub), "%.0f%% RH", d->outdoor_humidity.value);
} else {
sub[0] = '\0';
}
@@ -1023,13 +1102,11 @@ static void update_body(const ha_data_t *d)
if (d->sun_up) {
label_set_if_changed(cell_value[CELL_SUN],
d->sunset[0] ? d->sunset : NO_VALUE);
snprintf(sub, sizeof(sub), "sets - rose %s",
d->sunrise[0] ? d->sunrise : NO_VALUE);
snprintf(sub, sizeof(sub), "sunset");
} else {
label_set_if_changed(cell_value[CELL_SUN],
d->sunrise[0] ? d->sunrise : NO_VALUE);
snprintf(sub, sizeof(sub), "rises - set %s",
d->sunset[0] ? d->sunset : NO_VALUE);
snprintf(sub, sizeof(sub), "sunrise");
}
label_set_if_changed(cell_sub[CELL_SUN], sub);
@@ -1042,6 +1119,86 @@ static void update_body(const ha_data_t *d)
label_set_if_changed(cell_sub[CELL_PRESSURE], "inHg");
}
/*
* Point the needle at the direction the wind is coming FROM, which is what a
* weather vane does and what the cardinal label says. 0 degrees is north, at
* the top of the dial, increasing clockwise -- so screen x uses sin and y uses
* -cos, because screen y grows downward.
*
* The needle deliberately does not animate. A spin would look better, but
* every animated frame is a redraw, and redrawing is what starves the bounce
* refill ISR (README, PSRAM bandwidth). Once a minute it snaps, and the dirty
* area is just the dial.
*/
static void compass_render(const ha_data_t *d)
{
if (compass_needle == NULL) {
return;
}
const int32_t c = COMPASS_SIZE / 2;
/*
* Below this, treat it as calm and draw no needle.
*
* A vane that is not being pushed reports whatever it last settled on --
* with the station becalmed that is a steady 0 degrees, which renders as a
* confident needle pointing due north. "Calm" is the honest reading; an
* arrow would be inventing information the sensor did not supply.
*/
const bool calm = d->wind_mph.valid && d->wind_mph.value < 0.5f;
if (!d->wind_dir_deg.valid || calm) {
/* Degenerate zero-length line: nothing drawn, dial stays as a dial. */
if (compass_pts[1].x != c || compass_pts[1].y != c) {
compass_pts[0] = (lv_point_precise_t){ c, c };
compass_pts[1] = (lv_point_precise_t){ c, c };
lv_line_set_points(compass_needle, compass_pts, 2);
}
label_set_if_changed(wind_dir_label, calm ? "Calm" : NO_VALUE);
return;
}
const float rad = d->wind_dir_deg.value * (float)M_PI / 180.0f;
const float head_r = (float)c - 8.0f; /* stop inside the border */
const float tail_r = (float)c * 0.35f; /* short tail through centre */
const int32_t hx = c + (int32_t)lroundf(head_r * sinf(rad));
const int32_t hy = c - (int32_t)lroundf(head_r * cosf(rad));
const int32_t tx = c - (int32_t)lroundf(tail_r * sinf(rad));
const int32_t ty = c + (int32_t)lroundf(tail_r * cosf(rad));
/* Only rewrite the points when the needle actually moved --
* lv_line_set_points() invalidates unconditionally. */
if (compass_pts[0].x != tx || compass_pts[0].y != ty ||
compass_pts[1].x != hx || compass_pts[1].y != hy) {
compass_pts[0] = (lv_point_precise_t){ tx, ty };
compass_pts[1] = (lv_point_precise_t){ hx, hy };
lv_line_set_points(compass_needle, compass_pts, 2);
}
label_set_if_changed(wind_dir_label, ha_wind_cardinal(d->wind_dir_deg.value));
}
static void update_wind(const ha_data_t *d)
{
char buf[24];
compass_render(d);
if (d->wind_mph.valid) {
snprintf(buf, sizeof(buf), "%.1f", d->wind_mph.value);
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(wind_speed_label, buf);
lv_obj_align_to(wind_unit_label, wind_speed_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 3, -3);
/* The arrow is the 5-minute trend in average speed, not the instantaneous
* reading -- see HA_TREND_CH_WIND. */
trend_render(wind_trend_icon, d->trend_wind);
}
static void update_forecast(const ha_data_t *d)
{
char buf[32];
@@ -1108,6 +1265,7 @@ void ui_update(const ha_data_t *data)
lv_obj_align_to(cond_label, cond_icon, LV_ALIGN_OUT_LEFT_MID, -8, 0);
update_body(data);
update_wind(data);
update_forecast(data);
for (int i = 0; i < HA_LIGHT_COUNT; i++) {