/* * 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 #include #include /* --- 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