Files
rotaryThermo/main/net/zigbee_client.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

384 lines
11 KiB
C

#include "zigbee_client.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
#include "cJSON.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "mqtt_client.h"
static const char *TAG = "zigbee";
typedef struct {
const char *name;
const char *friendly;
} room_def_t;
#define ROOM_DEF_ENTRY(display, friendly_name) { display, friendly_name },
static const room_def_t s_rooms[] = {
APP_ROOM_TABLE(ROOM_DEF_ENTRY)
};
static room_reading_t s_readings[ROOM_COUNT];
static SemaphoreHandle_t s_lock;
static esp_mqtt_client_handle_t s_client;
static volatile bool s_connected;
/* ------------------------------------------------------------ parsing --- */
/*
* Match an incoming topic to a room.
*
* z2m publishes to <base>/<friendly_name>, and also to sub-topics such as
* <base>/<friendly_name>/availability. Only the exact device topic carries
* the state payload, so this compares the whole string rather than a prefix.
*/
static int room_for_topic(const char *topic, int topic_len)
{
char expected[160];
for (int i = 0; i < ROOM_COUNT; i++) {
int n = snprintf(expected, sizeof(expected), "%s/%s",
APP_MQTT_BASE_TOPIC, s_rooms[i].friendly);
if (n == topic_len && strncmp(topic, expected, (size_t)topic_len) == 0) {
return i;
}
}
return -1;
}
/*
* struct tm -> epoch seconds, interpreting the fields as UTC.
*
* timegm() is not in ESP-IDF's newlib, and mktime() is not a substitute: it
* would apply APP_TIMEZONE to a timestamp that z2m already emitted in UTC,
* shifting every reading's apparent age by the local offset. On a US Central
* install that is six hours, which would make live sensors look stale and trip
* the fallback ladder permanently.
*
* Days-from-civil (Howard Hinnant) avoids both problems and has no state.
*/
static int64_t tm_to_epoch_utc(const struct tm *t)
{
int y = t->tm_year + 1900;
unsigned m = (unsigned)t->tm_mon + 1;
unsigned d = (unsigned)t->tm_mday;
y -= (m <= 2);
const int era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = (unsigned)(y - era * 400);
const unsigned doy = (153u * (m + (m > 2 ? -3u : 9u)) + 2u) / 5u + d - 1u;
const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
const int64_t days = (int64_t)era * 146097 + (int64_t)doe - 719468;
return days * 86400 + t->tm_hour * 3600 + t->tm_min * 60 + t->tm_sec;
}
/*
* z2m reports last_seen either as an ISO-8601 string or as epoch milliseconds,
* depending on the `last_seen` advanced setting. Handle both, and treat
* anything unparseable as absent rather than as zero age.
*/
static bool parse_sensor_age(const cJSON *root, uint32_t *age_s)
{
const cJSON *ls = cJSON_GetObjectItemCaseSensitive(root, "last_seen");
if (!ls) {
return false;
}
time_t now = time(NULL);
/* Before SNTP has synced, wall-clock time is meaningless and any age we
* computed would be nonsense. Report absent. */
if (now < 1600000000) {
return false;
}
time_t seen = 0;
if (cJSON_IsNumber(ls)) {
seen = (time_t)(ls->valuedouble / 1000.0);
} else if (cJSON_IsString(ls) && ls->valuestring) {
struct tm tm = {0};
const char *rest = strptime(ls->valuestring, "%Y-%m-%dT%H:%M:%S", &tm);
if (!rest) {
return false;
}
int64_t epoch = tm_to_epoch_utc(&tm);
/*
* Honour the offset suffix. z2m's ISO_8601 setting emits UTC ("Z"),
* but ISO_8601_local emits local time with an offset ("-05:00").
* Ignoring that suffix would shift every reading's apparent age by
* the local offset — on a US install that is five or six hours, which
* makes live sensors look stale and trips the fallback ladder
* permanently. local = UTC + offset, so subtract it.
*/
if (*rest == '.') { /* fractional seconds */
rest++;
while (*rest >= '0' && *rest <= '9') {
rest++;
}
}
if (*rest == '+' || *rest == '-') {
int sign = (*rest == '-') ? -1 : 1;
int oh = 0, om = 0;
if (sscanf(rest + 1, "%2d:%2d", &oh, &om) < 1) {
return false;
}
epoch -= (int64_t)sign * (oh * 3600 + om * 60);
}
/* 'Z' or nothing: already UTC. */
seen = (time_t)epoch;
} else {
return false;
}
if (seen <= 0 || seen > now) {
*age_s = 0;
return true;
}
*age_s = (uint32_t)(now - seen);
return true;
}
static void handle_payload(int room, const char *data, int len)
{
cJSON *root = cJSON_ParseWithLength(data, (size_t)len);
if (!root) {
ESP_LOGW(TAG, "%s: unparseable payload", s_rooms[room].name);
return;
}
const cJSON *temp = cJSON_GetObjectItemCaseSensitive(root, "temperature");
if (!cJSON_IsNumber(temp)) {
/*
* Not an error. z2m publishes on any attribute change, so a humidity
* or link-quality update legitimately arrives with no temperature.
* Leaving the previous reading untouched is the point — a partial
* message must not age or clear good data.
*/
cJSON_Delete(root);
return;
}
room_reading_t r = {
.valid = true,
.temperature_c = (float)temp->valuedouble,
.received_us = esp_timer_get_time(),
};
const cJSON *batt = cJSON_GetObjectItemCaseSensitive(root, "battery");
if (cJSON_IsNumber(batt)) {
r.has_battery = true;
r.battery_percent = (int)batt->valuedouble;
}
uint32_t age = 0;
if (parse_sensor_age(root, &age)) {
r.has_sensor_age = true;
r.sensor_age_at_receipt_s = age;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
/* Carry battery forward if this message did not include it. */
if (!r.has_battery && s_readings[room].has_battery) {
r.has_battery = true;
r.battery_percent = s_readings[room].battery_percent;
}
s_readings[room] = r;
xSemaphoreGive(s_lock);
if (r.has_battery) {
ESP_LOGI(TAG, "%s: %.1fC, battery %d%%",
s_rooms[room].name, r.temperature_c, r.battery_percent);
} else {
ESP_LOGI(TAG, "%s: %.1fC", s_rooms[room].name, r.temperature_c);
}
cJSON_Delete(root);
}
/* -------------------------------------------------------------- events -- */
static void mqtt_event_handler(void *args, esp_event_base_t base,
int32_t event_id, void *event_data)
{
(void)args;
(void)base;
esp_mqtt_event_handle_t event = event_data;
switch ((esp_mqtt_event_id_t)event_id) {
case MQTT_EVENT_CONNECTED: {
s_connected = true;
ESP_LOGI(TAG, "broker connected");
char topic[160];
for (int i = 0; i < ROOM_COUNT; i++) {
snprintf(topic, sizeof(topic), "%s/%s",
APP_MQTT_BASE_TOPIC, s_rooms[i].friendly);
/* QoS 0 is right here: readings are periodic and a missed one
* simply ages, which the control loop already handles. */
esp_mqtt_client_subscribe(s_client, topic, 0);
ESP_LOGI(TAG, "subscribed %s -> %s", topic, s_rooms[i].name);
}
break;
}
case MQTT_EVENT_DISCONNECTED:
s_connected = false;
/* Readings are deliberately left alone. They age; they are not
* cleared. PRD 4.1. */
ESP_LOGW(TAG, "broker disconnected — readings will age");
break;
case MQTT_EVENT_DATA: {
int room = room_for_topic(event->topic, event->topic_len);
if (room < 0) {
break;
}
/*
* Long payloads arrive in fragments. z2m sensor JSON is small enough
* that this should not happen, but a silently truncated parse would be
* a nasty way to find out otherwise.
*/
if (event->data_len != event->total_data_len) {
ESP_LOGW(TAG, "%s: fragmented payload (%d of %d), ignoring",
s_rooms[room].name, event->data_len, event->total_data_len);
break;
}
handle_payload(room, event->data, event->data_len);
break;
}
case MQTT_EVENT_ERROR:
ESP_LOGW(TAG, "mqtt error");
break;
default:
break;
}
}
/* ----------------------------------------------------------------- api -- */
esp_err_t zigbee_client_init(void)
{
if (s_client) {
return ESP_OK;
}
s_lock = xSemaphoreCreateMutex();
if (!s_lock) {
return ESP_ERR_NO_MEM;
}
esp_mqtt_client_config_t cfg = {
.broker.address.uri = APP_MQTT_URI,
.credentials = {
.username = (sizeof(APP_MQTT_USER) > 1) ? APP_MQTT_USER : NULL,
.authentication.password =
(sizeof(APP_MQTT_PASS) > 1) ? APP_MQTT_PASS : NULL,
},
.session.keepalive = 60,
.network.reconnect_timeout_ms = 10000,
.network.disable_auto_reconnect = false,
};
s_client = esp_mqtt_client_init(&cfg);
if (!s_client) {
ESP_LOGE(TAG, "client init failed");
return ESP_FAIL;
}
ESP_ERROR_CHECK(esp_mqtt_client_register_event(
s_client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL));
/*
* Start unconditionally, even with no WiFi yet. The client retries on its
* own and must never block the control loop — PRD section 6.
*/
esp_err_t err = esp_mqtt_client_start(s_client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "client start failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "started, broker %s, %d room(s)", APP_MQTT_URI, ROOM_COUNT);
return ESP_OK;
}
int zigbee_client_room_count(void)
{
return ROOM_COUNT;
}
const char *zigbee_client_room_name(int idx)
{
if (idx < 0 || idx >= ROOM_COUNT) {
return "?";
}
return s_rooms[idx].name;
}
bool zigbee_client_get_reading(int idx, room_reading_t *out)
{
if (idx < 0 || idx >= ROOM_COUNT || !out) {
return false;
}
/* The UI and control loop both call this. If init failed we report "no
* reading" and let the fallback ladder handle it, rather than taking a
* NULL semaphore and crashing a device that controls heat. */
if (!s_lock) {
*out = (room_reading_t){0};
return false;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
*out = s_readings[idx];
xSemaphoreGive(s_lock);
return true;
}
uint32_t zigbee_client_reading_age_s(int idx)
{
room_reading_t r;
if (!zigbee_client_get_reading(idx, &r) || !r.valid) {
return UINT32_MAX;
}
uint32_t local = (uint32_t)((esp_timer_get_time() - r.received_us) / 1000000);
return r.has_sensor_age ? local + r.sensor_age_at_receipt_s : local;
}
bool zigbee_client_is_connected(void)
{
return s_connected;
}
int zigbee_client_lowest_battery(void)
{
int lowest = -1;
if (!s_lock) {
return -1;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < ROOM_COUNT; i++) {
if (s_readings[i].valid && s_readings[i].has_battery) {
if (lowest < 0 || s_readings[i].battery_percent < lowest) {
lowest = s_readings[i].battery_percent;
}
}
}
xSemaphoreGive(s_lock);
return lowest;
}