Projects
Inventor Projects

AirCube: StuckAtPrototype's Open Air-Quality Monitor and Illustrated Build Guide

Added October 29, 2026
66 min read
An actual AirCube Pro sensor PCB seated in its open white printed enclosure during assembly
Image credit

mechanical/guide_images/pro_C.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

Before you build

StuckAtPrototype's original project and contributor credits are retained below. These nine complete repository documents cover the overview, photographed Base/Pro assembly, firmware updates, Home Assistant, community SmartThings integration, development, Bluetooth protocol and firmware/web builds. Assembly begins with a populated PCB and printed parts; it is not a ten-minute PCB fabrication tutorial. Base eCO2 is a VOC-derived estimate, not a direct carbon-dioxide measurement. Pro adds an SCD41 true-CO2 sensor. Preserve the source's warm-up and calibration limitations: a green LED is not proof that a room is safe, and this is not a medical or certified life-safety instrument. The pictured open board is the Pro model. Release versions and vendor instructions are pinned to the reviewed source revision, not a promise that every future Home Assistant or SmartThings release behaves identically. No creator endorsement of this republication is claimed.

More builds
Difficulty (editorial)
Intermediate
Documentation
Build guide
Hardware
ESP32, Environmental sensors

A populated Base or Pro PCB, printed enclosure parts, a data-capable USB cable and the exact integration's prerequisites.

The creator's complete assembly, firmware, Home Assistant and integration documentation, with real Base and Pro build photographs.

AirCube

See your air. AirCube is a desktop air quality monitor with built-in Home Assistant support over Zigbee or Bluetooth (BLE). Every model tracks temperature, humidity, eCO2, eTVOC, and VOC Level; AirCube Pro also measures true CO2 and ambient light. A glanceable LED shows the current air quality.

Works standalone out of the box. Pairs with Home Assistant in minutes. Other platforms are supported through community-contributed extensions.

AirCube Web -- Live readings, history and firmware updates in your browser. Nothing to install.

Watch the overview -- What AirCube does and why it's open source, in under three minutes.

StuckAtPrototype Labs -- Step-by-step video walkthroughs: ZHA setup, Zigbee2MQTT setup, and updating firmware from the browser.

AirCube -- Assembled AirCube

AirCube Populated PCB -- AirCube Populated PCB


Getting Started

1. Plug it in -- Connect the USB-C cable to any USB port or charger. AirCube powers on automatically.

2. Wait for warm-up -- The air quality sensor needs about 3 minutes to stabilize after power-on. During this time, the LED may not reflect accurate readings.

3. Read the color -- Once warmed up, the LED tells you everything:

LED ColorAir Quality
GreenGood
YellowModerate
OrangePoor
RedBad -- consider ventilating

The color shifts smoothly as conditions change. No app needed -- just glance at it.

Firmware 1.5.0 and above drive the LED from canonical VOC Level (TVOC-derived, absolute). The color is a smooth green-to-red gradient -- same feel as older firmware, but tied to fixed indoor-air bands instead of the relative AQI-S baseline. See LED Reference for the exact mapping. Firmware 1.4.3 and below used the same gradient shape, but driven by AQI-S instead.

4. Adjust brightness -- Press the button to cycle through brightness levels.

That's it. AirCube works out of the box with no setup, no accounts, and no Wi-Fi.


Base vs. Pro

Both models use the ScioSense ENS161 for VOC Level, eCO2, and eTVOC. Choose Pro when you also want a direct CO2 measurement and automatic LED dimming based on room light.

FeatureBasePro
VOC Level, eCO2, and eTVOCENS161ENS161
Temperature and humidityENS210Sensirion SCD41
True CO2--Sensirion SCD41 (NDIR)
Ambient light--VCNL4040
Automatic LED dimming--Yes
LED air-quality sourceVOC LevelWhichever is worse: VOC Level or true CO2

eCO2 vs. true CO2: eCO2 is an estimate derived from detected VOCs. The Pro's SCD41 measures CO2 directly using NDIR, while the ENS161 still detects odors and fumes that a pure CO2 sensor can miss.

What AirCube Measures

MeasurementRangeWhat It Tells You
VOC Level0 -- 500TVOC-derived score against fixed indoor-air bands (firmware 1.5.0+; 0--500 scale in 1.5.1+)
AQI-S (deprecated)--Legacy relative VOC score. As of firmware 1.5.0 this always reports 0 over USB serial -- kept only for compatibility. Use VOC Level instead.
eCO2 (equivalent CO2)400 -- 65,000 ppmEstimated CO2 level derived from detected VOCs
CO2 (Pro only)400 -- 5,000 ppmTrue CO2 concentration from the SCD41 (direct NDIR measurement)
eTVOC (equivalent Total VOC)0 -- 65,000 ppbTotal volatile organic compound concentration
TemperatureRoom temperature in Celsius
Humidity0 -- 100 %Relative humidity percentage
Illuminance (Pro only)Ambient light in lux; also used to dim the LED automatically

Understanding the readings

AirCube Base uses a ScioSense ENS161 gas sensor and an ENS210 temperature/humidity sensor. AirCube Pro uses the ENS161 alongside an SCD41 for true CO2, temperature, and humidity, plus a VCNL4040 ambient light sensor.

eCO2 (ppm) -- Estimated CO2 derived from detected VOCs, not a direct CO2 measurement. Useful for judging ventilation; also picks up odors and fumes a pure CO2 sensor would miss.

eTVOC (ppb) -- Total volatile organic compounds in the air. Spikes after cooking or cleaning are normal; sustained high readings mean you should ventilate.

VOC Level (0--500) -- TVOC mapped to a fixed indoor-air scale (firmware 1.5.1+). Linear ramp between band edges; TVOC-only (eCO2 does not affect VOC Level). Reported over Zigbee and USB serial.

RatingLED colorVOC LevelTVOC (ppb)
ExcellentGreen0 -- 150 -- 65
GoodGreen → lime15 -- 5065 -- 220
ModerateLime → yellow50 -- 100220 -- 650
PoorYellow → orange → red100 -- 200650 -- 2,200
UnhealthyRed200 -- 5002,200 -- 5,500

On Base hardware, the LED color is derived from TVOC/VOC Level alone (eCO2 does not drive the LED). VOC Level is linear between band edges, so the LED fades smoothly. See below for Pro's CO2 arbitration.

AQI-S (deprecated, USB serial only) -- A legacy ScioSense relative score from firmware before 1.5.0. As of firmware 1.5.0, AQI-S is deprecated and always reports 0 -- it's kept in the serial output only for compatibility with older tools. Use VOC Level, eCO2/eTVOC, or true CO2 (Pro) instead.

VOC Level drives the LED color on Base hardware. On Pro, the LED shows whichever is worse -- VOC Level or CO2 Level (from the true CO2 reading). See LED Reference.

Warm-up and initial start-up

The ENS161 needs about 3 minutes of warm-up in standard mode before readings stabilize. On the very first power-on of a new sensor the initial start-up takes about 1 hour as the sensor conditions itself. Readings during these periods may be inaccurate -- the LED and status flag will indicate when the sensor is ready.


Built-in vs. community

Maintained by StuckAtPrototype: firmware, hardware, desktop app, and the Home Assistant integration documented in HOME_ASSISTANT.md.

Community-contributed: integrations built and shared by the community. They live in this repo and are welcome, but StuckAtPrototype does not test or ship them. They may require extra setup and can break when a vendor updates their platform. See Community extensions.


Home Assistant Integration

AirCube was designed for Home Assistant. It connects over Zigbee -- no USB cable to your server, no cloud, no Wi-Fi credentials to configure. Plug it in, hold the button for 3 seconds to start pairing, and six core entities show up: temperature, humidity, eCO2, eTVOC, VOC Level, and brightness.

AirCube Pro adds two more: true CO2 (SCD41) and illuminance (VCNL4040). Both are exposed in ZHA and Zigbee2MQTT 2.x -- eight entities total on a Pro.

Once connected you can:

  • Track air quality over time with built-in history graphs
  • Set up automations -- turn on a fan when eCO2 gets too high, send a notification when VOC Level spikes
  • Monitor every room -- each AirCube pairs independently, name them however you like

You'll need: a Zigbee coordinator dongle (we recommend the SONOFF ZBDongle-E, ~$13) plugged into your Home Assistant machine.

Works with ZHA (built-in) and Zigbee2MQTT.

Video walkthroughs: Set up AirCube with ZHA · Set up AirCube with Zigbee2MQTT

Full setup guide: Connecting AirCube to Home Assistant

No Zigbee dongle? AirCube also works over Bluetooth

AirCube boots into BLE mode by default (until you pair it to Zigbee) and broadcasts standard BTHome data -- temperature, humidity, CO2, and TVOC. If your Home Assistant instance has a Bluetooth adapter or an ESPHome Bluetooth proxy nearby, it can often pick up these readings passively, with no pairing step and no Zigbee hardware at all. Building a custom BLE client? See the BLE GATT Protocol reference.


Community extensions

The integrations below are community-contributed. They are not maintained by StuckAtPrototype and compatibility with vendor hub or app updates is not guaranteed.

SmartThings (Samsung Zigbee hub) — community-contributed

Some users run AirCube on a Samsung SmartThings Zigbee hub over Zigbee (no Wi-Fi configuration on the device). The hub must support SmartThings Edge.

By default, the SmartThings app may only show temperature and humidity until you install the community AirCube Zigbee Edge driver from this repository and assign it to the device.

Full setup guide: Connecting AirCube to SmartThings (pairing, SmartThings CLI, driver channel, verification in the app and Advanced Web App).

Troubleshooting: If you only see temperature and humidity, install the AirCube Zigbee Edge driver from smartthings/aircube-zigbee/ and assign it to the device. Details are in SMARTTHINGS.md.


Connect to Your Computer

Plug the AirCube into your computer with a data-capable USB-C cable to see live readings, charts, and history.

In your browser -- nothing to install

AirCube Web connects over USB straight from Chrome or Edge: live readings, LED brightness, history with CSV export, and firmware updates. Click Connect, pick your cube, and you're in.

Download the app

Check the Releases page for a ready-to-run Windows .exe -- no install required.

Or run from source

git clone https://github.com/StuckAtPrototype/AirCube.git
cd AirCube/scripts
pip install -r requirements.txt
python aircube_app.py

Select your serial port, click Connect, and you'll see live data.

Tip: Prefer a minimal taskbar-only view? See the companion AirCube Tray repo -- a lightweight Windows system-tray app that shows VOC Level as a live, color-coded number in your taskbar. It ships its own installer.


Firmware Updates

The current firmware is v2.0.7. It keeps the cube responsive when plugged into a charger or closed port, and on Pro units it disables SCD41 automatic self-calibration (replaced by a 425 ppm fresh-air calibration) and detects a CO2 channel that has stopped reporting. See the v2.0.7 release notes.

Flash it now with AirCube Web or read the Firmware Update Guide for step-by-step instructions.

Video walkthrough: Updating the AirCube is easy (web UI) -- under two minutes, start to finish.

All releases: GitHub Releases


Assembling Your AirCube

Building one yourself from printed parts and a populated PCB? All you need is three M2 x 5 mm screws and a small Phillips screwdriver -- about ten minutes of work.

Assembly Guide -- photo walkthrough for both Base and Pro.

Printable enclosure files live in mechanical/base/ and mechanical/pro/.


LED Reference

Firmware 1.5.0 and above

The LED is a continuous green-to-red gradient driven by canonical VOC Level (TVOC-derived). The hue moves linearly with VOC Level: pure green up to VOC Level 10, then fading green → lime → yellow → orange → red, reaching full red at VOC Level 200. eCO2 does not affect the LED.

