#include "relay.h" #include #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"; } }