#include "zigbee_client.h" #include #include #include #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 /, and also to sub-topics such as * //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; }