Compare commits
3
Commits
ae5d12b3a2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea1b8b8d15 | ||
|
|
7a58f9838a | ||
|
|
3aabcac90a |
No files matched your search
@@ -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
|
||||||
@@ -89,19 +140,37 @@ The forecast is a second, slower request (every 30th poll). It cannot be part
|
|||||||
of the template: HA removed the `forecast` attribute from weather entities in
|
of the template: HA removed the `forecast` attribute from weather entities in
|
||||||
2024.4, and templates cannot call services.
|
2024.4, and templates cannot call services.
|
||||||
|
|
||||||
### The wind sensors are dead
|
### Wind, and the compass
|
||||||
|
|
||||||
The station's anemometer has been offline since **2026-07-27**: `wind_speed`,
|
The station's anemometer was offline 2026-07-27 to 2026-08-07 and is live
|
||||||
`wind_gust`, `wind_direction` and `feels_like` all read `unavailable`, and the
|
again. It has its own column now: a dial whose needle points at the direction
|
||||||
WIND tile shows `--` by design. It is deliberately *not* backfilled from the
|
the wind is coming **from** (weather-vane convention, matching the cardinal
|
||||||
met.no forecast — regional modelled wind under a heading that means "my yard"
|
label beneath it), the speed in mph, and an arrow for the 5-minute trend.
|
||||||
is worse than showing nothing.
|
|
||||||
|
|
||||||
`sensor.moonlight_beach_max_gust` is a trap: it is **not** unavailable. It
|
Three things worth knowing:
|
||||||
still reports its last value from 2026-07-28 and will render as a plausible
|
|
||||||
live number. Anything that uses it needs a staleness check first.
|
|
||||||
|
|
||||||
### Fallback
|
- **The needle does not animate.** A spin would look better, but every
|
||||||
|
animated frame is a redraw, and redrawing is what starves the bounce refill
|
||||||
|
ISR. It snaps once a minute and the dirty area is just the dial.
|
||||||
|
- **Below 0.5 mph it shows "Calm" and draws no needle.** A vane that is not
|
||||||
|
being pushed reports whatever it last settled on — on this station that is a
|
||||||
|
steady 0°, which would otherwise render as a confident needle pointing due
|
||||||
|
north.
|
||||||
|
- **The trend is computed locally, not taken from HA's 10-minute average.**
|
||||||
|
`HA_TREND_CH_WIND` uses 1-minute buckets over a 5-minute window, which is as
|
||||||
|
fine as a 60-second poll can support. The threshold is 1.5 mph — wind is
|
||||||
|
noisy and a twitchy arrow is worse than none.
|
||||||
|
|
||||||
|
### Two indoor sensors
|
||||||
|
|
||||||
|
`temperature_4` is **downstairs** and `inside_temperature` is **upstairs** —
|
||||||
|
the names come from the Ambient Weather console, not the house, so they read
|
||||||
|
backwards. Downstairs is the primary: big number on the card, and it is what
|
||||||
|
the furnace controls on, because it is where people are and heat rises.
|
||||||
|
`HA_ENT_CONTROL_TEMP` in `ha_config.h` is the single place that choice is
|
||||||
|
made; the big card in `ui_manager.c` should move with it.
|
||||||
|
|
||||||
|
### Fallback### Fallback
|
||||||
|
|
||||||
When HA cannot be reached, `ambient_weather.c` fills in outdoor temperature,
|
When HA cannot be reached, `ambient_weather.c` fills in outdoor temperature,
|
||||||
humidity and wind from the cloud API and the status line says so. Indoor, sun,
|
humidity and wind from the cloud API and the status line says so. Indoor, sun,
|
||||||
@@ -141,6 +210,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 +249,28 @@ the buffer being displayed — copying into the live framebuffer races the LCD's
|
|||||||
own reads, and the resulting corrupt pixels persist indefinitely because LVGL
|
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), with `bb_invalidate_cache` enabled.
|
||||||
WiFi is up.
|
|
||||||
|
That flag matters more than it looks. Bounce mode is **not** a DMA read of the
|
||||||
|
framebuffer — the refill is a CPU `memcpy` in an ISR, which is why the driver
|
||||||
|
skips the usual write-back in `draw_bitmap` ("the data gets read by the CPU as
|
||||||
|
well"). So the panel streams 750KB per frame at ~44Hz *through the DCache*,
|
||||||
|
evicting the working set of everything else on the chip, continuously. That is
|
||||||
|
the real reason this board is so sensitive to WiFi activity and to any extra
|
||||||
|
rendering. `bb_invalidate_cache` frees each chunk after it is copied. The
|
||||||
|
header warns it "can be dangerous if data is written from other core(s)" —
|
||||||
|
that does not apply here, because it invalidates the buffer being *scanned*
|
||||||
|
and by design LVGL only ever draws into the other one. This was 16 for a long time because 20 "fails to allocate once
|
||||||
|
WiFi is up" — which was true when `display_init()` ran after
|
||||||
|
`wifi_init_sta()`. The two-phase split changed that: `display_alloc()` now
|
||||||
|
takes these buffers from a virgin heap with ~200KB free, long before WiFi has
|
||||||
|
fragmented it. Bigger chunks give the refill ISR more time to fetch the next
|
||||||
|
one before the LCD needs it, which attacks the artefact mechanism directly.
|
||||||
|
|
||||||
|
That leaves ~24KB of internal heap once everything is up. Watch the
|
||||||
|
"Free internal heap" log line — if it approaches single digits, come back
|
||||||
|
down to 18 or 16 scanlines.
|
||||||
|
|
||||||
### Known limitation: occasional left-edge artefacts
|
### Known limitation: occasional left-edge artefacts
|
||||||
|
|
||||||
@@ -202,6 +320,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -70,12 +85,17 @@ touch-to-wake is a natural small feature. Note the STC8H1K28 shares the I2C
|
|||||||
bus with the GT911, so brightness writes must stay in task context (never an
|
bus with the GT911, so brightness writes must stay in task context (never an
|
||||||
ISR) and will contend with touch polls on the same bus.
|
ISR) and will contend with touch polls on the same bus.
|
||||||
|
|
||||||
### 4. The wind sensors are dead (hardware, not software)
|
### 4. Left-edge artefacts, still open
|
||||||
|
|
||||||
The station anemometer has read `unavailable` since 2026-07-27 — wind speed,
|
Three things have been tried. Two "repaint to clean up" mitigations made it
|
||||||
gust, direction and feels-like. The WIND tile shows `--` by design and will
|
worse and are gone (README Gotchas). The 1Hz restyle that made them permanent
|
||||||
keep doing so until the hardware is fixed. Nothing to change in the firmware;
|
is fixed. Now: bounce buffer at 20 scanlines and `bb_invalidate_cache` on, so
|
||||||
noted so it is not re-diagnosed as a parsing bug. See README "Home Assistant".
|
framebuffer streaming no longer thrashes the DCache.
|
||||||
|
|
||||||
|
If they persist after that, the mechanism is the raw PSRAM ceiling and the
|
||||||
|
honest remaining levers are unattractive: 120MHz PSRAM (needs a newer IDF and
|
||||||
|
a DC-aware bootloader), or a lower pixel clock (below ~15MHz the panel stops
|
||||||
|
locking and the image rolls). Do not reach for another repaint.
|
||||||
|
|
||||||
### 5. The actual thermostat
|
### 5. The actual thermostat
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+25
-8
@@ -14,21 +14,38 @@
|
|||||||
|
|
||||||
#define HA_ENT_OUTDOOR_TEMP "sensor.moonlight_beach_temperature"
|
#define HA_ENT_OUTDOOR_TEMP "sensor.moonlight_beach_temperature"
|
||||||
#define HA_ENT_OUTDOOR_HUMIDITY "sensor.moonlight_beach_humidity"
|
#define HA_ENT_OUTDOOR_HUMIDITY "sensor.moonlight_beach_humidity"
|
||||||
#define HA_ENT_INDOOR_TEMP "sensor.moonlight_beach_inside_temperature"
|
|
||||||
#define HA_ENT_INDOOR_HUMIDITY "sensor.moonlight_beach_humidity_indoor"
|
/*
|
||||||
|
* Two indoor sensors.
|
||||||
|
*
|
||||||
|
* DOWN is the console's "4" channel and UP is the one the station calls
|
||||||
|
* "inside" -- the names come from the Ambient Weather hardware, not from the
|
||||||
|
* house, so they read backwards. Downstairs is the primary: it gets the big
|
||||||
|
* card AND it is what the thermostat controls on, because it is where people
|
||||||
|
* are. Heat rises, so upstairs typically reads several degrees warmer;
|
||||||
|
* controlling on it would under-heat the living space.
|
||||||
|
*
|
||||||
|
* To swap which one drives the furnace, change HA_ENT_CONTROL_TEMP below --
|
||||||
|
* that is the only place the choice is made.
|
||||||
|
*/
|
||||||
|
#define HA_ENT_INDOOR_DOWN_TEMP "sensor.moonlight_beach_temperature_4"
|
||||||
|
#define HA_ENT_INDOOR_DOWN_HUMIDITY "sensor.moonlight_beach_humidity_4"
|
||||||
|
#define HA_ENT_INDOOR_UP_TEMP "sensor.moonlight_beach_inside_temperature"
|
||||||
|
#define HA_ENT_INDOOR_UP_HUMIDITY "sensor.moonlight_beach_humidity_indoor"
|
||||||
#define HA_ENT_DEW_POINT "sensor.moonlight_beach_dew_point"
|
#define HA_ENT_DEW_POINT "sensor.moonlight_beach_dew_point"
|
||||||
#define HA_ENT_PRESSURE "sensor.moonlight_beach_relative_pressure"
|
#define HA_ENT_PRESSURE "sensor.moonlight_beach_relative_pressure"
|
||||||
#define HA_ENT_RAIN_TODAY "sensor.moonlight_beach_daily_rain"
|
#define HA_ENT_RAIN_TODAY "sensor.moonlight_beach_daily_rain"
|
||||||
#define HA_ENT_RAIN_RATE "sensor.moonlight_beach_precipitation_intensity"
|
#define HA_ENT_RAIN_RATE "sensor.moonlight_beach_precipitation_intensity"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The anemometer has been offline since 2026-07-27: wind speed, gust and
|
* The anemometer was offline from 2026-07-27 and came back 2026-08-07. All
|
||||||
* direction all read "unavailable". The display shows blanks for these by
|
* three read live again. The UI still treats them as optional -- ha_value_t
|
||||||
* design -- no substituting forecast wind for station wind.
|
* carries its own validity and the compass hides itself when direction is
|
||||||
|
* missing -- because this sensor has now failed once and will again.
|
||||||
*
|
*
|
||||||
* Do not be tempted by sensor.moonlight_beach_max_gust. It is NOT unavailable;
|
* The 10-minute averages are the station's own smoothing. The 5-minute trend
|
||||||
* it still reports its last value from 2026-07-28 and would render as a
|
* shown on the compass is computed locally from the instantaneous speed
|
||||||
* plausible live number. Anything using it needs a staleness check first.
|
* instead, so the trend window is ours to choose; see HA_TREND_CH_WIND.
|
||||||
*/
|
*/
|
||||||
#define HA_ENT_WIND_SPEED "sensor.moonlight_beach_wind_speed"
|
#define HA_ENT_WIND_SPEED "sensor.moonlight_beach_wind_speed"
|
||||||
#define HA_ENT_WIND_GUST "sensor.moonlight_beach_wind_gust"
|
#define HA_ENT_WIND_GUST "sensor.moonlight_beach_wind_gust"
|
||||||
|
|||||||
+63
-36
@@ -127,8 +127,10 @@ bool ha_parse_json(const char *json, ha_data_t *out)
|
|||||||
|
|
||||||
take_number(root, "out_t", &out->outdoor_temp_f);
|
take_number(root, "out_t", &out->outdoor_temp_f);
|
||||||
take_number(root, "out_h", &out->outdoor_humidity);
|
take_number(root, "out_h", &out->outdoor_humidity);
|
||||||
take_number(root, "in_t", &out->indoor_temp_f);
|
take_number(root, "dn_t", &out->indoor_down_temp_f);
|
||||||
take_number(root, "in_h", &out->indoor_humidity);
|
take_number(root, "dn_h", &out->indoor_down_humidity);
|
||||||
|
take_number(root, "up_t", &out->indoor_up_temp_f);
|
||||||
|
take_number(root, "up_h", &out->indoor_up_humidity);
|
||||||
take_number(root, "dew", &out->dew_point_f);
|
take_number(root, "dew", &out->dew_point_f);
|
||||||
take_number(root, "press", &out->pressure_inhg);
|
take_number(root, "press", &out->pressure_inhg);
|
||||||
take_number(root, "rain_d", &out->rain_today_in);
|
take_number(root, "rain_d", &out->rain_today_in);
|
||||||
@@ -308,22 +310,42 @@ const char *ha_condition_short(const char *slug)
|
|||||||
/* ------------------------------------------------------------------ trends */
|
/* ------------------------------------------------------------------ trends */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 20 slots, one kept per ~4 minutes, covers ~80 minutes. Feeding faster than
|
* 20 slots per channel. How much history that covers depends on the bucket
|
||||||
* that overwrites the newest slot instead of consuming a new one, so a 60s
|
* size, which is per-channel: a room temperature is judged over an hour, but
|
||||||
* poll cadence still fills the ring at 4-minute spacing.
|
* wind over an hour is meaningless -- it needs minutes.
|
||||||
*
|
*
|
||||||
* Size matters here more than it looks: this array is .bss, which lives in
|
* Size matters here more than it looks: this array is .bss, which lives in
|
||||||
* internal RAM on the S3 -- the same internal RAM the RGB panel's bounce
|
* internal RAM on the S3 -- the same internal RAM the RGB panel's bounce
|
||||||
* buffers come from, with only a few KB of slack (README, memory notes). A
|
* buffers come from, with only a few KB of slack (README, memory notes). Keep
|
||||||
* naive 72-slot-per-poll ring cost 2.6KB and was enough to tip display_init
|
* this structure small; if it ever needs to grow, move it to PSRAM instead.
|
||||||
* into "no mem for bounce buffer" on an unlucky boot. Keep this structure
|
|
||||||
* small; if it ever needs to grow, it must move to PSRAM instead.
|
|
||||||
*
|
*
|
||||||
* Static per channel; a reboot forgets history, and the display shows no
|
* Static per channel; a reboot forgets history, and the display shows no
|
||||||
* arrow until enough accumulates again (see ha_data.h).
|
* arrow until enough accumulates again.
|
||||||
*/
|
*/
|
||||||
#define TREND_SLOTS 20
|
#define TREND_SLOTS 20
|
||||||
#define TREND_BUCKET_MS (4u * 60u * 1000u)
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t bucket_ms; /* minimum spacing between retained samples */
|
||||||
|
uint32_t window_ms; /* how far back "the trend" looks */
|
||||||
|
uint32_t min_age_ms; /* refuse to judge on less history than this */
|
||||||
|
float threshold; /* change over a full window that counts as movement */
|
||||||
|
} trend_cfg_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Thresholds are the difference between a useful arrow and a flickering one.
|
||||||
|
* A thermostat's indoor reading wobbles a couple of tenths continuously, and
|
||||||
|
* wind is noisier still -- hence 1.5 mph over five minutes rather than
|
||||||
|
* anything tighter.
|
||||||
|
*/
|
||||||
|
static const trend_cfg_t TREND_CFG[HA_TREND_CHANNELS] = {
|
||||||
|
[HA_TREND_CH_INDOOR_DOWN] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.5f },
|
||||||
|
[HA_TREND_CH_INDOOR_UP] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.5f },
|
||||||
|
[HA_TREND_CH_OUTDOOR] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.5f },
|
||||||
|
[HA_TREND_CH_PRESSURE] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.02f },
|
||||||
|
/* Wind: 1-minute buckets over a 5-minute window. At a 60s poll that is
|
||||||
|
* five samples, which is the most the poll rate can support. */
|
||||||
|
[HA_TREND_CH_WIND] = { 1u * 60000u, 5u * 60000u, 2u * 60000u, 1.5f },
|
||||||
|
};
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
float value;
|
float value;
|
||||||
@@ -334,6 +356,12 @@ typedef struct {
|
|||||||
static trend_sample_t trend_ring[HA_TREND_CHANNELS][TREND_SLOTS];
|
static trend_sample_t trend_ring[HA_TREND_CHANNELS][TREND_SLOTS];
|
||||||
static int trend_head[HA_TREND_CHANNELS]; /* slot most recently written */
|
static int trend_head[HA_TREND_CHANNELS]; /* slot most recently written */
|
||||||
|
|
||||||
|
/* Wrap-safe: unsigned subtraction is correct across the 32-bit rollover. */
|
||||||
|
static uint32_t trend_elapsed(uint32_t now_ms, uint32_t then_ms)
|
||||||
|
{
|
||||||
|
return now_ms - then_ms;
|
||||||
|
}
|
||||||
|
|
||||||
void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
||||||
{
|
{
|
||||||
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
|
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
|
||||||
@@ -344,7 +372,8 @@ void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
|||||||
|
|
||||||
/* Inside the current bucket: refresh the newest sample in place, so
|
/* Inside the current bucket: refresh the newest sample in place, so
|
||||||
* compute always sees the latest reading without spending a slot. */
|
* compute always sees the latest reading without spending a slot. */
|
||||||
if (newest->used && (now_ms - newest->ms) < TREND_BUCKET_MS) {
|
if (newest->used &&
|
||||||
|
trend_elapsed(now_ms, newest->ms) < TREND_CFG[channel].bucket_ms) {
|
||||||
newest->value = value;
|
newest->value = value;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,41 +384,40 @@ void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
|||||||
trend_head[channel] = next;
|
trend_head[channel] = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_ms)
|
ha_trend_t ha_trend_compute(int channel, uint32_t now_ms)
|
||||||
{
|
{
|
||||||
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
|
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
|
||||||
return HA_TREND_UNKNOWN;
|
return HA_TREND_UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The newest sample is the one just fed; find the usable sample whose
|
const trend_cfg_t *cfg = &TREND_CFG[channel];
|
||||||
* age is closest to the window. Unsigned subtraction keeps this correct
|
|
||||||
* across tick wrap. */
|
/* Newest usable sample, and the one whose age is closest to the window. */
|
||||||
const trend_sample_t *newest = NULL;
|
const trend_sample_t *newest = NULL;
|
||||||
const trend_sample_t *anchor = NULL;
|
const trend_sample_t *anchor = NULL;
|
||||||
uint32_t best_dist = UINT32_MAX;
|
uint32_t best_dist = UINT32_MAX;
|
||||||
|
|
||||||
for (int i = 0; i < TREND_SLOTS; i++) {
|
for (int i = 0; i < TREND_SLOTS; i++) {
|
||||||
const trend_sample_t *s = &trend_ring[channel][i];
|
const trend_sample_t *sm = &trend_ring[channel][i];
|
||||||
if (!s->used) {
|
if (!sm->used) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t age = now_ms - s->ms;
|
const uint32_t age = trend_elapsed(now_ms, sm->ms);
|
||||||
if (age > HA_TREND_WINDOW_MS + HA_TREND_MIN_AGE_MS) {
|
if (age > cfg->window_ms + cfg->min_age_ms) {
|
||||||
continue; /* too old even for a sparse ring; ignore */
|
continue; /* too old even for a sparse ring */
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newest == NULL || age < (now_ms - newest->ms)) {
|
if (newest == NULL || age < trend_elapsed(now_ms, newest->ms)) {
|
||||||
newest = s;
|
newest = sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (age >= HA_TREND_MIN_AGE_MS) {
|
if (age >= cfg->min_age_ms) {
|
||||||
const uint32_t dist = (age > HA_TREND_WINDOW_MS)
|
const uint32_t dist = (age > cfg->window_ms) ? age - cfg->window_ms
|
||||||
? age - HA_TREND_WINDOW_MS
|
: cfg->window_ms - age;
|
||||||
: HA_TREND_WINDOW_MS - age;
|
|
||||||
if (dist < best_dist) {
|
if (dist < best_dist) {
|
||||||
best_dist = dist;
|
best_dist = dist;
|
||||||
anchor = s;
|
anchor = sm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,15 +427,14 @@ ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Scale the threshold to the actual span so a 20-minute-old anchor uses
|
* Scale the threshold to the actual span, so a half-window-old anchor
|
||||||
* a proportionally smaller bar than a full hour would. Without this the
|
* uses a proportionally smaller bar. Without this the period right after
|
||||||
* first hour after boot under-reports every trend.
|
* boot under-reports every trend.
|
||||||
*/
|
*/
|
||||||
const uint32_t span_ms = anchor->ms == newest->ms
|
const uint32_t span_ms = (anchor->ms == newest->ms) ? 1u
|
||||||
? 1
|
: newest->ms - anchor->ms;
|
||||||
: newest->ms - anchor->ms;
|
const float threshold = cfg->threshold *
|
||||||
const float threshold = threshold_per_hour *
|
((float)span_ms / (float)cfg->window_ms);
|
||||||
((float)span_ms / (float)HA_TREND_WINDOW_MS);
|
|
||||||
|
|
||||||
const float delta = newest->value - anchor->value;
|
const float delta = newest->value - anchor->value;
|
||||||
if (delta > threshold) {
|
if (delta > threshold) {
|
||||||
|
|||||||
+12
-8
@@ -71,9 +71,11 @@ typedef enum {
|
|||||||
} ha_trend_t;
|
} ha_trend_t;
|
||||||
|
|
||||||
enum {
|
enum {
|
||||||
HA_TREND_CH_INDOOR = 0,
|
HA_TREND_CH_INDOOR_DOWN = 0,
|
||||||
|
HA_TREND_CH_INDOOR_UP,
|
||||||
HA_TREND_CH_OUTDOOR,
|
HA_TREND_CH_OUTDOOR,
|
||||||
HA_TREND_CH_PRESSURE,
|
HA_TREND_CH_PRESSURE,
|
||||||
|
HA_TREND_CH_WIND,
|
||||||
HA_TREND_CHANNELS,
|
HA_TREND_CHANNELS,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,8 +93,11 @@ typedef struct {
|
|||||||
/* Weather station */
|
/* Weather station */
|
||||||
ha_value_t outdoor_temp_f;
|
ha_value_t outdoor_temp_f;
|
||||||
ha_value_t outdoor_humidity;
|
ha_value_t outdoor_humidity;
|
||||||
ha_value_t indoor_temp_f;
|
/* Downstairs is the primary reading and drives the control loop. */
|
||||||
ha_value_t indoor_humidity;
|
ha_value_t indoor_down_temp_f;
|
||||||
|
ha_value_t indoor_down_humidity;
|
||||||
|
ha_value_t indoor_up_temp_f;
|
||||||
|
ha_value_t indoor_up_humidity;
|
||||||
ha_value_t dew_point_f;
|
ha_value_t dew_point_f;
|
||||||
ha_value_t pressure_inhg;
|
ha_value_t pressure_inhg;
|
||||||
ha_value_t rain_today_in;
|
ha_value_t rain_today_in;
|
||||||
@@ -121,9 +126,11 @@ typedef struct {
|
|||||||
* query: an /api/history call returns hundreds of points per entity,
|
* query: an /api/history call returns hundreds of points per entity,
|
||||||
* which is silly when the device already sees every reading each poll.
|
* which is silly when the device already sees every reading each poll.
|
||||||
*/
|
*/
|
||||||
ha_trend_t trend_indoor;
|
ha_trend_t trend_indoor_down;
|
||||||
|
ha_trend_t trend_indoor_up;
|
||||||
ha_trend_t trend_outdoor;
|
ha_trend_t trend_outdoor;
|
||||||
ha_trend_t trend_pressure;
|
ha_trend_t trend_pressure;
|
||||||
|
ha_trend_t trend_wind;
|
||||||
} ha_data_t;
|
} ha_data_t;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -173,10 +180,7 @@ const char *ha_wind_cardinal(float degrees);
|
|||||||
* compute returns UNKNOWN until the oldest usable sample is at least
|
* compute returns UNKNOWN until the oldest usable sample is at least
|
||||||
* HA_TREND_MIN_AGE_MS old -- "trending" over 90 seconds is noise.
|
* HA_TREND_MIN_AGE_MS old -- "trending" over 90 seconds is noise.
|
||||||
*/
|
*/
|
||||||
#define HA_TREND_WINDOW_MS (60u * 60u * 1000u)
|
|
||||||
#define HA_TREND_MIN_AGE_MS (5u * 60u * 1000u)
|
|
||||||
|
|
||||||
void ha_trend_feed(int channel, float value, uint32_t now_ms);
|
void ha_trend_feed(int channel, float value, uint32_t now_ms);
|
||||||
ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_ms);
|
ha_trend_t ha_trend_compute(int channel, uint32_t now_ms);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -40,8 +40,10 @@
|
|||||||
"{" \
|
"{" \
|
||||||
"\"out_t\":" "{{ num('" HA_ENT_OUTDOOR_TEMP "') }}," \
|
"\"out_t\":" "{{ num('" HA_ENT_OUTDOOR_TEMP "') }}," \
|
||||||
"\"out_h\":" "{{ num('" HA_ENT_OUTDOOR_HUMIDITY "') }}," \
|
"\"out_h\":" "{{ num('" HA_ENT_OUTDOOR_HUMIDITY "') }}," \
|
||||||
"\"in_t\":" "{{ num('" HA_ENT_INDOOR_TEMP "') }}," \
|
"\"dn_t\":" "{{ num('" HA_ENT_INDOOR_DOWN_TEMP "') }}," \
|
||||||
"\"in_h\":" "{{ num('" HA_ENT_INDOOR_HUMIDITY "') }}," \
|
"\"dn_h\":" "{{ num('" HA_ENT_INDOOR_DOWN_HUMIDITY "') }}," \
|
||||||
|
"\"up_t\":" "{{ num('" HA_ENT_INDOOR_UP_TEMP "') }}," \
|
||||||
|
"\"up_h\":" "{{ num('" HA_ENT_INDOOR_UP_HUMIDITY "') }}," \
|
||||||
"\"dew\":" "{{ num('" HA_ENT_DEW_POINT "') }}," \
|
"\"dew\":" "{{ num('" HA_ENT_DEW_POINT "') }}," \
|
||||||
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
|
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
|
||||||
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
|
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -180,6 +193,25 @@ esp_err_t display_alloc(void)
|
|||||||
},
|
},
|
||||||
.flags = {
|
.flags = {
|
||||||
.fb_in_psram = true,
|
.fb_in_psram = true,
|
||||||
|
/*
|
||||||
|
* Free the cache lines the bounce refill just consumed.
|
||||||
|
*
|
||||||
|
* Bounce mode is not a DMA read of the framebuffer -- the refill
|
||||||
|
* is a CPU memcpy in an ISR, which is why the driver skips the
|
||||||
|
* usual write-back in draw_bitmap ("the data gets read by the CPU
|
||||||
|
* as well"). So the panel streams 750KB per frame at ~44Hz
|
||||||
|
* *through the DCache*, evicting the working set of everything
|
||||||
|
* else on the chip continuously. That is the real reason this
|
||||||
|
* board is so sensitive to WiFi activity and to any extra
|
||||||
|
* rendering.
|
||||||
|
*
|
||||||
|
* This flag invalidates each chunk after it has been copied, so
|
||||||
|
* framebuffer traffic stops polluting the cache. The header warns
|
||||||
|
* it "can be dangerous if data is written from other core(s)" --
|
||||||
|
* that does not apply here: it invalidates the buffer being
|
||||||
|
* scanned, and by design LVGL only ever draws into the other one.
|
||||||
|
*/
|
||||||
|
.bb_invalidate_cache = true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+189
-34
@@ -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"
|
||||||
@@ -38,18 +41,16 @@ static const char *TAG = "main";
|
|||||||
/* Consecutive failed polls before the displayed reading is treated as stale. */
|
/* Consecutive failed polls before the displayed reading is treated as stale. */
|
||||||
#define STALE_AFTER_POLLS 2
|
#define STALE_AFTER_POLLS 2
|
||||||
|
|
||||||
/*
|
|
||||||
* Trend arrow thresholds, per hour. Below these a reading renders as flat:
|
|
||||||
* a thermostat's indoor temp wobbles a couple of tenths continuously, and an
|
|
||||||
* arrow that flickers on noise is worse than none.
|
|
||||||
*/
|
|
||||||
#define TREND_TEMP_PER_HOUR_F 0.5f
|
|
||||||
#define TREND_PRESS_PER_HOUR_INHG 0.02f
|
|
||||||
|
|
||||||
/* Depth 8: a burst of taps while the network is slow should queue rather than
|
/* Depth 8: a burst of taps while the network is slow should queue rather than
|
||||||
* be dropped, but a user cannot meaningfully get more than a few ahead. */
|
* 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 +62,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 +281,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++;
|
||||||
|
|
||||||
@@ -205,21 +309,31 @@ static void ha_task(void *arg)
|
|||||||
* lost on reboot; arrows reappear ~5 minutes later.
|
* lost on reboot; arrows reappear ~5 minutes later.
|
||||||
*/
|
*/
|
||||||
const uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
const uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
||||||
if (ha_data.indoor_temp_f.valid) {
|
struct { int ch; const ha_value_t *v; } feeds[] = {
|
||||||
ha_trend_feed(HA_TREND_CH_INDOOR, ha_data.indoor_temp_f.value, now_ms);
|
{ HA_TREND_CH_INDOOR_DOWN, &ha_data.indoor_down_temp_f },
|
||||||
|
{ HA_TREND_CH_INDOOR_UP, &ha_data.indoor_up_temp_f },
|
||||||
|
{ HA_TREND_CH_OUTDOOR, &ha_data.outdoor_temp_f },
|
||||||
|
{ HA_TREND_CH_PRESSURE, &ha_data.pressure_inhg },
|
||||||
|
{ HA_TREND_CH_WIND, &ha_data.wind_mph },
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof(feeds) / sizeof(feeds[0]); i++) {
|
||||||
|
if (feeds[i].v->valid) {
|
||||||
|
ha_trend_feed(feeds[i].ch, feeds[i].v->value, now_ms);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (ha_data.outdoor_temp_f.valid) {
|
ha_data.trend_indoor_down = ha_trend_compute(HA_TREND_CH_INDOOR_DOWN, now_ms);
|
||||||
ha_trend_feed(HA_TREND_CH_OUTDOOR, ha_data.outdoor_temp_f.value, now_ms);
|
ha_data.trend_indoor_up = ha_trend_compute(HA_TREND_CH_INDOOR_UP, now_ms);
|
||||||
}
|
ha_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, now_ms);
|
||||||
if (ha_data.pressure_inhg.valid) {
|
ha_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, now_ms);
|
||||||
ha_trend_feed(HA_TREND_CH_PRESSURE, ha_data.pressure_inhg.value, now_ms);
|
ha_data.trend_wind = ha_trend_compute(HA_TREND_CH_WIND, now_ms);
|
||||||
}
|
|
||||||
ha_data.trend_indoor =
|
/*
|
||||||
ha_trend_compute(HA_TREND_CH_INDOOR, TREND_TEMP_PER_HOUR_F, now_ms);
|
* Downstairs drives the furnace -- it is where people are, and
|
||||||
ha_data.trend_outdoor =
|
* heat rises, so controlling on upstairs would under-heat the
|
||||||
ha_trend_compute(HA_TREND_CH_OUTDOOR, TREND_TEMP_PER_HOUR_F, now_ms);
|
* living space. Change this line and the big card in ui_manager
|
||||||
ha_data.trend_pressure =
|
* together if that ever needs to swap.
|
||||||
ha_trend_compute(HA_TREND_CH_PRESSURE, TREND_PRESS_PER_HOUR_INHG, now_ms);
|
*/
|
||||||
|
publish_indoor_temp(&ha_data.indoor_down_temp_f);
|
||||||
|
|
||||||
ui_update(&ha_data);
|
ui_update(&ha_data);
|
||||||
ui_set_stale(false);
|
ui_set_stale(false);
|
||||||
@@ -253,6 +367,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 +418,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 +462,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));
|
||||||
|
|||||||
+511
-113
@@ -94,6 +94,9 @@ static const char *TAG = "ui_manager";
|
|||||||
*/
|
*/
|
||||||
#define NO_VALUE "--"
|
#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
|
/* 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
|
* subset TTF; NULL if tiny_ttf fails, in which case icons render empty
|
||||||
* rather than as tofu. */
|
* rather than as tofu. */
|
||||||
@@ -106,18 +109,38 @@ static lv_obj_t *date_label;
|
|||||||
static lv_obj_t *cond_label;
|
static lv_obj_t *cond_label;
|
||||||
static lv_obj_t *cond_icon;
|
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 *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;
|
||||||
|
static lv_obj_t *upstairs_value_label; /* secondary, deliberately small */
|
||||||
|
static lv_obj_t *upstairs_sub_label;
|
||||||
|
static lv_obj_t *trend_upstairs_icon;
|
||||||
|
|
||||||
/* 3x2 reading grid: value and caption per cell. */
|
/* 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.
|
||||||
|
*
|
||||||
|
* 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 +161,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 +213,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 +353,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 +479,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 +534,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 +560,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,22 +784,19 @@ 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, 296, 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, "DOWNSTAIRS", &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);
|
||||||
lv_obj_align(heading, LV_ALIGN_TOP_LEFT, 0, 0);
|
lv_obj_align(heading, LV_ALIGN_TOP_LEFT, 0, 0);
|
||||||
|
|
||||||
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 +808,117 @@ 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 --- */
|
/*
|
||||||
|
* 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);
|
||||||
|
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, 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, 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, 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);
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* --- 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_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 +930,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", "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,8 +941,8 @@ 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, 117, cell_h, COL_PANEL);
|
||||||
lv_obj_set_style_pad_all(cell, 10, LV_PART_MAIN);
|
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_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);
|
lv_obj_set_style_text_letter_space(cap, 1, LV_PART_MAIN);
|
||||||
@@ -777,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_t *ic = icon_create(cell, cell_icons[i], icon_font_16, COL_TEXT_FAINT);
|
||||||
lv_obj_align(ic, LV_ALIGN_TOP_RIGHT, 0, -1);
|
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);
|
lv_obj_align(cell_value[i], LV_ALIGN_LEFT_MID, 0, 4);
|
||||||
|
|
||||||
/* Only readings with a meaningful hourly drift get an arrow slot. */
|
/* Only readings with a meaningful hourly drift get an arrow slot. */
|
||||||
@@ -846,26 +1020,45 @@ static void update_body(const ha_data_t *d)
|
|||||||
char buf[48];
|
char buf[48];
|
||||||
char sub[48];
|
char sub[48];
|
||||||
|
|
||||||
/* Indoor card. Rounded with lroundf, not truncation: below 0 degF a cast
|
/* Downstairs -- the primary reading, and what the furnace controls on. */
|
||||||
* rounds toward zero and turns -10.6 into -10. */
|
if (d->indoor_down_temp_f.valid) {
|
||||||
if (d->indoor_temp_f.valid) {
|
snprintf(buf, sizeof(buf), "%.1f", d->indoor_down_temp_f.value);
|
||||||
snprintf(buf, sizeof(buf), "%.1f", d->indoor_temp_f.value);
|
|
||||||
} else {
|
} else {
|
||||||
snprintf(buf, sizeof(buf), NO_VALUE);
|
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||||
}
|
}
|
||||||
label_set_if_changed(temp_label, buf);
|
label_set_if_changed(temp_label, buf);
|
||||||
/* Both hang off the number, whose width just changed with its digits. */
|
/* 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);
|
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);
|
lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4);
|
||||||
|
|
||||||
if (d->indoor_humidity.valid) {
|
if (d->indoor_down_humidity.valid) {
|
||||||
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->indoor_humidity.value);
|
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->indoor_down_humidity.value);
|
||||||
} else {
|
} else {
|
||||||
snprintf(buf, sizeof(buf), NO_VALUE);
|
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||||
}
|
}
|
||||||
label_set_if_changed(indoor_detail_label, buf);
|
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 */
|
/* Outdoor cell */
|
||||||
if (d->outdoor_temp_f.valid) {
|
if (d->outdoor_temp_f.valid) {
|
||||||
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(d->outdoor_temp_f.value));
|
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(d->outdoor_temp_f.value));
|
||||||
@@ -877,40 +1070,18 @@ 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);
|
||||||
|
|
||||||
|
/* 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) {
|
if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
|
||||||
snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°",
|
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%% RH", d->outdoor_humidity.value);
|
||||||
} else {
|
} else {
|
||||||
sub[0] = '\0';
|
sub[0] = '\0';
|
||||||
}
|
}
|
||||||
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 +1092,23 @@ 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), "sunset");
|
||||||
|
} else {
|
||||||
|
label_set_if_changed(cell_value[CELL_SUN],
|
||||||
|
d->sunrise[0] ? d->sunrise : NO_VALUE);
|
||||||
|
snprintf(sub, sizeof(sub), "sunrise");
|
||||||
|
}
|
||||||
|
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));
|
||||||
@@ -940,6 +1119,86 @@ static void update_body(const ha_data_t *d)
|
|||||||
label_set_if_changed(cell_sub[CELL_PRESSURE], "inHg");
|
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)
|
static void update_forecast(const ha_data_t *d)
|
||||||
{
|
{
|
||||||
char buf[32];
|
char buf[32];
|
||||||
@@ -957,9 +1216,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,13 +1254,18 @@ 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);
|
||||||
|
|
||||||
update_body(data);
|
update_body(data);
|
||||||
|
update_wind(data);
|
||||||
update_forecast(data);
|
update_forecast(data);
|
||||||
|
|
||||||
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
|
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
|
||||||
@@ -1025,19 +1292,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 +1344,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 +1494,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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
-10
@@ -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
|
||||||
@@ -210,13 +212,17 @@ static void apply_mock_data(void)
|
|||||||
|
|
||||||
sim_data.outdoor_temp_f = (ha_value_t){ 73.0f, true };
|
sim_data.outdoor_temp_f = (ha_value_t){ 73.0f, true };
|
||||||
sim_data.outdoor_humidity = (ha_value_t){ 80.0f, true };
|
sim_data.outdoor_humidity = (ha_value_t){ 80.0f, true };
|
||||||
sim_data.indoor_temp_f = (ha_value_t){ 78.1f, true };
|
sim_data.indoor_down_temp_f = (ha_value_t){ 71.4f, true };
|
||||||
sim_data.indoor_humidity = (ha_value_t){ 70.0f, true };
|
sim_data.indoor_down_humidity = (ha_value_t){ 46.0f, true };
|
||||||
|
sim_data.indoor_up_temp_f = (ha_value_t){ 74.8f, true };
|
||||||
|
sim_data.indoor_up_humidity = (ha_value_t){ 43.0f, true };
|
||||||
sim_data.dew_point_f = (ha_value_t){ 66.0f, true };
|
sim_data.dew_point_f = (ha_value_t){ 66.0f, true };
|
||||||
sim_data.pressure_inhg = (ha_value_t){ 29.89f, true };
|
sim_data.pressure_inhg = (ha_value_t){ 29.89f, true };
|
||||||
sim_data.rain_today_in = (ha_value_t){ 0.0f, true };
|
sim_data.rain_today_in = (ha_value_t){ 0.0f, true };
|
||||||
sim_data.rain_rate_inhr = (ha_value_t){ 0.0f, true };
|
sim_data.rain_rate_inhr = (ha_value_t){ 0.0f, true };
|
||||||
/* wind/gust/wdir left invalid on purpose -- that is the real state */
|
sim_data.wind_mph = (ha_value_t){ 6.4f, true };
|
||||||
|
sim_data.gust_mph = (ha_value_t){ 11.2f, true };
|
||||||
|
sim_data.wind_dir_deg = (ha_value_t){ 292.0f, true }; /* WNW */
|
||||||
|
|
||||||
snprintf(sim_data.condition, sizeof(sim_data.condition), "cloudy");
|
snprintf(sim_data.condition, sizeof(sim_data.condition), "cloudy");
|
||||||
snprintf(sim_data.sunrise, sizeof(sim_data.sunrise), "6:05 AM");
|
snprintf(sim_data.sunrise, sizeof(sim_data.sunrise), "6:05 AM");
|
||||||
@@ -241,15 +247,22 @@ static void apply_mock_data(void)
|
|||||||
*/
|
*/
|
||||||
const uint32_t now = lv_tick_get();
|
const uint32_t now = lv_tick_get();
|
||||||
const uint32_t hour_ago = now - 55u * 60u * 1000u;
|
const uint32_t hour_ago = now - 55u * 60u * 1000u;
|
||||||
ha_trend_feed(HA_TREND_CH_INDOOR, 77.1f, hour_ago); /* -> rising */
|
const uint32_t min_ago = now - 4u * 60u * 1000u;
|
||||||
ha_trend_feed(HA_TREND_CH_INDOOR, 78.1f, now);
|
ha_trend_feed(HA_TREND_CH_INDOOR_DOWN, 70.6f, hour_ago); /* -> rising */
|
||||||
ha_trend_feed(HA_TREND_CH_OUTDOOR, 74.6f, hour_ago); /* -> falling */
|
ha_trend_feed(HA_TREND_CH_INDOOR_DOWN, 71.4f, now);
|
||||||
|
ha_trend_feed(HA_TREND_CH_INDOOR_UP, 75.5f, hour_ago); /* -> falling */
|
||||||
|
ha_trend_feed(HA_TREND_CH_INDOOR_UP, 74.8f, now);
|
||||||
|
ha_trend_feed(HA_TREND_CH_OUTDOOR, 74.6f, hour_ago); /* -> falling */
|
||||||
ha_trend_feed(HA_TREND_CH_OUTDOOR, 73.0f, now);
|
ha_trend_feed(HA_TREND_CH_OUTDOOR, 73.0f, now);
|
||||||
ha_trend_feed(HA_TREND_CH_PRESSURE, 29.95f, hour_ago); /* -> falling */
|
ha_trend_feed(HA_TREND_CH_PRESSURE, 29.95f, hour_ago); /* -> falling */
|
||||||
ha_trend_feed(HA_TREND_CH_PRESSURE, 29.89f, now);
|
ha_trend_feed(HA_TREND_CH_PRESSURE, 29.89f, now);
|
||||||
sim_data.trend_indoor = ha_trend_compute(HA_TREND_CH_INDOOR, 0.5f, now);
|
ha_trend_feed(HA_TREND_CH_WIND, 3.9f, min_ago); /* -> rising */
|
||||||
sim_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, 0.5f, now);
|
ha_trend_feed(HA_TREND_CH_WIND, 6.4f, now);
|
||||||
sim_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, 0.02f, now);
|
sim_data.trend_indoor_down = ha_trend_compute(HA_TREND_CH_INDOOR_DOWN, now);
|
||||||
|
sim_data.trend_indoor_up = ha_trend_compute(HA_TREND_CH_INDOOR_UP, now);
|
||||||
|
sim_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, now);
|
||||||
|
sim_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, now);
|
||||||
|
sim_data.trend_wind = ha_trend_compute(HA_TREND_CH_WIND, now);
|
||||||
|
|
||||||
static const char *const days[HA_FORECAST_DAYS] = { "Tue", "Wed", "Thu", "Fri", "Sat" };
|
static const char *const days[HA_FORECAST_DAYS] = { "Tue", "Wed", "Thu", "Fri", "Sat" };
|
||||||
static const char *const cond[HA_FORECAST_DAYS] = {
|
static const char *const cond[HA_FORECAST_DAYS] = {
|
||||||
@@ -273,6 +286,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_down_temp_f.valid;
|
||||||
|
thermostat_tick(have_temp ? sim_data.indoor_down_temp_f.value : 0.0f,
|
||||||
|
have_temp, lv_tick_get());
|
||||||
|
thermostat_get_status(&st);
|
||||||
|
|
||||||
|
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 +427,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 +449,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 +469,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}]}
|
||||||
@@ -1 +1 @@
|
|||||||
{"changed_states":[],"service_response":{"weather.forecast_home":{"forecast":[{"condition":"partlycloudy","datetime":"2026-08-04T19:00:00+00:00","wind_bearing":248.4,"uv_index":10.5,"temperature":80,"templow":70,"wind_speed":9.63,"precipitation":0.0,"humidity":72},{"condition":"sunny","datetime":"2026-08-05T19:00:00+00:00","wind_bearing":263.9,"uv_index":10.0,"temperature":80,"templow":67,"wind_speed":10.94,"precipitation":0.0,"humidity":70},{"condition":"sunny","datetime":"2026-08-06T19:00:00+00:00","wind_bearing":278.8,"uv_index":9.9,"temperature":82,"templow":68,"wind_speed":10.31,"precipitation":0.0,"humidity":73},{"condition":"clear-night","datetime":"2026-08-07T19:00:00+00:00","wind_bearing":285.6,"temperature":82,"templow":69,"wind_speed":6.28,"precipitation":0.0,"humidity":71},{"condition":"clear-night","datetime":"2026-08-08T19:00:00+00:00","wind_bearing":268.9,"temperature":82,"templow":68,"wind_speed":7.15,"precipitation":0.0,"humidity":72},{"condition":"clear-night","datetime":"2026-08-09T19:00:00+00:00","wind_bearing":212.3,"temperature":81,"templow":69,"wind_speed":8.08,"precipitation":0.0,"humidity":74}]}}}
|
{"changed_states":[],"service_response":{"weather.forecast_home":{"forecast":[{"condition":"sunny","datetime":"2026-08-07T19:00:00+00:00","wind_bearing":264.8,"uv_index":0.1,"temperature":75,"templow":72,"wind_speed":7.15,"precipitation":0.0,"humidity":78},{"condition":"sunny","datetime":"2026-08-08T19:00:00+00:00","wind_bearing":286.5,"uv_index":9.8,"temperature":82,"templow":69,"wind_speed":11.43,"precipitation":0.0,"humidity":74},{"condition":"sunny","datetime":"2026-08-09T19:00:00+00:00","wind_bearing":278.2,"uv_index":9.4,"temperature":82,"templow":69,"wind_speed":10.75,"precipitation":0.0,"humidity":73},{"condition":"clear-night","datetime":"2026-08-10T19:00:00+00:00","wind_bearing":236.1,"uv_index":7.3,"temperature":80,"templow":69,"wind_speed":9.2,"precipitation":0.0,"humidity":74},{"condition":"partlycloudy","datetime":"2026-08-11T19:00:00+00:00","wind_bearing":262.0,"temperature":80,"templow":68,"wind_speed":6.28,"precipitation":0.0,"humidity":71},{"condition":"clear-night","datetime":"2026-08-12T19:00:00+00:00","wind_bearing":235.5,"temperature":78,"templow":68,"wind_speed":8.08,"precipitation":0.0,"humidity":70}]}}}
|
||||||
@@ -1 +1 @@
|
|||||||
{"out_t":76.6,"out_h":73.0,"in_t":78.6,"in_h":69.0,"dew":67.28,"press":29.899,"rain_d":0.0,"rain_r":0.0,"wind":null,"gust":null,"wdir":null,"cond":"partlycloudy","sunrise":"6:05 AM","sunset":"7:45 PM","sun_up":true,"clock":"11:32 AM","date":"Tue Aug 4","lights":[{"s":"off","b":null},{"s":"off","b":null},{"s":"off","b":null},{"s":"off","b":null},{"s":"unavailable","b":null},{"s":"off","b":null}]}
|
{"out_t":74.3,"out_h":80.0,"dn_t":82.2,"dn_h":61.0,"up_t":85.6,"up_h":61.0,"dew":67.72,"press":29.988,"rain_d":0.0,"rain_r":0.0,"wind":0.0,"gust":2.91,"wdir":0.0,"cond":"sunny","sunrise":"6:07 AM","sunset":"7:41 PM","sun_up":false,"clock":"7:51 PM","date":"Fri Aug 7","lights":[{"s":"on","b":127},{"s":"on","b":255},{"s":"on","b":255},{"s":"on","b":255},{"s":"on","b":255},{"s":"on","b":null}]}
|
||||||
+11
-1
@@ -64,11 +64,21 @@ def main() -> int:
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
ap.add_argument("--host", default="192.168.1.134")
|
ap.add_argument("--host", default="192.168.1.134")
|
||||||
ap.add_argument("--name", default="live", help="snapshot basename")
|
ap.add_argument("--name", default="live", help="snapshot basename")
|
||||||
|
ap.add_argument("--token-file",
|
||||||
|
default=str(Path.home() / ".config/thermostat-hw/ha_token"),
|
||||||
|
help="file holding the long-lived token; read directly, never printed")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
# Read the token from its file by default. Passing it through the
|
||||||
|
# environment means it appears on a command line, and command lines end up
|
||||||
|
# in shell history and terminal transcripts; reading the path here keeps
|
||||||
|
# the value inside the process that consumes it.
|
||||||
token = os.environ.get("HA_TOKEN")
|
token = os.environ.get("HA_TOKEN")
|
||||||
if not token:
|
if not token:
|
||||||
sys.exit("HA_TOKEN is not set. See tools/ha_discover.py for how to make one.")
|
try:
|
||||||
|
token = Path(args.token_file).read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
sys.exit(f"No token: set HA_TOKEN or create {args.token_file}")
|
||||||
|
|
||||||
SNAP_DIR.mkdir(parents=True, exist_ok=True)
|
SNAP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user