flowchart LR
    A["VOC Level 0–10<br/>TVOC 0–~43 ppb"] --> G["Steady green"]
    B["VOC Level 10–200<br/>TVOC ~43–2 200 ppb"] --> GR["Green → lime → yellow → orange → red"]
    C["VOC Level 200+<br/>TVOC 2 200+ ppb"] --> R["Steady red"]
LED colorVOC LevelTVOC (ppb)Rating
Steady green0 -- 100 -- ~43Excellent
Green → lime10 -- 50~43 -- 220Good
Lime → yellow50 -- 100220 -- 650Moderate
Yellow → orange → red100 -- 200650 -- 2 200Poor
Steady red200+2 200+Unhealthy
Flashing blue----Zigbee pairing mode

Key gradient landmarks: yellow around VOC Level 105 (~730 ppb) and orange around VOC Level 150 (~1,460 ppb). Over Zigbee, TVOC-derived VOC Level is reported alongside eCO2, eTVOC, temperature, humidity, and brightness. AQI-S is deprecated, always reports 0, and does not drive the LED.

On Pro hardware, the LED shows whichever reading is worse: VOC Level (as above) or CO2 Level, a 0--500 score derived from the true SCD41 CO2 reading using the same band scale. If CO2 Level is higher than VOC Level, the LED reflects the CO2 reading instead. On Base hardware, CO2 Level is always 0, so VOC Level always drives the LED.

Firmware 1.4.3 and below (legacy)

Same gradient shape as above, but driven by AQI-S (relative, 24-hour baseline) instead of canonical VOC Level:

LEDMeaning
Steady greenGood air quality (AQI-S 0--10)
Yellow through redDegrading to poor air quality (AQI-S 10--200)
Steady redPoor air quality (AQI-S 200+)
Flashing blueZigbee pairing mode

Button

ActionWhat It Does
Short pressCycle brightness (off, 10%, 30%, 60%, 100%)
Hold 3 secondsEnter Zigbee pairing mode

Troubleshooting

LED doesn't turn on

  • Make sure the USB-C cable is firmly connected and the power source is active.
  • Try a different USB port or charger.

Readings seem wrong right after power-on

  • Normal. The air quality sensor needs about 3 minutes to warm up. Readings will stabilize.

Computer doesn't detect AirCube

  • Some USB cables are charge-only. Use a cable that supports data.
  • Windows users may need to install USB drivers.
  • Linux users: add yourself to the dialout group and re-login.

Home Assistant: eCO2, TVOC, or VOC Level sensors are missing

  • The custom quirk or converter isn't loaded yet. See the Home Assistant guide for step-by-step instructions.

Home Assistant: CO2 or Illuminance is missing (Pro)

  • These come from standard Zigbee clusters, so ZHA and Zigbee2MQTT 2.x pick them up without any extra configuration. If they're absent, confirm the unit is a Pro (Base has no SCD41 or VCNL4040), then remove and re-pair so the coordinator re-reads the endpoint. The legacy Zigbee2MQTT 1.x converter does not expose them.

Home Assistant: AirCube won't pair

  • Make sure permit join is enabled in ZHA or Zigbee2MQTT.
  • Hold the button for 3 seconds to enter pairing mode (LED flashes blue).
  • Move AirCube closer to the coordinator during pairing.

Open Source

AirCube is fully open source -- firmware, PCB design, enclosure, desktop software, and Home Assistant integration. Community-contributed integrations (see Community extensions) also live in this repository. Everything is under the Apache 2.0 license.

Developers and makers: See the Contributing Guide for build instructions, architecture docs, serial protocol reference, and how to submit changes. Curious about the PCB? These Holes Are Why My Circuit Board Works explains the cutouts in the board.

StuckAtPrototype LabsVideo walkthroughs: ZHA, Zigbee2MQTT, firmware updates
Contributing GuideBuild from source, firmware architecture, serial protocol, how to contribute
Assembly GuideAssemble the printed enclosure -- Base and Pro
BLE GATT ProtocolBLE GATT protocol reference for building custom BLE clients
Firmware Update GuideUpdate your AirCube firmware from a browser
AirCube WebThe browser app: Web Serial monitoring, settings and flashing
Home Assistant GuideZHA and Zigbee2MQTT setup
Samsung hub integrationCommunity-contributed: Edge driver, CLI setup (see Community extensions)
GitHub IssuesBug reports and feature requests
LicenseApache 2.0

Assembling Your AirCube

This guide walks through putting an AirCube together from printed parts and a populated PCB -- Base first, then Pro. Each model takes about ten minutes with a single screwdriver.

You'll need a populated AirCube PCB. If you don't have one, grab the AirCube Populated PCB or build one from the KiCad files in kicad/.

Already assembled? Head back to Getting Started.


What You Need

ItemNotes
3x M2 x 5 mm screwsSelf-tapping, for plastic. Longer screws work too -- they just bottom out deeper.
Small Phillips screwdriverPH0 or similar. Hand-driven, no power tools.
Populated AirCube PCBBase or Pro.
All printed parts for your modelSee below.

Printed parts

Base -- files in mechanical/base:

  • AirCube bottom_prod_12-11.step -- bottom shell, holds the PCB
  • AirCube top 20mm PROD.step -- top shell
  • AirCube air wall 11-7-43.3mf -- air wall, separates the sensor from the rest of the cube

Pro -- files in mechanical/pro:

  • AirCube bottom_prod_12-11.step -- bottom shell, holds the PCB
  • AirCube_top-7-16-26-716.stl -- top shell
  • AirCube_air_wall_7-14-26-1149.stl -- air wall
  • AirCube_light_wall-7-10-26-1034.stl -- light wall, shapes the LED glow
  • AirCube_button_7-15-26-514.stl -- button cap

Print everything before you start. A missing air wall means taking the whole cube apart again.


Base Assembly

1. Gather your screws

Three M2 x 5 mm self-tapping screws. Longer screws are fine.

2. Print all the parts

Bottom, top, and air wall -- all three on the bench before you begin.

Base printed parts and screws laid out
Base printed parts and screws laid out
Image credit

mechanical/guide_images/base_A.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

3. Drop in the PCB, USB-C end first

Angle the board so the USB-C connector slides into its opening in the bottom shell, then lower the rest of the board flat into the pocket.

PCB seated in the bottom shell with the USB-C port through its opening
PCB seated in the bottom shell with the USB-C port through its opening
Image credit

mechanical/guide_images/base_B.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

4. Fit the front screw only

The bottom shell has three screw holes. Drive only the one nearest the front edge for now -- it holds the PCB down and sets up the alignment for the air wall.

Underside of the bottom shell showing the three screw holes
Underside of the bottom shell showing the three screw holes
Image credit

mechanical/guide_images/base_C.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

Leave the other two holes empty until the top is on.

Front screw driven, air wall standing by
Front screw driven, air wall standing by
Image credit

mechanical/guide_images/base_D.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

5. Install the air wall

Drop the air wall into the cutout as pictured. It should sit flat against the PCB with no gap along the side wall.

Air wall seated against the PCB
Air wall seated against the PCB
Image credit

mechanical/guide_images/base_E.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

6. Install the top

Lower the top onto the bottom. Line it up so the USB-C opening at the back stays fully clear -- if the port is pinched or partly covered, lift the top and reseat it.

Top installed with the USB-C opening clear and the screw holes accessible
Top installed with the USB-C opening clear and the screw holes accessible
Image credit

mechanical/guide_images/base_F.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

7. Secure the first screw

Drive the front screw home. Snug, not tight -- these are self-tapping screws in plastic and it's easy to strip the thread.

8. Install the other two screws

Same treatment for the remaining two holes. Done.

Finished Base AirCube
Finished Base AirCube
Image credit

mechanical/guide_images/base_G.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.


Pro Assembly

Pro adds two parts to the Base sequence: a button cap and a light wall.

1. Gather your screws

Three M2 x 5 mm self-tapping screws. Longer screws are fine.

2. Print all the parts

Bottom, top, air wall, light wall, and button cap.

Pro printed parts, button cap and screws laid out
Pro printed parts, button cap and screws laid out
Image credit

mechanical/guide_images/pro_A.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

3. Put the button into the enclosure

Drop the button cap into its slot in the bottom shell from the inside. It goes in before the PCB -- the board is what holds it captive.

Bottom shell interior with the button slot
Bottom shell interior with the button slot
Image credit

mechanical/guide_images/pro_B.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

4. Drop in the PCB, USB-C end first

Angle the board so the USB-C connector slides into its opening, then lower it flat. Check the button cap is still seated and moves freely against the switch.

PCB seated in the bottom shell, button cap captured
PCB seated in the bottom shell, button cap captured
Image credit

mechanical/guide_images/pro_C.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

5. Fit the front screw only

Drive only the screw nearest the front edge. It holds the PCB down and sets up the alignment for the air wall.

Front screw driven, remaining holes empty
Front screw driven, remaining holes empty
Image credit

mechanical/guide_images/pro_E.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

6. Install the light wall

Slide the light wall into its pocket along the side wall, as pictured.

Light wall seated in its pocket along the side wall
Light wall seated in its pocket along the side wall
Image credit

mechanical/guide_images/pro_D.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

7. Install the air wall

Drop the air wall into the cutout as pictured. It should sit flat with no gap along the side wall.

Air wall seated against the PCB
Air wall seated against the PCB
Image credit

mechanical/guide_images/pro_F.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

8. Install the top

Lower the top on, lined up so the USB-C opening at the back stays fully clear.

Top installed with the USB-C opening clear and the three screw holes accessible
Top installed with the USB-C opening clear and the three screw holes accessible
Image credit

mechanical/guide_images/pro_G.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.

9. Secure the first screw

Drive the front screw home. Snug, not tight -- self-tapping screws strip plastic threads easily.

10. Install the other two screws

Same treatment for the remaining two holes. Done.

Finished Pro AirCube
Finished Pro AirCube
Image credit

mechanical/guide_images/pro_H.jpg

StuckAtPrototype, AirCube contributors · Apache-2.0

Resized without cropping and converted to WebP.


Tips and Troubleshooting

Don't overtighten. Every screw here is a self-tapping screw biting into printed plastic. Stop as soon as it feels snug -- another quarter turn is how you strip the hole.

Check the USB-C opening before you drive any screws. With the top resting in place, look down the back face. The port should be fully exposed. Clamping a misaligned top down with screws can crack the shell.

Air wall won't sit flush? Back the front screw out a turn or two, reseat the air wall, then retighten. The front screw sets PCB alignment, so a board that's sitting slightly proud leaves the wall standing off.

Button feels stuck (Pro)? Take the top off and check the cap is square in its slot. It should have a little free travel and click against the switch on the board.

Stripped a screw hole? A slightly longer M2 screw usually bites into fresh plastic deeper down.


Next Steps

Getting StartedPower it up and read the LED
Firmware Update GuideFlash the latest firmware from your browser
Home Assistant GuideZHA and Zigbee2MQTT setup
Contributing GuideBuild from source, hardware and firmware architecture

AirCube Firmware Update Guide

Update your AirCube from the browser with AirCube Web. It talks to the cube over USB using the Web Serial API, so there is nothing to install -- and once you are connected you also get live readings, the LED brightness control and your history, the same as the desktop app.

Prefer to watch? Updating the AirCube is easy (web UI) shows the whole process in under two minutes.

