diff --git a/HOME_ASSISTANT.md b/HOME_ASSISTANT.md index 2d00a44..ce21d0a 100644 --- a/HOME_ASSISTANT.md +++ b/HOME_ASSISTANT.md @@ -216,122 +216,39 @@ Use this method if you prefer Zigbee2MQTT or already have it running. ## B5 -- Add the AirCube Converter -1. Open **File editor** (install from Add-on Store if needed). -2. Navigate to the `zigbee2mqtt` folder. -3. Create a new file called **`aircube.js`** and paste: - -```javascript -const {temperature, humidity} = require('zigbee-herdsman-converters/lib/modernExtend'); -const fz = require('zigbee-herdsman-converters/converters/fromZigbee'); -const exposes = require('zigbee-herdsman-converters/lib/exposes'); -const e = exposes.presets; - -const CUSTOM_CLUSTER_ID = 0xFC01; -const ATTR_ECO2 = 0x0000; -const ATTR_ETVOC = 0x0001; -const ATTR_AQI = 0x0002; - -const ANALOG_OUTPUT_CLUSTER = 'genAnalogOutput'; -const ATTR_PRESENT_VALUE = 0x0055; - -const fzAirCubeAirQuality = { - cluster: CUSTOM_CLUSTER_ID, - type: ['attributeReport', 'readResponse'], - convert: (model, msg, publish, options, meta) => { - const result = {}; - if (msg.data.hasOwnProperty(ATTR_ECO2)) { - result.eco2 = msg.data[ATTR_ECO2]; - } - if (msg.data.hasOwnProperty(ATTR_ETVOC)) { - result.voc = msg.data[ATTR_ETVOC]; - } - if (msg.data.hasOwnProperty(ATTR_AQI)) { - result.aqi = msg.data[ATTR_AQI]; - } - return result; - }, -}; - -const fzAirCubeBrightness = { - cluster: ANALOG_OUTPUT_CLUSTER, - type: ['attributeReport', 'readResponse'], - convert: (model, msg, publish, options, meta) => { - if (msg.data.hasOwnProperty('presentValue')) { - return {brightness: Math.round(msg.data.presentValue)}; - } - }, -}; - -const tzAirCubeBrightness = { - key: ['brightness'], - convertSet: async (entity, key, value, meta) => { - await entity.write(ANALOG_OUTPUT_CLUSTER, {presentValue: value}); - return {state: {brightness: value}}; - }, - convertGet: async (entity, key, meta) => { - await entity.read(ANALOG_OUTPUT_CLUSTER, ['presentValue']); - }, -}; - -const definition = { - zigbeeModel: ['AirCube'], - model: 'AirCube', - vendor: 'StuckAtPrototype', - description: 'AirCube air quality monitor', - extend: [ - temperature(), - humidity(), - ], - fromZigbee: [fzAirCubeAirQuality, fzAirCubeBrightness], - toZigbee: [tzAirCubeBrightness], - exposes: [ - e.numeric('eco2', exposes.access.STATE) - .withUnit('ppm') - .withDescription('Equivalent CO2 concentration') - .withValueMin(400) - .withValueMax(8192), - e.numeric('voc', exposes.access.STATE) - .withUnit('ppb') - .withDescription('Total volatile organic compounds') - .withValueMin(0) - .withValueMax(65535), - e.numeric('aqi', exposes.access.STATE) - .withUnit('') - .withDescription('Air Quality Index') - .withValueMin(0) - .withValueMax(500), - e.numeric('brightness', exposes.access.ALL) - .withDescription('LED brightness') - .withValueMin(0) - .withValueMax(100), - ], - configure: async (device, coordinatorEndpoint) => { - const endpoint = device.getEndpoint(10); - await endpoint.bind('msTemperatureMeasurement', coordinatorEndpoint); - await endpoint.bind('msRelativeHumidity', coordinatorEndpoint); - await endpoint.configureReporting('msTemperatureMeasurement', [{ - attribute: 'measuredValue', minimumReportInterval: 1, - maximumReportInterval: 60, reportableChange: 50, - }]); - await endpoint.configureReporting('msRelativeHumidity', [{ - attribute: 'measuredValue', minimumReportInterval: 1, - maximumReportInterval: 60, reportableChange: 100, - }]); - }, -}; - -module.exports = definition; -``` +The converter file format depends on your Zigbee2MQTT version: +- **Z2M 2.x** (2024+): Uses ES modules (`.mjs`) +- **Z2M 1.x** (legacy): Uses CommonJS (`.js`) + +Both converter files are in the [`z2m/`](z2m/) folder of this repo. + +### Z2M 2.x (Recommended) +1. Open **File editor** (install from Add-on Store if needed). +2. Navigate to the `zigbee2mqtt` folder and create an `external_converters` subfolder. +3. Copy [`z2m/aircube.mjs`](z2m/aircube.mjs) into the `external_converters` folder. 4. Open **`configuration.yaml`** in the `zigbee2mqtt` folder and add: ```yaml external_converters: - - aircube.js + - external_converters/aircube.mjs ``` 5. **Restart Zigbee2MQTT** from the add-on page. +### Z2M 1.x (Legacy) + +1. Open **File editor**. +2. Copy [`z2m/aircube.js`](z2m/aircube.js) into the `zigbee2mqtt` folder. +3. Open **`configuration.yaml`** in the `zigbee2mqtt` folder and add: + + ```yaml + external_converters: + - aircube.js + ``` + +4. **Restart Zigbee2MQTT** from the add-on page. + ## B6 -- Pair the AirCube 1. In the Zigbee2MQTT dashboard, click **Permit join (All)**. @@ -423,8 +340,13 @@ entities: - **ZHA:** Check that `custom_quirks_path` is set in `configuration.yaml` and the `aircube.py` file is in the right folder. The path in `configuration.yaml` must be `/config/custom_zha_quirks/` (not `/homeassistant/...`). Restart Home Assistant, then remove and re-pair the AirCube. - **ZHA (HA 2026.x):** The File editor shows the root as `/homeassistant/` instead of `/config/`. **Do not** create a new folder called `config` inside `/homeassistant/`. Place `custom_zha_quirks` directly inside `/homeassistant/`, next to `configuration.yaml`. The path in `configuration.yaml` should still say `/config/custom_zha_quirks/`. - **Firmware:** Make sure you are running the latest AirCube firmware from this repo. It actively sends attribute reports for the custom cluster so ZHA updates the sensors. +<<<<<<< HEAD - **Firmware version:** The device reports its build as the Zigbee Basic cluster **Software build ID** (`sw_build_id`, attribute `0x4000` on cluster `0x0000`, endpoint `10`). In ZHA you can read it under the device’s **Manage Zigbee device** UI. The string comes from ESP-IDF’s app version (`firmware/version.txt` at build time). - **Z2M:** Check that `external_converters` is in the Z2M `configuration.yaml` and `aircube.js` is in the `zigbee2mqtt` folder. Restart Zigbee2MQTT. +======= +- **Z2M 2.x:** Make sure you're using `aircube.mjs` (not `aircube.js`). Z2M 2.x requires ES module format. If Z2M renames the file to `aircube.mjs.invalid`, the converter has a load error — check the Z2M logs. +- **Z2M 1.x:** Check that `external_converters` is in the Z2M `configuration.yaml` and `aircube.js` is in the `zigbee2mqtt` folder. Restart Zigbee2MQTT. +>>>>>>> 631ce7a (Add Z2M 2.x external converter (.mjs) for AirCube) ### eCO2 / eTVOC / AQI values are stuck at 0 diff --git a/firmware/main/button.c b/firmware/main/button.c index 8371fa7..282595e 100644 --- a/firmware/main/button.c +++ b/firmware/main/button.c @@ -191,8 +191,9 @@ static void button_task(void *pvParameters) led_set_intensity(new_brightness); save_brightness_to_nvs(current_brightness_index); - - ESP_LOGI(TAG, "Short press – Brightness set to %.1f", new_brightness); + zigbee_report_brightness(); + + ESP_LOGI(TAG, "Short press – Brightness set to %.1f", new_brightness); } } } diff --git a/firmware/main/zigbee.c b/firmware/main/zigbee.c index 8ac6943..ed9d389 100644 --- a/firmware/main/zigbee.c +++ b/firmware/main/zigbee.c @@ -26,7 +26,6 @@ #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" @@ -67,12 +66,11 @@ static volatile bool s_rejoining = false; static TickType_t s_pairing_start = 0; 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 */ /* ── Helpers ─────────────────────────────────────────────────────────── */ @@ -100,6 +98,11 @@ static void init_sw_build_id(void) memcpy(&s_sw_build_id[1], app_desc->version, n); } +static float current_brightness_percent(void) +{ + return led_get_intensity() * 100.0f; +} + static void report_attr(uint16_t cluster_id, uint16_t attr_id) { esp_zb_zcl_report_attr_cmd_t report_cmd = { 0 }; @@ -119,6 +122,7 @@ static void report_attr(uint16_t cluster_id, uint16_t attr_id) /* ── 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) ──────────────────────────────── */ @@ -213,7 +217,6 @@ void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) 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()) { @@ -227,19 +230,13 @@ void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) } } else { ESP_LOGI(TAG, "Device rebooted – already commissioned"); - s_connected = true; - s_rejoining = false; - s_rejoin_backoff_ms = REJOIN_BACKOFF_INIT_MS; + s_connected = 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_LOGI(TAG, "Waiting for coordinator (%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_INITIALIZATION, 1000); } @@ -260,6 +257,8 @@ void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) s_pairing = false; s_rejoining = false; s_rejoin_backoff_ms = REJOIN_BACKOFF_INIT_MS; + 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)) { @@ -398,7 +397,7 @@ static esp_zb_cluster_list_t *create_cluster_list(void) /* ---- Analog Output cluster 0x000D (brightness, writable) ---- */ esp_zb_analog_output_cluster_cfg_t ao_cfg = { .out_of_service = false, - .present_value = 60.0f, + .present_value = current_brightness_percent(), .status_flags = 0, }; esp_zb_attribute_list_t *ao_cluster = @@ -498,6 +497,24 @@ static void configure_reporting(void) }; 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); + } static void apply_zigbee_tx_power(void) @@ -617,11 +634,8 @@ void zigbee_update_sensors(float temp_c, float humidity, int eco2, int etvoc, in 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; - } + /* Lock the Zigbee stack while writing attributes */ + esp_zb_lock_acquire(portMAX_DELAY); /* Standard clusters */ esp_zb_zcl_set_attribute_val(AIRCUBE_ENDPOINT, @@ -668,6 +682,33 @@ bool zigbee_is_connected(void) return s_connected; } +static void report_startup_brightness_cb(uint8_t unused) +{ + (void)unused; + + 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(); + esp_zb_lock_acquire(portMAX_DELAY); + 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"); @@ -675,11 +716,7 @@ void zigbee_start_pairing(void) 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; - } + esp_zb_lock_acquire(portMAX_DELAY); 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); diff --git a/firmware/main/zigbee.h b/firmware/main/zigbee.h index f61f020..e82e65a 100644 --- a/firmware/main/zigbee.h +++ b/firmware/main/zigbee.h @@ -82,6 +82,14 @@ void zigbee_start_pairing(void); */ bool zigbee_is_pairing(void); +/** + * @brief Immediately report the current LED brightness to the Zigbee coordinator. + * + * Writes the current brightness to the Analog Output cluster and sends + * an unsolicited attribute report. Safe to call from any task context. + */ +void zigbee_report_brightness(void); + #ifdef __cplusplus } #endif diff --git a/z2m/aircube.js b/z2m/aircube.js index 130214b..55a5e0b 100644 --- a/z2m/aircube.js +++ b/z2m/aircube.js @@ -1,5 +1,7 @@ /** - * Zigbee2MQTT External Converter for AirCube + * Zigbee2MQTT External Converter for AirCube (Z2M 1.x) + * + * This file uses CommonJS format for Z2M 1.x. For Z2M 2.x, use aircube.mjs instead. * * Place this file in your Zigbee2MQTT data directory and reference it * in configuration.yaml: diff --git a/z2m/aircube.mjs b/z2m/aircube.mjs new file mode 100644 index 0000000..79ce9f3 --- /dev/null +++ b/z2m/aircube.mjs @@ -0,0 +1,130 @@ +/** + * Zigbee2MQTT External Converter for AirCube (Z2M 2.x) + * + * Z2M 2.x requires ES module format (.mjs). For Z2M 1.x, use aircube.js instead. + * + * Place this file in your Zigbee2MQTT external_converters directory and + * reference it in configuration.yaml: + * + * external_converters: + * - external_converters/aircube.mjs + * + * Standard clusters (auto-handled by Z2M): + * - Temperature Measurement (0x0402) + * - Relative Humidity (0x0405) + * + * Custom cluster 0xFC01 attributes: + * 0x0000 = eCO2 (uint16, ppm) + * 0x0001 = eTVOC (uint16, ppb) + * 0x0002 = AQI (uint16, index) + */ + +import {temperature, humidity} from 'zigbee-herdsman-converters/lib/modernExtend'; +import * as exposes from 'zigbee-herdsman-converters/lib/exposes'; + +const e = exposes.presets; + +// Z2M 2.x requires the cluster ID as a string for custom (non-standard) clusters. +const CUSTOM_CLUSTER_ID = '64513'; // 0xFC01 + +const ATTR_ECO2 = 0x0000; +const ATTR_ETVOC = 0x0001; +const ATTR_AQI = 0x0002; + +const fzAirCubeAirQuality = { + cluster: CUSTOM_CLUSTER_ID, + type: ['attributeReport', 'readResponse'], + convert: (model, msg, publish, options, meta) => { + const result = {}; + if (msg.data.hasOwnProperty(ATTR_ECO2)) { + result.eco2 = msg.data[ATTR_ECO2]; + } + if (msg.data.hasOwnProperty(ATTR_ETVOC)) { + result.voc = msg.data[ATTR_ETVOC]; + } + if (msg.data.hasOwnProperty(ATTR_AQI)) { + result.aqi = msg.data[ATTR_AQI]; + } + return result; + }, +}; + +const fzBrightness = { + cluster: 'genAnalogOutput', + type: ['attributeReport', 'readResponse'], + convert: (model, msg, publish, options, meta) => { + if (msg.data.hasOwnProperty('presentValue')) { + return { brightness: Math.round(msg.data['presentValue']) }; + } + }, +}; + +const tzBrightness = { + key: ['brightness'], + convertSet: async (entity, key, value, meta) => { + const clamped = Math.min(100, Math.max(0, value)); + await entity.write('genAnalogOutput', { presentValue: clamped }); + return { state: { brightness: clamped } }; + }, + convertGet: async (entity, key, meta) => { + await entity.read('genAnalogOutput', ['presentValue']); + }, +}; + +const definition = { + zigbeeModel: ['AirCube'], + model: 'AirCube', + vendor: 'StuckAtPrototype', + description: 'AirCube air quality monitor', + extend: [ + temperature(), + humidity(), + ], + fromZigbee: [fzAirCubeAirQuality, fzBrightness], + toZigbee: [tzBrightness], + exposes: [ + e.numeric('eco2', exposes.access.STATE) + .withUnit('ppm') + .withDescription('Equivalent carbon dioxide concentration') + .withValueMin(400) + .withValueMax(8192), + e.numeric('voc', exposes.access.STATE) + .withUnit('ppb') + .withDescription('Total volatile organic compounds') + .withValueMin(0) + .withValueMax(65535), + e.numeric('aqi', exposes.access.STATE) + .withUnit('') + .withDescription('Air Quality Index') + .withValueMin(0) + .withValueMax(500), + e.numeric('brightness', exposes.access.ALL) + .withUnit('%') + .withDescription('LED brightness (0-100)') + .withValueMin(0) + .withValueMax(100), + ], + configure: async (device, coordinatorEndpoint) => { + const endpoint = device.getEndpoint(10); + /* Bind standard clusters */ + await endpoint.bind('msTemperatureMeasurement', coordinatorEndpoint); + await endpoint.bind('msRelativeHumidity', coordinatorEndpoint); + /* Configure reporting for standard clusters */ + await endpoint.configureReporting('msTemperatureMeasurement', [{ + attribute: 'measuredValue', minimumReportInterval: 1, + maximumReportInterval: 60, reportableChange: 50, + }]); + await endpoint.configureReporting('msRelativeHumidity', [{ + attribute: 'measuredValue', minimumReportInterval: 1, + maximumReportInterval: 60, reportableChange: 100, + }]); + /* Bind and configure reporting for brightness */ + await endpoint.bind('genAnalogOutput', coordinatorEndpoint); + await endpoint.configureReporting('genAnalogOutput', [{ + attribute: 'presentValue', minimumReportInterval: 1, + maximumReportInterval: 60, reportableChange: 5, + }]); + }, +}; + +export default definition;