Files
rotaryThermo/main/hvac/relay.c
T
mattandClaude Opus 5 4c7b120473 Fix TWDT starvation, vsync race, and UI responsiveness from review
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>
2026-08-03 20:05:51 -07:00

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";
}
}