Prerequisites

  • Browser: Google Chrome or Microsoft Edge (Safari and Firefox do not support Web Serial)
  • OS: Windows, macOS, Linux, or ChromeOS
  • Cable: A data-capable USB-C cable (charge-only cables won't work)

Step-by-Step

1) Open AirCube Web

Go to stuckatprototype.github.io/AirCube.

2) Connect your AirCube

  1. Plug the USB-C cable into your AirCube and your computer.
  2. Close anything else that might be holding the serial port (AirCube Tray, Arduino Serial Monitor, idf.py monitor).
  3. Click + Connect and pick your AirCube from the browser's device prompt. It appears as "USB JTAG/serial debug unit" or similar.

With one cube connected the app opens straight to its page and starts showing live readings.

3) Flash the firmware

  1. Open the ⋯ menu on the device page and choose Flash firmware. (You can also reach it from Settings → Firmware.)
  2. Pick the version you want. The newest release is marked (latest).
  3. Leave the flash offset at 0x0 (merged release image).
  4. Click Flash and watch the log. It takes about 30 seconds.

The cube reboots itself when the write finishes and reconnects automatically. The LED should light up green after a few seconds.

Your settings -- brightness level and Zigbee pairing -- are preserved across firmware updates.

Flashing a development build

If you built the firmware yourself with idf.py build, you have two options:

  • Flash build/AirCube.bin (the app only) at offset 0x10000.
  • Or run esptool merge-bin first and flash the merged image at 0x0.

Use the Local .bin button in the flash dialog to pick the file, then choose the matching offset.

Troubleshooting

  • No device in the connect dialog: Make sure another app isn't using the serial port. Try a different USB port or cable. On some USB-C cables, flipping the connector helps.
  • Browser not supported: Use Chrome or Edge on a desktop OS. Safari and Firefox do not implement Web Serial, and iOS browsers cannot access USB devices at all.
  • "Failed to open serial port": Another tab or application already has it. Close AirCube Tray and any serial monitors, then click Refresh in the dialog.
  • Flashing fails partway: Unplug the AirCube, plug it back in, and try again. The bootloader is only reached at reset, so a fresh power cycle usually clears it.
  • Charge-only cable: Some USB-C cables carry power but no data. Use the same cable that works with the desktop app.
  • Linux: If the device never appears, your user may need permission for the ESP USB device. Add a udev rule for 303a:1001 or add yourself to the dialout group, then replug.
  • The cube doesn't reconnect after flashing: Unplug and replug the USB-C cable, then click Connect again.
  • "Firmware update needed" banner, brightness and history unavailable: The cube is running firmware from before February 2026 (shipped through January 2026). That firmware streams readings over USB but does not listen for commands on it, so only live readings work until it is updated. Click Update firmware on the banner; flashing works on these units because the bootloader, not the firmware, handles the transfer. If the flash does not start, unplug the cube, plug it back in and click Update firmware again straight away. AirCube Tray can hang on these units; use the web app to update them.

Alternatives

  • AirCube Tray -- the Windows desktop app, which can also flash over USB.
  • ESP Launchpad -- Espressif's generic web flasher. Use the DIY tab, add the .bin from Releases, and set the flash address to 0x0.
  • esptool -- esptool --chip esp32h2 -p PORT write-flash 0x0 AirCube_firmware_vX.Y.Z.bin

Success

After it reboots, the AirCube LED lights up and begins showing air quality colors within a few seconds. The gas sensor needs about 3 minutes to warm up before readings stabilize.

Connecting AirCube to Home Assistant

This guide walks you through adding your AirCube air quality monitor to Home Assistant over Zigbee. After setup, you'll have live temperature, humidity, eCO2, eTVOC, and VOC Level readings plus a brightness slider in your smart home dashboard. On an AirCube Pro you also get true CO2 and illuminance.

The AirCube works with both ZHA (built-in) and Zigbee2MQTT. Pick whichever you already use. If you're starting fresh, ZHA is simpler.

Prefer to follow along? Both methods have a step-by-step video on the StuckAtPrototype Labs channel:

A note on names. Home Assistant renamed a couple of things in 2026.2, so the menus below are listed with both names -- use whichever your version shows:

HA 2026.2 and newerHA 2026.1 and earlier
Apps / App StoreAdd-ons / Add-on Store
Settings > ZigbeeSettings > Devices & Services > ZHA

Nothing functional changed -- only the labels. The integration is still called Zigbee Home Automation (ZHA) in the Add Integration list.


What You Need

  • AirCube -- powered via USB-C
  • Zigbee coordinator dongle -- plugs into your Home Assistant machine
  • Home Assistant -- running on any supported hardware (Raspberry Pi, mini PC, etc.)

Any Zigbee 3.0 coordinator works. If you don't have one yet, the SONOFF ZBDongle-E is the easiest to get started with (~$13).

DongleNotes
SONOFF ZBDongle-EBest value, widely available
SONOFF ZBDongle-PProven, large community
ConBee II / IIIAlso works, popular alternative

Use this method if you're using Home Assistant's built-in Zigbee Home Automation integration (the default). No extra apps (add-ons) required.

Video: AirCube Home Assistant ZHA Walkthrough covers every step below.

A1 -- Set Up ZHA

If you already have ZHA running with your coordinator, skip to A2.

  1. Plug your Zigbee coordinator dongle into your Home Assistant machine.
  2. Go to Settings > Devices & Services > Add Integration.
  3. Search for Zigbee Home Automation (ZHA) and add it.
  4. Select your coordinator from the serial port list and follow the prompts.

A2 -- Add the AirCube Quirk

The AirCube uses a custom Zigbee cluster (0xFC01) for air quality data and a standard Analog Output cluster (0x000D) for LED brightness. The quirk below tells ZHA to create sensor entities for eCO2, eTVOC, and VOC Level, plus a brightness slider (0--100%).

  1. Install the File editor app (add-on) if you don't have it:

    • Settings > Apps > App Store (Settings > Add-ons > Add-on Store on HA 2026.1 and earlier) -- search File editor, install, start it.
  2. Open File editor from the sidebar.

  3. Create a folder called custom_zha_quirks next to your configuration.yaml.

    Where do I create it? Open the File editor and look for configuration.yaml. On HA 2026.x it's in /homeassistant/, on HA 2025.x and earlier it's in /config/. Create custom_zha_quirks in whichever folder contains your configuration.yaml. Do not create a new folder called config -- just put custom_zha_quirks directly alongside configuration.yaml.

  4. Inside custom_zha_quirks, create a new file called aircube.py and paste this content. This is kept in sync with zha/aircube.py in this repo -- if you'd rather not copy/paste, you can grab the file directly from there instead.

"""StuckAtPrototype AirCube air quality monitor quirk for ZHA.

This single file is compatible with both old and new Home Assistant:

  * Modern HA (zigpy >= 0.65.1, i.e. HA >= ~2024.8):
        Full quirks v2 support. Exposes eCO2, tVOC and AQI sensors from the
        custom 0xFC01 cluster plus a Brightness number on the Analog Output
        cluster.

  * Old HA (zigpy < 0.65, e.g. HA 2024.1.x):
        `zigpy.quirks.v2` does not exist yet, so importing QuirkBuilder raises
        ImportError and the whole quirk fails to load. We fall back to a classic
        v1 quirk so the module imports cleanly and the device is named correctly.

        IMPORTANT: on these old ZHA versions there is no supported way to turn
        custom-cluster (0xFC01) attributes into entities -- eCO2/tVOC/AQI will
        NOT appear until HA is updated to a version with quirks v2, or the values
        are read over BLE via the firmware's BTHome broadcaster. Temperature,
        humidity and brightness still work on old HA because they use standard
        Zigbee clusters and are discovered automatically.
"""

from zigpy.quirks import CustomCluster
from zigpy.zcl.foundation import ZCLAttributeDef
import zigpy.types as t


class AirQualityCluster(CustomCluster):
    """AirCube custom air quality cluster (0xFC01) — read-only sensors."""

    cluster_id = 0xFC01
    name = "AirCube Air Quality"
    ep_attribute = "aircube_air_quality"

    class AttributeDefs(CustomCluster.AttributeDefs):
        eco2 = ZCLAttributeDef(
            id=0x0000, type=t.uint16_t, is_manufacturer_specific=False
        )
        etvoc = ZCLAttributeDef(
            id=0x0001, type=t.uint16_t, is_manufacturer_specific=False
        )
        aqi = ZCLAttributeDef(
            id=0x0002, type=t.uint16_t, is_manufacturer_specific=False
        )


ANALOG_OUTPUT_CLUSTER_ID = 0x000D


# ---------------------------------------------------------------------------
# Detect whether this HA/zigpy version supports the quirks v2 API.
# ---------------------------------------------------------------------------
try:
    from zigpy.quirks.v2 import QuirkBuilder
    from zigpy.quirks.v2.homeassistant import EntityType

    try:
        from zigpy.quirks.v2.homeassistant.sensor import (
            SensorDeviceClass,
            SensorStateClass,
        )
    except ImportError:
        from homeassistant.components.sensor import (
            SensorDeviceClass,
            SensorStateClass,
        )

    _HAS_QUIRKS_V2 = True
except ImportError:
    _HAS_QUIRKS_V2 = False


if _HAS_QUIRKS_V2:
    # -----------------------------------------------------------------------
    # Modern HA: full quirks v2 definition.
    # -----------------------------------------------------------------------
    (
        QuirkBuilder("StuckAtPrototype", "AirCube")
        .replaces(AirQualityCluster, endpoint_id=10)
        .sensor(
            AirQualityCluster.AttributeDefs.eco2.name,
            AirQualityCluster.cluster_id,
            endpoint_id=10,
            unit="ppm",
            translation_key="equivalent_co2",
            state_class=SensorStateClass.MEASUREMENT,
            fallback_name="Equivalent CO2",
        )
        .sensor(
            AirQualityCluster.AttributeDefs.etvoc.name,
            AirQualityCluster.cluster_id,
            endpoint_id=10,
            unit="ppb",
            device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
            state_class=SensorStateClass.MEASUREMENT,
            fallback_name="tVOC",
        )
        .sensor(
            AirQualityCluster.AttributeDefs.aqi.name,
            AirQualityCluster.cluster_id,
            endpoint_id=10,
            state_class=SensorStateClass.MEASUREMENT,
            translation_key="voc_level",
            fallback_name="VOC Level",
        )
        .number(
            "present_value",
            ANALOG_OUTPUT_CLUSTER_ID,
            endpoint_id=10,
            min_value=0,
            max_value=100,
            step=1,
            mode="slider",
            entity_type=EntityType.STANDARD,
            translation_key="brightness",
            fallback_name="Brightness",
        )
        .add_to_registry()
    )
