A fully local bridge that reads Airthings Wave Plus air quality sensors over BLE and exposes them to Home Assistant via ZHA (Zigbee Home Automation) — no cloud, no subscription, no Airthings account required.
Built on an ESP32-C6, which has both a native BLE radio and an IEEE 802.15.4 (Zigbee) radio, making it uniquely suited for this bridge role.
A pre-built firmware binary is attached to the latest GitHub release. If you have an ESP32-C6 and don't want to set up an ESP-IDF toolchain, you can flash it directly using Espressif's web flasher or esptool:
pip install esptool
esptool.py --chip esp32c6 --port /dev/ttyUSB0 write_flash 0x0 airthings-bridge.binThen follow the Home Assistant setup steps below. Welcome! 🎉
- Reads up to 4 Airthings Wave Plus (2930) sensors over BLE every 5 minutes
- Exposes all sensor values to Home Assistant as standard ZHA entities
- Fully configurable from Home Assistant — no C code changes needed. Sensor MAC addresses and location names are set via a Python script that writes to the bridge over Zigbee
- Detects offline sensors and marks their HA entities as Unknown with visible history gaps — so you can tell the difference between stable air quality and a dead battery
- Stores configuration in NVM — survives reboots without reconfiguration
| Entity | Unit | Notes |
|---|---|---|
| Temperature | °C | |
| Humidity | % | |
| Pressure | hPa | |
| CO₂ | ppm | |
| VOC | ppb | |
| Radon (short-term) | Bq/m³ | Short-term average (as shown in Airthings app) |
| Radon (long-term) | Bq/m³ | Long-term average (as shown in Airthings app) |
| BLE MAC | — | Diagnostic |
| Location | — | Diagnostic, set by you |
The bridge supports up to 4 BLE devices. Each BLE device (physical Airthings sensor) occupies one slot — this is just the term used internally and in the HA entities (Slot 01, Slot 02, etc.) to identify which physical sensor the data comes from. Once you assign a location name ("Basement", "Bedroom") via the configuration script, that name appears in the diagnostic entities alongside the slot number.
- ESP32-C6 development board — tested with the Waveshare ESP32-C6-Zero. Any ESP32-C6 variant should work; the boot button on GPIO9 is used for network reset
- USB power or 5V rail
- No additional wiring — BLE and Zigbee radios are on-chip
Tested with:
- Airthings Wave Plus (model 2930)
- Waveshare ESP32-C6-Zero development board
Other Airthings Wave models may work if they share the same BLE GATT service UUID prefix (b42e1c08) and characteristic handle, but have not been tested. Please open an issue if you try one.
├── firmware/
│ ├── main/ ESP32-C6 firmware source (ESP-IDF)
│ │ ├── main.c App entry, Zigbee tasks, NVM, heartbeat
│ │ ├── bluetooth.c/h NimBLE BLE scanner and GATT reader
│ │ ├── zigbee_bridge.c/h Zigbee endpoint registration
│ │ ├── sensor_queue.h FreeRTOS queue message struct
│ │ ├── CMakeLists.txt
│ │ └── idf_component.yml
│ ├── sdkconfig.defaults Required — BLE + Zigbee build config
│ └── partitions.csv Required — custom 4MB flash layout
├── ha/
│ ├── quirks/
│ │ └── airthings_bridge.py ZHA custom quirk (zigpy v2 API)
│ └── python_scripts/
│ └── set_airthings_slots.py Slot configuration script
├── CHANGELOG.md
└── README.md
- ESP-IDF v5.5.x (minimum 5.3.2)
- ESP32-C6 target
The firmware depends on two Espressif component manager libraries declared in firmware/main/idf_component.yml:
dependencies:
espressif/esp-zigbee-lib: "^1.6.8"
espressif/esp-zboss-lib: "^1.6.4"
idf: ">=5.3.2"These are downloaded automatically by the ESP-IDF component manager on first build. No manual installation required.
Copy both files to the project root before building:
sdkconfig.defaults — enables the BLE + Zigbee dual-radio configuration. Without this, the build will either fail or produce a firmware that doesn't work. These settings took considerable trial and error to get right — don't skip this file.
partitions.csv — custom 4MB flash layout with dedicated partitions for Zigbee storage (zb_storage, zb_fct) and NVM sensor config (storage).
Both files are included in the repository root.
Note: The
sdkconfig.defaultsfile contains some Zigbee buffer tuning keys (ZB_BUF_POOL_SIZE,ZB_SCHEDULER_Q_SIZE,ZB_NWK_NEIGHBOUR_TABLE_SIZE) that are deprecated in newer ZBOSS versions and may generate warnings during the build. These warnings are harmless and can be ignored.
cd firmware
idf.py set-target esp32c6
idf.py build
idf.py flash
idf.py monitorThere is a bug in ESP-IDF 5.5.x where modifying any .h file causes the component manager to enter an infinite CMake re-run loop due to timezone-induced timestamp mismatches. If this happens, run:
find . -type f -exec touch {} +
rm -rf build
idf.py buildThis equalizes all file timestamps and breaks the loop. Changes to .c files only are not affected.
The quirk uses the zigpy v2 QuirkBuilder API. Copy ha/quirks/airthings_bridge.py to your HA custom quirks folder:
<config>/custom_zha_quirks/airthings_bridge.py
Ensure configuration.yaml has:
zha:
custom_quirks_path: /config/custom_zha_quirksReload ZHA and check the logs for:
airthings_bridge.py: loaded OK
Put your Zigbee coordinator into pairing mode and power on the ESP32-C6. It will join automatically. The device appears in ZHA as Airthings Bridge.
Copy ha/python_scripts/set_airthings_slots.py to:
<config>/python_scripts/set_airthings_slots.py
Add to configuration.yaml:
python_script:Edit the SLOTS list at the top of the script with your sensor MACs and location names. Enter MACs in standard AA:BB:CC:DD:EE:FF format — you can find them in the Airthings app under device settings:
SLOTS = [
{"mac": "AA:BB:CC:DD:EE:FF", "location": "Basement"}, # Slot 1 / EP10
{"mac": "11:22:33:44:55:66", "location": "Bedroom"}, # Slot 2 / EP11
{"mac": "", "location": ""}, # Slot 3 / EP12 — unused
{"mac": "", "location": ""}, # Slot 4 / EP13 — unused
]Run it once from Developer Tools → Actions → python_script.set_airthings_slots. Configuration is written to the bridge over Zigbee and stored in NVM — run it again only if you change sensors.
type: button
name: Configure Airthings Bridge
icon: mdi:bluetooth-transfer
tap_action:
action: call-service
service: python_script.set_airthings_slotsAirthings Wave Plus (BLE)
│
│ GATT read every 5 min
▼
ESP32-C6 (BLE radio)
│
│ FreeRTOS queue
▼
Zigbee send task
│
│ Custom ZCL cluster 0xFC00
│ 49-byte packed payload
▼
ESP32-C6 (802.15.4 / Zigbee 3.0 radio)
│
│ Zigbee mesh → coordinator
▼
ZHA (zigpy v2 quirk unpacks payload)
│
▼
Home Assistant entities
Bytes 0-27 7 × float32 LE: temp, hum, pressure, co2, voc, radon_st, radon_lt
Bytes 28-33 6 bytes raw BLE MAC address
Byte 34 Location name length
Bytes 35-47 Location name (max 13 bytes, zero-padded, ASCII)
Byte 48 Stale flag (0 = fresh, 1 = sensor offline)
Total: 49 bytes
The firmware tracks the last successful BLE read timestamp per slot. A heartbeat task runs every 5 minutes and sends a stale payload for any slot not heard from in 10 minutes. The ZHA quirk sets all measurement entities to Unknown when the stale flag is set — creating visible gaps in the HA history graph rather than silently showing stale data.
Espressif's Zigbee stack (ZBOSS) is closed-source and its handling of custom cluster attribute writes is limited. ZHA automatically marks writes to manufacturer-specific clusters (cluster IDs ≥ 0xFC00) as manufacturer-specific ZCL frames, which ZBOSS rejects unless the attribute was registered with a matching manufacturer code — something the ESP-IDF API does not currently support for custom clusters.
The workaround uses the standard Basic cluster's location_description attribute (0x0010), which ZBOSS accepts unconditionally. Prefixed strings (M: for MAC, L: for location name) are parsed by the firmware's attribute write handler. It's a clean, deliberate engineering solution to a closed-source stack limitation.
Triple-clicking the boot button (GPIO9) within 2 seconds between clicks triggers a Zigbee network reset: the device sends a ZDO Leave request, clears its Zigbee network credentials, and restarts. It will then attempt to rejoin — put your coordinator into pairing mode first.
Note: Slot configurations (sensor MACs and location names) stored in NVM are not erased by this operation. Only the Zigbee network credentials are cleared. To fully wipe the device including slot configs, trigger the reset and then re-run set_airthings_slots.py after rejoining — or connect the ESP32-C6 via USB and erase the NVM partition manually from your build machine via idf.py erase-flash.
- Maximum 4 sensors (4 Zigbee endpoints: EP10–EP13)
- Airthings Wave Plus (model 2930) confirmed; other models untested
- BLE poll interval hardcoded at 5 minutes
- Sensor MACs must be entered manually (Airthings does not advertise MACs in scan data)
Pull requests and issue reports are welcome. Please be kind — this is a hobby project. If you test with other Airthings models or find bugs, open an issue with as much detail as you can.
This project is an independent, community-driven hobby effort and is not affiliated with, endorsed by, or supported by Airthings AS in any way. Airthings is a trademark of Airthings AS.
The BLE communication protocol used by the Airthings Wave Plus was determined through community reverse engineering. Airthings has not officially documented this interface, and it may change in future firmware updates without notice. If your sensor stops working after an Airthings firmware update, please open an issue.
By using this project you agree that:
- You use it at your own risk. No warranty is provided, express or implied. The authors are not responsible for any damage to your devices, your Zigbee network, your Home Assistant installation, or anything else.
- You are responsible for regulatory compliance. Operating a Zigbee (IEEE 802.15.4) radio transmitter may be subject to local regulations. The ESP32-C6 is FCC/CE certified as a module, but you are responsible for ensuring your use complies with the laws of your jurisdiction.
- Reverse engineering notice. Reading sensor data from your own devices for personal home automation use is generally considered fair use in most jurisdictions. That said, check the terms of service of any relevant platform if you have concerns.
If you find a bug, please report it kindly. We're all hobbyists here, doing this for fun and for the community. 🙂
MIT — see LICENSE for full text.