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
+1420 -53

No files matched your search

+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 "?";
}
}