else:
    # -----------------------------------------------------------------------
    # Old HA (no quirks v2): classic v1 fallback.
    #
    # This keeps the module importable (stops the ImportError crash) and names
    # the device. Temperature, humidity and brightness are exposed by ZHA's
    # standard discovery. The eCO2/tVOC/AQI values on cluster 0xFC01 cannot be
    # surfaced as entities on this ZHA version -- update HA, or read them over
    # BLE (BTHome), for those.
    # -----------------------------------------------------------------------
    from zigpy.quirks import CustomDevice
    from zigpy.profiles import zha
    from zigpy.zcl.clusters.general import AnalogOutput, Basic, Identify
    from zigpy.zcl.clusters.measurement import (
        RelativeHumidity,
        TemperatureMeasurement,
    )
    from zhaquirks.const import (
        DEVICE_TYPE,
        ENDPOINTS,
        INPUT_CLUSTERS,
        MODELS_INFO,
        OUTPUT_CLUSTERS,
        PROFILE_ID,
    )

    class AirCube(CustomDevice):
        """AirCube v1 quirk for HA versions without quirks v2."""

        signature = {
            MODELS_INFO: [("StuckAtPrototype", "AirCube")],
            ENDPOINTS: {
                # <SimpleDescriptor endpoint=10 profile=260 device_type=770
                #  input_clusters=[0, 3, 13, 1026, 1029, 64513]
                #  output_clusters=[]>
                10: {
                    PROFILE_ID: zha.PROFILE_ID,
                    DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR,
                    INPUT_CLUSTERS: [
                        Basic.cluster_id,
                        Identify.cluster_id,
                        AnalogOutput.cluster_id,
                        TemperatureMeasurement.cluster_id,
                        RelativeHumidity.cluster_id,
                        AirQualityCluster.cluster_id,
                    ],
                    OUTPUT_CLUSTERS: [],
                },
            },
        }

        replacement = {
            ENDPOINTS: {
                10: {
                    PROFILE_ID: zha.PROFILE_ID,
                    DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR,
                    INPUT_CLUSTERS: [
                        Basic,
                        Identify,
                        AnalogOutput,
                        TemperatureMeasurement,
                        RelativeHumidity,
                        AirQualityCluster,
                    ],
                    OUTPUT_CLUSTERS: [],
                },
            },
        }
  1. Open your main configuration.yaml (in /config/) and add:

    zha:
      custom_quirks_path: /config/custom_zha_quirks/
      enable_quirks: true
    

    Note: Use /config/custom_zha_quirks/ exactly as shown -- this path works on both HA 2025.x and 2026.x, even though the File editor in 2026.x shows the root as /homeassistant/. The trailing / is required on HA 2026.x. Do not change /config/ to /homeassistant/ in this setting.

    If you already have a zha: section, just add the two lines underneath it.

  2. Restart Home Assistant from Settings > System > Restart.

  3. Remove and re-pair the AirCube once after adding the quirk (ZHA caches device data at first join).

A3 -- Pair the AirCube

  1. Go to Settings > Zigbee (Settings > Devices & Services > ZHA on HA 2026.1 and earlier).
  2. Click Add Device.
  3. Plug in your AirCube via USB-C. It boots into BLE mode by default -- Zigbee pairing is not automatic.
  4. Hold the button on the AirCube for 3 seconds. The LEDs will start flashing blue, and the device reboots into Zigbee mode to begin network steering.
  5. Wait 10-30 seconds. The AirCube will appear in ZHA. Give it a name like AirCube Living Room.
  6. When the LEDs stop flashing blue and return to a steady color, pairing is complete.

A4 -- Verify Sensors

Go to Settings > Zigbee > Devices (Settings > Devices & Services > ZHA on HA 2026.1 and earlier) and click on the AirCube device. You should see six entities:

EntityWhat It DoesUnit
TemperatureRoom temperatureC
HumidityRelative humidity%
Equivalent CO2eCO2 concentration (estimated)ppm
tVOCeTVOC concentrationppb
VOC LevelTVOC-derived VOC Level (0--500)--
BrightnessLED brightness (slider)0--100

Temperature and humidity are detected automatically by ZHA. eCO2, eTVOC, and VOC Level come from the custom quirk. The brightness slider uses the standard Analog Output cluster.

AirCube Pro shows two more entities, for eight total:

EntityWhat It DoesUnit
Carbon dioxideTrue CO2 from the SCD41 (direct NDIR measurement)ppm
IlluminanceAmbient light from the VCNL4040lx

These use the standard Zigbee CO2 (0x040D) and Illuminance (0x0400) clusters, so ZHA discovers them on its own -- the quirk doesn't need to do anything. They only exist on Pro hardware; a Base has neither sensor. Exact entity labels can vary slightly between Home Assistant versions.


Method B -- Zigbee2MQTT

Use this method if you prefer Zigbee2MQTT or already have it running.

Video: AirCube Home Assistant Z2M Walkthrough covers every step below.

B1 -- Install MQTT Broker

  1. Go to Settings > Apps > App Store (Settings > Add-ons > Add-on Store on HA 2026.1 and earlier).
  2. Search for Mosquitto broker, click Install, then Start.
  3. Go to Settings > Devices & Services > Add Integration.
  4. Search for MQTT and add it. Accept the defaults.

B2 -- Install Zigbee2MQTT

  1. Go to Settings > Apps > App Store (Settings > Add-ons > Add-on Store on HA 2026.1 and earlier).
  2. Click the three-dot menu (top-right) > Repositories.
  3. Add this URL:
    https://github.com/zigbee2mqtt/hassio-zigbee2mqtt
    
  4. Search for Zigbee2MQTT and click Install.

B3 -- Plug In Your Coordinator

  1. Plug the Zigbee dongle into your Home Assistant machine.
  2. Go to Settings > System > Hardware > three-dot menu > All Hardware.
  3. Find your dongle. Write down its path (e.g. /dev/ttyACM0).

B4 -- Configure and Start Zigbee2MQTT

  1. Go to Settings > Apps > Zigbee2MQTT > Configuration tab (Settings > Add-ons > ... on HA 2026.1 and earlier).
  2. Set the serial port:
    serial:
      port: /dev/ttyACM0
    
  3. Enable Start on boot and Watchdog, then click Start.

B5 -- Add the AirCube Converter

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/ folder of this repo.

  1. Open File editor (install from the App Store / Add-on Store if needed).

  2. Navigate to the zigbee2mqtt folder (the one containing configuration.yaml) and create an external_converters subfolder next to it.

  3. Copy z2m/aircube.mjs into the external_converters folder.

  4. Open configuration.yaml in the zigbee2mqtt folder and enable external JavaScript:

    advanced:
      enable_external_js: true
    

    If you already have an advanced: section, just add the one line under it. This step is required. Zigbee2MQTT ships with external converters switched off, and when they are off Z2M never even looks in the external_converters folder -- you get no error, just a device with no custom sensors. There is an equivalent Enable external JS toggle in the Z2M web UI under Settings > Advanced.

  5. In the same configuration.yaml, remove any external_converters: block if one is present:

    # Delete these lines -- they break converter loading on Z2M 2.x
    external_converters:
      - external_converters/aircube.mjs
    

    Z2M 2.0 removed this setting. Everything inside the external_converters folder is now loaded automatically, and leaving the old setting in place stops the converter (and sometimes Z2M itself) from starting.

  6. Restart Zigbee2MQTT from its app (add-on) page.

  7. Check the Zigbee2MQTT log. You should see Loaded external converter 'aircube.mjs'. If you instead see External JS (converters/extensions) is disabled, step 4 did not take effect.

Sample configuration.yaml

A complete, working Z2M 2.x config with the AirCube converter enabled. Yours will have different values for network_key, port, and your MQTT credentials -- copy the structure, not the secrets.

mqtt:
  base_topic: zigbee2mqtt
  server: mqtt://core-mosquitto:1883
  user: your_mqtt_user
  password: your_mqtt_password

serial:
  port: /dev/ttyACM0

advanced:
  # Keep whatever network_key you already have. Changing it un-pairs every device.
  network_key: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
  # Required for the AirCube converter. Off by default; nothing in
  # external_converters/ is read while this is false.
  enable_external_js: true

frontend:
  enabled: true
  port: 8099

homeassistant:
  enabled: true

devices:
  '0x1051dbfffe65fc77':
    friendly_name: AirCube Living Room

The converter itself is not referenced anywhere in this file. aircube.mjs just has to exist in the external_converters folder next to configuration.yaml.

Two mistakes that are easy to make here:

  • Do not add a second advanced: block. If enable_external_js: false is already in your config, edit that line in place. YAML does not merge duplicate keys -- Z2M parses the file with js-yaml, which treats a repeated top-level key as a fatal error, so the bridge will refuse to start.
  • Do not add an external_converters: list. That setting was removed in Z2M 2.0. Leaving it in place breaks converter loading.

Z2M 1.x (Legacy)

  1. Open File editor.

  2. Copy z2m/aircube.js into the zigbee2mqtt folder.

  3. Open configuration.yaml in the zigbee2mqtt folder and add:

    external_converters:
      - aircube.js
    
  4. Restart Zigbee2MQTT from its app (add-on) page.

B6 -- Pair the AirCube

  1. In the Zigbee2MQTT dashboard, click Permit join (All).
  2. Plug in your AirCube via USB-C (or hold the button 3 seconds if already plugged in).
  3. Wait for the LEDs to stop flashing blue.
  4. Name the device in Zigbee2MQTT (e.g. AirCube Living Room).

B7 -- Verify Sensors

Go to Settings > Devices & Services > MQTT and click on the AirCube. You should see six entities:

EntityWhat It DoesUnit
TemperatureRoom temperatureC
HumidityRelative humidity%
Equivalent CO2eCO2 concentration (estimated)ppm
VOC partseTVOC concentrationppb
VOC levelTVOC-derived VOC Level (0--500)--
BrightnessLED brightness (slider)0--100

On the Pro, two more entities appear from the dedicated sensors when using Zigbee2MQTT 2.x with aircube.mjs: CO2 (true CO2 from the SCD41, in ppm) and Illuminance (ambient light from the VCNL4040, in lx). These are absent on the Base. The legacy Zigbee2MQTT 1.x aircube.js converter exposes only the six core entities.

If the device card says "Automatically generated definition", the converter did not load. See the troubleshooting section below.


Dashboard

These cards work with both ZHA and Zigbee2MQTT.

Quick Entities Card

Edit your dashboard, click Add Card, choose Entities, and select:

  • AirCube Temperature
  • AirCube Humidity
  • AirCube Equivalent CO2
  • AirCube tVOC
  • AirCube VOC Level
  • AirCube Brightness
  • AirCube Carbon dioxide and AirCube Illuminance (Pro only)

VOC Level Gauge

Add a Manual card and paste:

type: gauge
entity: sensor.aircube_living_room_voc_level
name: Air Quality
min: 0
max: 500
severity:
  green: 0
  yellow: 50
  red: 200

24-Hour History

type: history-graph
title: Air Quality - Last 24 Hours
hours_to_show: 24
entities:
  - entity: sensor.aircube_living_room_temperature
  - entity: sensor.aircube_living_room_humidity
  - entity: sensor.aircube_living_room_voc_level

Entity names depend on what you named the device. Check Settings > Devices & Services for the exact entity IDs.


LED Reference

The LED follows canonical VOC Level (TVOC-derived) on a continuous green-to-red gradient. The hue moves linearly with VOC Level, so the color fades smoothly rather than stepping between bands. eCO2 does not affect the LED.

LED colorVOC LevelTVOC (ppb)Rating
Steady green0--100--~43Excellent
Green → lime10--50~43--220Good
Lime → yellow50--100220--650Moderate
Yellow → orange → red100--200650--2,200Poor
Steady red200+2,200+Unhealthy
Flashing blue----Pairing mode (searching for Zigbee network)
Off----Brightness set to 0 (press button to cycle)

On firmware 1.4.3 and below, the same gradient was driven by AQI-S (relative) instead of canonical VOC Level. See the README LED Reference for the full mapping.

Button

ActionResult
Short pressCycle LED brightness (off, 10%, 30%, 60%, 100%)
Hold 3 secondsEnter Zigbee pairing mode (LEDs flash blue)

Troubleshooting

The AirCube LEDs flash blue but it never connects

  • Make sure pairing/permit join is enabled in ZHA or Zigbee2MQTT.
  • Move the AirCube closer to the coordinator. Zigbee works best within 10-30 meters indoors.
  • Check that your coordinator is online in the integration dashboard.

