Files
thermostat-hw/main/control/thermostat.c
mattandClaude 3aabcac90a Add thermostat control for heat and fan with cycle protection
Turns the weather dashboard into an actual thermostat. Relays are not
fitted yet -- the GPIOs are driven and can be metered at the UART1-OUT
plug (idle 3.3V, energized 0V, active-low module assumed).

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

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

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

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

Two deliberate decisions:

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 20:09:36 -07:00

309 lines
8.1 KiB
C

/*
* 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;
}
}