The TRV that cried “1 %”
For a week, a Sonoff TRVZB in my office convinced me its batteries were dead. What it had really lost was its calibration.
- Originally
- 2026-11
- Revision
- 1
My office radiator valve reported a dying battery through three sets of good batteries, while the room quietly overheated. The batteries were never the problem.
In short
The radiator valve in my office, a Sonoff TRVZB, reported 1 % battery through three sets of batteries. Meanwhile the office overheated, even though my thermostat logic had ordered the valve shut. The batteries were fine. The TRV was uncalibrated: it no longer knew where its valve pin was, so “closed” meant nothing, and it reported 1 % battery. A recalibration, triggered with the button, fixed both problems at once.
| Before | After | |
|---|---|---|
| Battery | 1 % | 100 % |
| idle_steps / closing_steps | 0 / 0 | 201 / 251 |
| Link quality | 21 | ~90 |
The setup
Every heated room in my house uses the same pattern:
- A Sonoff TRVZB on the radiator, paired through Zigbee2MQTT.
- A Versatile Thermostat (VTherm)
over_climateentity on top of it in Home Assistant, in valve-regulation mode. VTherm reads a separate room sensor and writes the TRV’svalve_opening_degree/valve_closing_degreedirectly. The TRV’s own thermostat logic is effectively bypassed: it is just a motor told “be 30 % open”.
That design matters later. VTherm can only ask for a percentage. Whether the valve actually ends up there is entirely the TRV’s business.
The battery that wouldn’t charge
8 November. A health survey of my Home Assistant setup flagged the office TRV at 1 % battery. I replaced the batteries and assumed the problem was solved.
9 November. While building a low-battery Telegram alert for all TRVs, I noticed the office valve still showed 1 %. The history told a more interesting story:
- It had already been at 1 % on 1 November.
- It went unavailable on 2 November and stayed offline for a week.
- When it came back, all 29 of its entities updated within 30 ms.
That simultaneous burst is the signature of Zigbee2MQTT republishing its cached device state, for example after a restart. So my first conclusion was that the 1 % was stale: an old value replayed, not a new measurement.
A wrong turn
To force a fresh reading, I sent a get to the device from Home Assistant. Nothing seemed to happen: the entities’ last_reported timestamps didn’t move. I concluded that the TRV was still offline and only looked available.
That conclusion was wrong, and finding out why was the first real lesson. I watched the raw MQTT topic directly with mosquitto_sub:
"last_seen":"2026-11-09T17:16:18Z", "linkquality":83, "elapsed":421896The TRV was alive and talking every few minutes. Home Assistant’s MQTT entities don’t record a new state when the value hasn’t changed, so last_reported sat still while the device chattered away. Home Assistant timestamps are not a liveness check; Zigbee2MQTT’s last_seen is.
Two more things turned up:
get {"battery": ""}has no converter on the TRVZB. Zigbee2MQTT logs an error and does nothing.- A raw ZCL read works:
It came back at 1 %: fresh, real, and still wrong-looking.zigbee2mqtt/<ieee>/set {"read":{"cluster":"genPowerCfg","attributes":["batteryPercentageRemaining"]}}
So the TRV itself believed its batteries were empty.
Good batteries, bad numbers
10 November. I put in new batteries and confirmed them with a battery tester. A fresh read: 1 %. Link quality had also dropped from 83 to 21. The firmware was the same as on my three healthy TRVs, which ruled out a firmware bug.
Then the actual symptom arrived: the office was overheating.
| VTherm target | 17.5 °C |
| Room temperature | 21.0 °C |
| VTherm command | 0 % open |
| TRV acknowledges | opening 0, closing 100, running_state: idle |
| TRV’s own temperature (on the radiator) | 23.2 °C |
Every part of the control chain was doing its job. VTherm said “close”, the TRV said “OK”, and the radiator stayed warm. The commands were arriving; the valve wasn’t closing.
Comparing the TRVZB’s calibration attributes across all four of my TRVs turned up an outlier:
| TRV | idle_steps | closing_steps |
|---|---|---|
| Office | 20 | 435 |
| Living room | 196 | 285 |
| Bedroom | 210 | 270 |
| Kid’s room | 193 | 274 |
idle_steps is how far the motor travels before it meets the valve pin. A value of 20 suggested it had calibrated while not properly seated on the valve.
The zeros
I took the TRV off, cleaned the contacts, fitted a different battery brand and remounted the head. Link quality came back to 87, so mounting had been part of the signal problem. Battery: still 1 %.
This time I read the calibration attributes live from the Sonoff-specific cluster instead of trusting Zigbee2MQTT’s cache:
{"read":{"cluster":"customSonoffTrvzb",
"attributes":["idleSteps","closingSteps","valveOpeningLimitVoltage",
"valveClosingLimitVoltage","valveMotorRunningVoltage"]}}Every value came back as 0. The 20/435 from before had been cached ghosts as well.
To make sure “0” wasn’t just the read failing, I sent the same read to a healthy TRV as a control: 200 / 273 / 1611 mV. The method worked. The office TRV was not calibrated at all.
The trick
The original TRVZB has no software recalibration command. Neither Zigbee2MQTT nor the eWeLink app can trigger one (the newer TRV Gen2 can). Calibration happens on the device after a power-cycle, and it needs a button press:
- Take the batteries out, wait about 10 seconds, and put them back.
- Wait for the display to show IN. The motor has pulled the pin fully back.
- Screw the head firmly and straight onto the valve.
- Short-press the middle button. The display flashes Ad, the motor runs out to find the pin, and the display returns to the temperature. Done.
Holding the button for 3 seconds means pairing mode or child lock instead, so avoid that.
This was almost certainly the missing piece all along. After every battery swap, the TRV had been sitting at “IN”, waiting for a press that never came.
The result
One read after the display showed the temperature again:
"battery":100, "idle_steps":201, "closing_steps":251,
"valve_opening_limit_voltage":2901, "valve_closing_limit_voltage":2573,
"linkquality":91The calibration is in line with the other TRVs, the battery reads 100 %, and VTherm is back in control.
Lessons learned
- “Low battery” on a TRVZB can mean “uncalibrated”. If fresh, tested batteries still read about 1 %, recalibrate before blaming the cells.
- Recalibration needs the button. A power-cycle alone isn’t enough: wait for IN, mount, press, Ad, temperature.
- Mounting matters twice: for calibration (a loose or tilted head gives bogus step counts) and for the radio signal (link quality 21 → 87 just from remounting).
- Home Assistant’s
last_reportedis not a liveness check for MQTT entities. Use Zigbee2MQTT’slast_seen. - Zigbee2MQTT shows cached values. When it matters, read the attribute live with
{"read": …}and compare against a known-good device. - “Command acknowledged” is not “command executed”. The TRV happily confirmed 0 % open while the radiator stayed hot.
- The TRVZB drifts out of calibration under many small moves, which is exactly how VTherm’s valve mode drives it. If it happens again, I’ll raise VTherm’s
auto_regulation_dpercent/auto_regulation_period_minso it makes fewer, larger moves.
Toolbox
A health check for any TRVZB. Healthy is roughly idle 200 and closing 250–285; all zeros means uncalibrated.
IEEE=0x… # the TRV's Zigbee address
mosquitto_sub -t "zigbee2mqtt/$IEEE" -W 60 -v &
sleep 2
mosquitto_pub -t "zigbee2mqtt/$IEEE/set" \
-m '{"read":{"cluster":"customSonoffTrvzb","attributes":["idleSteps","closingSteps"]}}'
mosquitto_pub -t "zigbee2mqtt/$IEEE/set" \
-m '{"read":{"cluster":"genPowerCfg","attributes":["batteryPercentageRemaining"]}}'
waitMy daily low-battery automation warns at 07:30 and 19:30 if any TRV is under 20 % or unavailable. Given lesson 1, a warning from it is now also a hint to check the calibration.