Three bugs and two UX defects found on review, plus hardening: - The control loop fed the task watchdog then slept exactly the watchdog period (both 10s), tripping the TWDT fractionally late on every single iteration. Sleep is now sliced 1s with a reset per slice. Also set CONFIG_ESP_TASK_WDT_PANIC=y - the sdkconfig comment claimed a hung task panics the chip, but nothing actually made that true. - The LVGL flush called draw_bitmap (which only queues the framebuffer switch) and released LVGL immediately, letting it render the next frame into the buffer still being scanned out - the exact PRD 8.2 race the double-buffer design exists to prevent. display_present_frame() now blocks on a vsync handshake until the swap has latched. - WiFi reconnect slept up to 30s inside the system event handler, stalling all queued WiFi/IP events. Moved to an esp_timer. - The setpoint label, arc, and mode/room lines rendered the control loop's copy of state, which refreshes every 10s - every knob detent looked dead for up to 10 seconds. The UI now reads settings live; when the fallback ladder clamps the setpoint it shows both values. Room-select focus now shows which room is being selected. - z2m ISO_8601_local timestamps carry a UTC offset the parser ignored, which would shift apparent reading age by the local offset (5-6h in the US) and permanently trip the fallback ladder. Offset is now honoured. - Suppressed the cosmetic "control loop stopped" fault in the window between relay_init and the control task's first call-in at boot. - Explicit pullups on the encoder A/B lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
362 lines
9.9 KiB
C
362 lines
9.9 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 bool s_ever_requested; /* control loop has called in at least once */
|
|
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_ever_requested) {
|
|
/*
|
|
* Only fault once the control loop has *stopped* talking.
|
|
* Before its first-ever call this is just boot ordering — the
|
|
* relay is held off regardless (the seeded-stale watchdog is
|
|
* the bounded-startup interlock), and flashing "control loop
|
|
* stopped" on every power-up would teach the user to ignore
|
|
* the one fault that matters most.
|
|
*/
|
|
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_ever_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_ever_requested = true;
|
|
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";
|
|
}
|
|
}
|