Compare commits

...
2 Commits
Author SHA1 Message Date
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
mattandClaude 3aabcac90a Add thermostat control for heat and fan with cycle protection
Turns the weather dashboard into an actual thermostat. Relays are not
fitted yet -- the GPIOs are driven and can be metered at the UART1-OUT
plug (idle 3.3V, energized 0V, active-low module assumed).

Control loop (main/control/thermostat.c) is pure logic: no ESP-IDF, no
network, no clock of its own. The caller passes a temperature and a
millisecond timestamp, which is what lets the simulator and the test
harness run the identical decision code.

Equipment protections, all verified by tools/thermostat_test.c:

  deadband      1.0F    stops the burner chattering at the setpoint
  minimum on    3 min   a shorter call is all ignition wear, no heat
  minimum off   5 min   back-to-back ignitions are what kill igniters
  cycles/hour   5       standard gas default; catches a flapping sensor
  boot lockout  60 s    a boot loop must not machine-gun the gas valve

That test drives the state machine with synthetic time, so two hours of
furnace behaviour runs in milliseconds. It exits non-zero on failure and
is worth re-running after any edit to thermostat.c -- this switches a gas
valve.

Two deliberate decisions:

  - G is never energized during a heat call. The furnace runs its own
    blower with a fan-on delay that waits for the heat exchanger and a
    fan-off delay that scavenges residual heat; driving G ourselves would
    blow cold air at people and throw that heat away. G is manual
    circulation only.
  - The control loop starts before every early return in app_main. Those
    returns are all "no WiFi" or "no HA token", and none of them is a
    reason to leave a house unheated.

Relay pins are IO20 heat / IO19 fan on the UART1-OUT plug, measured on
this board since Elecrow documents the GPIO numbers but not the connector
pin order. Heat is on IO20 because it is USB D+, the line carrying the
hardware pull-up -- with an active-low module it fails toward "no heat"
rather than "heat stuck on" if the USB PHY is ever re-enabled. Both pins
were confirmed free of the onboard mic before use.
CONFIG_USJ_ENABLE_USB_SERIAL_JTAG and CONFIG_ESP_PHY_ENABLE_USB are now
off to release them; the console is on UART0 via the CH340 regardless.

UI: the indoor card gains a status dot, mode and fan buttons, and a
setpoint column with finger-sized up/down targets. The reading grid drops
from six cells to four to make room -- wind was the one to cut, since the
station anemometer has been dead since 2026-07-27 and that cell has never
shown a number. Wind now rides on the outdoor cell's sub-line and will
reappear on its own if the sensor is fixed. Sunrise and sunset share a
cell, showing whichever comes next.

Known weakness, documented in README and top of TODO: indoor temperature
comes from Home Assistant over the network, so an HA outage ages the
reading until the 15-minute failsafe forces heat off. A local I2C sensor
removes the failure mode entirely and is the next thing to add. The
setpoint also resets to 68F on reboot, because persisting it means an NVS
write and a flash write while the panel scans faults the bounce-buffer
ISR.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 20:09:36 -07:00
17 changed files with 1579 additions and 129 deletions

No files matched your search

