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,444 @@
|
||||
#include "control.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_task_wdt.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "relay.h"
|
||||
#include "settings.h"
|
||||
#include "zigbee_client.h"
|
||||
|
||||
static const char *TAG = "control";
|
||||
|
||||
static SemaphoreHandle_t s_lock;
|
||||
static control_state_t s_state;
|
||||
|
||||
/* Freeze-protection duty cycle bookkeeping, used only in CONTROL_SRC_NONE. */
|
||||
static int64_t s_freeze_phase_since_us;
|
||||
static bool s_freeze_heating;
|
||||
|
||||
static inline float clampf(float v, float lo, float hi)
|
||||
{
|
||||
return v < lo ? lo : (v > hi ? hi : v);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- room picking -- */
|
||||
|
||||
/* Freshest-first: is this room usable as a governing source right now? */
|
||||
static bool room_is_fresh(int idx)
|
||||
{
|
||||
return zigbee_client_reading_age_s(idx) <= CONTROL_STALE_AFTER_S;
|
||||
}
|
||||
|
||||
/*
|
||||
* Coldest fresh room. PRD 4.2 recommends min across rooms as the default
|
||||
* formula: nobody is cold, at the cost of overheating elsewhere.
|
||||
*/
|
||||
static int coldest_fresh_room(float *temp_out)
|
||||
{
|
||||
int best = -1;
|
||||
float coldest = 0;
|
||||
|
||||
for (int i = 0; i < zigbee_client_room_count(); i++) {
|
||||
room_reading_t r;
|
||||
if (!zigbee_client_get_reading(i, &r) || !r.valid || !room_is_fresh(i)) {
|
||||
continue;
|
||||
}
|
||||
if (best < 0 || r.temperature_c < coldest) {
|
||||
best = i;
|
||||
coldest = r.temperature_c;
|
||||
}
|
||||
}
|
||||
|
||||
if (best >= 0 && temp_out) {
|
||||
*temp_out = coldest;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/* Least-stale room with any reading at all, for the aged-data rung. */
|
||||
static int freshest_known_room(float *temp_out)
|
||||
{
|
||||
int best = -1;
|
||||
uint32_t best_age = UINT32_MAX;
|
||||
|
||||
for (int i = 0; i < zigbee_client_room_count(); i++) {
|
||||
room_reading_t r;
|
||||
if (!zigbee_client_get_reading(i, &r) || !r.valid) {
|
||||
continue;
|
||||
}
|
||||
uint32_t age = zigbee_client_reading_age_s(i);
|
||||
if (best < 0 || age < best_age) {
|
||||
best = i;
|
||||
best_age = age;
|
||||
if (temp_out) {
|
||||
*temp_out = r.temperature_c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/*
|
||||
* Which room the active mode wants to govern, ignoring freshness.
|
||||
*
|
||||
* Returns -1 for formula mode, where no single room is "the" room until we
|
||||
* have looked at the readings.
|
||||
*/
|
||||
static int mode_preferred_room(const settings_t *cfg, bool *clock_valid_out)
|
||||
{
|
||||
*clock_valid_out = true;
|
||||
|
||||
switch ((room_mode_t)cfg->mode) {
|
||||
case ROOM_MODE_MANUAL:
|
||||
return cfg->selected_room;
|
||||
|
||||
case ROOM_MODE_SCHEDULE: {
|
||||
time_t now = time(NULL);
|
||||
/*
|
||||
* Before SNTP syncs, the clock reads 1970 and every schedule rule
|
||||
* would resolve to whatever covers midnight. Acting on that is worse
|
||||
* than not acting: fall through to the formula and say so on screen.
|
||||
*/
|
||||
if (now < 1600000000) {
|
||||
*clock_valid_out = false;
|
||||
return -1;
|
||||
}
|
||||
struct tm tm;
|
||||
localtime_r(&now, &tm);
|
||||
return settings_schedule_room_for_time(tm.tm_hour, tm.tm_min);
|
||||
}
|
||||
|
||||
case ROOM_MODE_FORMULA:
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- the fallback ladder -- */
|
||||
|
||||
/*
|
||||
* PRD 5.5, in order:
|
||||
* 1. the selected room, if fresh
|
||||
* 2. another room's fresh reading
|
||||
* 3. (a local sensor, if one were fitted — none is)
|
||||
* 4. aged data with the setpoint clamped conservatively
|
||||
* 5. no data at all: open-loop freeze protection
|
||||
*
|
||||
* Rungs 4 and 5 always raise a visible fault. Neither ever holds the relay
|
||||
* closed on its own; the relay layer's interlocks still apply on top.
|
||||
*/
|
||||
static void evaluate_source(const settings_t *cfg, control_state_t *st)
|
||||
{
|
||||
bool clock_valid = true;
|
||||
int preferred = mode_preferred_room(cfg, &clock_valid);
|
||||
st->schedule_clock_valid = clock_valid;
|
||||
|
||||
/* Rung 1: the mode's chosen room, fresh. */
|
||||
if (preferred >= 0 && preferred < zigbee_client_room_count() &&
|
||||
room_is_fresh(preferred)) {
|
||||
room_reading_t r;
|
||||
zigbee_client_get_reading(preferred, &r);
|
||||
st->governing_room = preferred;
|
||||
st->governing_temp_c = r.temperature_c;
|
||||
st->source = CONTROL_SRC_SELECTED;
|
||||
st->effective_setpoint_c = st->setpoint_c;
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Rung 2: any fresh room, coldest wins.
|
||||
*
|
||||
* In formula mode this is the normal path rather than a fallback, so it
|
||||
* is only reported as CONTROL_SRC_OTHER_ROOM — which the UI shows as a
|
||||
* fault — when a specific room was actually asked for and failed.
|
||||
*/
|
||||
float temp = 0;
|
||||
int fresh = coldest_fresh_room(&temp);
|
||||
if (fresh >= 0) {
|
||||
st->governing_room = fresh;
|
||||
st->governing_temp_c = temp;
|
||||
st->source = (preferred < 0 && clock_valid)
|
||||
? CONTROL_SRC_SELECTED
|
||||
: CONTROL_SRC_OTHER_ROOM;
|
||||
st->effective_setpoint_c = st->setpoint_c;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Rung 4: nothing fresh anywhere. Run on the least-stale reading we have,
|
||||
* with the setpoint clamped down so a wrong number cannot cook the house. */
|
||||
int known = freshest_known_room(&temp);
|
||||
if (known >= 0) {
|
||||
st->governing_room = known;
|
||||
st->governing_temp_c = temp;
|
||||
st->source = CONTROL_SRC_STALE;
|
||||
st->effective_setpoint_c =
|
||||
fminf(st->setpoint_c, CONTROL_FALLBACK_SETPOINT_C);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Rung 5: no reading since boot. */
|
||||
st->governing_room = -1;
|
||||
st->governing_temp_c = NAN;
|
||||
st->source = CONTROL_SRC_NONE;
|
||||
st->effective_setpoint_c = CONTROL_FALLBACK_SETPOINT_C;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- decision --- */
|
||||
|
||||
/*
|
||||
* Open-loop freeze protection. Only reachable with no reading at all.
|
||||
*
|
||||
* Deliberately a low duty cycle rather than "off": PRD 5.5 says stale or
|
||||
* missing data must not silently mean no heat, because frozen pipes are the
|
||||
* failure this is guarding against.
|
||||
*/
|
||||
static bool freeze_protection_wants_heat(void)
|
||||
{
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (s_freeze_phase_since_us == 0) {
|
||||
s_freeze_phase_since_us = now;
|
||||
s_freeze_heating = false;
|
||||
}
|
||||
|
||||
uint32_t phase_s = (uint32_t)((now - s_freeze_phase_since_us) / 1000000);
|
||||
uint32_t limit = s_freeze_heating ? CONTROL_FREEZE_DUTY_ON_S
|
||||
: CONTROL_FREEZE_DUTY_OFF_S;
|
||||
|
||||
if (phase_s >= limit) {
|
||||
s_freeze_heating = !s_freeze_heating;
|
||||
s_freeze_phase_since_us = now;
|
||||
}
|
||||
|
||||
return s_freeze_heating;
|
||||
}
|
||||
|
||||
/* Hysteresis. Below setpoint-deadband call for heat, above setpoint+deadband
|
||||
* stop, in between hold whatever we were doing. */
|
||||
static bool decide_heat(const control_state_t *st, bool currently_on)
|
||||
{
|
||||
if (st->source == CONTROL_SRC_NONE) {
|
||||
return freeze_protection_wants_heat();
|
||||
}
|
||||
|
||||
float t = st->governing_temp_c;
|
||||
if (isnan(t)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (t < st->effective_setpoint_c - CONTROL_DEADBAND_C) {
|
||||
return true;
|
||||
}
|
||||
if (t > st->effective_setpoint_c + CONTROL_DEADBAND_C) {
|
||||
return false;
|
||||
}
|
||||
return currently_on;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- main task --- */
|
||||
|
||||
static void control_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
esp_task_wdt_add(NULL);
|
||||
|
||||
control_source_t last_source = CONTROL_SRC_SELECTED;
|
||||
bool last_call = false;
|
||||
|
||||
while (1) {
|
||||
esp_task_wdt_reset();
|
||||
|
||||
settings_t cfg;
|
||||
settings_get(&cfg);
|
||||
|
||||
control_state_t st;
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
st = s_state;
|
||||
xSemaphoreGive(s_lock);
|
||||
|
||||
st.setpoint_c = clampf(cfg.setpoint_c,
|
||||
CONTROL_SETPOINT_MIN_C, CONTROL_SETPOINT_MAX_C);
|
||||
st.mode = (room_mode_t)cfg.mode;
|
||||
|
||||
evaluate_source(&cfg, &st);
|
||||
|
||||
/* Reset the freeze-protection phase whenever real data returns, so a
|
||||
* brief outage does not leave us mid-duty-cycle. */
|
||||
if (st.source != CONTROL_SRC_NONE) {
|
||||
s_freeze_phase_since_us = 0;
|
||||
s_freeze_heating = false;
|
||||
}
|
||||
|
||||
st.calling_for_heat = decide_heat(&st, relay_is_on());
|
||||
|
||||
/*
|
||||
* Always call, every cycle, including when asking for off. This is the
|
||||
* relay watchdog refresh: silence is what tells the relay layer the
|
||||
* control loop has died.
|
||||
*/
|
||||
relay_request_heat(st.calling_for_heat);
|
||||
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
s_state = st;
|
||||
xSemaphoreGive(s_lock);
|
||||
|
||||
if (st.source != last_source) {
|
||||
if (st.source == CONTROL_SRC_SELECTED) {
|
||||
ESP_LOGI(TAG, "source ok: %s",
|
||||
zigbee_client_room_name(st.governing_room));
|
||||
} else {
|
||||
ESP_LOGW(TAG, "degraded to %s", control_source_str(st.source));
|
||||
}
|
||||
last_source = st.source;
|
||||
}
|
||||
|
||||
if (st.calling_for_heat != last_call) {
|
||||
if (isnan(st.governing_temp_c)) {
|
||||
ESP_LOGI(TAG, "heat %s (no reading, freeze protection)",
|
||||
st.calling_for_heat ? "requested" : "released");
|
||||
} else {
|
||||
ESP_LOGI(TAG, "heat %s: %.1fC vs setpoint %.1fC",
|
||||
st.calling_for_heat ? "requested" : "released",
|
||||
st.governing_temp_c, st.effective_setpoint_c);
|
||||
}
|
||||
last_call = st.calling_for_heat;
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(CONTROL_INTERVAL_S * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- api --- */
|
||||
|
||||
esp_err_t control_init(void)
|
||||
{
|
||||
if (s_lock) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
s_lock = xSemaphoreCreateMutex();
|
||||
if (!s_lock) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
settings_t cfg;
|
||||
settings_get(&cfg);
|
||||
|
||||
s_state = (control_state_t){
|
||||
.setpoint_c = cfg.setpoint_c,
|
||||
.effective_setpoint_c = cfg.setpoint_c,
|
||||
.governing_temp_c = NAN,
|
||||
.governing_room = -1,
|
||||
.mode = (room_mode_t)cfg.mode,
|
||||
.source = CONTROL_SRC_NONE,
|
||||
.calling_for_heat = false,
|
||||
.schedule_clock_valid = false,
|
||||
};
|
||||
|
||||
if (xTaskCreate(control_task, "control", 4096, NULL, 5, NULL) != pdPASS) {
|
||||
ESP_LOGE(TAG, "task create failed");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "started: %ds cadence, deadband +/-%.1fC, stale after %ds",
|
||||
CONTROL_INTERVAL_S, CONTROL_DEADBAND_C, CONTROL_STALE_AFTER_S);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void control_get_state(control_state_t *out)
|
||||
{
|
||||
if (!out) {
|
||||
return;
|
||||
}
|
||||
if (!s_lock) {
|
||||
*out = (control_state_t){
|
||||
.governing_temp_c = NAN,
|
||||
.governing_room = -1,
|
||||
.source = CONTROL_SRC_NONE,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
*out = s_state;
|
||||
xSemaphoreGive(s_lock);
|
||||
}
|
||||
|
||||
void control_set_setpoint(float celsius)
|
||||
{
|
||||
settings_set_setpoint(clampf(celsius,
|
||||
CONTROL_SETPOINT_MIN_C, CONTROL_SETPOINT_MAX_C));
|
||||
}
|
||||
|
||||
void control_adjust_setpoint(float delta_c)
|
||||
{
|
||||
settings_t cfg;
|
||||
settings_get(&cfg);
|
||||
control_set_setpoint(cfg.setpoint_c + delta_c);
|
||||
}
|
||||
|
||||
float control_get_setpoint(void)
|
||||
{
|
||||
settings_t cfg;
|
||||
settings_get(&cfg);
|
||||
return clampf(cfg.setpoint_c, CONTROL_SETPOINT_MIN_C, CONTROL_SETPOINT_MAX_C);
|
||||
}
|
||||
|
||||
void control_set_mode(room_mode_t mode)
|
||||
{
|
||||
if (mode < ROOM_MODE_COUNT) {
|
||||
settings_set_mode((uint8_t)mode);
|
||||
}
|
||||
}
|
||||
|
||||
room_mode_t control_get_mode(void)
|
||||
{
|
||||
settings_t cfg;
|
||||
settings_get(&cfg);
|
||||
return (room_mode_t)cfg.mode;
|
||||
}
|
||||
|
||||
void control_select_room(int room_index)
|
||||
{
|
||||
if (room_index < 0 || room_index >= zigbee_client_room_count()) {
|
||||
return;
|
||||
}
|
||||
settings_set_selected_room((uint8_t)room_index);
|
||||
/* PRD 4.2: manual override must always be reachable regardless of the
|
||||
* active mode. Picking a room *is* that override. */
|
||||
settings_set_mode(ROOM_MODE_MANUAL);
|
||||
}
|
||||
|
||||
int control_get_selected_room(void)
|
||||
{
|
||||
settings_t cfg;
|
||||
settings_get(&cfg);
|
||||
return cfg.selected_room;
|
||||
}
|
||||
|
||||
const char *control_source_str(control_source_t src)
|
||||
{
|
||||
switch (src) {
|
||||
case CONTROL_SRC_SELECTED: return "ok";
|
||||
case CONTROL_SRC_OTHER_ROOM: return "sensor stale, using another room";
|
||||
case CONTROL_SRC_STALE: return "all sensors stale, setpoint reduced";
|
||||
case CONTROL_SRC_NONE: return "no sensor data, freeze protection";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const char *control_mode_str(room_mode_t mode)
|
||||
{
|
||||
switch (mode) {
|
||||
case ROOM_MODE_MANUAL: return "Manual";
|
||||
case ROOM_MODE_SCHEDULE: return "Schedule";
|
||||
case ROOM_MODE_FORMULA: return "Coldest";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* control — setpoint, hysteresis, room selection, and the stale-data ladder.
|
||||
*
|
||||
* Runs on the ESP32, not in Home Assistant. The house keeps heating when the
|
||||
* broker, zigbee2mqtt or HA is down; it degrades through the documented
|
||||
* fallback ladder rather than simply stopping.
|
||||
*
|
||||
* All temperatures in this module are degrees Celsius. Fahrenheit exists only
|
||||
* in the UI layer.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/* -------------------------------------------------------------- tuning -- */
|
||||
|
||||
/* Evaluate on a fixed cadence, not on every incoming reading (PRD 4.3). */
|
||||
#define CONTROL_INTERVAL_S 10
|
||||
|
||||
/* Hysteresis. Call for heat below setpoint-deadband, stop at setpoint+deadband. */
|
||||
#define CONTROL_DEADBAND_C 0.5f
|
||||
|
||||
#define CONTROL_SETPOINT_MIN_C 5.0f
|
||||
#define CONTROL_SETPOINT_MAX_C 30.0f
|
||||
#define CONTROL_SETPOINT_DEFAULT_C 20.0f /* exactly 68F */
|
||||
|
||||
/* A reading older than this no longer governs anything (PRD 5.5, N=20). */
|
||||
#define CONTROL_STALE_AFTER_S (20 * 60)
|
||||
|
||||
/*
|
||||
* Conservative setpoint used once we are running on stale or absent data.
|
||||
* Low enough that a wrong reading cannot cook the house, high enough to keep
|
||||
* pipes safe.
|
||||
*/
|
||||
#define CONTROL_FALLBACK_SETPOINT_C 16.0f
|
||||
|
||||
/*
|
||||
* Last-resort freeze protection, used only when there is no reading at all —
|
||||
* not stale, not old, none since boot.
|
||||
*
|
||||
* With no temperature there is no closed loop to run, so this is an open-loop
|
||||
* duty cycle: heat for ON, then wait at least OFF. PRD 5.5 requires that
|
||||
* stale data must never hold the relay closed and must never silently mean no
|
||||
* heat either; with zero data those are the only two failure modes available,
|
||||
* and a low duty cycle sits between them. Every relay interlock still applies
|
||||
* on top, so this cannot short-cycle the furnace.
|
||||
*/
|
||||
#define CONTROL_FREEZE_DUTY_ON_S (6 * 60)
|
||||
#define CONTROL_FREEZE_DUTY_OFF_S (24 * 60)
|
||||
|
||||
/* --------------------------------------------------------------- modes -- */
|
||||
|
||||
typedef enum {
|
||||
ROOM_MODE_MANUAL = 0, /* encoder picks the governing room */
|
||||
ROOM_MODE_SCHEDULE, /* time-of-day rules */
|
||||
ROOM_MODE_FORMULA, /* min across rooms — coldest room wins */
|
||||
ROOM_MODE_COUNT,
|
||||
} room_mode_t;
|
||||
|
||||
/*
|
||||
* How the governing temperature is currently being derived. This is the
|
||||
* fallback ladder of PRD 5.5, in order of preference, and it is what the UI
|
||||
* renders as a fault.
|
||||
*/
|
||||
typedef enum {
|
||||
CONTROL_SRC_SELECTED = 0, /* the chosen room, fresh */
|
||||
CONTROL_SRC_OTHER_ROOM, /* chosen room stale, another is fresh */
|
||||
CONTROL_SRC_STALE, /* nothing fresh; running aged data, clamped */
|
||||
CONTROL_SRC_NONE, /* no reading at all; freeze-protection duty */
|
||||
} control_source_t;
|
||||
|
||||
typedef struct {
|
||||
float setpoint_c; /* what the user asked for */
|
||||
float effective_setpoint_c; /* after any fallback clamp */
|
||||
float governing_temp_c; /* NaN when source is CONTROL_SRC_NONE */
|
||||
int governing_room; /* index into the room table, -1 if none */
|
||||
room_mode_t mode;
|
||||
control_source_t source;
|
||||
bool calling_for_heat; /* what control asked the relay for */
|
||||
bool schedule_clock_valid;
|
||||
} control_state_t;
|
||||
|
||||
/* Start the control task. Requires relay_init() and zigbee_client_init(). */
|
||||
esp_err_t control_init(void);
|
||||
|
||||
void control_get_state(control_state_t *out);
|
||||
|
||||
/* Setpoint, clamped to bounds and persisted to NVS. */
|
||||
void control_set_setpoint(float celsius);
|
||||
void control_adjust_setpoint(float delta_c);
|
||||
float control_get_setpoint(void);
|
||||
|
||||
void control_set_mode(room_mode_t mode);
|
||||
room_mode_t control_get_mode(void);
|
||||
|
||||
/*
|
||||
* Manual room selection. Also switches to ROOM_MODE_MANUAL — PRD 4.2 requires
|
||||
* manual override to be reachable regardless of the active mode.
|
||||
*/
|
||||
void control_select_room(int room_index);
|
||||
int control_get_selected_room(void);
|
||||
|
||||
const char *control_source_str(control_source_t src);
|
||||
const char *control_mode_str(room_mode_t mode);
|
||||
@@ -0,0 +1,350 @@
|
||||
#include "relay.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
|
||||
#include "esp_check.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_task_wdt.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "pcf8574.h"
|
||||
|
||||
static const char *TAG = "relay";
|
||||
|
||||
/* The module is active low: pin low closes the contact. */
|
||||
#define RELAY_PIN_ASSERTED false
|
||||
#define RELAY_PIN_DEASSERTED true
|
||||
|
||||
/*
|
||||
* 250ms. Fast enough that max-on-time and the watchdog are enforced with
|
||||
* sub-second precision, slow enough to be free.
|
||||
*/
|
||||
#define SAFETY_TICK_MS 250
|
||||
|
||||
/* Ring of call-start timestamps, for the trailing-hour cycle count. */
|
||||
#define CYCLE_HISTORY_LEN 8
|
||||
|
||||
static SemaphoreHandle_t s_lock;
|
||||
static bool s_initialised;
|
||||
|
||||
static bool s_heat_on;
|
||||
static bool s_requested;
|
||||
static relay_fault_t s_fault;
|
||||
|
||||
static int64_t s_boot_us;
|
||||
static int64_t s_last_request_us;
|
||||
static int64_t s_on_since_us; /* valid while s_heat_on */
|
||||
static int64_t s_off_since_us; /* when the last call ended, or boot */
|
||||
static int64_t s_last_start_us; /* start of the most recent call, 0=never */
|
||||
|
||||
static int64_t s_cycle_starts[CYCLE_HISTORY_LEN];
|
||||
static uint8_t s_cycle_next;
|
||||
|
||||
static inline int64_t now_us(void)
|
||||
{
|
||||
return esp_timer_get_time();
|
||||
}
|
||||
|
||||
static inline uint32_t secs_since(int64_t then_us)
|
||||
{
|
||||
if (then_us == 0) {
|
||||
return UINT32_MAX;
|
||||
}
|
||||
int64_t d = now_us() - then_us;
|
||||
return (d < 0) ? 0 : (uint32_t)(d / 1000000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- output --- */
|
||||
|
||||
/*
|
||||
* The one place that touches the pin.
|
||||
*
|
||||
* A failure to close the contact is a comfort problem. A failure to open it is
|
||||
* the problem this whole module exists to prevent, so opening retries hard and
|
||||
* escalates the log level.
|
||||
*/
|
||||
static esp_err_t drive_pin(bool asserted)
|
||||
{
|
||||
const int attempts = asserted ? 1 : 5;
|
||||
esp_err_t err = ESP_FAIL;
|
||||
|
||||
for (int i = 0; i < attempts; i++) {
|
||||
err = pcf8574_write(PCF_PIN_RELAY,
|
||||
asserted ? RELAY_PIN_ASSERTED : RELAY_PIN_DEASSERTED);
|
||||
if (err == ESP_OK) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (!asserted) {
|
||||
ESP_LOGE(TAG, "FAILED TO OPEN CONTACT (attempt %d/%d): %s",
|
||||
i + 1, attempts, esp_err_to_name(err));
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
}
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t relay_emergency_off(void)
|
||||
{
|
||||
esp_err_t err = drive_pin(false);
|
||||
|
||||
if (s_lock) {
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
}
|
||||
s_heat_on = false;
|
||||
s_requested = false;
|
||||
if (err != ESP_OK) {
|
||||
s_fault = RELAY_FAULT_IO;
|
||||
}
|
||||
if (s_lock) {
|
||||
xSemaphoreGive(s_lock);
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- gating -- */
|
||||
|
||||
/* Seconds still to wait before a new call may start. Caller holds the lock. */
|
||||
static uint32_t hold_off_remaining_locked(void)
|
||||
{
|
||||
uint32_t worst = 0;
|
||||
|
||||
uint32_t since_boot = secs_since(s_boot_us);
|
||||
if (since_boot < RELAY_STARTUP_LOCKOUT_S) {
|
||||
worst = RELAY_STARTUP_LOCKOUT_S - since_boot;
|
||||
}
|
||||
|
||||
uint32_t off_for = secs_since(s_off_since_us);
|
||||
if (off_for < RELAY_MIN_OFF_TIME_S) {
|
||||
uint32_t r = RELAY_MIN_OFF_TIME_S - off_for;
|
||||
if (r > worst) {
|
||||
worst = r;
|
||||
}
|
||||
}
|
||||
|
||||
if (s_last_start_us != 0) {
|
||||
uint32_t since_start = secs_since(s_last_start_us);
|
||||
if (since_start < RELAY_MIN_CYCLE_PERIOD_S) {
|
||||
uint32_t r = RELAY_MIN_CYCLE_PERIOD_S - since_start;
|
||||
if (r > worst) {
|
||||
worst = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return worst;
|
||||
}
|
||||
|
||||
static uint8_t cycles_last_hour_locked(void)
|
||||
{
|
||||
int64_t cutoff = now_us() - (int64_t)3600 * 1000000;
|
||||
uint8_t n = 0;
|
||||
|
||||
for (int i = 0; i < CYCLE_HISTORY_LEN; i++) {
|
||||
if (s_cycle_starts[i] != 0 && s_cycle_starts[i] >= cutoff) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- transitions -- */
|
||||
|
||||
static void turn_on_locked(void)
|
||||
{
|
||||
if (drive_pin(true) != ESP_OK) {
|
||||
s_fault = RELAY_FAULT_IO;
|
||||
ESP_LOGE(TAG, "could not close contact");
|
||||
return;
|
||||
}
|
||||
|
||||
s_heat_on = true;
|
||||
s_on_since_us = now_us();
|
||||
s_last_start_us = s_on_since_us;
|
||||
|
||||
s_cycle_starts[s_cycle_next] = s_on_since_us;
|
||||
s_cycle_next = (uint8_t)((s_cycle_next + 1) % CYCLE_HISTORY_LEN);
|
||||
|
||||
/* A successful start means whatever held us off has cleared. */
|
||||
if (s_fault != RELAY_FAULT_IO) {
|
||||
s_fault = RELAY_FAULT_NONE;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "HEAT ON (cycles in last hour: %u)",
|
||||
(unsigned)cycles_last_hour_locked());
|
||||
}
|
||||
|
||||
static void turn_off_locked(relay_fault_t reason)
|
||||
{
|
||||
if (drive_pin(false) != ESP_OK) {
|
||||
s_fault = RELAY_FAULT_IO;
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t ran = secs_since(s_on_since_us);
|
||||
s_heat_on = false;
|
||||
s_off_since_us = now_us();
|
||||
|
||||
if (reason != RELAY_FAULT_NONE) {
|
||||
s_fault = reason;
|
||||
ESP_LOGW(TAG, "HEAT OFF after %" PRIu32 "s — %s",
|
||||
ran, relay_fault_str(reason));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "HEAT OFF after %" PRIu32 "s", ran);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- safety task -- */
|
||||
|
||||
static void safety_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
esp_task_wdt_add(NULL);
|
||||
|
||||
while (1) {
|
||||
esp_task_wdt_reset();
|
||||
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
|
||||
/*
|
||||
* Interlock 2 — watchdog. Checked before anything else, and it applies
|
||||
* whether or not heat is currently wanted: if the control loop has
|
||||
* gone silent we do not trust s_requested at all.
|
||||
*/
|
||||
bool control_alive =
|
||||
secs_since(s_last_request_us) <= RELAY_WATCHDOG_TIMEOUT_S;
|
||||
|
||||
if (!control_alive) {
|
||||
if (s_heat_on) {
|
||||
turn_off_locked(RELAY_FAULT_WATCHDOG);
|
||||
} else if (s_fault == RELAY_FAULT_NONE) {
|
||||
s_fault = RELAY_FAULT_WATCHDOG;
|
||||
}
|
||||
s_requested = false;
|
||||
|
||||
} else if (s_heat_on) {
|
||||
/* Interlock 3 — maximum on-time. */
|
||||
if (secs_since(s_on_since_us) >= RELAY_MAX_ON_TIME_S) {
|
||||
turn_off_locked(RELAY_FAULT_MAX_ON_TIME);
|
||||
} else if (!s_requested) {
|
||||
turn_off_locked(RELAY_FAULT_NONE);
|
||||
}
|
||||
|
||||
} else {
|
||||
/* Off, and the control loop is alive. May we start? */
|
||||
if (s_requested && hold_off_remaining_locked() == 0) {
|
||||
turn_on_locked();
|
||||
}
|
||||
}
|
||||
|
||||
xSemaphoreGive(s_lock);
|
||||
vTaskDelay(pdMS_TO_TICKS(SAFETY_TICK_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- api -- */
|
||||
|
||||
esp_err_t relay_init(void)
|
||||
{
|
||||
if (s_initialised) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (!pcf8574_is_ready()) {
|
||||
ESP_LOGE(TAG, "expander not ready — refusing to arm");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
s_lock = xSemaphoreCreateMutex();
|
||||
if (!s_lock) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
s_boot_us = now_us();
|
||||
s_off_since_us = s_boot_us;
|
||||
s_last_start_us = 0;
|
||||
s_heat_on = false;
|
||||
s_requested = false;
|
||||
s_fault = RELAY_FAULT_NONE;
|
||||
|
||||
/*
|
||||
* Seed the watchdog as already stale. The relay therefore cannot close
|
||||
* until the control loop has actually called in once — PRD 5.6's bounded
|
||||
* startup state, enforced rather than assumed.
|
||||
*/
|
||||
s_last_request_us = 0;
|
||||
|
||||
ESP_RETURN_ON_ERROR(drive_pin(false), TAG, "initial de-assert");
|
||||
|
||||
/* Priority above the UI: this task decides whether the house catches an
|
||||
* hour of unwanted heat, the UI decides whether a label repaints. */
|
||||
if (xTaskCreate(safety_task, "relay_safety", 3072, NULL, 6, NULL) != pdPASS) {
|
||||
ESP_LOGE(TAG, "safety task create failed");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
s_initialised = true;
|
||||
ESP_LOGI(TAG, "armed — max on %us, min off %us, min cycle period %us, "
|
||||
"startup lockout %us",
|
||||
RELAY_MAX_ON_TIME_S, RELAY_MIN_OFF_TIME_S,
|
||||
RELAY_MIN_CYCLE_PERIOD_S, RELAY_STARTUP_LOCKOUT_S);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void relay_request_heat(bool want_heat)
|
||||
{
|
||||
if (!s_lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
s_requested = want_heat;
|
||||
s_last_request_us = now_us();
|
||||
|
||||
/* Recovering from a watchdog trip: the control loop is talking again. */
|
||||
if (s_fault == RELAY_FAULT_WATCHDOG) {
|
||||
s_fault = RELAY_FAULT_NONE;
|
||||
}
|
||||
xSemaphoreGive(s_lock);
|
||||
}
|
||||
|
||||
bool relay_is_on(void)
|
||||
{
|
||||
return s_heat_on;
|
||||
}
|
||||
|
||||
void relay_status(relay_status_t *out)
|
||||
{
|
||||
if (!out) {
|
||||
return;
|
||||
}
|
||||
if (!s_lock) {
|
||||
*out = (relay_status_t){0};
|
||||
return;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
out->heat_on = s_heat_on;
|
||||
out->heat_requested = s_requested;
|
||||
out->fault = s_fault;
|
||||
out->on_seconds = s_heat_on ? secs_since(s_on_since_us) : 0;
|
||||
out->off_seconds = s_heat_on ? 0 : secs_since(s_off_since_us);
|
||||
out->hold_off_seconds = s_heat_on ? 0 : hold_off_remaining_locked();
|
||||
out->cycles_last_hour = cycles_last_hour_locked();
|
||||
xSemaphoreGive(s_lock);
|
||||
}
|
||||
|
||||
const char *relay_fault_str(relay_fault_t fault)
|
||||
{
|
||||
switch (fault) {
|
||||
case RELAY_FAULT_NONE: return "none";
|
||||
case RELAY_FAULT_WATCHDOG: return "control loop stopped";
|
||||
case RELAY_FAULT_MAX_ON_TIME: return "max on-time exceeded";
|
||||
case RELAY_FAULT_IO: return "expander I/O error";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -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