One Writer for the AC: Fixing a Zigbee2MQTT Race That Left My Thermostat at 20 C
6 of 79 AC boosts left my Zigbee thermostat stuck at 20 C for up to 196 minutes. The cause was concurrent /set messages. The fix was one queued Home Assistant script.
HomeLab, Smart home. Updated . 5 min read.
Six of my last 79 AC boosts never ended. The boost set the thermostat to 20 C for a few minutes, the timer finished, the restore ran, and the target stayed at 20 C for anywhere from 44 to 196 minutes.
Nothing errored. Home Assistant sent the right values. The device took them, and then a moment later it took the old one back.
TL;DR: Zigbee2MQTT runs two /set messages for the same device as concurrent tasks, and my TYBAC-006 thermostat doesn't handle overlapping writes. Several automations were writing to it, and scene restore always sent the mode and the target together. The fix is one script, script.ac_apply, in mode: queued, which is the only thing allowed to write climate.ac_thermostat. It writes one attribute at a time and checks the device's own report before it returns.
How I knew it was real
A stuck target on a dashboard can be a display bug. This one wasn't. The thermostat exposes its valve as binary_sensor.ac_thermostat_valve, and on the stuck boosts the valve confirmed it. The AC was really cooling to 20 C, for more than three hours in the worst case.
Across 10 days that was 6 of 79 boosts. Not every time, and not never, which usually means a race.
Where the race is
A boost ends by putting back whatever was there before it. My first version did that with a scene: snapshot before, restore after. Scene restore on a climate entity always writes the mode, so Home Assistant sent set_hvac_mode and set_temperature together.
Both arrive at Zigbee2MQTT as /set messages for the same device. Zigbee2MQTT doesn't queue them per device. mqtt.js emits each message, and eventBus.js runs each handler as its own async task, so both writes are in flight at once.
The TYBAC-006 is a Tuya TS0601 device. With two writes in flight it reported the new value, then the old value, and usually the new one again. Usually. On 6 boosts the old value came last and stayed.
The scene was only one source. Boost, pause, their timers, the sleep and wake setpoints, and the night comfort guard all wrote to the thermostat on their own. Any two of them firing close together could collide the same way.
One writer
Every one of those paths now calls one script:
ac_apply:
alias: AC Apply
mode: queued
max: 10
fields:
hvac_mode:
description: cool or off. Leave empty to keep the current mode.
temperature:
description: Target in C. Ignored while the AC is off.Boost, pause, restore, sleep guard, sleep, wake and disarm all go through it with hvac_mode, temperature, or both. The one exception is "Back home", which only sets the mode.
The body does four things:
- Stops early if
climate.ac_thermostathas no value. It does nothing while the thermostat is unavailable. - Writes the mode only if it differs, then waits for the device to report it (10 second timeout).
- Writes the target in a separate call, only if it's off by 0.25 C or more, and waits for the report again.
- Waits 5 more seconds and checks both again, because this device can report the old value a moment after it accepts a write.
That loop runs up to 3 times. If the device still reports something else, my phone gets a notice with the tag ac-apply saying what I asked for and what the device says.
Why queued, and not the other modes
Home Assistant scripts have four modes, and the choice is the whole fix:
| Mode | What happens to a second call | For this device |
|---|---|---|
parallel | Runs at the same time | The bug, moved into Home Assistant |
single | Dropped | A restore that arrives during a boost is lost |
restart | Cancels the running one | Kills a write halfway through its check |
queued | Waits its turn | One write at a time, in order |
queued with max: 10 means a burst of calls gets written in order, one full write and check at a time. Ten is far more than the handful of paths that can fire together.
The other half is how callers invoke it. They use action: script.ac_apply, not script.turn_on. Calling the script directly makes the caller wait until it finishes, so the next step in the boost controller runs only after the write has been checked.
Boost, pause, resume and timer completion also go through their own queued script, script.ac_override, so two button presses can't interleave either. It saves the setting from before the first override in input_text.ac_override_baseline, as <mode> <target>, and restores that when the override ends. It never saves 20 C as a baseline. If the target is 20 C when an override starts, it saves the sleep or awake setpoint instead, so a stuck boost can't become the thing it restores later.
Checking against the device, not the echo
The retry loop only helps if it's reading the truth. By default Zigbee2MQTT publishes the new state as soon as it sends the command, before the device confirms it. With that, the check in step 3 would pass on Zigbee2MQTT's own echo.
So AC Thermostat now has optimistic: false in Zigbee2MQTT. Home Assistant sees only reports that came from the device.
The safety nets
- The device's own weekly schedule now holds 25.5 C in every slot instead of 20.0. If it ever drops out of manual mode, it falls back to a safe target, and the manual-mode watchdog turns manual mode back on.
- "Recover Stuck Boost" (
automation.ac_override_recover_after_restart) runs at start, every 5 minutes, and when the target sits at 20 C for 2 minutes. It acts only when an unfinished override exists. With no timer running it restores the baseline, or the sleep or awake setpoint. A manual 20 C on the wall also gets reset after 2 minutes. - An off thermostat that happens to keep a 20 C target doesn't trigger recovery.
Scenes are gone from the AC override.
Testing it without touching the AC
I didn't want to test a race by pressing buttons for a week. The config copies live next to a test_ac.py that runs the AC scripts and automations with Home Assistant's installed script and template engines, inside a temporary directory in the container. It never calls the live API or touches a device.
The tests shorten only the waits in ac_apply. A fake device can report the old value again after a write, which is exactly what the TYBAC-006 does, and the test proves the retry catches it. All 25 tests pass, and the config check passes.
The first live run was a boost end: it restored 25.5 C in one round, with no stale report after it.
It isn't perfect. The device can still refuse 3 writes in a row. Then the phone notice arrives and the recovery automation retries. The next step would be a reconciler that holds the wanted state in helpers and keeps pushing until the device agrees. I'll build it only if a failure shows up in the next 7 days.
Questions
- Why does my Zigbee thermostat sometimes keep the old setpoint?
- If Home Assistant sends the HVAC mode and the target at the same time, Zigbee2MQTT handles both /set messages for the device concurrently. The device can then report the new value, the old value, and the new one again, and sometimes the old value wins.
- How do I stop Home Assistant automations from fighting over one device?
- Give the device one writer: a script with mode queued that every automation calls. It writes the mode, waits for the device report, then writes the target, and verifies both before it returns.
- What does optimistic: false do in Zigbee2MQTT?
- It stops Zigbee2MQTT from publishing the new state before the device confirms it. Home Assistant then sees only real device reports, so a check after a write cannot pass on an echo.
- Why not use a scene to restore a thermostat after a boost?
- Scene restore always writes the HVAC mode as well as the target. For this thermostat that meant two concurrent writes, which is what caused the stuck setpoint.