+106 -3
View File
@@ -63,6 +63,57 @@ idf.py build flash monitor
Requires ESP-IDF **v5.3 or newer** (the code uses the `i2c_master` driver API Requires ESP-IDF **v5.3 or newer** (the code uses the `i2c_master` driver API
and `dma_burst_size`). and `dma_burst_size`).
## Thermostat control
Single-stage gas furnace, no A/C. The control loop is in
`main/control/thermostat.c` and is **pure logic** — no ESP-IDF, no network, no
clock of its own. The caller supplies a temperature and a millisecond
timestamp; that is what lets the desktop simulator and the test harness run
the identical decision code.
| Protection | Value | Why |
|---|---|---|
| Deadband | 1.0 °F | stops the burner chattering at the setpoint |
| Minimum on | 3 min | a shorter call delivers almost no heat for a full ignition's wear |
| Minimum off | 5 min | back-to-back ignitions are what kill igniters |
| Cycles/hour | 5 | standard gas default; catches a flapping sensor |
| Boot lockout | 60 s | a boot loop must not machine-gun the gas valve |
| Sensor staleness | 15 min | see below |
Verify after any change to that file — it switches a gas valve:
```bash
gcc -Wall -Wextra -I main/control -o /tmp/therm_test \
tools/thermostat_test.c main/control/thermostat.c -lm && /tmp/therm_test
```
Two decisions worth knowing:
- **G is never energized during a heat call.** The furnace runs its own
blower with a fan-on delay that waits for the heat exchanger and a fan-off
delay that scavenges residual heat. Driving G ourselves would blow cold air
at people and throw that heat away. G is manual circulation only.
- **The control loop starts before any of app_main's early returns.** Every
one of those is about WiFi or HA being unusable, and none of them is a
reason to leave a house unheated.
### The sensor is the weak link
Indoor temperature arrives over the network from Home Assistant. If HA stops
answering, the reading ages; after 15 minutes the state becomes `NO_SENSOR`,
heat is forced off and the card turns red. Running blind on an old number
could overheat the house or let it freeze, and neither is acceptable.
This is a deliberate compromise, not a design. **The real fix is a local
sensor on the I2C bus**, which removes the failure mode entirely — an SHT30
or BME280 at a free address. Note the panel dissipates ~5 W, so it must be
remote from the enclosure or it will read several degrees high.
**Setpoint does not survive a reboot** (defaults to 68 °F). Persisting it
would mean an NVS write, and a flash write while the panel is scanning faults
the bounce-buffer ISR — see Memory notes. Storing it in an HA `input_number`
would work and needs no flash.
## Home Assistant ## Home Assistant
Everything on screen except the forecast comes from **one** POST to Everything on screen except the forecast comes from **one** POST to
@@ -141,6 +192,36 @@ then `0x10` and `0x18` to configure and release the GT911. Brightness is
`0x00`–`0xF5` **inverted** (`0x00` = full) on v1.3; v1.2 uses a narrower `0x00`–`0xF5` **inverted** (`0x00` = full) on v1.3; v1.2 uses a narrower
`0x05`–`0x10` range instead. `0x05`–`0x10` range instead.
### UART1-OUT plug — the only free GPIO on this board
Nearly every pin is spoken for: 20 on the RGB bus, 2 on I2C, IO4/5/6 on
I2S+SD, IO8 buzzer, IO43/44 the console. The UART1-OUT plug (PH2.0-4P) is
what's left, and it is where the furnace relays connect.
| Wire | Pin | |
|------|-----|---|
| black | GND | |
| red | 3.3 V | 1 A budget; two relay coils ≈ 260 mA |
| white | **IO20** | measured, not documented by Elecrow |
| yellow | **IO19** | measured, not documented by Elecrow |
Elecrow publishes the GPIO numbers but **not the pin order**, so the
white/yellow mapping above was measured on this board (2026-08-05) by driving
each pin at a distinct blink rate and metering the plug. Static levels cannot
work for this: a pin held high is indistinguishable from the 3.3 V rail, and
one held low from GND.
Both pins were also confirmed electrically free — each follows an internal
pulldown to 0 and pullup to 1, so nothing else is driving them.
> **IO19/IO20 are shared** with the onboard I2S mic (MIC_CLK/MIC_SD) and are
> the ESP32-S3's native USB D−/D+. DIP switches select the mic away:
> S1=0 S0=1 is *wireless module* mode, which leaves these pins to us. Native
> USB is unused — the board flashes over UART0 through a CH340
> (`/dev/ttyUSB0`), so `CONFIG_USJ_ENABLE_USB_SERIAL_JTAG` and
> `CONFIG_ESP_PHY_ENABLE_USB` can both be turned off to release the pins
> fully.
## Display architecture ## Display architecture
Two 800×480×2 framebuffers in PSRAM, LVGL in `RENDER_MODE_DIRECT`. LVGL draws Two 800×480×2 framebuffers in PSRAM, LVGL in `RENDER_MODE_DIRECT`. LVGL draws
@@ -150,9 +231,17 @@ the buffer being displayed — copying into the live framebuffer races the LCD's
own reads, and the resulting corrupt pixels persist indefinitely because LVGL own reads, and the resulting corrupt pixels persist indefinitely because LVGL
does not know they are wrong and never reinvalidates them. does not know they are wrong and never reinvalidates them.
A 16-scanline bounce buffer stages each chunk in internal RAM. 20 scanlines A **20**-scanline bounce buffer stages each chunk in internal RAM (64KB across
would be better but needs 64KB of internal RAM and fails to allocate once two buffers). This was 16 for a long time because 20 "fails to allocate once
WiFi is up. 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 ### Known limitation: occasional left-edge artefacts
@@ -202,6 +291,20 @@ about PSRAM bandwidth or panel timing.
## Gotchas ## 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 - **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 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 dead touch zones. Light tiles therefore carry their own handlers, and the
+22 -7
View File
@@ -1,5 +1,21 @@
# TODO # TODO
## Thermostat: next steps
1. **Fit the relay board.** Wiring is in README "UART1-OUT plug". Until then
the GPIOs are the only output — meter them (idle reads 3.3 V, energized
0 V) to confirm the control loop end to end.
2. **Local temperature sensor.** The single biggest weakness: indoor temp
comes from HA over the network, so an HA outage eventually forces heat off.
An SHT30/BME280 on the I2C bus fixes it. Mount it away from the panel,
which self-heats by several degrees.
3. **Persist the setpoint.** Resets to 68 °F on every boot. NVS is not an
option while the panel scans (cache fault); an HA `input_number` is.
4. **Watchdog.** If the firmware hangs mid-call the relay stays latched until
someone notices. The furnace's own high limit makes that uncomfortable
rather than dangerous, but the task watchdog should reboot into the safe
state.
## Not yet verified on hardware ## Not yet verified on hardware
Flashed and booting clean (two-phase display init, icons on, first poll ~9s, Flashed and booting clean (two-phase display init, icons on, first poll ~9s,
@@ -11,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 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 pressure cells) once the local ring has history. They are local-history
based and reset on reboot — that is by design, not a bug. based and reset on reboot — that is by design, not a bug.
3. Artefact lifetime: interactions schedule a one-shot full repaint 600ms 3. Artefact lifetime. Two "repaint to clean up" mitigations were tried and
after the last touch, and every poll repaints too. Artefacts should now both made it worse -- see README Gotchas. They are gone, the 1Hz restyle
visibly self-clean in under a second after touching, and within a poll that made the artefacts permanent is fixed, and the bounce buffer is up to
otherwise. If they *persist* beyond that, the repaint isn't reaching 20 scanlines. If lines still come and go, the remaining honest levers are
them; if they merely *recur* during interaction, that is the PSRAM fewer redraws still, less WiFi interrupt load during rendering, or 120MHz
bandwidth ceiling (README) and the honest remaining levers are fewer PSRAM on a newer IDF.
redraws or 120MHz PSRAM on a newer IDF.
## Done ## Done
+4
View File
@@ -6,6 +6,8 @@ idf_component_register(
"drivers/backlight/backlight.c" "drivers/backlight/backlight.c"
"drivers/display/display_rgb.c" "drivers/display/display_rgb.c"
"drivers/touch/touch_gt911.c" "drivers/touch/touch_gt911.c"
"drivers/relay/relay.c"
"control/thermostat.c"
"ui/lvgl_port.c" "ui/lvgl_port.c"
"ui/ui_manager.c" "ui/ui_manager.c"
"ui/icon_font.c" "ui/icon_font.c"
@@ -19,6 +21,8 @@ idf_component_register(
"drivers/backlight" "drivers/backlight"
"drivers/display" "drivers/display"
"drivers/touch" "drivers/touch"
"drivers/relay"
"control"
"ui" "ui"
"api" "api"
REQUIRES REQUIRES
+308
View File
@@ -0,0 +1,308 @@
/*
* Thermostat control logic. See thermostat.h for the reasoning behind each
* protection; this file is the mechanism.
*
* Pure logic by design -- compiled into the desktop simulator as well as the
* firmware, so the whole control loop can be exercised against a fake furnace
* before it ever switches a gas valve.
*/
#include <stdio.h>
#include <string.h>
#include "thermostat.h"
/* Room for the CPH ceiling plus a couple, so the rolling-hour scan always has
* the full picture even at the limit. */
#define CYCLE_SLOTS (THERM_MAX_CYCLES_PER_HOUR + 3)
#define ONE_HOUR_MS (60u * 60u * 1000u)
static struct {
therm_mode_t mode;
therm_fan_t fan;
float setpoint_f;
bool heat_on;
uint32_t heat_since_ms; /* when the burner last turned on */
uint32_t heat_off_ms; /* when it last turned off */
bool have_off_stamp;
float last_temp_f;
bool have_temp;
uint32_t last_temp_ms;
uint32_t boot_ms;
uint32_t last_tick_ms; /* so get_status() can age the cycle ring */
/* Rolling-hour ring of burner start times. */
uint32_t cycle_ms[CYCLE_SLOTS];
bool cycle_used[CYCLE_SLOTS];
int cycle_head;
therm_state_t state;
uint32_t lockout_remaining_s;
} s;
/* Wrap-safe: unsigned subtraction gives the correct elapsed time across the
* 32-bit rollover at ~49 days. */
static uint32_t elapsed(uint32_t now_ms, uint32_t then_ms)
{
return now_ms - then_ms;
}
static void cycle_record(uint32_t now_ms)
{
s.cycle_ms[s.cycle_head] = now_ms;
s.cycle_used[s.cycle_head] = true;
s.cycle_head = (s.cycle_head + 1) % CYCLE_SLOTS;
}
static uint8_t cycles_in_last_hour(uint32_t now_ms)
{
uint8_t n = 0;
for (int i = 0; i < CYCLE_SLOTS; i++) {
if (s.cycle_used[i] && elapsed(now_ms, s.cycle_ms[i]) < ONE_HOUR_MS) {
n++;
}
}
return n;
}
/* When the oldest cycle inside the window ages out, freeing a slot. */
static uint32_t ms_until_cycle_slot(uint32_t now_ms)
{
uint32_t soonest = UINT32_MAX;
for (int i = 0; i < CYCLE_SLOTS; i++) {
if (!s.cycle_used[i]) {
continue;
}
const uint32_t age = elapsed(now_ms, s.cycle_ms[i]);
if (age < ONE_HOUR_MS) {
const uint32_t remaining = ONE_HOUR_MS - age;
if (remaining < soonest) {
soonest = remaining;
}
}
}
return (soonest == UINT32_MAX) ? 0 : soonest;
}
void thermostat_init(uint32_t now_ms)
{
memset(&s, 0, sizeof(s));
s.mode = THERM_MODE_HEAT;
s.fan = THERM_FAN_AUTO;
s.setpoint_f = THERM_SETPOINT_DEFAULT_F;
s.boot_ms = now_ms;
s.state = THERM_STATE_IDLE;
}
void thermostat_set_mode(therm_mode_t mode)
{
s.mode = mode;
}
void thermostat_set_fan(therm_fan_t fan)
{
s.fan = fan;
}
therm_mode_t thermostat_get_mode(void)
{
return s.mode;
}
therm_fan_t thermostat_get_fan(void)
{
return s.fan;
}
static void clamp_setpoint(void)
{
if (s.setpoint_f < THERM_SETPOINT_MIN_F) {
s.setpoint_f = THERM_SETPOINT_MIN_F;
} else if (s.setpoint_f > THERM_SETPOINT_MAX_F) {
s.setpoint_f = THERM_SETPOINT_MAX_F;
}
}
void thermostat_adjust_setpoint(float delta_f)
{
s.setpoint_f += delta_f;
clamp_setpoint();
}
void thermostat_set_setpoint(float value_f)
{
s.setpoint_f = value_f;
clamp_setpoint();
}
float thermostat_get_setpoint(void)
{
return s.setpoint_f;
}
static void heat_start(uint32_t now_ms)
{
s.heat_on = true;
s.heat_since_ms = now_ms;
cycle_record(now_ms);
}
static void heat_stop(uint32_t now_ms)
{
s.heat_on = false;
s.heat_off_ms = now_ms;
s.have_off_stamp = true;
}
void thermostat_tick(float indoor_f, bool temp_valid, uint32_t now_ms)
{
s.last_tick_ms = now_ms;
if (temp_valid) {
s.last_temp_f = indoor_f;
s.last_temp_ms = now_ms;
s.have_temp = true;
}
s.lockout_remaining_s = 0;
/*
* Mode off, or the reading has gone stale enough that acting on it is
* guesswork. Either way the burner stops -- and note this bypasses the
* minimum-on timer, which only exists to stop rapid restarts. Stopping
* heat is never bad for the furnace.
*/
const bool sensor_dead =
!s.have_temp || elapsed(now_ms, s.last_temp_ms) > THERM_SENSOR_STALE_MS;
if (s.mode == THERM_MODE_OFF || sensor_dead) {
if (s.heat_on) {
heat_stop(now_ms);
}
s.state = (s.mode == THERM_MODE_OFF) ? THERM_STATE_OFF : THERM_STATE_NO_SENSOR;
return;
}
const float half = THERM_DEADBAND_F / 2.0f;
const float temp = s.last_temp_f;
if (s.heat_on) {
/* Running: stop once we are above the top of the deadband, but not
* before the minimum on time is served. */
const bool satisfied = (temp >= s.setpoint_f + half);
const bool min_on_served = elapsed(now_ms, s.heat_since_ms) >= THERM_MIN_ON_MS;
if (satisfied && min_on_served) {
heat_stop(now_ms);
s.state = THERM_STATE_IDLE;
} else {
s.state = THERM_STATE_HEATING;
}
return;
}
/* Idle: does the house want heat at all? */
if (temp > s.setpoint_f - half) {
s.state = THERM_STATE_IDLE;
return;
}
/* It does. Every protection gets a veto, and whichever has the longest
* remaining time is the one the display should report. */
uint32_t wait_ms = 0;
const uint32_t since_boot = elapsed(now_ms, s.boot_ms);
if (since_boot < THERM_BOOT_LOCKOUT_MS) {
wait_ms = THERM_BOOT_LOCKOUT_MS - since_boot;
}
if (s.have_off_stamp) {
const uint32_t off_for = elapsed(now_ms, s.heat_off_ms);
if (off_for < THERM_MIN_OFF_MS) {
const uint32_t remaining = THERM_MIN_OFF_MS - off_for;
if (remaining > wait_ms) {
wait_ms = remaining;
}
}
}
if (cycles_in_last_hour(now_ms) >= THERM_MAX_CYCLES_PER_HOUR) {
const uint32_t remaining = ms_until_cycle_slot(now_ms);
if (remaining > wait_ms) {
wait_ms = remaining;
}
}
if (wait_ms > 0) {
s.state = THERM_STATE_LOCKOUT;
/* Round up, so a display counting down never shows 0 while still
* locked out. */
s.lockout_remaining_s = (wait_ms + 999u) / 1000u;
return;
}
heat_start(now_ms);
s.state = THERM_STATE_HEATING;
}
void thermostat_get_status(therm_status_t *out)
{
if (out == NULL) {
return;
}
out->mode = s.mode;
out->fan = s.fan;
out->setpoint_f = s.setpoint_f;
out->state = s.state;
out->heat_relay = s.heat_on;
out->lockout_remaining_s = s.lockout_remaining_s;
/* Aged against the last tick, not the last temperature: a stale sensor
* must not freeze the cycle count at whatever it was when the reading
* stopped arriving. */
out->cycles_last_hour = cycles_in_last_hour(s.last_tick_ms);
/*
* G is NOT energized during a heat call.
*
* A gas furnace runs its own blower from its control board, with a
* fan-on delay that waits for the heat exchanger to warm and a fan-off
* delay that scavenges the residual heat afterwards. Driving G ourselves
* during heat would start the blower early and blow cold air at people --
* and throw away the post-cycle heat. G is for manual circulation only.
*/
out->fan_relay = (s.fan == THERM_FAN_ON);
}
void thermostat_state_text(const therm_status_t *st, char *buf, size_t len)
{
if (st == NULL || buf == NULL || len == 0) {
return;
}
switch (st->state) {
case THERM_STATE_OFF:
snprintf(buf, len, "Off");
break;
case THERM_STATE_HEATING:
snprintf(buf, len, "Heating");
break;
case THERM_STATE_LOCKOUT:
if (st->lockout_remaining_s >= 60) {
snprintf(buf, len, "Hold %lum", (unsigned long)(st->lockout_remaining_s / 60));
} else {
snprintf(buf, len, "Hold %lus", (unsigned long)st->lockout_remaining_s);
}
break;
case THERM_STATE_NO_SENSOR:
snprintf(buf, len, "No sensor");
break;
case THERM_STATE_IDLE:
default:
snprintf(buf, len, "Idle");
break;
}
}
+144
View File
@@ -0,0 +1,144 @@
/*
* Thermostat control logic for a single-stage gas furnace with no A/C.
*
* Pure logic: no ESP-IDF, no LVGL, no network, no clock of its own. The
* caller supplies the temperature, a monotonic millisecond timestamp, and
* applies the relay outputs. That is what lets the desktop simulator run the
* real control loop against a fake furnace.
*
* Everything here exists to protect the equipment and the occupants:
*
* - hysteresis, so the burner does not chatter around the setpoint
* - minimum on and off times, so a cycle is long enough to be useful and
* the furnace gets a rest between them
* - a cycles-per-hour ceiling, the standard HVAC guard against short
* cycling wearing out the igniter and heat exchanger
* - a sensor-staleness failsafe, because the indoor temperature arrives
* over the network and the network can go away
*/
#ifndef THERMOSTAT_H
#define THERMOSTAT_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* --- Tunables ----------------------------------------------------------- */
/*
* Total deadband in °F, split either side of the setpoint. Heat starts below
* (setpoint - half) and stops above (setpoint + half). 1.0°F is a normal
* residential value; smaller feels more precise and cycles far more.
*/
#define THERM_DEADBAND_F 1.0f
/*
* Once the burner lights it runs at least this long. A gas furnace spends the
* first minutes bringing the heat exchanger up before the blower even starts,
* so a shorter call delivers almost no heat to the house for a full ignition
* cycle's worth of wear.
*/
#define THERM_MIN_ON_MS (3u * 60u * 1000u)
/*
* And stays off at least this long afterwards. This is the single most
* important protection here -- back-to-back ignitions are what destroy
* igniters.
*/
#define THERM_MIN_OFF_MS (5u * 60u * 1000u)
/*
* Ceiling on burner starts in any rolling hour. 5 is the usual default for
* gas (Honeywell ships 5 CPH for gas, 3 for oil). Min-on plus min-off already
* implies a floor of 8 minutes per cycle, so this mostly catches pathological
* cases -- a flapping sensor, or a setpoint being nudged repeatedly.
*/
#define THERM_MAX_CYCLES_PER_HOUR 5
/*
* How long a stale indoor reading may still be used before heat is forced
* off. The temperature comes from Home Assistant; if HA dies mid-winter,
* running blind on an old number could overheat the house or let it freeze.
*
* The honest fix is a local sensor on the I2C bus, which removes this failure
* mode entirely. Until then this is a deliberate compromise: keep heating on
* the last known value long enough to ride out a restart, then stop and say
* so loudly on screen.
*/
#define THERM_SENSOR_STALE_MS (15u * 60u * 1000u)
/*
* No heat for the first minute after boot, whatever the temperature says. If
* the firmware ever gets into a boot loop, this stops it from machine-gunning
* the gas valve.
*/
#define THERM_BOOT_LOCKOUT_MS (60u * 1000u)
#define THERM_SETPOINT_MIN_F 50.0f
#define THERM_SETPOINT_MAX_F 85.0f
#define THERM_SETPOINT_DEFAULT_F 68.0f
/* --- Types -------------------------------------------------------------- */
typedef enum {
THERM_MODE_OFF = 0,
THERM_MODE_HEAT,
} therm_mode_t;
typedef enum {
THERM_FAN_AUTO = 0, /* furnace runs its own blower on a heat call */
THERM_FAN_ON, /* continuous circulation */
} therm_fan_t;
typedef enum {
THERM_STATE_OFF = 0, /* mode is off */
THERM_STATE_IDLE, /* warm enough, nothing to do */
THERM_STATE_HEATING, /* burner called */
THERM_STATE_LOCKOUT, /* wants heat, held back by a protection timer */
THERM_STATE_NO_SENSOR, /* reading too stale to act on; heat forced off */
} therm_state_t;
typedef struct {
therm_mode_t mode;
therm_fan_t fan;
float setpoint_f;
therm_state_t state;
bool heat_relay;
bool fan_relay;
/* Seconds until the protection timer clears, for the display. 0 unless
* state is THERM_STATE_LOCKOUT. */
uint32_t lockout_remaining_s;
uint8_t cycles_last_hour;
} therm_status_t;
/* --- API ---------------------------------------------------------------- */
/* now_ms seeds the boot lockout, so pass the same clock used for tick(). */
void thermostat_init(uint32_t now_ms);
void thermostat_set_mode(therm_mode_t mode);
void thermostat_set_fan(therm_fan_t fan);
therm_mode_t thermostat_get_mode(void);
therm_fan_t thermostat_get_fan(void);
/* Clamped to THERM_SETPOINT_MIN_F..MAX_F. */
void thermostat_adjust_setpoint(float delta_f);
void thermostat_set_setpoint(float value_f);
float thermostat_get_setpoint(void);
/*
* Run the control loop. Call at roughly 1Hz -- more often is harmless, less
* often makes the lockout countdown coarse.
*
* `temp_valid` false means the reading is unavailable right now; the last
* good value keeps being used until THERM_SENSOR_STALE_MS has passed.
* `now_ms` must be monotonic; wrap is handled.
*/
void thermostat_tick(float indoor_f, bool temp_valid, uint32_t now_ms);
void thermostat_get_status(therm_status_t *out);
/* Human-readable state, for the status line ("Heating", "Locked out 4m"). */
void thermostat_state_text(const therm_status_t *st, char *buf, size_t len);
#endif
+16 -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 * displays whatever was left in the buffer, showing up as short stale lines
* at the left edge where each chunk begins. * 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. */ * 20 lines: two buffers at 800px x 2 bytes = 64KB of internal RAM.
#define BOUNCE_BUFFER_LINES 16 *
* 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 esp_lcd_panel_handle_t panel = NULL;
static lv_display_t *disp = NULL; static lv_display_t *disp = NULL;
+135
View File
@@ -0,0 +1,135 @@
/*
* Furnace call relays. See relay.h for the pin assignment and why heat is on
* IO20 rather than IO19.
*/
#include "driver/gpio.h"
#include "esp_check.h"
#include "esp_log.h"
#include "relay.h"
static const char *TAG = "relay";
#define RELAY_HEAT_GPIO 20 /* white wire on the UART1-OUT plug */
#define RELAY_FAN_GPIO 19 /* yellow wire */
/*
* Standard opto-isolated relay boards are active low: pulling IN to 0V lights
* the optocoupler and energizes the coil. Set to 0 if you fit an active-high
* module -- and re-check the boot state below, because the safe direction
* flips with it.
*/
#define RELAY_ACTIVE_LOW 1
#if RELAY_ACTIVE_LOW
#define RELAY_LEVEL_ON 0
#define RELAY_LEVEL_OFF 1
#else
#define RELAY_LEVEL_ON 1
#define RELAY_LEVEL_OFF 0
#endif
static const int relay_gpio[RELAY_COUNT] = {
[RELAY_HEAT] = RELAY_HEAT_GPIO,
[RELAY_FAN] = RELAY_FAN_GPIO,
};
static bool relay_state[RELAY_COUNT];
esp_err_t relay_init(void)
{
for (int i = 0; i < RELAY_COUNT; i++) {
const int pin = relay_gpio[i];
/*
* IO19/IO20 are the S3's native USB D-/D+ and are also wired to the
* onboard I2S mic. gpio_reset_pin() detaches whatever peripheral
* currently owns the pad; the DIP switches must be in wireless-module
* mode (S1=0 S0=1) so the mic is not driving them, and
* CONFIG_ESP_PHY_ENABLE_USB / CONFIG_USJ_ENABLE_USB_SERIAL_JTAG are
* off so the USB PHY is not either.
*/
gpio_reset_pin(pin);
/*
* Drive the de-energized level BEFORE the pad becomes an output.
* Configuring the direction first would briefly present whatever was
* in the output register -- and on the heat channel that is a burst
* of gas.
*/
ESP_RETURN_ON_ERROR(gpio_set_level(pin, RELAY_LEVEL_OFF), TAG,
"gpio_set_level(%d) failed", pin);
const gpio_config_t cfg = {
.pin_bit_mask = 1ULL << pin,
/* INPUT_OUTPUT so relay_get() can read the actual pad rather than
* the output latch -- a readback that disagrees means something
* else is driving the line. */
.mode = GPIO_MODE_INPUT_OUTPUT,
#if RELAY_ACTIVE_LOW
/* Holds the de-energized level through reset and reflash, before
* any of our code runs. */
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
#else
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_ENABLE,
#endif
.intr_type = GPIO_INTR_DISABLE,
};
ESP_RETURN_ON_ERROR(gpio_config(&cfg), TAG, "gpio_config(%d) failed", pin);
ESP_RETURN_ON_ERROR(gpio_set_level(pin, RELAY_LEVEL_OFF), TAG,
"gpio_set_level(%d) failed", pin);
relay_state[i] = false;
}
ESP_LOGI(TAG, "Relays idle. heat=IO%d fan=IO%d, active %s (idle reads %s)",
RELAY_HEAT_GPIO, RELAY_FAN_GPIO,
RELAY_ACTIVE_LOW ? "low" : "high",
RELAY_ACTIVE_LOW ? "3.3V" : "0V");
return ESP_OK;
}
void relay_set(relay_id_t id, bool energized)
{
if (id < 0 || id >= RELAY_COUNT) {
return;
}
if (relay_state[id] == energized) {
return;
}
relay_state[id] = energized;
gpio_set_level(relay_gpio[id], energized ? RELAY_LEVEL_ON : RELAY_LEVEL_OFF);
/*
* Logged on every change, deliberately. With no relay board fitted yet
* this log plus a multimeter on the plug is the whole test rig, and once
* one is fitted it is the record of what the firmware asked the furnace
* to do.
*/
ESP_LOGI(TAG, "%s -> %s (IO%d now %s)",
relay_name(id), energized ? "ON" : "OFF", relay_gpio[id],
(energized ? RELAY_LEVEL_ON : RELAY_LEVEL_OFF) ? "3.3V" : "0V");
}
bool relay_get(relay_id_t id)
{
if (id < 0 || id >= RELAY_COUNT) {
return false;
}
return relay_state[id];
}
const char *relay_name(relay_id_t id)
{
switch (id) {
case RELAY_HEAT: return "heat(W)";
case RELAY_FAN: return "fan(G)";
default: return "?";
}
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Furnace call relays on the UART1-OUT plug.
*
* Pin assignment was measured on this board -- Elecrow documents the GPIO
* numbers but not the connector pin order. See README, "UART1-OUT plug".
*
* heat (W) IO20, white wire
* fan (G) IO19, yellow wire
*
* Heat is on IO20 deliberately. IO20 is the ESP32-S3's USB D+, the line with
* the hardware pull-up; with an active-low relay module a pull-up means
* de-energized, so if the USB PHY is ever switched back on this pin fails
* toward "no heat" rather than "heat stuck on". IO19 (D-) has no such pull-up.
*
* The simulator provides its own implementation of this interface, which is
* why the API mentions no GPIOs.
*/
#ifndef RELAY_H
#define RELAY_H
#include <stdbool.h>
#include "esp_err.h"
typedef enum {
RELAY_HEAT = 0, /* W -- gas valve call */
RELAY_FAN, /* G -- blower, manual circulation only */
RELAY_COUNT,
} relay_id_t;
/* Leaves both relays de-energized. Safe to call before anything else; it does
* not depend on the display or the network. */
esp_err_t relay_init(void);
void relay_set(relay_id_t id, bool energized);
bool relay_get(relay_id_t id);
const char *relay_name(relay_id_t id);
#endif
+169 -12
View File
@@ -5,6 +5,7 @@
#include "esp_log.h" #include "esp_log.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/queue.h" #include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "ambient_weather.h" #include "ambient_weather.h"
@@ -15,6 +16,8 @@
#include "ha_data.h" #include "ha_data.h"
#include "i2c_bus.h" #include "i2c_bus.h"
#include "lvgl_port.h" #include "lvgl_port.h"
#include "relay.h"
#include "thermostat.h"
#include "touch_gt911.h" #include "touch_gt911.h"
#include "ui_manager.h" #include "ui_manager.h"
#include "wifi_manager.h" #include "wifi_manager.h"
@@ -50,6 +53,12 @@ static const char *TAG = "main";
* be dropped, but a user cannot meaningfully get more than a few ahead. */ * be dropped, but a user cannot meaningfully get more than a few ahead. */
#define CMD_QUEUE_DEPTH 8 #define CMD_QUEUE_DEPTH 8
/* The control loop runs at 1Hz -- fast enough for a live lockout countdown,
* and far faster than a house changes temperature. */
#define THERM_TASK_STACK 4096
#define THERM_TASK_PRIO 4
#define THERM_TICK_MS 1000
typedef struct { typedef struct {
int index; int index;
bool on; bool on;
@@ -61,6 +70,110 @@ static QueueHandle_t cmd_queue;
/* The single copy of what is on screen. Only the HA task touches it. */ /* The single copy of what is on screen. Only the HA task touches it. */
static ha_data_t ha_data; static ha_data_t ha_data;
/*
* Indoor temperature handed from the HA task to the control task.
*
* Guarded rather than assumed-atomic: a float store is a single word on this
* core, but the value and its timestamp must agree with each other, and the
* staleness failsafe depends on that pairing being consistent.
*/
static SemaphoreHandle_t temp_mutex;
static float indoor_temp_f;
static bool indoor_temp_valid;
static uint32_t indoor_temp_ms;
static uint32_t uptime_ms(void)
{
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
static void publish_indoor_temp(const ha_value_t *v)
{
if (temp_mutex == NULL || xSemaphoreTake(temp_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
return;
}
if (v->valid) {
indoor_temp_f = v->value;
indoor_temp_valid = true;
indoor_temp_ms = uptime_ms();
}
xSemaphoreGive(temp_mutex);
}
static bool take_indoor_temp(float *out_f)
{
if (temp_mutex == NULL || xSemaphoreTake(temp_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
return false;
}
const bool valid = indoor_temp_valid;
*out_f = indoor_temp_f;
xSemaphoreGive(temp_mutex);
return valid;
}
/*
* The control loop. Deliberately independent of Home Assistant: it needs only
* a temperature and a clock, so an HA outage cannot stop the house being
* heated -- the reading simply ages, and thermostat_tick() applies its own
* staleness failsafe once it is too old to trust.
*/
static void thermostat_task(void *arg)
{
therm_status_t status;
while (1) {
float temp = 0.0f;
const bool valid = take_indoor_temp(&temp);
thermostat_tick(temp, valid, uptime_ms());
thermostat_get_status(&status);
relay_set(RELAY_HEAT, status.heat_relay);
relay_set(RELAY_FAN, status.fan_relay);
ui_update_thermostat(&status);
vTaskDelay(pdMS_TO_TICKS(THERM_TICK_MS));
}
}
/*
* Runs on the LVGL task with the lock held. No network here, so the change
* applies immediately and the display is refreshed straight back -- the
* button feels instant because it is.
*/
static void therm_cmd_from_ui(ui_therm_cmd_t cmd)
{
switch (cmd) {
case UI_THERM_SETPOINT_UP:
thermostat_adjust_setpoint(1.0f);
break;
case UI_THERM_SETPOINT_DOWN:
thermostat_adjust_setpoint(-1.0f);
break;
case UI_THERM_MODE_TOGGLE:
thermostat_set_mode(thermostat_get_mode() == THERM_MODE_HEAT
? THERM_MODE_OFF : THERM_MODE_HEAT);
break;
case UI_THERM_FAN_TOGGLE:
thermostat_set_fan(thermostat_get_fan() == THERM_FAN_ON
? THERM_FAN_AUTO : THERM_FAN_ON);
break;
}
/*
* Apply the fan immediately rather than waiting up to a second for the
* control task -- a button that lags its own relay feels broken. Heat is
* left to the control task, which is where the protection timers live;
* nothing here may light a burner.
*/
therm_status_t status;
thermostat_get_status(&status);
relay_set(RELAY_FAN, status.fan_relay);
ui_update_thermostat(&status);
}
/* /*
* Runs on the LVGL task with the LVGL lock held, so it must not block. * Runs on the LVGL task with the LVGL lock held, so it must not block.
* Queueing hands the HTTP request to the HA task; a request from here would * Queueing hands the HTTP request to the HA task; a request from here would
@@ -176,20 +289,19 @@ static void ha_task(void *arg)
lvgl_port_resume(); 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 * There used to be one, on the theory that repainting would clean up
* interrupt load starves the bounce-refill ISR, and once bad pixels * glitched pixels LVGL does not know are wrong. It made things
* are on screen LVGL has no idea they are wrong -- it never * materially worse, and the README already said why: the corruption
* reinvalidates them, so they sit there indefinitely. A full * scales with how much is written, because LVGL's writes into PSRAM
* invalidate after each poll bounds their lifetime at one poll * compete with the panel's own DMA reads out of it. A full repaint is
* interval. Mitigation, not cure: it cannot prevent the glitch, * 768KB of exactly that contention -- the largest possible dose of
* only clean up after it. * 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++; poll_count++;
@@ -221,6 +333,10 @@ static void ha_task(void *arg)
ha_data.trend_pressure = ha_data.trend_pressure =
ha_trend_compute(HA_TREND_CH_PRESSURE, TREND_PRESS_PER_HOUR_INHG, now_ms); ha_trend_compute(HA_TREND_CH_PRESSURE, TREND_PRESS_PER_HOUR_INHG, 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);
ui_update(&ha_data); ui_update(&ha_data);
ui_set_stale(false); ui_set_stale(false);
@@ -253,6 +369,35 @@ static void ha_task(void *arg)
} }
} }
/*
* The furnace must run whether or not anything else does.
*
* Every early return further down app_main() is about WiFi or Home Assistant
* being unusable -- no SSID, no token, no cloud fallback. None of those are
* reasons to leave a house unheated, so the control loop starts before any of
* them can bail out. It needs only a temperature and a clock; where the
* temperature comes from is somebody else's problem.
*/
static void start_thermostat(void)
{
temp_mutex = xSemaphoreCreateMutex();
if (temp_mutex == NULL) {
ESP_LOGE(TAG, "Could not create the temperature mutex");
return;
}
ui_set_therm_cb(therm_cmd_from_ui);
xTaskCreate(thermostat_task, "therm", THERM_TASK_STACK, NULL,
THERM_TASK_PRIO, NULL);
ESP_LOGI(TAG, "Thermostat running: setpoint %.0f F, %d min on / %d min off, "
"max %d cycles/hr",
thermostat_get_setpoint(),
(int)(THERM_MIN_ON_MS / 60000), (int)(THERM_MIN_OFF_MS / 60000),
THERM_MAX_CYCLES_PER_HOUR);
}
void app_main(void) void app_main(void)
{ {
ESP_LOGI(TAG, "Thermostat HMI -- CrowPanel 7.0\" ESP32-S3 (800x480)"); ESP_LOGI(TAG, "Thermostat HMI -- CrowPanel 7.0\" ESP32-S3 (800x480)");
@@ -275,6 +420,15 @@ void app_main(void)
*/ */
ESP_ERROR_CHECK(display_alloc()); ESP_ERROR_CHECK(display_alloc());
/*
* Relays before anything that can fail or take time. relay_init() only
* touches two GPIOs, and until it runs those pads are whatever reset left
* them -- so the sooner they are pinned to the de-energized level, the
* shorter the window in which a relay board could see an undefined input.
*/
ESP_ERROR_CHECK(relay_init());
thermostat_init(uptime_ms());
const esp_err_t wifi_err = wifi_init_sta(); const esp_err_t wifi_err = wifi_init_sta();
if (wifi_err != ESP_OK) { if (wifi_err != ESP_OK) {
ESP_LOGE(TAG, "WiFi not up yet -- polling starts when it connects"); ESP_LOGE(TAG, "WiFi not up yet -- polling starts when it connects");
@@ -310,6 +464,9 @@ void app_main(void)
vTaskDelay(pdMS_TO_TICKS(100)); vTaskDelay(pdMS_TO_TICKS(100));
ESP_ERROR_CHECK(backlight_set(255)); ESP_ERROR_CHECK(backlight_set(255));
/* Before the WiFi/HA branches below, any of which may return. */
start_thermostat();
if (wifi_err == ESP_OK) { if (wifi_err == ESP_OK) {
char status[96]; char status[96];
wifi_get_status(status, sizeof(status)); wifi_get_status(status, sizeof(status));
+344 -104
View File
@@ -111,13 +111,18 @@ static lv_obj_t *temp_unit_label;
static lv_obj_t *indoor_detail_label; static lv_obj_t *indoor_detail_label;
static lv_obj_t *trend_indoor_icon; static lv_obj_t *trend_indoor_icon;
/* 3x2 reading grid: value and caption per cell. */ /*
* 2x2 reading grid.
*
* Was 3x2. The thermostat controls need finger-sized targets and that space
* had to come from somewhere; wind was the right thing to cut, since the
* station anemometer has been dead since 2026-07-27 and the cell has never
* shown a number. Sunrise and sunset share one cell now.
*/
enum { enum {
CELL_OUTDOOR = 0, CELL_OUTDOOR = 0,
CELL_WIND,
CELL_RAIN, CELL_RAIN,
CELL_SUNRISE, CELL_SUN,
CELL_SUNSET,
CELL_PRESSURE, CELL_PRESSURE,
CELL_COUNT, CELL_COUNT,
}; };
@@ -138,7 +143,17 @@ static lv_obj_t *fc_temp[HA_FORECAST_DAYS];
static lv_obj_t *status_label; static lv_obj_t *status_label;
/* Thermostat card */
static lv_obj_t *therm_state_label;
static lv_obj_t *therm_state_dot;
static lv_obj_t *setpoint_label;
static lv_obj_t *mode_btn;
static lv_obj_t *mode_btn_label;
static lv_obj_t *fan_btn;
static lv_obj_t *fan_btn_label;
static ui_light_cmd_cb_t light_cb; static ui_light_cmd_cb_t light_cb;
static ui_therm_cmd_cb_t therm_cb;
/* Per-room icons, riding in HA_LIGHT_LIST so order can never drift from the /* Per-room icons, riding in HA_LIGHT_LIST so order can never drift from the
* entity table. */ * entity table. */
@@ -180,33 +195,20 @@ static bool slider_touched[HA_LIGHT_COUNT];
static int32_t slider_sent_pct[HA_LIGHT_COUNT]; static int32_t slider_sent_pct[HA_LIGHT_COUNT];
/* /*
* One-shot "repair" repaint, rescheduled by every interaction to fire 600ms * There is deliberately no "repair" repaint here any more.
* after the last one.
* *
* Redrawing is itself what glitches the panel: the flush's PSRAM writes * An earlier version scheduled a full-screen invalidate 600ms after each
* contend with the LCD's scan-out, and a burst of interactive redraws is the * interaction, on the theory that repainting would clear glitched pixels
* most likely moment for the bounce ISR to lose (README, PSRAM bandwidth). * LVGL does not know are wrong. It made the artefacts worse and constant
* The glitched lines then persist because LVGL believes those pixels are * rather than occasional, for the reason the README already gave: the
* fine. One full repaint after the burst settles cleans them up within * corruption scales with how much is written, because LVGL's PSRAM writes
* ~0.6s instead of leaving them until the next poll's repaint, up to 60s * compete with the panel's DMA reads out of the same PSRAM. Repainting to
* away. It runs once per burst, not periodically -- a constant repaint loop * fix corruption caused by repainting does not converge.
* would be a constant supply of fresh glitch opportunities. *
* 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 */ /* --------------------------------------------------------- condition icons */
@@ -333,18 +335,72 @@ static void trend_render(lv_obj_t *icon, ha_trend_t t)
return; return;
} }
switch (t) { /*
case HA_TREND_RISING: * The colour write is skipped when the glyph did not change. Style writes
label_set_if_changed(icon, ICON_TREND_UP); * 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); lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT_WARM), LV_PART_MAIN);
break; } else if (t == HA_TREND_FALLING) {
case HA_TREND_FALLING:
label_set_if_changed(icon, ICON_TREND_DOWN);
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT), LV_PART_MAIN);
break; }
default: }
label_set_if_changed(icon, "");
break; /* ------------------------------------------------------ thermostat controls */
/*
* A flat touch button with a centred label. `out_label` is optional -- pass
* it for buttons whose caption changes (mode, fan), omit for fixed ones.
*
* Sized by the caller rather than by content: these are finger targets on a
* capacitive panel, so the hit area is the specification, not the text.
*/
static lv_obj_t *button_create(lv_obj_t *parent, int32_t w, int32_t h,
const char *text, lv_obj_t **out_label)
{
lv_obj_t *btn = lv_obj_create(parent);
lv_obj_set_size(btn, w, h);
lv_obj_set_style_radius(btn, 8, LV_PART_MAIN);
lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(btn, lv_color_hex(COL_PANEL_HI), LV_PART_MAIN);
/* Visible press feedback matters more here than on the light tiles: these
* act instantly and locally, so the flash is the whole acknowledgement. */
lv_obj_set_style_bg_color(btn, lv_color_hex(COL_ACCENT), LV_STATE_PRESSED);
lv_obj_remove_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t *lbl = label_create(btn, text, &lv_font_montserrat_16, COL_TEXT);
lv_obj_center(lbl);
if (out_label != NULL) {
*out_label = lbl;
}
return btn;
}
/*
* Runs on the LVGL task with the lock held. Unlike the light commands there
* is no network involved, so the callback applies the change and pushes the
* new state straight back -- the LVGL lock is recursive, so re-entering
* ui_update_thermostat() from here is fine.
*/
static void therm_button_clicked(lv_event_t *e)
{
const ui_therm_cmd_t cmd = (ui_therm_cmd_t)(intptr_t)lv_event_get_user_data(e);
if (therm_cb != NULL) {
therm_cb(cmd);
} }
} }
@@ -405,7 +461,6 @@ static void light_tile_clicked(lv_event_t *e)
: -1, : -1,
}; };
light_apply_optimistic(idx, expect); light_apply_optimistic(idx, expect);
schedule_repair();
/* -1: a tap is on/off only. Brightness is the slider's job, and sending /* -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 * the current brightness here would fight a user who dimmed the light
@@ -461,7 +516,6 @@ static void light_slider_changed(lv_event_t *e)
lv_color_hex(pct > 0 ? COL_ON : COL_TEXT_DIM), lv_color_hex(pct > 0 ? COL_ON : COL_TEXT_DIM),
LV_PART_MAIN); LV_PART_MAIN);
schedule_repair();
if (light_cb == NULL || !lv_obj_has_state(slider, LV_STATE_PRESSED)) { if (light_cb == NULL || !lv_obj_has_state(slider, LV_STATE_PRESSED)) {
return; return;
@@ -488,7 +542,6 @@ static void light_slider_released(lv_event_t *e)
const bool touched = slider_touched[idx]; const bool touched = slider_touched[idx];
slider_touched[idx] = false; slider_touched[idx] = false;
schedule_repair();
if (touched && pct == slider_sent_pct[idx]) { if (touched && pct == slider_sent_pct[idx]) {
return; /* tap without drag: the press command already said this */ return; /* tap without drag: the press command already said this */
@@ -713,9 +766,9 @@ static void build_body(lv_obj_t *scr)
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
/* --- indoor card: the number a thermostat exists to show --- */ /* --- thermostat card: current temperature and what the furnace is doing --- */
lv_obj_t *card = panel_create(row, 296, LV_PCT(100), COL_PANEL); lv_obj_t *card = panel_create(row, 300, LV_PCT(100), COL_PANEL);
lv_obj_set_style_pad_all(card, 14, LV_PART_MAIN); 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, "INDOOR", &lv_font_montserrat_14, COL_TEXT_DIM);
lv_obj_set_style_text_letter_space(heading, 2, LV_PART_MAIN); lv_obj_set_style_text_letter_space(heading, 2, LV_PART_MAIN);
@@ -724,11 +777,8 @@ static void build_body(lv_obj_t *scr)
lv_obj_t *card_icon = icon_create(card, ICON_HOME_THERMO, icon_font_26, COL_TEXT_FAINT); lv_obj_t *card_icon = icon_create(card, ICON_HOME_THERMO, icon_font_26, COL_TEXT_FAINT);
lv_obj_align(card_icon, LV_ALIGN_TOP_RIGHT, 0, -2); lv_obj_align(card_icon, LV_ALIGN_TOP_RIGHT, 0, -2);
/* Centred vertically rather than pinned under the heading: the card is
* 182px and a 48px number at the top leaves an obvious hole in the middle
* of the most important element on the screen. */
temp_label = label_create(card, "--", &lv_font_montserrat_48, COL_TEXT); temp_label = label_create(card, "--", &lv_font_montserrat_48, COL_TEXT);
lv_obj_align(temp_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_align(temp_label, LV_ALIGN_TOP_LEFT, 0, 22);
/* Degree sign as its own label so it can sit small and high beside a /* Degree sign as its own label so it can sit small and high beside a
* 48px number instead of scaling with it. */ * 48px number instead of scaling with it. */
@@ -740,10 +790,56 @@ static void build_body(lv_obj_t *scr)
trend_indoor_icon = icon_create(card, "", icon_font_26, COL_TEXT_DIM); trend_indoor_icon = icon_create(card, "", icon_font_26, COL_TEXT_DIM);
lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4); lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4);
indoor_detail_label = label_create(card, "--", &lv_font_montserrat_16, COL_TEXT_DIM); indoor_detail_label = label_create(card, "--", &lv_font_montserrat_14, COL_TEXT_DIM);
lv_obj_align(indoor_detail_label, LV_ALIGN_BOTTOM_LEFT, 0, 0); lv_obj_align(indoor_detail_label, LV_ALIGN_TOP_LEFT, 0, 78);
/* --- 3x2 reading grid --- */ /* 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);
lv_obj_set_size(therm_state_dot, 12, 12);
lv_obj_set_style_radius(therm_state_dot, 6, LV_PART_MAIN);
lv_obj_set_style_border_width(therm_state_dot, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(therm_state_dot, lv_color_hex(COL_TEXT_FAINT), LV_PART_MAIN);
lv_obj_remove_flag(therm_state_dot, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_align(therm_state_dot, LV_ALIGN_TOP_LEFT, 2, 104);
therm_state_label = label_create(card, "--", &lv_font_montserrat_16, COL_TEXT_DIM);
lv_obj_align(therm_state_label, LV_ALIGN_TOP_LEFT, 22, 100);
/* 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);
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);
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_set_style_pad_all(setcol, 8, LV_PART_MAIN);
lv_obj_t *up_btn = button_create(setcol, LV_PCT(100), 52, "+", NULL);
lv_obj_align(up_btn, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_add_event_cb(up_btn, therm_button_clicked, LV_EVENT_CLICKED,
(void *)(intptr_t)UI_THERM_SETPOINT_UP);
lv_obj_t *set_cap = label_create(setcol, "SET", &lv_font_montserrat_12, COL_TEXT_FAINT);
lv_obj_set_style_text_letter_space(set_cap, 1, LV_PART_MAIN);
lv_obj_align(set_cap, LV_ALIGN_CENTER, 0, -14);
setpoint_label = label_create(setcol, "--", &lv_font_montserrat_32, COL_ACCENT_WARM);
lv_obj_align(setpoint_label, LV_ALIGN_CENTER, 0, 8);
lv_obj_t *down_btn = button_create(setcol, LV_PCT(100), 52, "-", NULL);
lv_obj_align(down_btn, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_add_event_cb(down_btn, therm_button_clicked, LV_EVENT_CLICKED,
(void *)(intptr_t)UI_THERM_SETPOINT_DOWN);
/* --- 2x2 reading grid --- */
lv_obj_t *grid = lv_obj_create(row); lv_obj_t *grid = lv_obj_create(row);
lv_obj_set_size(grid, LV_GRID_CONTENT, LV_PCT(100)); lv_obj_set_size(grid, LV_GRID_CONTENT, LV_PCT(100));
lv_obj_set_flex_grow(grid, 1); lv_obj_set_flex_grow(grid, 1);
@@ -755,11 +851,10 @@ static void build_body(lv_obj_t *scr)
lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
static const char *const captions[CELL_COUNT] = { static const char *const captions[CELL_COUNT] = {
"OUTDOOR", "WIND", "RAIN TODAY", "SUNRISE", "SUNSET", "PRESSURE", "OUTDOOR", "RAIN TODAY", "SUN", "PRESSURE",
}; };
static const char *const cell_icons[CELL_COUNT] = { static const char *const cell_icons[CELL_COUNT] = {
ICON_THERMOMETER, ICON_W_WINDY, ICON_WATER, ICON_THERMOMETER, ICON_WATER, ICON_SUNSET, ICON_GAUGE,
ICON_SUNRISE, ICON_SUNSET, ICON_GAUGE,
}; };
/* Two rows of cells plus one gap must equal BAND_BODY_H exactly, or the /* Two rows of cells plus one gap must equal BAND_BODY_H exactly, or the
@@ -767,7 +862,7 @@ static void build_body(lv_obj_t *scr)
const int32_t cell_h = (BAND_BODY_H - BAND_GAP) / 2; const int32_t cell_h = (BAND_BODY_H - BAND_GAP) / 2;
for (int i = 0; i < CELL_COUNT; i++) { for (int i = 0; i < CELL_COUNT; i++) {
lv_obj_t *cell = panel_create(grid, 156, cell_h, COL_PANEL); 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_set_style_pad_all(cell, 10, LV_PART_MAIN);
lv_obj_t *cap = label_create(cell, captions[i], &lv_font_montserrat_12, COL_TEXT_FAINT); lv_obj_t *cap = label_create(cell, captions[i], &lv_font_montserrat_12, COL_TEXT_FAINT);
@@ -877,8 +972,29 @@ static void update_body(const ha_data_t *d)
lv_obj_align_to(cell_trend[CELL_OUTDOOR], cell_value[CELL_OUTDOOR], lv_obj_align_to(cell_trend[CELL_OUTDOOR], cell_value[CELL_OUTDOOR],
LV_ALIGN_OUT_RIGHT_MID, 6, 0); LV_ALIGN_OUT_RIGHT_MID, 6, 0);
if (d->outdoor_humidity.valid && d->dew_point_f.valid) { /*
snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°", * 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°",
d->outdoor_humidity.value, d->dew_point_f.value); d->outdoor_humidity.value, d->dew_point_f.value);
} else if (d->outdoor_humidity.valid) { } else if (d->outdoor_humidity.valid) {
snprintf(sub, sizeof(sub), "%.0f%% humidity", d->outdoor_humidity.value); snprintf(sub, sizeof(sub), "%.0f%% humidity", d->outdoor_humidity.value);
@@ -887,30 +1003,6 @@ static void update_body(const ha_data_t *d)
} }
label_set_if_changed(cell_sub[CELL_OUTDOOR], sub); label_set_if_changed(cell_sub[CELL_OUTDOOR], sub);
/*
* Wind. The station anemometer has been offline since 2026-07-27, so this
* cell is expected to read "--" until the hardware is fixed. It is
* deliberately NOT backfilled from the met.no forecast: showing regional
* modelled wind under a heading that means "my yard" is worse than
* showing nothing.
*/
format_value(&d->wind_mph, "%.0f", buf, sizeof(buf));
if (d->wind_mph.valid) {
const size_t n = strlen(buf);
snprintf(buf + n, sizeof(buf) - n, " mph");
}
label_set_if_changed(cell_value[CELL_WIND], buf);
if (d->wind_dir_deg.valid && d->gust_mph.valid) {
snprintf(sub, sizeof(sub), "%s gust %.0f",
ha_wind_cardinal(d->wind_dir_deg.value), d->gust_mph.value);
} else if (d->wind_dir_deg.valid) {
snprintf(sub, sizeof(sub), "%s", ha_wind_cardinal(d->wind_dir_deg.value));
} else {
snprintf(sub, sizeof(sub), "sensor offline");
}
label_set_if_changed(cell_sub[CELL_WIND], sub);
/* Rain */ /* Rain */
format_value(&d->rain_today_in, "%.2f\"", buf, sizeof(buf)); format_value(&d->rain_today_in, "%.2f\"", buf, sizeof(buf));
label_set_if_changed(cell_value[CELL_RAIN], buf); label_set_if_changed(cell_value[CELL_RAIN], buf);
@@ -921,15 +1013,25 @@ static void update_body(const ha_data_t *d)
} }
label_set_if_changed(cell_sub[CELL_RAIN], sub); label_set_if_changed(cell_sub[CELL_RAIN], sub);
/* Sun. These strings are already local time from HA -- the board has no /*
* clock of its own. */ * Sun. One cell for both times now: the value line shows whichever is
label_set_if_changed(cell_value[CELL_SUNRISE], * next, and the sub-line the other, so the useful number is the big one.
d->sunrise[0] ? d->sunrise : NO_VALUE); * These strings are already local time from HA -- the board has no clock.
label_set_if_changed(cell_sub[CELL_SUNRISE], d->sun_up ? "up now" : "before dawn"); */
/* Plain hyphen as the separator, not a middot -- Montserrat has no
label_set_if_changed(cell_value[CELL_SUNSET], * U+00B7 and it renders as a tofu box. See the note on NO_VALUE. */
d->sunset[0] ? d->sunset : NO_VALUE); if (d->sun_up) {
label_set_if_changed(cell_sub[CELL_SUNSET], d->sun_up ? "still up" : "down"); 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);
} 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);
}
label_set_if_changed(cell_sub[CELL_SUN], sub);
/* Pressure */ /* Pressure */
format_value(&d->pressure_inhg, "%.2f", buf, sizeof(buf)); format_value(&d->pressure_inhg, "%.2f", buf, sizeof(buf));
@@ -957,9 +1059,12 @@ static void update_forecast(const ha_data_t *d)
if (icon_font_26 != NULL) { if (icon_font_26 != NULL) {
const cond_icon_t *ci = cond_icon_lookup(f->condition); const cond_icon_t *ci = cond_icon_lookup(f->condition);
label_set_if_changed(fc_icon[i], ci->icon); const char *current = lv_label_get_text(fc_icon[i]);
lv_obj_set_style_text_color(fc_icon[i], lv_color_hex(ci->colour), if (current == NULL || strcmp(current, ci->icon) != 0) {
LV_PART_MAIN); 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) { if (f->high_f.valid && f->low_f.valid) {
@@ -992,8 +1097,12 @@ void ui_update(const ha_data_t *data)
if (icon_font_26 != NULL) { if (icon_font_26 != NULL) {
const cond_icon_t *ci = cond_icon_lookup( const cond_icon_t *ci = cond_icon_lookup(
data->condition[0] ? data->condition : NULL); data->condition[0] ? data->condition : NULL);
label_set_if_changed(cond_icon, ci->icon); /* Colour only when the glyph moved -- see trend_render(). */
lv_obj_set_style_text_color(cond_icon, lv_color_hex(ci->colour), LV_PART_MAIN); 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. */ /* 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); lv_obj_align_to(cond_label, cond_icon, LV_ALIGN_OUT_LEFT_MID, -8, 0);
@@ -1025,19 +1134,34 @@ void ui_update(const ha_data_t *data)
void ui_set_stale(bool stale) 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)) { if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out"); ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out");
return; return;
} }
if (temp_label != NULL) { 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_obj_set_style_text_color(temp_label,
lv_color_hex(stale ? COL_STALE : COL_TEXT), lv_color_hex(stale ? COL_STALE : COL_TEXT),
LV_PART_MAIN); LV_PART_MAIN);
} }
last_stale = stale;
last_stale_valid = true;
lvgl_port_unlock(); lvgl_port_unlock();
} }
@@ -1062,6 +1186,128 @@ void ui_set_light_cb(ui_light_cmd_cb_t cb)
light_cb = cb; light_cb = cb;
} }
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;
}
char buf[32];
const bool heat_mode = (st->mode == THERM_MODE_HEAT);
const bool mode_changed = first || st->mode != last_therm.mode;
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);
/* 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();
}
void ui_init(void) void ui_init(void)
{ {
/* ui_init() runs from app_main, not the LVGL task, so it needs the lock /* ui_init() runs from app_main, not the LVGL task, so it needs the lock
@@ -1090,12 +1336,6 @@ void ui_init(void)
lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); 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_header(scr);
build_body(scr); build_body(scr);
build_lights(scr); build_lights(scr);
+22
View File
@@ -6,6 +6,7 @@
#include "ha_data.h" #include "ha_data.h"
#include "lvgl.h" #include "lvgl.h"
#include "thermostat.h"
/* /*
* The screen layout. * The screen layout.
@@ -46,4 +47,25 @@ void ui_set_stale(bool stale);
typedef void (*ui_light_cmd_cb_t)(int index, bool on, int16_t brightness); typedef void (*ui_light_cmd_cb_t)(int index, bool on, int16_t brightness);
void ui_set_light_cb(ui_light_cmd_cb_t cb); void ui_set_light_cb(ui_light_cmd_cb_t cb);
/* Redraw the thermostat card from `st`. Same cheapness rules as ui_update. */
void ui_update_thermostat(const therm_status_t *st);
typedef enum {
UI_THERM_SETPOINT_UP = 0,
UI_THERM_SETPOINT_DOWN,
UI_THERM_MODE_TOGGLE,
UI_THERM_FAN_TOGGLE,
} ui_therm_cmd_t;
/*
* Called when the user operates a thermostat control.
*
* Runs on the LVGL task with the lock held, so it must not block -- but
* unlike the light commands there is no network here, so the handler can
* apply the change and call ui_update_thermostat() straight back. The LVGL
* lock is recursive, so that re-entry is fine.
*/
typedef void (*ui_therm_cmd_cb_t)(ui_therm_cmd_t cmd);
void ui_set_therm_cb(ui_therm_cmd_cb_t cb);
#endif #endif
+4
View File
@@ -94,3 +94,7 @@ CONFIG_LV_FONT_MONTSERRAT_48=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
CONFIG_LV_USE_TINY_TTF=y CONFIG_LV_USE_TINY_TTF=y
# CONFIG_USJ_ENABLE_USB_SERIAL_JTAG is not set
# CONFIG_ESP_PHY_ENABLE_USB is not set
+4
View File
@@ -60,6 +60,7 @@ endif()
add_executable(thermostat_sim add_executable(thermostat_sim
sim_main.c sim_main.c
lvgl_port_sim.c lvgl_port_sim.c
relay_sim.c
compat/esp_compat.c compat/esp_compat.c
# The point of the exercise: real firmware sources, compiled unmodified. # The point of the exercise: real firmware sources, compiled unmodified.
@@ -68,6 +69,7 @@ add_executable(thermostat_sim
${FW_ROOT}/main/ui/ui_manager.c ${FW_ROOT}/main/ui/ui_manager.c
${FW_ROOT}/main/ui/icon_font.c ${FW_ROOT}/main/ui/icon_font.c
${FW_ROOT}/main/api/ha_data.c ${FW_ROOT}/main/api/ha_data.c
${FW_ROOT}/main/control/thermostat.c
${CJSON_DIR}/cJSON.c ${CJSON_DIR}/cJSON.c
) )
@@ -77,6 +79,8 @@ target_include_directories(thermostat_sim PRIVATE
${CMAKE_CURRENT_LIST_DIR}/compat # ESP-IDF header stand-ins ${CMAKE_CURRENT_LIST_DIR}/compat # ESP-IDF header stand-ins
${FW_ROOT}/main/ui # ui_manager.h, lvgl_port.h ${FW_ROOT}/main/ui # ui_manager.h, lvgl_port.h
${FW_ROOT}/main/api # ha_data.h, ha_config.h, ha_template.h ${FW_ROOT}/main/api # ha_data.h, ha_config.h, ha_template.h
${FW_ROOT}/main/control # thermostat.h
${FW_ROOT}/main/drivers/relay # relay.h, implemented by relay_sim.c
${CJSON_DIR} ${CJSON_DIR}
${LVGL_DIR}/src/libs/lodepng # PNG encoder for --png mode ${LVGL_DIR}/src/libs/lodepng # PNG encoder for --png mode
${SDL2_INCLUDE_DIRS} ${SDL2_INCLUDE_DIRS}
+64
View File
@@ -0,0 +1,64 @@
/*
* Host implementation of relay.h -- a fake furnace.
*
* Lets the real control loop run on the desktop: hysteresis, minimum on and
* off times and the cycles-per-hour ceiling all behave exactly as they will on
* the device, with the relay changes printed instead of switching 24VAC.
*
* Timestamps are wall-clock relative to start-up so a log can be read against
* the protection timings in thermostat.h without arithmetic.
*/
#include <stdbool.h>
#include <stdio.h>
#include <time.h>
#include "relay.h"
static bool relay_state[RELAY_COUNT];
static struct timespec t0;
static double seconds_since_start(void)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (double)(now.tv_sec - t0.tv_sec) +
(double)(now.tv_nsec - t0.tv_nsec) / 1e9;
}
esp_err_t relay_init(void)
{
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < RELAY_COUNT; i++) {
relay_state[i] = false;
}
fprintf(stderr, "[relay-sim] both relays idle\n");
return ESP_OK;
}
void relay_set(relay_id_t id, bool energized)
{
if (id < 0 || id >= RELAY_COUNT || relay_state[id] == energized) {
return;
}
relay_state[id] = energized;
fprintf(stderr, "[relay-sim] %7.1fs %-8s -> %s\n",
seconds_since_start(), relay_name(id), energized ? "ON" : "OFF");
}
bool relay_get(relay_id_t id)
{
if (id < 0 || id >= RELAY_COUNT) {
return false;
}
return relay_state[id];
}
const char *relay_name(relay_id_t id)
{
switch (id) {
case RELAY_HEAT: return "heat(W)";
case RELAY_FAN: return "fan(G)";
default: return "?";
}
}
+61
View File
@@ -32,6 +32,8 @@
#include "ha_data.h" #include "ha_data.h"
#include "ha_template.h" #include "ha_template.h"
#include "lvgl_port.h" #include "lvgl_port.h"
#include "relay.h"
#include "thermostat.h"
#include "ui_manager.h" #include "ui_manager.h"
/* Must match the physical panel: main/drivers/display/display_rgb.h. A /* Must match the physical panel: main/drivers/display/display_rgb.h. A
@@ -273,6 +275,50 @@ static void apply_mock_data(void)
* means tiles respond to clicks in the simulator; on the device the real state * means tiles respond to clicks in the simulator; on the device the real state
* comes back from Home Assistant on the next poll instead. * comes back from Home Assistant on the next poll instead.
*/ */
/*
* Runs the real control loop against the fake furnace in relay_sim.c.
*
* Called from the LVGL loop rather than a thread, which keeps the simulator
* single-threaded -- the point is to exercise the decision logic, not the
* firmware's task structure.
*/
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,
have_temp, lv_tick_get());
thermostat_get_status(&st);
relay_set(RELAY_HEAT, st.heat_relay);
relay_set(RELAY_FAN, st.fan_relay);
ui_update_thermostat(&st);
}
/* Mirrors therm_cmd_from_ui() in main.c. */
static void sim_therm_cmd(ui_therm_cmd_t cmd)
{
switch (cmd) {
case UI_THERM_SETPOINT_UP:
thermostat_adjust_setpoint(1.0f);
break;
case UI_THERM_SETPOINT_DOWN:
thermostat_adjust_setpoint(-1.0f);
break;
case UI_THERM_MODE_TOGGLE:
thermostat_set_mode(thermostat_get_mode() == THERM_MODE_HEAT
? THERM_MODE_OFF : THERM_MODE_HEAT);
break;
case UI_THERM_FAN_TOGGLE:
thermostat_set_fan(thermostat_get_fan() == THERM_FAN_ON
? THERM_FAN_AUTO : THERM_FAN_ON);
break;
}
sim_thermostat_tick();
}
static void sim_light_cmd(int index, bool on, int16_t brightness) static void sim_light_cmd(int index, bool on, int16_t brightness)
{ {
if (index < 0 || index >= HA_LIGHT_COUNT) { if (index < 0 || index >= HA_LIGHT_COUNT) {
@@ -370,6 +416,10 @@ int main(int argc, char **argv)
ESP_ERROR_CHECK(lvgl_port_start()); ESP_ERROR_CHECK(lvgl_port_start());
ui_set_light_cb(sim_light_cmd); ui_set_light_cb(sim_light_cmd);
ESP_ERROR_CHECK(relay_init());
thermostat_init(lv_tick_get());
ui_set_therm_cb(sim_therm_cmd);
/* /*
* A snapshot replaces the mock wholesale rather than layering onto it -- * A snapshot replaces the mock wholesale rather than layering onto it --
* mixing the two would show a screen that never occurs in reality. * mixing the two would show a screen that never occurs in reality.
@@ -388,6 +438,7 @@ int main(int argc, char **argv)
} }
ui_update(&sim_data); ui_update(&sim_data);
sim_thermostat_tick();
ui_set_status(data_path != NULL ? data_path : "simulator - mock data"); ui_set_status(data_path != NULL ? data_path : "simulator - mock data");
if (png_path != NULL) { if (png_path != NULL) {
@@ -407,8 +458,18 @@ int main(int argc, char **argv)
/* Interactive: LV_SDL_DIRECT_EXIT makes closing the window exit the /* Interactive: LV_SDL_DIRECT_EXIT makes closing the window exit the
* process, so this loop does not need its own termination condition. */ * process, so this loop does not need its own termination condition. */
uint32_t last_tick = 0;
while (true) { while (true) {
lv_timer_handler(); lv_timer_handler();
/* Same 1Hz cadence as the firmware's control task, so the lockout
* countdown and cycle timing behave identically here. */
const uint32_t now = lv_tick_get();
if (now - last_tick >= 1000) {
last_tick = now;
sim_thermostat_tick();
}
usleep(5000); usleep(5000);
} }
+1
View File
@@ -0,0 +1 @@
{"out_t": 28.0, "out_h": 71.0, "in_t": 62.4, "in_h": 41.0, "dew": 21.0, "press": 29.899, "rain_d": 0.0, "rain_r": 0.0, "wind": null, "gust": null, "wdir": null, "cond": "snowy", "sunrise": "6:05 AM", "sunset": "7:45 PM", "sun_up": false, "clock": "6:12 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}]}
+135
View File
@@ -0,0 +1,135 @@
/*
* Drives thermostat.c with synthetic time to prove the protections actually
* fire. Nothing here touches hardware; time is a variable, so two hours of
* furnace behaviour runs in milliseconds.
*
* This logic controls a gas valve, so it gets verified before it gets flashed:
*
* gcc -Wall -Wextra -I main/control -o /tmp/therm_test \
* tools/thermostat_test.c main/control/thermostat.c -lm && /tmp/therm_test
*
* Exits non-zero on failure. Worth re-running after any edit to thermostat.c.
*/
#include <stdio.h>
#include <string.h>
#include "thermostat.h"
static therm_state_t last = (therm_state_t)-1;
static uint32_t t_ms;
static const char *sname(therm_state_t s)
{
switch (s) {
case THERM_STATE_OFF: return "OFF";
case THERM_STATE_IDLE: return "IDLE";
case THERM_STATE_HEATING: return "HEATING";
case THERM_STATE_LOCKOUT: return "LOCKOUT";
case THERM_STATE_NO_SENSOR: return "NO_SENSOR";
}
return "?";
}
/* Advance `secs` seconds at 1Hz, printing only transitions. */
static void run(float temp, int temp_valid, int secs)
{
therm_status_t st;
for (int i = 0; i < secs; i++) {
t_ms += 1000;
thermostat_tick(temp, temp_valid, t_ms);
thermostat_get_status(&st);
if (st.state != last) {
char txt[32];
thermostat_state_text(&st, txt, sizeof(txt));
printf(" %6.1f min %-9s heat=%d fan=%d cycles=%u \"%s\"\n",
t_ms / 60000.0, sname(st.state), st.heat_relay, st.fan_relay,
st.cycles_last_hour, txt);
last = st.state;
}
}
}
int main(void)
{
therm_status_t st;
printf("min_on=%us min_off=%us max_cph=%d deadband=%.1fF\n\n",
THERM_MIN_ON_MS / 1000, THERM_MIN_OFF_MS / 1000,
THERM_MAX_CYCLES_PER_HOUR, THERM_DEADBAND_F);
thermostat_init(0);
thermostat_set_setpoint(68.0f);
printf("1. cold house at 60F, boot lockout then heat\n");
run(60.0f, 1, 120);
printf("\n2. house reaches 69F -- must serve min-on before stopping\n");
run(69.0f, 1, 300);
printf("\n3. cold again immediately -- must serve min-off\n");
run(60.0f, 1, 400);
printf("\n4. hammer it: alternate hot/cold to trip the cycles-per-hour cap\n");
for (int i = 0; i < 12; i++) {
run(69.0f, 1, 200);
run(60.0f, 1, 400);
}
thermostat_get_status(&st);
printf(" -> cycles in last hour: %u (cap %d)\n",
st.cycles_last_hour, THERM_MAX_CYCLES_PER_HOUR);
if (st.cycles_last_hour > THERM_MAX_CYCLES_PER_HOUR) {
printf(" *** FAIL: cap exceeded\n");
return 1;
}
printf("\n5. sensor goes away -- rides it out, then forces heat off\n");
thermostat_init(t_ms);
last = (therm_state_t)-1;
thermostat_set_setpoint(68.0f);
run(60.0f, 1, 120); /* heating */
run(0.0f, 0, 14 * 60); /* stale but within the grace window */
run(0.0f, 0, 3 * 60); /* past it */
thermostat_get_status(&st);
if (st.state != THERM_STATE_NO_SENSOR || st.heat_relay) {
printf(" *** FAIL: expected NO_SENSOR with heat off\n");
return 1;
}
printf("\n6. mode off stops heat immediately, ignoring min-on\n");
thermostat_init(t_ms);
last = (therm_state_t)-1;
thermostat_set_setpoint(68.0f);
run(60.0f, 1, 120);
thermostat_get_status(&st);
if (!st.heat_relay) {
printf(" *** FAIL: expected to be heating first\n");
return 1;
}
thermostat_set_mode(THERM_MODE_OFF);
run(60.0f, 1, 2);
thermostat_get_status(&st);
if (st.heat_relay) {
printf(" *** FAIL: heat still on after mode off\n");
return 1;
}
printf("\n7. fan is independent of heat, and never follows a heat call\n");
thermostat_init(t_ms);
last = (therm_state_t)-1;
thermostat_set_setpoint(68.0f);
run(60.0f, 1, 120);
thermostat_get_status(&st);
if (st.fan_relay) {
printf(" *** FAIL: fan energized during heat (furnace owns the blower)\n");
return 1;
}
thermostat_set_fan(THERM_FAN_ON);
run(60.0f, 1, 2);
thermostat_get_status(&st);
if (!st.fan_relay) {
printf(" *** FAIL: fan did not turn on\n");
return 1;
}
printf("\nall checks passed\n");
return 0;
}