Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 29 additions & 107 deletions HOME_ASSISTANT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**.
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions firmware/main/button.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down
89 changes: 63 additions & 26 deletions firmware/main/zigbee.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────── */

Expand Down Expand Up @@ -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 };
Expand All @@ -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) ──────────────────────────────── */

Expand Down Expand Up @@ -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()) {
Expand All @@ -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);
}
Expand All @@ -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)) {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -668,18 +682,41 @@ 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");
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;
}
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);
Expand Down
8 changes: 8 additions & 0 deletions firmware/main/zigbee.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion z2m/aircube.js
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Loading