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>
384 lines
11 KiB
C
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;
|
|
}
|