Implement rotary thermostat firmware from PRD
ESP-IDF v5.3.2 firmware for the CrowPanel 2.1" rotary display. Builds clean;
nothing has been run on hardware yet.
Hardware verified against Elecrow's own source for this board and
cross-checked against their ESPHome config, per PRD 8.1. Three corrections to
the PRD's pinout, all documented in docs/HARDWARE.md:
- touch is CST826 at 0x15, not CST816 (chip ID reads 0x11, not 0xB4)
- the ST7701S needs a 3-wire SPI bus on GPIO 16/2/1 that the PRD omitted;
without it the init sequence cannot be sent and the panel stays black
- the backlight is GPIO6
Two further departures from the PRD, both forced by facts on the ground:
- Transport is MQTT, not HTTP. zigbee2mqtt has no REST endpoint for live
sensor state. Subscribing to the broker also gets retained topics, so
every room's last reading arrives on connect rather than after a poll.
- The furnace is 2-wire R/W with no C, not 3-wire. The board needs its own
5V supply. The thermostat's 5 CPH setting is enforced as a minimum
12-minute period between heat-call starts, which a minimum off-time alone
does not achieve.
All of PRD section 5 is implemented in the relay layer itself: fail-open,
watchdog-gated output, max on-time, min off-time, cycle-rate limit, startup
lockout, and the stale-data fallback ladder down to open-loop freeze
protection. The one gap that cannot be closed in software — the PCF8574 not
resetting when the ESP32 does — is documented rather than papered over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
393592e327
commit
d2e4c974dd
35 files changed
+4685
No files matched your search
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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);
|
||||
Reference in new issue
Block a user