Skip to content

About

Local Airthings Wave Plus BLE → Zigbee bridge for Home Assistant via ZHA with ESP32-C6

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Airthings BLE → Zigbee Bridge

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.


Just want to try it?

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.bin

Then follow the Home Assistant setup steps below. Welcome! 🎉


What it does

  • 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

Sensor entities per device (slot)

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

A note on "slots"

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.


Hardware

  • 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:

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.


Repository structure

├── 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

Building the firmware

Prerequisites

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.

Required configuration files

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.defaults file 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.

Build and flash

cd firmware
idf.py set-target esp32c6
idf.py build
idf.py flash
idf.py monitor

⚠️ ESP-IDF 5.5.x build issue

There 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 build

This equalizes all file timestamps and breaks the loop. Changes to .c files only are not affected.


Home Assistant setup

1. Install the ZHA quirk

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_quirks

Reload ZHA and check the logs for:

airthings_bridge.py: loaded OK

2. Pair the bridge

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.

3. Configure sensor slots

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.

4. Dashboard button (optional)

type: button
name: Configure Airthings Bridge
icon: mdi:bluetooth-transfer
tap_action:
  action: call-service
  service: python_script.set_airthings_slots

Architecture

Airthings 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

Payload layout

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

Stale detection

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.

Why the M:/L: configuration protocol?

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.


Zigbee network reset

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.


Limitations

  • 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)

Contributing

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.


Disclaimer

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. 🙂

License

MIT — see LICENSE for full text.

About

Local Airthings Wave Plus BLE → Zigbee bridge for Home Assistant via ZHA with ESP32-C6

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages