Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
0e3b0bd1dThis file is pinned to the selected release. Download the original to open it in your design tool.
/**
* @file zigbee.c
* @brief Zigbee integration for AirCube
*
* Implements a Zigbee End Device that exposes environmental sensor data
* via standard and custom ZCL clusters.
*
* Clusters on Endpoint 10:
* - Basic (0x0000) : Device identity, SWBuildID (firmware version)
* - Identify (0x0003) : Standard identify
* - Temperature Meas (0x0402) : Actual temperature in 0.01 C
* - Humidity Meas (0x0405) : Actual humidity in 0.01 %
* - Custom (0xFC01) : eCO2, eTVOC, VOC Level (TVOC-derived)
* - Analog Output (0x000D) : LED brightness (0-100)
* - CO2 Meas (0x040D) : Pro only - true CO2 from SCD41
* - Illuminance Meas (0x0400) : Pro only - ambient light from VCNL4040
*
* @author StuckAtPrototype, LLC
*/
#include "zigbee.h"
#include "button.h"
#include "device_model.h"
#include "radio_mode.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
#include <math.h>
#include "esp_check.h"
#include "esp_app_desc.h"
#include "esp_log.h"
#include "esp_system.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_zigbee_core.h"
#include "ha/esp_zigbee_ha_standard.h"
#include "zcl/esp_zigbee_zcl_command.h"
static const char *TAG = "zigbee";
/* ── Endpoint & cluster configuration ────────────────────────────────── */
#define AIRCUBE_ENDPOINT 10
/* Custom cluster for air quality metrics (manufacturer-specific range) */
#define CUSTOM_CLUSTER_ID 0xFC01
#define ATTR_ECO2_ID 0x0000 /* uint16 – ppm */
#define ATTR_ETVOC_ID 0x0001 /* uint16 – ppb */
#define ATTR_AQI_ID 0x0002 /* uint16 – TVOC-derived VOC Level (0-500) */
/* Analog Output cluster (0x000D) for brightness – standard cluster so
ZCL Write Attributes from coordinators is handled natively by ZBOSS. */
/* Zigbee channel mask – scan all channels */
#define AIRCUBE_CHANNEL_MASK ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK
/* ZCL string attributes: first byte is the string length */
#define MANUFACTURER_NAME "\x10" "StuckAtPrototype"
#define MODEL_IDENTIFIER "\x07" "AirCube"
/* ZCL char string: length byte + payload. Basic cluster SWBuildID max 16 chars (Zigbee 3.0). */
#define SW_BUILD_ZCL_MAX_CHARS 16
#define SW_BUILD_ZCL_BUF_LEN (SW_BUILD_ZCL_MAX_CHARS + 1)
/* ── State ───────────────────────────────────────────────────────────── */
static volatile bool s_connected = false;
static volatile bool s_pairing = false;
static volatile bool s_rejoining = false;
static TickType_t s_pairing_start = 0;
static TickType_t s_last_join_tick = 0; /* When we most recently joined */
static uint32_t s_rejoin_backoff_ms = 0;
static uint8_t s_sw_build_id[SW_BUILD_ZCL_BUF_LEN];
static uint8_t s_init_fail_count = 0;
#define INIT_FAIL_MAX 5 /* Reboot after this many consecutive init failures */
#define PAIRING_TIMEOUT_MS 60000 /* Auto-cancel pairing after 60 s */
#define REJOIN_BACKOFF_INIT_MS 1000 /* First rejoin attempt after 1 s */
#define REJOIN_BACKOFF_MAX_MS 300000 /* Cap backoff at 5 minutes */
#define STARTUP_REPORT_DELAY_MS 1000 /* Allow coordinator to finish startup */
/* Flap watchdog: if we have to rejoin too many times in a short window,
* the radio link is unstable enough that the ZBOSS MAC layer can hit an
* internal assertion (mac/mac.c). Reboot proactively before that happens. */
#define REJOIN_FLAP_WINDOW_MS 300000 /* 5-minute sliding window */
#define REJOIN_FLAP_LIMIT 10 /* Rejoins allowed inside window */
#define STABLE_UPTIME_MS 60000 /* Connected for >= this */
/* = considered "stable" */
static TickType_t s_flap_window_start = 0;
static uint16_t s_flap_rejoin_count = 0;
/* ── Helpers ─────────────────────────────────────────────────────────── */
/** Convert float C to ZCL int16 (hundredths of a degree). */
static int16_t temp_to_zb(float temp_c)
{
return (int16_t)(temp_c * 100.0f);
}
/** Convert float %RH to ZCL uint16 (hundredths of a percent). */
static uint16_t humidity_to_zb(float rh)
{
return (uint16_t)(rh * 100.0f);
}
/** Convert lux to the ZCL Illuminance MeasuredValue: 10000*log10(lux)+1.
* 0 lux -> 0 (per spec, "too low to be measured"). Clamped to 0xFFFE. */
static uint16_t lux_to_zb(float lux)
{
if (lux <= 0.0f) {
return 0;
}
float v = 10000.0f * log10f(lux) + 1.0f;
if (v < 1.0f) v = 1.0f;
if (v > 65534.0f) v = 65534.0f;
return (uint16_t)v;
}
/** Zigbee ZCL char string (length-prefixed) from ESP-IDF app image version. */
static void init_sw_build_id(void)
{
const esp_app_desc_t *app_desc = esp_app_get_description();
size_t n = strnlen(app_desc->version, sizeof(app_desc->version));
if (n > SW_BUILD_ZCL_MAX_CHARS) {
n = SW_BUILD_ZCL_MAX_CHARS;
}
s_sw_build_id[0] = (uint8_t)n;
memcpy(&s_sw_build_id[1], app_desc->version, n);
}
static float current_brightness_percent(void)
{
return (float)button_get_brightness_percent();
}
static void report_attr(uint16_t cluster_id, uint16_t attr_id)
{
esp_zb_zcl_report_attr_cmd_t report_cmd = { 0 };
report_cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000;
report_cmd.zcl_basic_cmd.dst_endpoint = 1;
report_cmd.zcl_basic_cmd.src_endpoint = AIRCUBE_ENDPOINT;
report_cmd.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT;
report_cmd.clusterID = cluster_id;
report_cmd.manuf_specific = 0;
report_cmd.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI;
report_cmd.dis_default_resp = 1;
report_cmd.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC;
report_cmd.attributeID = attr_id;
esp_zb_zcl_report_attr_cmd_req(&report_cmd);
}
/* ── Forward declarations ─────────────────────────────────────────────── */
static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask);
static void report_startup_brightness_cb(uint8_t unused);
/* ── Rejoin helper (exponential backoff) ──────────────────────────────── */
static void schedule_rejoin(void)
{
TickType_t now = xTaskGetTickCount();
/* Sliding-window flap detector. If the radio link is so bad that we
* keep cycling parent-link-failure -> rejoin, the ZBOSS MAC layer
* eventually asserts in mac/mac.c. Reboot ourselves first so we come
* back clean instead of crashing inside the stack. */
if (s_flap_rejoin_count == 0 ||
(now - s_flap_window_start) > pdMS_TO_TICKS(REJOIN_FLAP_WINDOW_MS)) {
s_flap_window_start = now;
s_flap_rejoin_count = 0;
}
s_flap_rejoin_count++;
if (s_flap_rejoin_count > REJOIN_FLAP_LIMIT) {
ESP_LOGE(TAG, "Rejoin flap watchdog tripped (%u rejoins in %u ms) – rebooting",
(unsigned)s_flap_rejoin_count,
(unsigned)REJOIN_FLAP_WINDOW_MS);
esp_restart();
}
if (!s_rejoining) {
s_rejoining = true;
/* Only reset backoff if we previously had a stable connection.
* Otherwise keep growing it so a flapping parent doesn't make us
* hammer NETWORK_STEERING every second. */
if (s_last_join_tick != 0 &&
(now - s_last_join_tick) >= pdMS_TO_TICKS(STABLE_UPTIME_MS)) {
s_rejoin_backoff_ms = REJOIN_BACKOFF_INIT_MS;
} else if (s_rejoin_backoff_ms == 0) {
s_rejoin_backoff_ms = REJOIN_BACKOFF_INIT_MS;
}
}
ESP_LOGW(TAG, "Scheduling rejoin in %lu ms (flap %u/%u)",
(unsigned long)s_rejoin_backoff_ms,
(unsigned)s_flap_rejoin_count,
(unsigned)REJOIN_FLAP_LIMIT);
esp_zb_scheduler_alarm((esp_zb_callback_t)bdb_start_top_level_commissioning_cb,
ESP_ZB_BDB_MODE_NETWORK_STEERING, s_rejoin_backoff_ms);
s_rejoin_backoff_ms *= 2;
if (s_rejoin_backoff_ms > REJOIN_BACKOFF_MAX_MS) {
s_rejoin_backoff_ms = REJOIN_BACKOFF_MAX_MS;
}
}
/* ── NVS pairing flag (survives the reboot caused by factory_reset) ─── */
#define NVS_NAMESPACE "aircube"
#define NVS_KEY_PAIRING "zb_pair_req"
static void set_pairing_flag(void)
{
nvs_handle_t h;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h) == ESP_OK) {
nvs_set_u8(h, NVS_KEY_PAIRING, 1);
nvs_commit(h);
nvs_close(h);
}
}
static bool consume_pairing_flag(void)
{
nvs_handle_t h;
uint8_t val = 0;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h) != ESP_OK) {
return false;
}
bool found = (nvs_get_u8(h, NVS_KEY_PAIRING, &val) == ESP_OK && val);
nvs_erase_key(h, NVS_KEY_PAIRING);
nvs_commit(h);
nvs_close(h);
return found;
}
/* ── ZED configuration macros (matching Espressif examples) ──────────── */
/* keep_alive: how often (ms) the ZED data-polls its parent.
* 3 s is aggressive: two missed polls and the NWK layer raises
* PARENT_LINK_FAILURE. On marginal RF that triggers a flap loop and
* eventually a MAC assertion. 7 s gives the link more headroom. */
#define AIRCUBE_ZED_CONFIG() \
{ \
.esp_zb_role = ESP_ZB_DEVICE_TYPE_ED, \
.install_code_policy = false, \
.nwk_cfg.zed_cfg = { \
.ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, \
.keep_alive = 7000, \
}, \
}
#define AIRCUBE_RADIO_CONFIG() \
{ \
.radio_mode = ZB_RADIO_MODE_NATIVE, \
}
#define AIRCUBE_HOST_CONFIG() \
{ \
.host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, \
}
/* ── Commissioning helper ────────────────────────────────────────────── */
static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask)
{
ESP_RETURN_ON_FALSE(esp_zb_bdb_start_top_level_commissioning(mode_mask) == ESP_OK, ,
TAG, "Failed to start Zigbee commissioning");
}
/* ── Signal handler (called by the Zigbee stack) ─────────────────────── */
void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct)
{
uint32_t *p_sg_p = signal_struct->p_app_signal;
esp_err_t err_status = signal_struct->esp_err_status;
esp_zb_app_signal_type_t sig_type = *p_sg_p;
switch (sig_type) {
case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP:
ESP_LOGI(TAG, "Zigbee stack initialized");
esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION);
break;
case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START:
case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT:
if (err_status == ESP_OK) {
s_init_fail_count = 0;
ESP_LOGI(TAG, "Device started up in%s factory-reset mode",
esp_zb_bdb_is_factory_new() ? "" : " non");
if (esp_zb_bdb_is_factory_new()) {
if (s_pairing || consume_pairing_flag()) {
s_pairing = true;
s_pairing_start = xTaskGetTickCount();
ESP_LOGI(TAG, "Pairing requested – start network steering");
esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING);
} else {
/* BLE-first: a factory-new device with no pairing request
* has nothing to do in Zigbee mode (stale flags, e.g. the
* zb_storage partition was erased externally). Go BLE. */
ESP_LOGI(TAG, "Factory-new, no pairing requested – reverting to BLE mode");
radio_mode_revert_to_ble();
}
} else {
ESP_LOGI(TAG, "Device rebooted – already commissioned");
s_connected = true;
s_rejoining = false;
s_last_join_tick = xTaskGetTickCount();
s_rejoin_backoff_ms = REJOIN_BACKOFF_INIT_MS;
/* Normalize mode flags: commissioned = Zigbee mode next boot
* too, and any leftover pairing request is satisfied. */
consume_pairing_flag();
radio_mode_set_joined(true);
esp_zb_scheduler_alarm((esp_zb_callback_t)report_startup_brightness_cb,
0, STARTUP_REPORT_DELAY_MS);
}
} else {
s_init_fail_count++;
if (s_init_fail_count >= INIT_FAIL_MAX) {
ESP_LOGE(TAG, "Zigbee init failed %d times – rebooting to reset radio",
s_init_fail_count);
esp_restart();
}
ESP_LOGI(TAG, "Waiting for coordinator (%s), attempt %d/%d, retrying",
esp_err_to_name(err_status), s_init_fail_count, INIT_FAIL_MAX);
esp_zb_scheduler_alarm((esp_zb_callback_t)bdb_start_top_level_commissioning_cb,
ESP_ZB_BDB_MODE_INITIALIZATION, 1000);
}
break;
case ESP_ZB_BDB_SIGNAL_STEERING:
if (err_status == ESP_OK) {
esp_zb_ieee_addr_t extended_pan_id;
esp_zb_get_extended_pan_id(extended_pan_id);
ESP_LOGI(TAG, "Joined network (Ext PAN: %02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x, "
"PAN: 0x%04hx, CH: %d, Addr: 0x%04hx)",
extended_pan_id[7], extended_pan_id[6], extended_pan_id[5],
extended_pan_id[4], extended_pan_id[3], extended_pan_id[2],
extended_pan_id[1], extended_pan_id[0],
esp_zb_get_pan_id(), esp_zb_get_current_channel(),
esp_zb_get_short_address());
s_connected = true;
s_pairing = false;
s_rejoining = false;
s_last_join_tick = xTaskGetTickCount();
/* Persist the joined flag so the next boot picks Zigbee mode
* (BLE-first: without this flag the device boots into BLE). */
radio_mode_set_joined(true);
/* Note: we deliberately don't reset s_rejoin_backoff_ms here.
* schedule_rejoin() will reset it only after we've been
* connected long enough to count as a stable uptime. */
esp_zb_scheduler_alarm((esp_zb_callback_t)report_startup_brightness_cb,
0, STARTUP_REPORT_DELAY_MS);
} else {
if (s_pairing &&
(xTaskGetTickCount() - s_pairing_start) < pdMS_TO_TICKS(PAIRING_TIMEOUT_MS)) {
ESP_LOGI(TAG, "Network steering failed (status: %s), retrying",
esp_err_to_name(err_status));
esp_zb_scheduler_alarm((esp_zb_callback_t)bdb_start_top_level_commissioning_cb,
ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000);
} else if (s_rejoining) {
ESP_LOGW(TAG, "Rejoin steering failed (%s), backing off",
esp_err_to_name(err_status));
schedule_rejoin();
} else {
ESP_LOGW(TAG, "Network steering stopped – %s",
s_pairing ? "timed out" : "no pairing requested");
s_pairing = false;
/* BLE-first: a pairing attempt that never joined leaves the
* device factory-new. Go back to BLE mode instead of
* lingering in an idle Zigbee mode. */
if (esp_zb_bdb_is_factory_new()) {
radio_mode_revert_to_ble();
}
}
}
break;
case ESP_ZB_ZDO_SIGNAL_LEAVE: {
esp_zb_zdo_signal_leave_params_t *leave_params =
(esp_zb_zdo_signal_leave_params_t *)esp_zb_app_signal_get_params(p_sg_p);
uint8_t leave_type = leave_params ? leave_params->leave_type : 0xFF;
ESP_LOGW(TAG, "Left network (leave_type: 0x%x)", leave_type);
s_connected = false;
if (s_pairing) {
/* Local factory reset for re-pairing (zigbee_start_pairing set
* the pairing flag; the stack reboots us back into Zigbee mode). */
break;
}
if (leave_type == ESP_ZB_NWK_LEAVE_TYPE_REJOIN) {
/* Coordinator asked us to leave-and-rejoin: stay in Zigbee. */
schedule_rejoin();
} else {
/* Removed from the network (hub deleted the device or remote
* factory reset). BLE-first: clear the joined flag and reboot
* into BLE mode. */
ESP_LOGW(TAG, "Removed from network - reverting to BLE mode");
radio_mode_revert_to_ble();
}
break;
}
case ESP_ZB_NLME_STATUS_INDICATION: {
esp_zb_zdo_signal_nwk_status_indication_params_t *nwk_status =
(esp_zb_zdo_signal_nwk_status_indication_params_t *)esp_zb_app_signal_get_params(p_sg_p);
uint8_t status = nwk_status ? nwk_status->status : 0xFF;
uint16_t addr = nwk_status ? nwk_status->network_addr : 0xFFFF;
ESP_LOGW(TAG, "Network status indication: 0x%02x, addr: 0x%04x", status, addr);
if (status == ESP_ZB_NWK_COMMAND_STATUS_PARENT_LINK_FAILURE) {
ESP_LOGW(TAG, "Parent link failure – marking disconnected");
s_connected = false;
if (!s_rejoining && !s_pairing) {
schedule_rejoin();
}
}
break;
}
case ESP_ZB_BDB_SIGNAL_TC_REJOIN_DONE:
if (err_status == ESP_OK) {
ESP_LOGI(TAG, "Trust Center rejoin succeeded");
s_connected = true;
s_rejoining = false;
s_last_join_tick = xTaskGetTickCount();
/* Backoff stays as-is; schedule_rejoin() will reset only after
* a stable uptime. */
} else {
ESP_LOGW(TAG, "Trust Center rejoin failed (%s)", esp_err_to_name(err_status));
s_connected = false;
if (!s_pairing) {
schedule_rejoin();
}
}
break;
default:
ESP_LOGI(TAG, "ZDO signal: %s (0x%x), status: %s",
esp_zb_zdo_signal_to_string(sig_type), sig_type,
esp_err_to_name(err_status));
break;
}
}
/* ── Cluster list creation ───────────────────────────────────────────── */
static esp_zb_cluster_list_t *create_cluster_list(void)
{
esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create();
/* ---- Basic cluster (mandatory: identity + firmware version for coordinators) ---- */
init_sw_build_id();
esp_zb_attribute_list_t *basic_cluster = esp_zb_basic_cluster_create(NULL);
ESP_ERROR_CHECK(esp_zb_basic_cluster_add_attr(basic_cluster,
ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, (void *)MANUFACTURER_NAME));
ESP_ERROR_CHECK(esp_zb_basic_cluster_add_attr(basic_cluster,
ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, (void *)MODEL_IDENTIFIER));
ESP_ERROR_CHECK(esp_zb_basic_cluster_add_attr(basic_cluster,
ESP_ZB_ZCL_ATTR_BASIC_SW_BUILD_ID, (void *)s_sw_build_id));
ESP_ERROR_CHECK(esp_zb_cluster_list_add_basic_cluster(cluster_list,
basic_cluster, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE));
/* ---- Identify cluster (mandatory) ---- */
ESP_ERROR_CHECK(esp_zb_cluster_list_add_identify_cluster(cluster_list,
esp_zb_identify_cluster_create(NULL), ESP_ZB_ZCL_CLUSTER_SERVER_ROLE));
/* ---- Temperature Measurement cluster (0x0402) ---- */
esp_zb_temperature_meas_cluster_cfg_t temp_cfg = {
.measured_value = 0,
.min_value = -1000, /* -10.00 C */
.max_value = 8000, /* 80.00 C */
};
ESP_ERROR_CHECK(esp_zb_cluster_list_add_temperature_meas_cluster(cluster_list,
esp_zb_temperature_meas_cluster_create(&temp_cfg),
ESP_ZB_ZCL_CLUSTER_SERVER_ROLE));
/* ---- Relative Humidity Measurement cluster (0x0405) ---- */
esp_zb_humidity_meas_cluster_cfg_t hum_cfg = {
.measured_value = 0,
.min_value = 0, /* 0.00 % */
.max_value = 10000, /* 100.00 % */
};
ESP_ERROR_CHECK(esp_zb_cluster_list_add_humidity_meas_cluster(cluster_list,
esp_zb_humidity_meas_cluster_create(&hum_cfg),
ESP_ZB_ZCL_CLUSTER_SERVER_ROLE));
/* ---- Custom cluster 0xFC01 (eCO2, eTVOC, VOC Level) ---- */
esp_zb_attribute_list_t *custom_cluster = esp_zb_zcl_attr_list_create(CUSTOM_CLUSTER_ID);
uint16_t default_val = 0;
ESP_ERROR_CHECK(esp_zb_custom_cluster_add_custom_attr(custom_cluster,
ATTR_ECO2_ID, ESP_ZB_ZCL_ATTR_TYPE_U16,
ESP_ZB_ZCL_ATTR_ACCESS_READ_ONLY | ESP_ZB_ZCL_ATTR_ACCESS_REPORTING,
&default_val));
ESP_ERROR_CHECK(esp_zb_custom_cluster_add_custom_attr(custom_cluster,
ATTR_ETVOC_ID, ESP_ZB_ZCL_ATTR_TYPE_U16,
ESP_ZB_ZCL_ATTR_ACCESS_READ_ONLY | ESP_ZB_ZCL_ATTR_ACCESS_REPORTING,
&default_val));
ESP_ERROR_CHECK(esp_zb_custom_cluster_add_custom_attr(custom_cluster,
ATTR_AQI_ID, ESP_ZB_ZCL_ATTR_TYPE_U16,
ESP_ZB_ZCL_ATTR_ACCESS_READ_ONLY | ESP_ZB_ZCL_ATTR_ACCESS_REPORTING,
&default_val));
ESP_ERROR_CHECK(esp_zb_cluster_list_add_custom_cluster(cluster_list,
custom_cluster, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE));
/* ---- Analog Output cluster 0x000D (brightness, writable) ---- */
esp_zb_analog_output_cluster_cfg_t ao_cfg = {
.out_of_service = false,
.present_value = current_brightness_percent(),
.status_flags = 0,
};
esp_zb_attribute_list_t *ao_cluster =
esp_zb_analog_output_cluster_create(&ao_cfg);
ESP_ERROR_CHECK(esp_zb_cluster_list_add_analog_output_cluster(cluster_list,
ao_cluster, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE));
/* ---- Pro-only standard clusters: true CO2 + ambient light ---- */
if (aircube_model_is_pro()) {
/* Carbon Dioxide Measurement (0x040D). MeasuredValue is a float
expressed as a fraction of one (e.g. 400 ppm -> 0.0004). */
esp_zb_carbon_dioxide_measurement_cluster_cfg_t co2_cfg = {
.measured_value = 0.0f,
.min_measured_value = 0.0f,
.max_measured_value = 0.01f, /* 10000 ppm */
};
ESP_ERROR_CHECK(esp_zb_cluster_list_add_carbon_dioxide_measurement_cluster(
cluster_list,
esp_zb_carbon_dioxide_measurement_cluster_create(&co2_cfg),
ESP_ZB_ZCL_CLUSTER_SERVER_ROLE));
/* Illuminance Measurement (0x0400). MeasuredValue is
10000*log10(lux)+1 as a uint16. */
esp_zb_illuminance_meas_cluster_cfg_t illum_cfg = {
.measured_value = 0,
.min_value = 1,
.max_value = 0xFFFE,
};
ESP_ERROR_CHECK(esp_zb_cluster_list_add_illuminance_meas_cluster(
cluster_list,
esp_zb_illuminance_meas_cluster_create(&illum_cfg),
ESP_ZB_ZCL_CLUSTER_SERVER_ROLE));
}
return cluster_list;
}
/* ── Reporting configuration ─────────────────────────────────────────── */
static void configure_reporting(void)
{
/* Temperature: report every 60s max, or on 0.50 C change */
esp_zb_zcl_reporting_info_t temp_rpt = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = AIRCUBE_ENDPOINT,
.cluster_id = ESP_ZB_ZCL_CLUSTER_ID_TEMP_MEASUREMENT,
.cluster_role = ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.u.send_info.min_interval = 1,
.u.send_info.max_interval = 60,
.u.send_info.def_min_interval = 1,
.u.send_info.def_max_interval = 60,
.u.send_info.delta.u16 = 50, /* 0.50 C */
.attr_id = ESP_ZB_ZCL_ATTR_TEMP_MEASUREMENT_VALUE_ID,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
esp_zb_zcl_update_reporting_info(&temp_rpt);
/* Humidity: report every 60s max, or on 1.0 %RH change */
esp_zb_zcl_reporting_info_t hum_rpt = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = AIRCUBE_ENDPOINT,
.cluster_id = ESP_ZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT,
.cluster_role = ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.u.send_info.min_interval = 1,
.u.send_info.max_interval = 60,
.u.send_info.def_min_interval = 1,
.u.send_info.def_max_interval = 60,
.u.send_info.delta.u16 = 100, /* 1.00 %RH */
.attr_id = ESP_ZB_ZCL_ATTR_REL_HUMIDITY_MEASUREMENT_VALUE_ID,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
esp_zb_zcl_update_reporting_info(&hum_rpt);
/* eCO2: report every 60s max, or on 50 ppm change */
esp_zb_zcl_reporting_info_t eco2_rpt = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = AIRCUBE_ENDPOINT,
.cluster_id = CUSTOM_CLUSTER_ID,
.cluster_role = ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.u.send_info.min_interval = 1,
.u.send_info.max_interval = 60,
.u.send_info.def_min_interval = 1,
.u.send_info.def_max_interval = 60,
.u.send_info.delta.u16 = 50,
.attr_id = ATTR_ECO2_ID,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
esp_zb_zcl_update_reporting_info(&eco2_rpt);
/* eTVOC: report every 60s max, or on 10 ppb change */
esp_zb_zcl_reporting_info_t etvoc_rpt = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = AIRCUBE_ENDPOINT,
.cluster_id = CUSTOM_CLUSTER_ID,
.cluster_role = ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.u.send_info.min_interval = 1,
.u.send_info.max_interval = 60,
.u.send_info.def_min_interval = 1,
.u.send_info.def_max_interval = 60,
.u.send_info.delta.u16 = 10,
.attr_id = ATTR_ETVOC_ID,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
esp_zb_zcl_update_reporting_info(&etvoc_rpt);
/* VOC Level (TVOC-derived): report every 60s max, or on 5-point change */
esp_zb_zcl_reporting_info_t aqi_rpt = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = AIRCUBE_ENDPOINT,
.cluster_id = CUSTOM_CLUSTER_ID,
.cluster_role = ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.u.send_info.min_interval = 1,
.u.send_info.max_interval = 60,
.u.send_info.def_min_interval = 1,
.u.send_info.def_max_interval = 60,
.u.send_info.delta.u16 = 5,
.attr_id = ATTR_AQI_ID,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
esp_zb_zcl_update_reporting_info(&aqi_rpt);
/* Brightness: report every 60s max, or on 5.0% change */
esp_zb_zcl_reporting_info_t brightness_rpt = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = AIRCUBE_ENDPOINT,
.cluster_id = ESP_ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT,
.cluster_role = ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.u.send_info.min_interval = 1,
.u.send_info.max_interval = 60,
.u.send_info.def_min_interval = 1,
.u.send_info.def_max_interval = 60,
.attr_id = ESP_ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
float brightness_delta = 5.0f;
memcpy(&brightness_rpt.u.send_info.delta, &brightness_delta, sizeof(float));
esp_zb_zcl_update_reporting_info(&brightness_rpt);
/* Pro-only: CO2 (0x040D) and Illuminance (0x0400) reporting. */
if (aircube_model_is_pro()) {
/* CO2: float MeasuredValue, report on ~50 ppm (5e-5 fraction) change */
esp_zb_zcl_reporting_info_t co2_rpt = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = AIRCUBE_ENDPOINT,
.cluster_id = ESP_ZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT,
.cluster_role = ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.u.send_info.min_interval = 1,
.u.send_info.max_interval = 60,
.u.send_info.def_min_interval = 1,
.u.send_info.def_max_interval = 60,
.attr_id = ESP_ZB_ZCL_ATTR_CARBON_DIOXIDE_MEASUREMENT_MEASURED_VALUE_ID,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
float co2_delta = 50.0f / 1000000.0f;
memcpy(&co2_rpt.u.send_info.delta, &co2_delta, sizeof(float));
esp_zb_zcl_update_reporting_info(&co2_rpt);
/* Illuminance: uint16 MeasuredValue, report on a small log-scale change */
esp_zb_zcl_reporting_info_t illum_rpt = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = AIRCUBE_ENDPOINT,
.cluster_id = ESP_ZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT,
.cluster_role = ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.u.send_info.min_interval = 1,
.u.send_info.max_interval = 60,
.u.send_info.def_min_interval = 1,
.u.send_info.def_max_interval = 60,
.u.send_info.delta.u16 = 100,
.attr_id = ESP_ZB_ZCL_ATTR_ILLUMINANCE_MEASUREMENT_MEASURED_VALUE_ID,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
esp_zb_zcl_update_reporting_info(&illum_rpt);
}
}
static void apply_zigbee_tx_power(void)
{
#if defined(CONFIG_AIRCUBE_ZB_TX_POWER_DBM)
#if defined(ESP_ZB_VER_MAJOR) && defined(ESP_ZB_VER_MINOR) && \
((ESP_ZB_VER_MAJOR > 1) || (ESP_ZB_VER_MAJOR == 1 && ESP_ZB_VER_MINOR >= 6))
const int8_t requested_dbm = (int8_t)CONFIG_AIRCUBE_ZB_TX_POWER_DBM;
int8_t applied_dbm = 0;
esp_zb_set_tx_power(requested_dbm);
esp_zb_get_tx_power(&applied_dbm);
if (applied_dbm != requested_dbm) {
ESP_LOGW(TAG, "Requested Zigbee TX power %d dBm, applied %d dBm",
requested_dbm, applied_dbm);
} else {
ESP_LOGI(TAG, "Zigbee TX power set to %d dBm", applied_dbm);
}
#else
ESP_LOGW(TAG, "Zigbee TX power config is not supported by this ESP Zigbee SDK");
#endif
#endif
}
/* ── Action handler (brightness writes via Analog Output cluster) ─────── */
static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id,
const void *message)
{
if (callback_id == ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID && message) {
const esp_zb_zcl_set_attr_value_message_t *m =
(const esp_zb_zcl_set_attr_value_message_t *)message;
if (m->info.dst_endpoint == AIRCUBE_ENDPOINT &&
m->info.cluster == ESP_ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT &&
m->attribute.id == ESP_ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID)
{
float raw = *(float *)m->attribute.data.value;
if (raw < 0.0f) raw = 0.0f;
if (raw > 100.0f) raw = 100.0f;
/* Shared write path: applies, persists to NVS, reports back */
button_set_brightness_percent((int)(raw + 0.5f));
ESP_LOGI(TAG, "Brightness set to %.0f%% via Zigbee", raw);
}
}
return ESP_OK;
}
/* ── Zigbee main task ────────────────────────────────────────────────── */
static void esp_zb_task(void *pvParameters)
{
/* Initialize Zigbee stack as End Device */
esp_zb_cfg_t zb_nwk_cfg = AIRCUBE_ZED_CONFIG();
esp_zb_init(&zb_nwk_cfg);
/* Create endpoint 10 with all our clusters */
esp_zb_ep_list_t *ep_list = esp_zb_ep_list_create();
esp_zb_endpoint_config_t endpoint_config = {
.endpoint = AIRCUBE_ENDPOINT,
.app_profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.app_device_id = ESP_ZB_HA_TEMPERATURE_SENSOR_DEVICE_ID,
.app_device_version = 0,
};
esp_zb_ep_list_add_ep(ep_list, create_cluster_list(), endpoint_config);
/* Register the device */
esp_zb_device_register(ep_list);
/* Set up automatic attribute reporting */
configure_reporting();
/* Use all channels for network steering */
esp_zb_set_primary_network_channel_set(AIRCUBE_CHANNEL_MASK);
/* Apply configured Zigbee TX power before stack start */
apply_zigbee_tx_power();
/* Handle attribute writes from coordinator (brightness via Analog Output) */
esp_zb_core_action_handler_register(zb_action_handler);
/* Start the Zigbee stack (false = not coordinator) */
ESP_ERROR_CHECK(esp_zb_start(false));
ESP_LOGI(TAG, "Zigbee stack started – waiting for network");
/* Run the Zigbee main loop (never returns) */
esp_zb_stack_main_loop();
}
/* ── Public API ──────────────────────────────────────────────────────── */
void zigbee_init(void)
{
/* Configure platform for native radio (ESP32-H2 on-chip 802.15.4) */
esp_zb_platform_config_t config = {
.radio_config = AIRCUBE_RADIO_CONFIG(),
.host_config = AIRCUBE_HOST_CONFIG(),
};
ESP_ERROR_CHECK(esp_zb_platform_config(&config));
/* Launch the Zigbee task */
xTaskCreate(esp_zb_task, "zigbee_main", 4096, NULL, 5, NULL);
ESP_LOGI(TAG, "Zigbee initialized");
}
void zigbee_update_sensors(float temp_c, float humidity, int eco2, int etvoc,
int aqi, float co2_ppm, float lux)
{
if (!s_connected) {
return; /* Don't update attributes until we've joined a network */
}
/* Convert to ZCL units */
int16_t zb_temp = temp_to_zb(temp_c);
uint16_t zb_hum = humidity_to_zb(humidity);
uint16_t zb_eco2 = (uint16_t)eco2;
uint16_t zb_etvoc = (uint16_t)etvoc;
uint16_t zb_aqi = (uint16_t)aqi;
/* Bounded lock: avoid blocking sensor_task forever if the stack is stuck */
if (!esp_zb_lock_acquire(pdMS_TO_TICKS(2000))) {
ESP_LOGW(TAG, "Zigbee lock timeout in update_sensors – skipping this cycle");
return;
}
/* Standard clusters */
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
ESP_ZB_ZCL_CLUSTER_ID_TEMP_MEASUREMENT, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ESP_ZB_ZCL_ATTR_TEMP_MEASUREMENT_VALUE_ID, &zb_temp, false);
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
ESP_ZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ESP_ZB_ZCL_ATTR_REL_HUMIDITY_MEASUREMENT_VALUE_ID, &zb_hum, false);
/* Custom cluster (0xFC01) */
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
CUSTOM_CLUSTER_ID, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ATTR_ECO2_ID, &zb_eco2, false);
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
CUSTOM_CLUSTER_ID, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ATTR_ETVOC_ID, &zb_etvoc, false);
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
CUSTOM_CLUSTER_ID, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ATTR_AQI_ID, &zb_aqi, false);
/* Sync current brightness to Analog Output cluster (covers button changes) */
float zb_brightness = current_brightness_percent();
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
ESP_ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ESP_ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID, &zb_brightness, false);
/* Pro-only: true CO2 (0x040D) and ambient light (0x0400) */
if (aircube_model_is_pro()) {
float zb_co2 = co2_ppm / 1000000.0f; /* ppm -> fraction of one */
uint16_t zb_illum = lux_to_zb(lux);
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
ESP_ZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ESP_ZB_ZCL_ATTR_CARBON_DIOXIDE_MEASUREMENT_MEASURED_VALUE_ID, &zb_co2, false);
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
ESP_ZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ESP_ZB_ZCL_ATTR_ILLUMINANCE_MEASUREMENT_MEASURED_VALUE_ID, &zb_illum, false);
}
/* One-shot attribute reports to ensure coordinator updates */
report_attr(ESP_ZB_ZCL_CLUSTER_ID_TEMP_MEASUREMENT,
ESP_ZB_ZCL_ATTR_TEMP_MEASUREMENT_VALUE_ID);
report_attr(ESP_ZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT,
ESP_ZB_ZCL_ATTR_REL_HUMIDITY_MEASUREMENT_VALUE_ID);
report_attr(CUSTOM_CLUSTER_ID, ATTR_ECO2_ID);
report_attr(CUSTOM_CLUSTER_ID, ATTR_ETVOC_ID);
report_attr(CUSTOM_CLUSTER_ID, ATTR_AQI_ID);
if (aircube_model_is_pro()) {
report_attr(ESP_ZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT,
ESP_ZB_ZCL_ATTR_CARBON_DIOXIDE_MEASUREMENT_MEASURED_VALUE_ID);
report_attr(ESP_ZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT,
ESP_ZB_ZCL_ATTR_ILLUMINANCE_MEASUREMENT_MEASURED_VALUE_ID);
}
esp_zb_lock_release();
}
bool zigbee_is_connected(void)
{
return s_connected;
}
static void report_startup_brightness_cb(uint8_t unused)
{
(void)unused;
if (!s_connected) {
return;
}
float zb_brightness = current_brightness_percent();
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
ESP_ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ESP_ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID, &zb_brightness, false);
report_attr(ESP_ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT,
ESP_ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID);
}
void zigbee_report_brightness(void)
{
if (!s_connected) {
return;
}
float zb_brightness = current_brightness_percent();
if (!esp_zb_lock_acquire(pdMS_TO_TICKS(2000))) {
ESP_LOGW(TAG, "Zigbee lock timeout in report_brightness – skipping");
return;
}
esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT,
ESP_ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
ESP_ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID, &zb_brightness, false);
report_attr(ESP_ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT,
ESP_ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID);
esp_zb_lock_release();
}
void zigbee_start_pairing(void)
{
ESP_LOGI(TAG, "Manual pairing requested");
s_pairing = true;
s_connected = false;
s_pairing_start = xTaskGetTickCount();
if (!esp_zb_lock_acquire(pdMS_TO_TICKS(5000))) {
ESP_LOGE(TAG, "Zigbee lock timeout in start_pairing – aborting");
s_pairing = false;
return;
}
if (esp_zb_bdb_is_factory_new()) {
ESP_LOGI(TAG, "Already factory-new – starting network steering directly");
esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING);
} else {
ESP_LOGI(TAG, "Clearing credentials – device will reboot");
set_pairing_flag();
esp_zb_factory_reset();
}
esp_zb_lock_release();
}
bool zigbee_is_pairing(void)
{
if (!s_pairing) {
return false;
}
/* Auto-timeout: stop the visual indicator after PAIRING_TIMEOUT_MS */
if ((xTaskGetTickCount() - s_pairing_start) > pdMS_TO_TICKS(PAIRING_TIMEOUT_MS)) {
s_pairing = false;
ESP_LOGW(TAG, "Pairing timed out");
return false;
}
return true;
}