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