Temperature and humidity show up but eCO2 / eTVOC / VOC Level are missing

  • The custom quirk (ZHA) or converter (Z2M) is not loaded.
  • 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.
  • 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 2.x: Open the device page in Home Assistant. If it says "Automatically generated definition", Z2M never loaded aircube.mjs and is guessing from the raw Zigbee clusters — which is why only the standard ones (temperature, humidity, CO2, illuminance, "Analog output 10") show up. The Z2M log tells you which failure it is; check, in order:
    • The log line External JS (converters/extensions) is disabled at startup. This is the most common cause. Set advanced: enable_external_js: true in configuration.yaml (see the sample config) and restart. While it is disabled Z2M does not read the external_converters folder at all, so there is no error message about the converter — only a Device ... is NOT supported warning.
    • aircube.mjs sits in an external_converters folder next to configuration.yaml (on the HA app/add-on that is /homeassistant/zigbee2mqtt/external_converters/, not .../data/external_converters/). If the folder or filename is wrong, Z2M skips it silently.
    • There is no external_converters: block left in configuration.yaml. That setting was removed in Z2M 2.0 and its presence prevents loading.
    • 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.
    • After fixing any of the above, restart Z2M and then re-interview the device (device page > Reconfigure) so the custom cluster is registered.
  • Z2M 1.x: Check that external_converters is in the Z2M configuration.yaml and aircube.js is in the zigbee2mqtt folder. Restart Zigbee2MQTT.

CO2 or Illuminance is missing (Pro)

  • These two entities come from standard Zigbee clusters, not the custom one, so they don't depend on the quirk or converter. ZHA and Zigbee2MQTT 2.x pick them up automatically.
  • Confirm the unit is a Pro. A Base has no SCD41 or VCNL4040, so the firmware never declares those clusters.
  • The clusters are read when the device first joins. If you flashed a Pro after pairing it, or the entities never appeared, remove and re-pair (ZHA) or Reconfigure the device (Z2M) so the coordinator re-reads the endpoint.
  • Z2M 1.x: the legacy aircube.js converter does not expose these. Move to Z2M 2.x and aircube.mjs.

eCO2 / eTVOC / VOC Level values are stuck at 0

This is normal for the first 5 minutes after power-on. The air quality sensor needs to warm up. Once ready, values will start updating (typically within 60 seconds).

I want to pair the AirCube to a different Home Assistant

Hold the button for 3 seconds to re-enter pairing mode. If the device won't leave its old network, unplug it, plug it back in, and immediately hold the button for 3 seconds while it boots.

Sensor values only update every 10 seconds

This is by design. The AirCube pushes new sensor values over Zigbee every 10 seconds. Additionally, the ZCL reporting configuration will send an immediate update when a reading changes significantly (temperature by 0.5 C, eCO2 by 50 ppm, VOC Level by 5 points, etc.).

Can I use multiple AirCubes?

Yes. The quirk/converter applies to every AirCube automatically. Just pair each one and give it a unique name. Each gets its own set of sensors and brightness control.

Connecting AirCube to SmartThings (Samsung hub)

This guide walks you through pairing AirCube to a SmartThings-compatible Zigbee hub and installing the community AirCube Zigbee Edge driver so you get temperature, humidity, eCO2, eTVOC, and VOC Level in the SmartThings app.

Default behavior: Without this driver, SmartThings often joins AirCube as a generic humidity / temperature device. You only get temp and humidity. The Edge driver adds the air quality sensors over Zigbee cluster 0xFC01 (same idea as the ZHA quirk and Zigbee2MQTT converter).

You will need

  • AirCube powered over USB-C
  • A SmartThings Edge–capable hub (e.g. SmartThings v3, Aeotec Smart Home Hub, SmartThings Station) on your Samsung account
  • A Mac, Windows, or Linux PC for the SmartThings CLI (one-time driver install)
  • A Samsung account used with SmartThings

The first time the CLI needs your SmartThings account, it starts a browser sign-in automatically (you may not see a separate login subcommand in smartthings --help). You do not need to create a Personal Access Token in the developer portal for normal interactive use. For automation / CI, you can use a PAT with channel scopes — see driver channels.


Step 1 — Put AirCube in pairing mode

  1. Plug in AirCube (or, if already powered, use the next step).
  2. Hold the button for about 3 seconds until the LEDs flash blue (Zigbee pairing mode).

See the README LED / button table if you need a refresher.


Step 2 — Add AirCube in the SmartThings mobile app

  1. Open the SmartThings app → Add device (or +).
  2. Choose Scan nearby / Zigbee / Generic Zigbee device per your app version, and enable pairing on the hub when prompted.
  3. Wait until AirCube appears and finish naming / room assignment.

Check: Open the new device — you should see temperature and humidity.
eCO2, TVOC, and VOC Level will not appear yet until the AirCube Zigbee driver is installed and selected (next steps).


Step 3 — Install the SmartThings CLI (sign-in is automatic)

  1. Install the CLI using the official instructions for your OS (Homebrew on Mac: brew install smartthings).
  2. Run smartthings --version and confirm the command works.

Authentication: Many recent builds do not list a smartthings login command. The first command that calls the SmartThings API (for example smartthings edge:drivers:package . in Step 5) will open a browser so you can sign in with your Samsung account. Complete that flow once; later CLI commands reuse the session.

  • To sign out on this machine: smartthings logout (then the next API command will prompt for login again).
  • Optional: Headless or automation-only setups can use a Personal Access Token instead; see CLI authentication.

Step 4 — Get the Edge driver source

From your clone of this repository (folder name may differ):

cd smartthings/aircube-zigbee

If you have not cloned yet:

git clone https://github.com/StuckAtPrototype/AirCube.git
cd AirCube/smartthings/aircube-zigbee

That directory contains config.yml, fingerprints.yml, profiles/, and src/init.lua.


Step 5 — Package the driver (build + upload)

From smartthings/aircube-zigbee:

smartthings edge:drivers:package .

The first time you run this (or any API command), the CLI may open the browser for Samsung sign-in — complete it, then re-run package if needed.

The CLI prints a Driver Id and Version (timestamp string). Save both — you need them to assign the driver to a channel.

Optional: also write a local zip:

smartthings edge:drivers:package . -b ./aircube-zigbee.zip

Recent CLI versions upload the driver when you package; there is no separate publish command.


Step 6 — Create a driver channel (first time only)

You need a driver channel to install your own driver on the hub.

This repo ships smartthings/driver-channel.json for edge:channels:create. It points termsOfServiceUrl at the StuckAtPrototype Terms of Service. Edit name / description in that file if you want different labels on your channel.

From the repository root (the folder that contains smartthings/):

smartthings edge:channels:create -i smartthings/driver-channel.json

List channels and copy your channel id (UUID):

smartthings edge:channels

Step 7 — Assign the packaged driver to your channel

smartthings edge:channels:assign <DRIVER_ID> "<VERSION>" --channel <CHANNEL_ID>

Use the Driver Id and Version from Step 5. You can omit arguments and use interactive prompts instead.


Step 8 — Enroll your hub, then install the driver

Order matters: enroll the hub on the channel before install.

smartthings edge:channels:enroll --channel <CHANNEL_ID>

Pick your hub when prompted (or pass flags if your CLI supports them).

Then install the driver onto that hub from the same channel:

smartthings edge:drivers:install --channel <CHANNEL_ID> --hub <HUB_DEVICE_UUID>

Hub id must be a UUID

<HUB_DEVICE_UUID> looks like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. It is not the sticker MAC, Zigbee EUI, or networkId string.

Where to find it

  • SmartThings Advanced Web App → open your hub as a device → copy Device id, or
  • Open any Zigbee child device (e.g. AirCube) → Parent device / hub → copy that hub’s id.

If you omit --hub, the CLI usually offers a list — picking from the list avoids typos.

If install returns HTTP 400, see Troubleshooting below.


Step 9 — Use the AirCube Zigbee driver for your device

The hub must run AirCube Zigbee instead of the generic Zigbee Humidity Sensor driver.

Option A — Switch driver (keep device / room)

smartthings edge:drivers:switch <DEVICE_ID>

Use the AirCube device id from the Advanced Web App (not the hub id). Follow prompts to select AirCube Zigbee.

Option B — Remove and re-pair

Remove AirCube from SmartThings, then add it again (Step 2) after the driver is installed on the hub. With a matching fingerprint, SmartThings should pick AirCube Zigbee automatically.


Step 10 — Confirm in the Advanced Web App

  1. Open SmartThings Advanced Web App.
  2. Select AirCube.
  3. Check Driver (or device summary): it should show AirCube Zigbee (or your packaged driver name), not only “Zigbee Humidity Sensor”.
  4. Under capabilities / state, you should eventually see carbon dioxide, TVOC, and air quality style attributes after reports arrive (allow a few minutes for sensor warm-up).

Step 11 — Confirm in the mobile app

Open the device card: you should see temperature, humidity, CO₂, TVOC, and air quality (labels may vary slightly by app version).

Gas sensors may read 0 for the first few minutes after power-on — that is normal while the ENS16x warms up (same as Home Assistant).


Troubleshooting

ProblemWhat to try
Only temp / humidityDriver not applied — repeat Step 9.
drivers:install 400 Bad RequestUse hub UUID, not sticker id. Run channels:enroll for the same channel first.
eCO2 / TVOC / VOC Level stuck at 0Wait 3–5+ minutes after power-on. Ensure driver is AirCube Zigbee and hub is online.
CLI / channel errorsRun smartthings logout then repeat the command that triggered login (browser). If you use a PAT instead, confirm it includes channel read/write. See driver channels.

Technical reference

Zigbee layout (endpoint 10): standard temp/humidity clusters; custom air quality cluster 0xFC01; LED brightness uses Analog Output 0x000D (not exposed in this driver v1). AirCube Pro additionally declares the standard CO2 (0x040D) and Illuminance (0x0400) clusters; this driver does not read them yet, so the app's CO₂ tile shows eCO2 from 0xFC01 on both models. Details match CONTRIBUTING.md → Zigbee Integration.


Contributors

If you improve the Edge driver or this guide, open a pull request on the AirCube repository so all users get the update.

Contributing to AirCube

AirCube is fully open source -- firmware, hardware, the browser and desktop apps, Home Assistant integration, and SmartThings Edge driver. Whether you want to fix a bug, add a feature, improve the docs, or port the desktop app to another platform, contributions are welcome.

This document covers everything you need to get the project building on your machine and understand how the code is organized.


ResourceLocation
Customer-facing READMEREADME.md
Home Assistant setup guideHOME_ASSISTANT.md
SmartThings setup guideSMARTTHINGS.md
Firmware update guideFIRMWARE_UPDATE.md
BLE GATT protocoldocs/BLE_GATT_PROTOCOL.md
Video walkthroughsStuckAtPrototype Labs on YouTube
Issue trackerGitHub Issues
LicenseApache 2.0

Project Layout

