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>
145 lines
5.2 KiB
C
145 lines
5.2 KiB
C
/*
|
|
* 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
|