Add thermostat control for heat and fan with cycle protection

Turns the weather dashboard into an actual thermostat. Relays are not
fitted yet -- the GPIOs are driven and can be metered at the UART1-OUT
plug (idle 3.3V, energized 0V, active-low module assumed).

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

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

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

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

Two deliberate decisions:

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
mattandClaude committed 2026-08-05 20:09:36 -07:00
1 parent ae5d12b3a2
commit 3aabcac90a
16 files changed
+1418 -51

No files matched your search

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