AirCube/
├── firmware/              # ESP-IDF firmware for the ESP32-H2
│   ├── CMakeLists.txt     # Top-level CMake (IDF project)
│   ├── version.txt        # Firmware version string (reported over serial and Zigbee sw_build_id)
│   └── main/
│       ├── main.c                # App entry point, FreeRTOS tasks, LED loop
│       ├── device_model.c/h      # Base vs Pro detection (SCD41 / VCNL4040 presence)
│       ├── ens210.c/h            # ENS210 temperature & humidity driver (I2C)
│       ├── ens16x_driver.c/h     # ENS16X air quality driver (I2C)
│       ├── scd41.c/h             # SCD41 true NDIR CO2 driver (I2C, Pro only)
│       ├── vcnl4040.c/h          # VCNL4040 ambient light driver (I2C, Pro only)
│       ├── i2c_driver.c/h        # Shared I2C bus init
│       ├── led.c/h               # Thread-safe LED color & intensity control
│       ├── led_color_lib.c/h     # Hue-to-GRB color math
│       ├── ws2812_control.c/h    # Low-level WS2812 RMT driver
│       ├── button.c/h            # Button debounce & brightness cycling
│       ├── auto_dim.c/h          # Pro-only lux-based LED auto-dim
│       ├── serial_protocol.c/h   # JSON serial command interface (USB)
│       ├── history.c/h           # 7-day sensor history ring buffer on flash
│       ├── radio_mode.c/h        # BLE-vs-Zigbee mode selection, pairing/reboot logic
│       ├── ble_gatt.c/h          # BLE GATT server + BTHome v2 advertising
│       ├── ble_bthome.c/h        # Standalone BTHome broadcaster reference -- not in the build (absent from CMakeLists.txt SRCS)
│       ├── zigbee.c/h            # Zigbee End Device (ZCL + custom cluster + brightness)
│       └── environmental.c/h     # (placeholder / future use)
│
├── scripts/               # Python desktop tools
│   ├── aircube_app.py             # Full GUI app (PyQt/Matplotlib)
│   ├── aircube_logger.py          # Headless CSV logger
│   ├── aircube_data_visualizer.py # CSV live viewer (no serial)
│   ├── aircube_replay_script.py   # Replay logged CSV with timing
│   ├── build_exe.py               # PyInstaller build for desktop app
│   ├── aircube.spec               # PyInstaller spec
│   ├── build_web_manifest.py      # Stages release binaries + manifest for AirCube Web
│   ├── selftest.html              # Browser self-test for the web app
│   └── requirements.txt
│
├── web/                   # AirCube Web -- browser app (Web Serial): live data, settings, flashing
│   ├── index.html
│   ├── js/, css/, vendor/
│   └── README.md
│
├── docs/
│   └── BLE_GATT_PROTOCOL.md   # BLE GATT / BTHome protocol reference
│
├── releases/              # Release notes per firmware version
│
├── kicad/                 # PCB design (KiCad)
│   ├── AirCube.kicad_pro/sch/pcb  # Schematic & layout
│   ├── gerbers/                    # Manufacturing files
│   └── AirCube v1.0 BOM.csv       # Bill of materials
│
├── mechanical/            # 3D-printable enclosure
│   ├── base/              # Base shells + air wall (STEP, 3MF)
│   ├── pro/               # Pro shells, air wall, light wall, button (STEP, STL)
│   └── guide_images/      # Photos used by ASSEMBLY.md
│
├── zha/                   # Home Assistant ZHA quirk
│   └── aircube.py
│
├── z2m/                   # Zigbee2MQTT external converters
│   ├── aircube.mjs        # Z2M 2.x (ES module) -- includes Pro CO2 + illuminance
│   └── aircube.js         # Z2M 1.x (legacy CommonJS) -- six core entities only
│
├── smartthings/           # Samsung SmartThings Edge driver (Zigbee hub)
│   ├── README.md
│   ├── driver-channel.json
│   └── aircube-zigbee/    # Driver package (config, fingerprints, profile, Lua)
│       ├── config.yml
│       ├── fingerprints.yml
│       ├── profiles/
│       └── src/
│
├── README.md              # Customer-facing product page
├── ASSEMBLY.md            # Enclosure assembly guide (Base and Pro)
├── FIRMWARE_UPDATE.md     # Flashing from the browser with AirCube Web
├── HOME_ASSISTANT.md      # Home Assistant integration guide
├── SMARTTHINGS.md         # SmartThings hub + CLI integration guide
├── CONTRIBUTING.md        # This file
└── LICENSE                # Apache 2.0

Setting Up the Firmware

Prerequisites

  • ESP-IDF v5.0 or later (v5.3+ recommended)
  • A USB-C cable (data-capable)
  • An AirCube board, or any ESP32-H2 dev board with ENS210 + ENS16X on I2C

Clone and build

git clone https://github.com/StuckAtPrototype/AirCube.git
cd AirCube/firmware

# Set the chip target (only needed once)
idf.py set-target esp32h2

# Build
idf.py build

# Flash and open serial monitor
idf.py -p COM3 flash monitor    # Windows -- replace COM3 with your port
idf.py -p /dev/ttyUSB0 flash monitor   # Linux

Press Ctrl+] to exit the IDF monitor.

Common build issues

ProblemFix
idf.py not foundRun the IDF export script first (export.bat on Windows, . ./export.sh on Linux/macOS)
Wrong chip targetRun idf.py set-target esp32h2 and rebuild
Stale build artifactsidf.py fullclean then idf.py build
Flash failsHold BOOT, press RESET, release BOOT to enter download mode

Firmware Architecture

Tasks and main loop

app_main() in main.c runs the initialization sequence, then spawns two FreeRTOS tasks and enters the LED update loop:

app_main()
  ├── Init: NVS, I2C, serial, LED, history, button
  ├── device_model_detect()          -- Base vs Pro, from SCD41 / VCNL4040 presence
  ├── ENS210 + ENS16X init (always); SCD41 + VCNL4040 init (Pro only)
  ├── radio_mode_init()              -- reads NVS join/pairing flags, picks boot mode
  │     ├── BLE mode (default)  ──► ble_gatt_init()   -- GATT server + BTHome advertising
  │     └── Zigbee mode         ──► zigbee_init()     -- only if previously joined, or a pairing request is pending
  ├── xTaskCreate(sensor_task)    -- reads sensors, sends JSON, logs history, pushes to the active radio
  ├── xTaskCreate(command_task)   -- polls for incoming serial commands
  └── Main loop (20ms tick)       -- smooth LED color transitions based on display_level (see below)

Exactly one of BLE or Zigbee runs per boot -- the ESP32-H2 has a single radio, and running both concurrently isn't supported. See "Pairing behavior" below for how the firmware switches between them.

Data flow

ENS210 (I2C)  ──► sensor_task ──► serial JSON output (USB)
ENS16X (I2C)  ──►      │       ├─► history_record_sample() ──► flash ring buffer
SCD41 (I2C, Pro) ──►    │       └─► active radio:
VCNL4040 (I2C, Pro) ──► │             ├─► zigbee_update_sensors()  ──► Zigbee attribute reports (every 10s), only in Zigbee mode
                        │             └─► ble_gatt live-data notify ──► BLE Live Data char. / BTHome advertising, only in BLE mode
                        │
     VOC Level, CO2 Level (Pro) ──► main loop ──► display_level = max(VOC Level, CO2 Level on Pro) ──► LED color (green-to-red)
                                                    ▲
Home Assistant / SmartThings ──► Zigbee Analog Output write ──► led_set_intensity() (brightness)

Module overview

Sensors

  • device_model.c -- Detects Base vs Pro hardware via aircube_model_detect(): Pro if either the SCD41 or VCNL4040 responds on I2C. aircube_model_is_pro() / aircube_model_name() ("base"/"pro") are used throughout the firmware (LED arbitration, Zigbee cluster gating, serial JSON, BLE device info).
  • ens210.c -- I2C driver for the ENS210 temperature/humidity sensor. Exposes ens210_get_temperature(), ens210_get_humidity().
  • ens16x_driver.c -- I2C driver for the ENS16X air quality sensor. Reads eTVOC, eCO2, VOC Level, and AQI-UBA. Accepts environmental compensation data from the ENS210. ens16x_read_aqi() (AQI-S) is deprecated and always returns 0 -- kept only for serial JSON compatibility.
  • scd41.c -- I2C driver for the Sensirion SCD41 (Pro only). Provides true NDIR CO2 in ppm, plus its own temperature/humidity readings. Used for both the LED's CO2 Level and, on Pro, the history/BLE/serial CO2 field.
  • vcnl4040.c -- I2C driver for the Vishay VCNL4040 ambient light sensor (Pro only). Feeds auto_dim.c for automatic night-time LED dimming.

LED

  • led.c -- Thread-safe color and intensity control for WS2812 LEDs. Uses a mutex so any task can call led_set_color() / led_set_intensity().
  • led_color_lib.c -- Converts a 16-bit hue to a 24-bit GRB value via get_color_from_hue().
  • ws2812_control.c -- Low-level RMT peripheral driver for WS2812 timing.

Communication

  • serial_protocol.c -- JSON-over-USB serial interface. Sends periodic sensor data, accepts commands (see Serial Protocol below).
  • radio_mode.c -- Chooses BLE or Zigbee at boot and manages the transition between them. Default boot mode is BLE, unless NVS records the device as already Zigbee-joined or a pairing request is pending. A long button press while in BLE mode sets an NVS pairing flag and reboots (esp_restart()) into Zigbee mode, where network steering begins and consumes the flag; a long press while already in Zigbee mode starts steering directly, with no reboot. radio_mode_revert_to_ble() clears the NVS flags and reboots back to BLE if steering fails/times out on a factory-new device, or if the device is removed from its Zigbee network.
  • ble_gatt.c -- BLE GATT server (service UUID A17C0DE0-...: Device Info, Live Data, History Request/Data, Brightness) plus inline BTHome v2 advertising, active only while the device is in BLE mode. See docs/BLE_GATT_PROTOCOL.md for the full protocol.
  • zigbee.c -- Registers a Zigbee End Device on the ESP32-H2's native 802.15.4 radio, active only while the device is in Zigbee mode. Exposes temperature/humidity via standard ZCL clusters, eCO2/eTVOC/VOC Level via custom cluster 0xFC01, LED brightness via the standard Analog Output cluster (0x000D), and on Pro hardware, true CO2 and illuminance via the standard CO2 (0x040D) and Illuminance (0x0400) clusters (see "Zigbee Integration" below).

Storage

  • history.c -- Append-only ring buffer on a dedicated flash partition. Accumulates sensor samples in RAM and flushes a min/avg/max summary every 5 minutes. Stores up to 7 days (2016 entries). Each slot is exactly 32 bytes.

Input

  • button.c -- GPIO debounce with short press (brightness cycle) and long press (radio pairing, via radio_mode_start_pairing()).

Serial Protocol Reference

The AirCube communicates over USB-Serial-JTAG at 115200 baud. All messages are single-line JSON terminated by \n.

Device output (sent every readout period, default 1s)

{
  "model": "pro",
  "fw": "2.0.3",
  "ens210": {"status": 0, "temperature_c": 23.45, "temperature_f": 74.21, "humidity": 52.30},
  "ens16x": {"status": "OK", "etvoc": 42, "eco2": 415, "aqi": 3, "aqi_s": 0, "aqi_uba": 1},
  "scd41": {"co2": 512},
  "vcnl4040": {"lux": 84.2},
  "health": {"ok": true, "temp_valid": true, "hum_valid": true, "co2_valid": true, "etvoc_valid": true, "sensor_missing": false, "frc_needed": false},
  "timestamp": 12345
}

model is "base" or "pro". fw is the running firmware version, the same string as firmware/version.txt; it was added in 2.0.3, so treat it as optional when talking to older units. aqi_s is the deprecated AQI-S score -- ens16x_read_aqi() always returns 0 now; the field is kept only for serial JSON compatibility. scd41.co2 and vcnl4040.lux are 0 on Base hardware (no SCD41/VCNL4040 fitted) and real readings on Pro. health carries per-channel validity so a consumer can tell a fresh reading from a held one; get_sensor_health returns the same picture in more detail. health.frc_needed is true on Pro after the first boot of the ASC-off firmware (an upgrade from <= 2.0.4) until the host runs scd41_frc or scd41_frc_ack. timestamp is milliseconds since boot.

