/* * relay — the heat call, and every safety interlock in PRD section 5. * * The interlocks live here, not in the control loop and certainly not in UI * code. Callers express *intent* by calling relay_request_heat(); this layer * decides whether the contact actually closes. Nothing above it can override * a limit, because nothing above it is given the means to. * * Hardware: PCF8574 P1 -> opto-isolated relay module IN, active low. * Relay COM to R (24VAC), NO to W. Normally open, so no power means no heat. * * What this layer guarantees * -------------------------- * 1. Fail-open. Normally-open relay; loss of power opens the contact. * 2. Watchdog-gated. The contact stays closed only while a control task keeps * calling relay_request_heat(). Stop calling and it drops within * RELAY_WATCHDOG_TIMEOUT_S. A hung control task cannot hold a heat call. * 3. Maximum on-time. Absolute cap per call. * 4. Minimum off-time, and a minimum period between call *starts* — see the * note on cycle rate below. * 5. Bounded startup. Off at boot, and held off for a lockout period. * * What it does not guarantee, stated plainly * ------------------------------------------ * A PCF8574 does not reset when the ESP32 does. If the firmware panics while * the contact is closed, the expander holds that latch until firmware comes * back and clears it. app_main() de-asserts P1 as its first real action, which * bounds the window to roughly a second — not to zero. Closing that gap * properly needs external hardware (a retriggerable monostable in series with * the relay coil), which this board does not have. * * Risk calibration, per PRD 5.1: the furnace's high-limit and rollout switches * are independent of the thermostat and stay in circuit. The realistic failure * mode of a stuck call is an overheated house and a large gas bill, with the * furnace cycling on its own limit — not a fire. */ #pragma once #include #include #include "esp_err.h" /* -------------------------------------------------------------- limits -- */ /* * How long the contact may stay closed without a fresh request. The control * loop evaluates every CONTROL_INTERVAL_S (10s), so this is three missed * evaluations — long enough not to trip on a scheduling hiccup, short enough * that a wedged control task is caught quickly. */ #define RELAY_WATCHDOG_TIMEOUT_S 30 /* Absolute cap on a single heat call. PRD 5.3 suggests 60 minutes. */ #define RELAY_MAX_ON_TIME_S 3600 /* Anti short-cycle: minimum gap between a call ending and the next starting. */ #define RELAY_MIN_OFF_TIME_S 300 /* * Minimum period between successive call *starts*. * * The existing thermostat is set to Gas/Oil Warm Air, 5 CPH — five cycles per * hour, i.e. one cycle per 12 minutes. A minimum off-time alone does not * enforce that: a 2-minute call followed by the 5-minute minimum gap would * give about 8 CPH. Rate-limiting the starts is what actually holds the * furnace to its configured cycle rate. */ #define RELAY_MIN_CYCLE_PERIOD_S 720 /* * Startup lockout. Off at boot regardless of what the control loop wants. * * Deliberately shorter than RELAY_MIN_OFF_TIME_S. A full 5-minute lockout on * every boot is a real comfort cost for a normal restart, while the case worth * protecting against — a brownout that resets the board mid-call — is covered * by a minute. The furnace's own control board also has anti-short-cycle * logic behind this. */ #define RELAY_STARTUP_LOCKOUT_S 60 /* -------------------------------------------------------------- status -- */ typedef enum { RELAY_FAULT_NONE = 0, RELAY_FAULT_WATCHDOG, /* control task stopped refreshing */ RELAY_FAULT_MAX_ON_TIME, /* hit the per-call cap */ RELAY_FAULT_IO, /* expander write failed */ } relay_fault_t; typedef struct { bool heat_on; /* contact actually closed */ bool heat_requested; /* what the control loop last asked for */ relay_fault_t fault; uint32_t on_seconds; /* duration of the current call, 0 if off */ uint32_t off_seconds; /* since the last call ended */ uint32_t hold_off_seconds; /* until a new call is permitted, 0 if now */ uint8_t cycles_last_hour; /* call starts in the trailing hour */ } relay_status_t; /* * Set up the expander pin and start the safety task. * * Assumes app_main() has already called relay_emergency_off(). Requires * pcf8574_init(). */ esp_err_t relay_init(void); /* * De-assert the contact, synchronously, with no dependency on the safety task * or on relay_init() having run. * * app_main() calls this first thing so a panic-reboot mid-call clears the * expander latch as early as possible. */ esp_err_t relay_emergency_off(void); /* * Express intent, and refresh the watchdog. * * Must be called at least every RELAY_WATCHDOG_TIMEOUT_S while heat is wanted. * Calling with false is how the control loop says "off" — it also refreshes * the watchdog, so an idle-but-healthy control loop is distinguishable from a * dead one. * * The return value is intent accepted, not contact closed. Ask relay_status() * what actually happened. */ void relay_request_heat(bool want_heat); bool relay_is_on(void); void relay_status(relay_status_t *out); const char *relay_fault_str(relay_fault_t fault);