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>
351 lines
9.3 KiB
C
351 lines
9.3 KiB
C
#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";
|
|
}
|
|
}
|