Commands (send to device)

All commands are JSON with a "cmd" field. Send a complete JSON object followed by \n.

Firmware before 2026-02-08 is talk-only over USB. Those builds (everything shipped through January 2026) read commands from UART0 and only print to USB-Serial-JTAG, so no command sent over USB is ever answered. Worse, the ESP32-H2's USB-Serial-JTAG receive FIFO holds 64 bytes and nothing drains it: after that many bytes the device NAKs every packet and a host write() blocks forever (pyserial without write_timeout, or a Web Serial write promise that never settles). Reads keep working, and the ROM bootloader drains the FIFO on reset, so flashing still works. A host must therefore detect a cube that streams readings but never answers get_config and stop writing to it, spending fewer than 64 bytes finding out. The web app does this (PROBE_MAX_ATTEMPTS in web/js/devices.js, WRITE_STALL_MS in web/js/serial.js); tray and CLI clients should set a write timeout and do the same.

CommandPayloadResponse
get_config{"cmd":"get_config"}{"config":{"intensity":0.60,"readout_period":1000,"auto_dim":{...}}}
set_intensity{"cmd":"set_intensity","value":0.3}{"status":"ok","cmd":"set_intensity","value":0.30}
set_auto_dim{"cmd":"set_auto_dim","enabled":true,"night_enter_lux":5,"day_exit_lux":15,"night_dim_pct":10}{"config":{...}} (full config echo)
set_readout_period{"cmd":"set_readout_period","value":500}{"status":"ok","cmd":"set_readout_period","value":500.00}
get_history_info{"cmd":"get_history_info"}{"history_info":{"entries":288,"capacity":2016,"slot_bytes":32,"window_us":300000000}}
get_history{"cmd":"get_history","start":0,"count":48}{"history":[...],"start":0,"count":48}
clear_history{"cmd":"clear_history"}{"status":"ok","cmd":"clear_history","value":0.00}
scd41_frc{"cmd":"scd41_frc"}{"status":"ok","cmd":"scd41_frc","target":425,"correction":<int>} (Pro only; sets current air to 425 ppm)
scd41_frc_ack{"cmd":"scd41_frc_ack"}{"status":"ok","cmd":"scd41_frc_ack","value":0.00} (clears health.frc_needed without calibrating)
scd41_selftest{"cmd":"scd41_selftest"}{"status":"ok","cmd":"scd41_selftest","result":"0x0000","malfunction":false} (~10 s, Pro only)
scd41_reinit{"cmd":"scd41_reinit"}{"status":"ok","cmd":"scd41_reinit","value":0.00} (Pro only)

scd41_frc is Forced Recalibration: the SCD41 treats the air it is currently measuring as 425 ppm (current outdoor background). Sensirion's command minimum is 3 minutes in the same measurement mode; in AirCube's ~30 s low-power periodic mode wait at least 10 minutes of outdoor or open-window air so several fresh frames have landed. A correction of the sensor's signed FRC word is returned on success; firmware replies with {"status":"error",...} if the SCD41 is missing or the sensor reports failure (0xFFFF). ASC is always disabled on Pro hardware and is not a user setting. The first boot after an upgrade from <= 2.0.4 sets health.frc_needed so the web app can ask for that calibration once; scd41_frc and scd41_frc_ack both clear it.

Shortcut: Typing just h in the serial monitor dumps the entire history as CSV.

Intensity range

set_intensity accepts 0.0 (off) to 1.0 (full brightness). It persists the configured brightness; on Pro hardware auto_dim may lower the effective LED output at night without changing the stored value.

Auto-dim (Pro only)

Lux-based night dimming uses the VCNL4040 ambient reading with hysteresis (default: enter night below 5 lux, exit day above 15 lux). Base hardware disables auto-dim automatically.

Button / HA brightness presets map to night policy:

Configured %PresetDayNight (auto-dim on)
0OffOffOff
1–1010%ConfiguredOff
11–3030%ConfiguredOff
31–6060%ConfiguredOff
61–100100%Configured10% (default night_dim_pct)

BLE/Zigbee report the configured brightness, not the auto-dimmed effective value.

Readout period range

set_readout_period accepts 100 to 10000 (milliseconds).

History slot format

Each history entry contains min/avg/max for all five measurements over one 5-minute window. Temperature and humidity are stored as int16 x 100 (e.g., 2345 = 23.45 C). VOC Level, eCO2, and eTVOC are raw uint16 values.

Abbreviated JSON keys in get_history responses:

KeyMeaning
seqSequence number
t_a, t_n, t_xTemperature avg, min, max (x100 C)
h_a, h_n, h_xHumidity avg, min, max (x100 %)
q_a, q_n, q_xVOC Level avg, min, max
c_a, c_n, c_xCO2 avg, min, max (ppm) -- true CO2 (SCD41) on Pro, eCO2 (ENS16X estimate) on Base
v_a, v_n, v_xeTVOC avg, min, max (ppb)

Zigbee Integration

The ESP32-H2 has a native IEEE 802.15.4 radio. AirCube registers as a Zigbee End Device with the following clusters on endpoint 10:

ClusterIDAttributes
Temperature Measurement0x0402measuredValue (int16, x100 C)
Relative Humidity0x0405measuredValue (uint16, x100 %)
Custom Air Quality0xFC01eco2 (0x0000), etvoc (0x0001), aqi (0x0002) -- all uint16, read-only
Analog Output0x000DpresentValue (float, 0--100) -- LED brightness, writable
Carbon Dioxide Measurement0x040DmeasuredValue (float, ppm) -- Pro only, true CO2 from the SCD41
Illuminance Measurement0x0400measuredValue (uint16, lux) -- Pro only, from the VCNL4040

The custom cluster requires a ZHA quirk or Zigbee2MQTT external converter on the Home Assistant side. Both are included in the repo (zha/aircube.py, and z2m/aircube.mjs for Z2M 2.x / z2m/aircube.js for Z2M 1.x). On a Samsung SmartThings hub, use the Edge driver in smartthings/aircube-zigbee/ and follow SMARTTHINGS.md.

Pro's CO2 (0x040D) and illuminance (0x0400) clusters are standard ZCL, so they need no custom handling on the coordinator side. Per integration:

IntegrationPro CO2 + illuminance
ZHADiscovered automatically from the standard clusters; the quirk only has to leave them alone
Zigbee2MQTT 2.x (aircube.mjs)Exposed via m.co2() / m.illuminance(), gated on the cluster being present so Base units don't show them
Zigbee2MQTT 1.x (aircube.js)Not exposed (legacy converter, six core entities only)
SmartThings Edge driverNot exposed -- the profile's carbonDioxideMeasurement carries eCO2 from 0xFC01. Contributions welcome

See HOME_ASSISTANT.md for Home Assistant setup instructions.

Pairing behavior

The ESP32-H2 runs exactly one radio stack at a time -- BLE or Zigbee, never both (see "Tasks and main loop" above). The device boots into BLE mode by default, unless it's already joined to a Zigbee network (or a pairing request is pending) per the NVS flags read at boot.

  • A 3-second button hold triggers pairing mode at any time (radio_mode_start_pairing()).
    • If the device is currently in BLE mode, this sets an NVS pairing flag and calls esp_restart(), rebooting into Zigbee mode, where network steering begins immediately and consumes the flag.
    • If the device is already in Zigbee mode (previously joined), this starts network steering directly -- no reboot.
  • During Zigbee pairing/steering, the LED flashes blue at 2 Hz.
  • Pairing mode times out after 60 seconds if no network is found.
  • If steering fails or times out on a factory-new device, or the device is later removed from its Zigbee network, radio_mode_revert_to_ble() clears the NVS flags and reboots back into BLE mode.

BLE Integration

When not joined to a Zigbee network, AirCube runs a BLE GATT server (ble_gatt.c) plus BTHome v2 advertising, so it works with the AirCube apps or a Home Assistant Bluetooth proxy with no pairing step at all. See docs/BLE_GATT_PROTOCOL.md for the full protocol (service/characteristic UUIDs, live data layout, history sync).


Desktop Apps (scripts/)

Prerequisites

cd scripts
pip install -r requirements.txt

aircube_app.py -- Full desktop GUI

Live sensor display, color-coded VOC Level, three-panel charts (temp/humidity, VOC Level, gas levels), optional CSV logging, configurable history depth (50--1000 points).

python aircube_app.py

AirCube Tray -- system tray monitor (separate repo)

For a minimal Windows taskbar-only view of VOC Level, see the companion AirCubeTray repo. It ships its own installer and auto-detects the AirCube over USB.

Other scripts

ScriptPurpose
aircube_logger.pyHeadless CSV logger (no GUI)
aircube_data_visualizer.pyLive plots from a CSV file (no serial)
aircube_replay_script.pyReplay a logged CSV with original timing

Building standalone executables

pip install pyinstaller

python build_exe.py     # Produces dist/AirCube.exe

The standalone tray app build lives in its own repo: AirCubeTray.


Hardware

Components

PartDescription
ESP32-H2-MINI-1MCU with 802.15.4 (Zigbee/Thread) and BLE radio
ENS161 / ENS16XAir quality sensor -- eTVOC, eCO2, VOC Level (I2C)
ENS210Temperature and humidity sensor (I2C) -- Base
Sensirion SCD41True NDIR CO2 plus temperature and humidity (I2C) -- Pro
Vishay VCNL4040Ambient light sensor for LED auto-dim (I2C) -- Pro
WS2812 x3RGB LEDs
USB-C connectorPower and data
Tactile buttonBrightness control and Zigbee pairing

PCB

KiCad project files are in kicad/. Includes schematic, layout, Gerber files for manufacturing, and a BOM CSV.

Wondering about the cutouts in the board? These Holes Are Why My Circuit Board Works walks through why they're there.

Enclosure

3D-printable files in mechanical/, split by model: mechanical/base/ (bottom, top, air wall) and mechanical/pro/ (bottom, top, air wall, light wall, button cap). Top and bottom halves snap together and are held by three M2 x 5 mm self-tapping screws.

Step-by-step build instructions with photos: ASSEMBLY.md.


How to Contribute

Reporting bugs

Open a GitHub Issue with:

  • What you expected vs. what happened
  • Steps to reproduce
  • Firmware version / commit hash
  • Serial monitor output if relevant

Submitting changes

  1. Fork the repo and create a branch from master.
  2. Make your changes. Keep commits focused -- one logical change per commit.
  3. Test on hardware if you're changing firmware. Test the desktop app if you're changing scripts.
  4. Open a pull request against master. Describe what you changed and why.

Code style

  • Firmware (C): Follow the existing style -- 4-space indentation, snake_case for functions and variables, UPPER_CASE for defines and constants. Use ESP-IDF logging macros (ESP_LOGI, ESP_LOGW, ESP_LOGE).
  • Python scripts: Standard Python conventions. No strict formatter enforced, but keep it readable.
  • Commit messages: Short summary line, imperative mood (e.g., "Add history CSV export command").

Ideas for contributions

  • SmartThings -- driver improvements; WWST certification with Samsung (certification overview)
  • New sensor support -- PM2.5, CO, noise level
  • Web dashboard -- local web server on the ESP32-H2 or a companion app
  • macOS / Linux tray app -- the current tray app is Windows-only
  • Matter support -- the ESP32-H2 supports Matter over Thread
  • Power optimization -- light sleep between sensor reads
  • OTA firmware updates -- over Zigbee or USB
  • Additional languages -- translations for the desktop app
  • Unit tests -- for the history module, serial parser, color math

License

Apache License 2.0. See LICENSE for the full text. Contributions are made under the same license.

AirCube BLE GATT Protocol (v1)

Protocol between an AirCube in BLE mode (its default radio mode when not joined to a Zigbee network) and client apps (iOS app, future desktop BLE). The firmware implements this in firmware/main/ble_gatt.c. The iOS client is maintained in a separate repository — there is no ios/ directory in this repo.

All multi-byte fields are little-endian (native ESP32 byte order).

Advertising

PacketContents
Advertisement (connectable)Flags (0x06) + BTHome v2 service data (UUID 0xFCD2 — same payload format as the reference implementation in ble_bthome.c, so HA Bluetooth proxies keep working)
Scan responseComplete local name AirCube + 128-bit AirCube service UUID

ble_bthome.c exists in the source tree as a standalone reference but is not part of the current build (it's not listed in firmware/main/CMakeLists.txt). The live BTHome v2 payload is generated inline by ble_gatt.c's do_advertise(). This advertisement is only broadcast while the device is in BLE mode — it stops once the device joins a Zigbee network.

iOS clients scan with withServices: [AIRCUBE_SERVICE]; CoreBluetooth matches UUIDs from the scan response.

Service and characteristics

Base UUID suffix: -1D0F-4E7C-8E4B-2A3D5F6B7C80

UUID prefixNamePropertiesSize
A17C0DE0AirCube Service——
A17C0DE1Device InfoRead14 B
A17C0DE2Live DataRead, Notify20 B
A17C0DE3History RequestWrite4 B
A17C0DE4History DataNotify≤ MTU−3
A17C0DE5BrightnessRead, Write, Notify1 B

Firmware prefers ATT MTU 256 (iOS typically negotiates 185).

Device Info (A17C0DE1, read)

OffsetTypeField
0u8protocol_version (= 1)
1u8model: 0 = Base, 1 = Pro
2u8fw_major
3u8fw_minor
4u8fw_patch
5u8reserved (0)
6u16history_capacity (2016)
8u16history_entry_count (current valid entries)
10u16history_window_s (300)
12u16newest_seq (sequence of newest slot; 0xFFFF if history empty)

Live Data (A17C0DE2, read + notify)

Notified once per sensor readout (~1 s). 20 bytes:

OffsetTypeField
0i16temperature, °C × 100
2u16humidity, % × 100
4u16VOC Level (0–500)
6u16eCO2, ppm (ENS16X estimate)
8u16eTVOC, ppb
10u16CO2, ppm (SCD41 NDIR; 0 on Base)
12u16ambient light, lux × 10 (VCNL4040; 0 on Base)
14u8AQI-UBA (1–5)
15u8flags: bit0 = Pro model
16u32uptime, ms

Brightness (A17C0DE5, read + write + notify)

Single byte: LED brightness percent, 0–100 (writes above 100 are clamped). Writes apply immediately and persist to NVS across reboots. The device notifies the confirmed value after any brightness change — including local ones (button press) and Zigbee writes — so subscribed clients stay in sync.

The hardware button cycles 0 → 10 → 30 → 60 → 100 → 0, snapping to the next level above the current (possibly BLE-written) percent.

History sync (streaming, not paged)

  1. Client subscribes to History Data notifications.
  2. Client writes a request to History Request:
OffsetTypeField
0u8opcode: 0x01 = start stream, 0x02 = abort
1u8reserved (0)
2u16after_seq: stream only slots with sequence > this. 0xFFFF = full history
  1. Firmware streams History Data notifications back-to-back until caught up, then sends a done frame. No per-page round trips.

History Data frames

Every frame starts with a 4-byte header:

OffsetTypeField
0u8frame_type: 0x01 data, 0x02 done, 0x03 error
1u8slot_count (data frames) / 0
2u16data: logical index of first slot in frame · done: total slots sent · error: reason code

Data frame (0x01): header + slot_count × 32-byte slots. slot_count is the largest n with 4 + 32n ≤ MTU−3 (5 slots at MTU 185).

Done frame (0x02): stream complete. The client anchors wall-clock time now: newest slot = time of done frame; each older slot is history_window_s earlier. Slots have no absolute timestamps (the device has no RTC).

Error frame (0x03) reason codes: 1 = busy (another history stream is active, e.g. over USB serial — retry after a short delay), 2 = bad request.

Slot binary layout (32 bytes, packed)

Matches history_slot_t in firmware/main/history.h:

OffsetTypeField
0u16sequence (0xFFFF = empty/invalid slot, skip it)
2i16 ×3temperature avg/min/max, °C × 100
8i16 ×3humidity avg/min/max, % × 100
14u16 ×3VOC Level avg/min/max
20u16 ×3CO2 avg/min/max, ppm (true CO2 on Pro, eCO2 on Base)
26u16 ×3eTVOC avg/min/max, ppb

Client sync algorithm

info = read Device Info
if info.newest_seq == 0xFFFF: nothing to sync
last = highest sequence cached for this device (or none)
write History Request {0x01, after_seq: last or 0xFFFF}
collect data frames until done frame
store slots keyed by (device_id, sequence)   # idempotent
anchor timestamps: newest slot = now, each older slot -5 min

Sequence numbers are monotonic u16 and wrap at 0xFFFE. If the device does not find after_seq in its buffer (client was away longer than 7 days, or a wrap occurred), it streams the full history; dedup by sequence on the client handles overlap.

Concurrency rules

  • One history stream at a time device-wide (BLE and USB serial share this lock). The loser gets error/busy and retries.
  • Only one BLE central may connect (NimBLE max connections = 1).
  • clear_history is serial-only in v1 and is refused while a stream runs.

Sizing

Full sync: 2016 slots × 32 B = 63 KB ≈ 2–5 s at typical iOS throughput. Incremental daily sync: 288 slots ≈ 9 KB, sub-second.

AirCube Firmware

ESP-IDF firmware for the AirCube air quality monitor. Target chip: ESP32-H2.

For product documentation, integration guides, and the full architecture/build reference, see:

Build and flash

idf.py set-target esp32h2
idf.py build
idf.py -p PORT flash monitor

See ../CONTRIBUTING.md for prerequisites and a table of common build issues.

AirCube Zigbee TX Power

AirCube exposes a build-time Zigbee TX power setting:

  • AirCube Configuration -> Zigbee TX power (dBm) (CONFIG_AIRCUBE_ZB_TX_POWER_DBM)
  • Default: 20 dBm (the ESP32-H2's maximum output, for best link margin)
  • Supported menuconfig range: -24 to 20 dBm

Set it with:

idf.py menuconfig

Then build and flash as usual. At boot, the firmware logs requested and applied TX power.

Lower values reduce range but can help in dense multi-device environments. Actual applied power may be limited by hardware, SDK behavior, and regional regulatory limits — the firmware logs a warning if the applied value doesn't match the requested one.

Troubleshooting

Program upload failures and other build issues are covered in ../CONTRIBUTING.md's "Common build issues" table. For general ESP-IDF issues, run idf.py -p PORT monitor and reboot the board to check for output logs, or lower the flashing baud rate in idf.py menuconfig.

For AirCube-specific bugs or feature requests, open a GitHub issue on this repo.

AirCube Web

The browser app for AirCube: live readings, LED brightness, auto-dim and readout period, history with CSV export, and firmware flashing -- all over USB via the Web Serial API.

Live at stuckatprototype.github.io/AirCube. Requires Chrome or Edge on a desktop OS.

Running it locally

There is no build step. Serve the folder over HTTP and open it:

python -m http.server 8000 --directory web

Then browse to http://localhost:8000/. Web Serial needs a secure context, and localhost counts as one.

Layout

PathPurpose
js/protocol.jsJSON-lines serial protocol (see CONTRIBUTING.md)
js/quality.jsAir-quality thresholds, labels and the 0-100 score
js/serial.jsWeb Serial link with request/reply matching
js/devices.jsMulti-cube registry, hotplug, history sync
js/flash.jsesptool-js wrapper
js/charts.jsSparkline, air gauge, history chart
js/views/Home, detail, settings, flash dialog
css/theme.cssDesign tokens
vendor/esptool-js and uPlot, vendored as ES modules

js/quality.js and js/protocol.js are ports of aircubeapp/models.py and aircubeapp/protocol.py from the tray app, which in turn mirror the iOS app's Theme.swift. If you change a threshold or a label in one, change it in all three so a cube reads the same everywhere.

Firmware catalog

The flash dialog reads firmware/manifest.json and downloads binaries from this same origin. It cannot pull them from GitHub Releases directly: release assets redirect to release-assets.githubusercontent.com, which sends no Access-Control-Allow-Origin header, so the browser blocks the fetch.

scripts/build_web_manifest.py stages the newest release binaries here and regenerates the manifest. The Pages workflow runs it on every deploy; the .bin files themselves are gitignored.

Tests

scripts/selftest.html drives the protocol parsers and all three views against a synthetic device. Serve the repository root and open it:

python -m http.server 8000

Then browse to http://localhost:8000/scripts/selftest.html. Every line should read PASS. Append ?light to check the light palette.

Source and license

Original documentation: StuckAtPrototype, AirCube contributors. Reproduced under Apache-2.0. Individual image credits identify any separately licensed media. The full original copyright and permission notice is retained in that license file.

Republished the complete reviewed README and build chapters. Reformatted HTML as Markdown, resolved relative links, and served repository images locally. Photos resized without cropping; diagrams retain source resolution. Externally hosted or separately unverified media is linked at its original location. Introductory selection notes are by Meteor Makers; the original author is not responsible for those notes. Combined source chapters in build order and remapped chapter links to this page.

Source revision: 2135e5bc3ff6. Retrieved 2026-10-29. This is a source snapshot, not a claim that the project was first published today or that its creator endorses Meteor Makers.

Keep exploring

Ten Open-Source Science and Electronics Builds to Explore

Choose a LEGO microscope, ESP32 oscilloscope, laboratory syringe pump, haptic dial, DIY e-reader, synthesizer, or JPL rover using complete credited source documentation.

Image credit

images/osr-side-outside.jpg

JPL / California Institute of Technology and Open Source Rover contributors · Apache-2.0

Resized without cropping and converted to WebP for the project card.

An ESP32-S3 Spectrometer That Logs the Changing Daylight

Bob Dougherty's complete guide to a networked Hamamatsu C12880MA spectrometer, with sensor connections, calibration requirements, API endpoints, and the original daylight measurements.

Image credit

examples/spec.png

Bob Dougherty · MIT

Resized without cropping and converted to WebP for the project card.

Turn an ESP32 into a Browser Oscilloscope

Bojan Jurca's complete setup guide for displaying ESP32 GPIO samples in a web browser, including the original interface screenshot, library requirements, FTP commands, and ADC limitations.

Image credit

oscilloscope.png

Bojan Jurca · MIT

Resized without cropping and converted to WebP for the project card.

JLed: Fades, Morse Code, and LED Patterns Without Blocking Your Arduino

Jan Delgado's complete JLed guide and selected original example chapters, with wiring diagrams, timing plots, and the full LED demonstration animation.

Image credit

doc/jled-wasm.png

Jan Delgado; graphics created with Fritzing · MIT + Fritzing CC BY-SA 3.0

Resized without cropping and converted to WebP for the project card.

Comments

Member comments are temporarily unavailable.

Log in to join the discussion.