Help required! Ideal Halo Boiler Control + Sonoff Zigbee 3.0 USB Dongle Plus

Hi,

I’ve had an Ideal Halo based boiler fitted about a month ago. I bought myself a Sonoff Zigbee 3.0 USB Dongle Plus from Amazon (it connected automatically) and I’m now trying to connect the two.

The [documentation[(https://idealheating.com/uploads/documents/halo-wifi-installation-223828-2.pdf) for the Halo system says to just press the “pair” button until the pairing light flashes red - I’ve done that. Then in Home Assistant, I’ve gone to Settings → Devices & Services and clicked on “1 device” under “Sonoff Zigbee 3.0 USB Dongle Plus” then “Add devices using this device”. That then shows on “Searching for Zigbee devices” and then says “Search again”…

Does anyone know what I’m doing wrong? Has anyone got it to work? Is the fact that I’ve already paired it to the Halo device mean that I can’t also pair it with another Zigbee device?

Thanks in advance!

I’ve tried to go the same route and to me it seems to me that the Ideal devices (Halo and smart interface) will not pair with non-Ideal devices. I tried for quite a while to connect the Halo to my own zigbee networks and the other way around, and it looks like their devices are locked down to secret network and installation keys. I asked Ideal support via email, but haven’t received a response…

Both the Halo and the smart interface have a number of (mostly standard) debug connectors on the back, but I wasn’t able to get any keys out of them. They use standard/well-known radio interfaces, which unfortunately also provide good security to keep the keys safe. I’m happy to run more experiments though, if someone has a good idea.

So I’ve just got a new Ideal C40 boiler and already have a wifi thermostat not opentherm. So I’ve bought a cheap Ideal Halo light with the intention of putting in an opentherm gatway. But looking at the halo light there is some pretty interesting connection on the back

Anybody hooked up the 6pin that is attached to the PIC tx2/rx2 or the tx1/rx1 pins to see if interfacing directly to it is possible?

Has anyone managed to get any further with interfacing with the Halo and Zigbee via HA and ZHA? I’ve tried and although ZHA can pair with it and therefore sees the device, no data is available. I’m assuming from comments before its because Ideal lock down their keys etc.

If you manage to pair it sucessfully with the ZHA integration then chances are good that it can be made to work even if none or not all attributes are exposed as entities in ZHA, but it means that the device will probably need a custom ZHA Device Handler (also known as a ZHA Quirk) coded for it in order to expose Zigbee clusters and attributes are not exposed as standard by default, however you either need to code such a custom quirk yourself or request device support hoping that someone else can help coding of a zha-quirk for it. See:

Also read the section above that for additional information on why custom zha device handlers are not needed for all Zigbee devices:

And some additional info here on what to do once a custom device quirk is written:

Thanks for the reply. I’ve given up on the idea now though and decided to implement an Opentherm solution with HA. It will give me much more and remote access too.

@IanC What did you go for in the end? I have an ideal halo thermo and was just considering swapping it out with something more HA friendly that supports opentherm. Drayton wiser is currently on the shortlist…

Thanks,

Guy

Hi Guy. Sorry for the late reply as I hadn’t been on the forums for quite sometime. I hope you got yourself sorted but if not the way I went in the end and have to say with great success was to implement the thermostat shield from Diyless opentherm thermostat shield.

It works really well. Basically I bought the ESP32/ESP8266 Thermostat Shield complete with the Opentherm Thermomstat case and I already had an ESP8266 D1 Mini. It gives complete control of the boiler and from my previous setup I already had setup a programmer section to control timings of the heating etc.

Hope that helps you
Ian

Did you take any special steps setting up the DIYLESS? Did you do the ESP Home setup in HA? I’ve got the headless esp32 variant and it constantly says idle when in the IP interface. (I didn’t set up with esp home, just plug and play with the OT terminals.

I’ve wired it up to my boilers opentherm socket but can’t decide if I need an additional loom, to change something or if I’ve just set the ESP up wrong.

I didn’t take any special steps at all. I simply followed all the installation instructions that Diyless provided and it worked fine. The only thing I did do was use his excel spreadsheet equitherm_calc to plot the heating curve to how I wanted it.

So, I have an Ideal Logic C2 30kW and it came with an Ideal Halo “RF” thermostat. I found this topic by searching. I’ve done a little bit of digging that may help others.

The back of the thermostat has two rows of header pins; one 1 mm pitch (and almost impossible for my logic analyser to clip on to) with 10 pins in total that I think might be the Silicon Labs PTI interface, and one 2.54 mm pins with 12 pins in total. I haven’t made much (if any) progress with the former, but the latter has one pin that is a simple UART 115.2 kBaud async serial output.

The boiler will indeed join any ZigBee network in pairing mode. Mqtt doesn’t like it, however, and the thermostat is itself a competing controller. The output from my (docker image) of mosquitto is:

[29/03/2026, 22:21:49] frontend:api:bridge: Sending {"topic":"bridge/request/permit_join","payload":{"time":254,"device":null,"transaction":"gnqwz-1"}}

[29/03/2026, 22:21:49] z2m: Zigbee: allowing new devices to join.

[29/03/2026, 22:21:49] zh:ember: [STACK STATUS] Network opened.

[29/03/2026, 22:21:50] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/response/permit_join', payload '{"data":{"time":254},"status":"ok","transaction":"gnqwz-1"}'

[29/03/2026, 22:25:04] frontend:api:bridge: Sending {"topic":"bridge/request/permit_join","payload":{"time":0,"device":null,"transaction":"gnqwz-2"}}

[29/03/2026, 22:25:04] z2m: Zigbee: disabling joining new devices.

[29/03/2026, 22:25:05] frontend:api:bridge: Sending {"topic":"bridge/request/permit_join","payload":{"time":0,"device":null,"transaction":"gnqwz-3"}}

[29/03/2026, 22:25:05] zh:ember: [STACK STATUS] Network closed.

[29/03/2026, 22:25:05] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/response/permit_join', payload '{"data":{"time":0},"status":"ok","transaction":"gnqwz-2"}'

[29/03/2026, 22:25:05] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/response/permit_join', payload '{"data":{"time":0},"status":"ok","transaction":"gnqwz-3"}'

[29/03/2026, 22:25:06] frontend:api:bridge: Sending {"topic":"bridge/request/permit_join","payload":{"time":254,"device":null,"transaction":"gnqwz-4"}}

[29/03/2026, 22:25:06] z2m: Zigbee: allowing new devices to join.

[29/03/2026, 22:25:06] zh:ember: [STACK STATUS] Network opened.

[29/03/2026, 22:25:07] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/response/permit_join', payload '{"data":{"time":254},"status":"ok","transaction":"gnqwz-4"}'

[29/03/2026, 22:27:18] zh:controller: Interview for '0x**************4d' started

[29/03/2026, 22:27:18] z2m: Starting interview of '0x**************4d'

[29/03/2026, 22:27:19] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"started"},"type":"device_interview"}'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 genPowerCfg.read(["mainsVoltage"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":1,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":213},"zclSequence":19,"commandIdentifier":1} timed out after 10000ms)'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 genPowerCfg.read(["mainsVoltage","mainsAlarmMask"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":1,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":214},"zclSequence":20,"commandIdentifier":1} timed out after 10000ms)'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 genDeviceTempCfg.read(["currentTemperature","devTempAlarmMask"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":2,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":216},"zclSequence":22,"commandIdentifier":1} timed out after 10000ms)'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 genIdentify.read(["identifyTime"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":3,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":217},"zclSequence":23,"commandIdentifier":1} timed out after 10000ms)'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 genDeviceTempCfg.read(["currentTemperature"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed (ZCL command 0x**************4d/5 genDeviceTempCfg.read(["currentTemperature"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":2,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":223},"zclSequence":29,"commandIdentifier":1} timed out after 10000ms))'

[29/03/2026, 22:27:19] z2m: Publish 'get' 'device_temperature' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 genDeviceTempCfg.read(["currentTemperature"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed (ZCL command 0x**************4d/5 genDeviceTempCfg.read(["currentTemperature"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":2,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":223},"zclSequence":29,"commandIdentifier":1} timed out after 10000ms))'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 hvacThermostat.read(["localTemp"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":513,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":225},"zclSequence":31,"commandIdentifier":1} timed out after 10000ms)'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 keepAlive.read(["tcKeepAliveBase"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":37,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":228},"zclSequence":32,"commandIdentifier":1} timed out after 10000ms)'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 mobileDeviceCfg.read(["keepAliveTime"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":34,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":229},"zclSequence":33,"commandIdentifier":1} timed out after 10000ms)'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed (ZCL command 0x**************4d/5 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed (ZCL command 0x**************4d/5 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":1026,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":230},"zclSequence":34,"commandIdentifier":1} timed out after 10000ms)))'

[29/03/2026, 22:27:19] z2m: Publish 'get' 'temperature' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed (ZCL command 0x**************4d/5 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed (ZCL command 0x**************4d/5 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":1026,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":230},"zclSequence":34,"commandIdentifier":1} timed out after 10000ms)))'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/5 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed (ZCL command 0x**************4d/5 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed (ZCL command 0x**************4d/5 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":1026,"sourceEndpoint":1,"destinationEndpoint":5,"options":4416,"groupId":0,"sequence":230},"zclSequence":34,"commandIdentifier":1} timed out after 10000ms)))'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/6 genBasic.read(["manufacturerName"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":0,"sourceEndpoint":1,"destinationEndpoint":6,"options":4416,"groupId":0,"sequence":218},"zclSequence":24,"commandIdentifier":1} timed out after 10000ms)'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/6 genPowerCfg.read(["mainsVoltage"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":1,"sourceEndpoint":1,"destinationEndpoint":6,"options":4416,"groupId":0,"sequence":219},"zclSequence":25,"commandIdentifier":1} timed out after 10000ms)'

[29/03/2026, 22:27:19] z2m: Publish 'set' 'read' to '0x**************4d' failed: 'Error: ZCL command 0x**************4d/6 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed (ZCL command 0x**************4d/6 msTemperatureMeasurement.read(["measuredValue"], {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"reservedBits":0,"writeUndiv":false}) failed ({"target":38776,"apsFrame":{"profileId":260,"clusterId":1026,"sourceEndpoint":1,"destinationEndpoint":6,"options":4416,"groupId":0,"sequence":231},"zclSequence":35,"commandIdentifier":1} timed out after 10000ms))'

[29/03/2026, 22:27:26] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/health', payload '{"response_time":1774819646798,"os":{"load_average":[1.74,2.2,2.3],"memory_used_mb":1088.57,"memory_percent":29.2426},"process":{"uptime_sec":472810,"memory_used_mb":106.55,"memory_percent":2.8624},"mqtt":{"connected":true,"queued":0,"published":1988,"received":39},"devices":{"0x**************4d":{"messages":32,"messages_per_sec":0.0011,"leave_count":0,"network_address_changes":0}}}'

[29/03/2026, 22:27:33] zh:controller: Interview failed for '0x**************4d with error 'Error: Interview failed because can not get active endpoints ('0x**************4d')'

[29/03/2026, 22:27:33] z2m: Failed to interview '0x**************4d', device has not successfully been paired

[29/03/2026, 22:27:33] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"failed"},"type":"device_interview"}'

[29/03/2026, 22:27:41] zh:controller: Interview for '0x**************4d' started

[29/03/2026, 22:27:41] z2m: Starting interview of '0x**************4d'

[29/03/2026, 22:27:41] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"started"},"type":"device_interview"}'

[29/03/2026, 22:27:55] zh:controller: Interview failed for '0x**************4d with error 'Error: Interview failed because can not get active endpoints ('0x**************4d')'

[29/03/2026, 22:27:55] z2m: Failed to interview '0x**************4d', device has not successfully been paired

[29/03/2026, 22:27:55] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"failed"},"type":"device_interview"}'

[29/03/2026, 22:28:02] zh:controller: Interview for '0x**************4d' started

[29/03/2026, 22:28:02] z2m: Starting interview of '0x**************4d'

[29/03/2026, 22:28:02] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"started"},"type":"device_interview"}'

[29/03/2026, 22:28:21] zh:controller: Interview failed for '0x**************4d with error 'Error: Interview failed because can not get active endpoints ('0x**************4d')'

[29/03/2026, 22:28:21] z2m: Failed to interview '0x**************4d', device has not successfully been paired

[29/03/2026, 22:28:21] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"failed"},"type":"device_interview"}'

[29/03/2026, 22:28:24] zh:controller: Interview for '0x**************4d' started

[29/03/2026, 22:28:24] z2m: Starting interview of '0x**************4d'

[29/03/2026, 22:28:24] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"started"},"type":"device_interview"}'

[29/03/2026, 22:28:38] zh:controller: Interview failed for '0x**************4d with error 'Error: Interview failed because can not get active endpoints ('0x**************4d')'

[29/03/2026, 22:28:38] z2m: Failed to interview '0x**************4d', device has not successfully been paired

[29/03/2026, 22:28:38] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"failed"},"type":"device_interview"}'

[29/03/2026, 22:28:43] zh:controller: Interview for '0x**************4d' started

[29/03/2026, 22:28:43] z2m: Starting interview of '0x**************4d'

[29/03/2026, 22:28:43] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"started"},"type":"device_interview"}'

[29/03/2026, 22:28:43] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d"},"type":"device_announce"}'

[29/03/2026, 22:28:46] zh:controller: Succesfully interviewed '0x**************4d'

[29/03/2026, 22:28:46] z2m: Successfully interviewed '0x**************4d', device has successfully been paired

[29/03/2026, 22:28:46] z2m: Device '0x**************4d' with Zigbee model 'ELBSingleCombi' and manufacturer name 'IDEAL BOILER' is NOT supported, please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html

[29/03/2026, 22:28:46] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"definition":{"description":"Automatically generated definition","exposes":[{"access":5,"category":"diagnostic","description":"Remaining battery in %","label":"Battery","name":"battery","property":"battery","type":"numeric","unit":"%","value_max":100,"value_min":0},{"access":5,"category":"diagnostic","description":"Temperature of the device","label":"Device temperature","name":"device_temperature","property":"device_temperature","type":"numeric","unit":"°C"},{"access":5,"description":"Measured temperature value","label":"Temperature","name":"temperature","property":"temperature","type":"numeric","unit":"°C"},{"access":1,"category":"diagnostic","description":"Link quality (signal strength)","label":"Linkquality","name":"linkquality","property":"linkquality","type":"numeric","unit":"lqi","value_max":255,"value_min":0}],"model":"ELBSingleCombi","options":[{"access":2,"description":"Calibrates the device_temperature value (absolute offset), takes into effect on next report of device.","label":"Device temperature calibration","name":"device_temperature_calibration","property":"device_temperature_calibration","type":"numeric","value_step":0.1},{"access":2,"description":"Calibrates the temperature value (absolute offset), takes into effect on next report of device.","label":"Temperature calibration","name":"temperature_calibration","property":"temperature_calibration","type":"numeric","value_step":0.1},{"access":2,"description":"Number of digits after decimal point for temperature, takes into effect on next report of device. This option can only decrease the precision, not increase it.","label":"Temperature precision","name":"temperature_precision","property":"temperature_precision","type":"numeric","value_max":3,"value_min":0}],"source":"generated","supports_ota":false,"vendor":"IDEAL BOILER"},"friendly_name":"0x**************4d","ieee_address":"0x**************4d","status":"successful","supported":false},"type":"device_interview"}'

[29/03/2026, 22:29:19] zh:ember: [STACK STATUS] Network closed.

[29/03/2026, 22:30:35] z2m: Device '0x**************4d' left the network

[29/03/2026, 22:30:35] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/event', payload '{"data":{"friendly_name":"0x**************4d","ieee_address":"0x**************4d"},"type":"device_leave"}'

[29/03/2026, 22:37:26] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/health', payload '{"response_time":1774820246801,"os":{"load_average":[2.02,2.32,2.37],"memory_used_mb":1087.13,"memory_percent":29.2039},"process":{"uptime_sec":473410,"memory_used_mb":110.66,"memory_percent":2.9727},"mqtt":{"connected":true,"queued":0,"published":2046,"received":39},"devices":{"0x**************4d":{"messages":53,"messages_per_sec":0.0017,"leave_count":1,"network_address_changes":0}}}'

[29/03/2026, 22:47:26] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/health', payload '{"response_time":1774820846801,"os":{"load_average":[1.83,2.13,2.25],"memory_used_mb":1085.16,"memory_percent":29.1511},"process":{"uptime_sec":474010,"memory_used_mb":110.55,"memory_percent":2.9697},"mqtt":{"connected":true,"queued":0,"published":2048,"received":39},"devices":{"0x**************4d":{"messages":53,"messages_per_sec":0.0017,"leave_count":1,"network_address_changes":0}}}'

[29/03/2026, 22:57:26] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/health', payload '{"response_time":1774821446804,"os":{"load_average":[1.99,2.4,2.37],"memory_used_mb":1093.39,"memory_percent":29.3719},"process":{"uptime_sec":474610,"memory_used_mb":110.55,"memory_percent":2.9697},"mqtt":{"connected":true,"queued":0,"published":2050,"received":39},"devices":{"0x**************4d":{"messages":53,"messages_per_sec":0.0017,"leave_count":1,"network_address_changes":0}}}'

[29/03/2026, 23:07:25] zh:ember: [NCP COUNTERS] 0,619,636,55,5,0,11,11,19,13,0,15,77,0,0,0,8,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0

[29/03/2026, 23:07:25] zh:ember: [ASH COUNTERS] 1029,177,48,129,0,0,0,0,2325,129,129,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0

[29/03/2026, 23:07:26] z2m:mqtt: MQTT publish: topic 'zigbee2mqtt/bridge/health', payload '{"response_time":1774822046804,"os":{"load_average":[1.89,2.28,2.38],"memory_used_mb":1137.14,"memory_percent":30.5473},"process":{"uptime_sec":475210,"memory_used_mb":110.41,"memory_percent":2.9659},"mqtt":{"connected":true,"queued":0,"published":2054,"received":39},"devices":{"0x**************4d":{"messages":53,"messages_per_sec":0.0016,"leave_count":1,"network_address_changes":0}}}'

where I have censored the network address due to some degree of paranoia (!).
I think this is because it has actually a proprietary command set over the protocol – it isn’t ZCP at all. The output from the UART serial link on the back is

\xFFFIRMWARE_VERSION(0) :53 
others Command(a0) :20 
NETWORK_STATUS(e1) :00 00 
OTID003SMemID_f8u8(71) :FF FF FF FF FF FF FF FF 
OTID011TSP0_u8u8(73) :FF FF FF FF FF FF FF FF 
OTID011TSP1_u8u8(74) :FF FF FF FF FF FF FF FF 
OTID011TSP2_u8u8(75) :FF FF FF FF FF FF FF FF 
OTID011TSP3_u8u8(76) :FF FF FF FF FF FF FF FF 
OTID125OTSVersion_f88(79) :FF FF FF FF FF FF FF FF 
OTID127SVersion_u8u8(7a) :FF FF FF FF FF FF FF FF 
others Command(a0) :20 
NETWORK_STATUS(e1) :00 00 
NETWORK_STATUS(e1) :00 00 
others Command(a0) :20 
NETWORK_STATUS(e1) :00 00 
others Command(a0) :20 
NETWORK_STATUS(e1) :00 00 
NETWORK_STATUS(e1) :00 00 
others Command(a0) :20 
NETWORK_STATUS(e1) :00 00 
NETWORK_STATUS(e1) :00 00 
others Command(a0) :20 
NETWORK_STATUS(e1) :01 00 
NETWORK_STATUS(e1) :01 00 
ZIGBEE_PARAMETERS(e0) :14 62 1A FA 82 BC 80 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
NETWORK_STATUS(e1) :00 00 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
others Command(a0) :20 
NETWORK_STATUS(e1) :00 00 
NETWORK_STATUS(e1) :01 00 
NETWORK_STATUS(e1) :01 00 
ZIGBEE_PARAMETERS(e0) :15 44 26 5B 0C B4 60 
NETWORK_STATUS(e1) :01 00 
ZIGBEE_PARAMETERS(e0) :15 44 26 5B 0C B1 54 
PinCode(60) :46 46 46 46 
SetServiceDate(61) :00 00 00 00 
SetServiceDayRemaining(62) :A9 01 
SetServiceType(63) :00 
ServiceSetpoint(64) :08 07 
SetSerivceStatus(65) :00 
LowerSetpointLimit(66) :F4 01 
UpperSetpointLimit(67) :B8 0B 
PRT_JOIN_NETWORK_RESPONSE(a8) :00 00 01 00 00 
CHRelayStates(38) :00 00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :00 
others Command(aa) :01 
UTC_DATE_AND_TIME_From_ZIFBEE(7) :A7 59 5C 31 00 00 00 00 
GatewayStatus(20) :00 
OTID000Status_f8f8(70) :00 00 00 00 00 00 00 00 
OTID005FaultCode_f8u8(72) :00 00 05 C0 00 00 05 00 
OTID127SVersion_u8u8(7a) :01 AC 7F C0 00 00 7F 80 
OTBoilerLostLinkStatus(6f) :00 
CURRENT_HEATING_SET_POINT(10) :F4 01 
ZIGBEE_PARAMETERS(e0) :15 44 26 5B 0C 00 FF 
Temperature_Calibration(1a) :00 
ZIGBEE_PARAMETERS(e0) :15 44 26 5B 0C B3 5C 
SYSTEMmode/hold/holdduration/(1b) :04 00 00 00 
TimeAMPMFormat(2a) :00 
TimeDaylightSaving(2b) :01 
InstallerContactNumber(2c) :
FrostProtectionSetpoint(31) :F4 01 
OptimizationType(32) :00 
TemperatureDispTolerence(33) :00 
CHRelayStates(38) :00 00 
SET_HOILDAY_MODE(a6) :00 00 00 00 00 00 00 00 00 
PreviousHeatMode(58) :01 
KEY_LOCK_LEVEL(91) :00 
PinCode(60) :46 46 46 46 
SetServiceDate(61) :00 00 00 00 
SetServiceDayRemaining(62) :A9 01 
SetServiceType(63) :00 
ServiceSetpoint(64) :08 07 
SetSerivceStatus(65) :00 
LowerSetpointLimit(66) :F4 01 
UpperSetpointLimit(67) :B8 0B 
SET_WEEKDAY_SCHEDULE(88) :00 40 68 01 34 08 E0 01 64 00 C0 03 34 08 28 05 64 00 
SET_WEEKDAY_SCHEDULE(88) :00 41 68 01 34 08 E0 01 64 00 C0 03 34 08 28 05 64 00 
SET_WEEKDAY_SCHEDULE(88) :00 42 68 01 34 08 E0 01 64 00 C0 03 34 08 28 05 64 00 
SET_WEEKDAY_SCHEDULE(88) :00 43 68 01 34 08 E0 01 64 00 C0 03 34 08 28 05 64 00 
SET_WEEKDAY_SCHEDULE(88) :00 46 68 01 34 08 E0 01 64 00 C0 03 34 08 28 05 64 00 
SET_WEEKDAY_SCHEDULE(88) :00 44 68 01 34 08 E0 01 64 00 C0 03 34 08 28 05 64 00 
SET_WEEKDAY_SCHEDULE(88) :00 45 68 01 34 08 E0 01 64 00 C0 03 34 08 28 05 64 00 
CURRENT_HEATING_SET_POINT(10) :F4 01 
ZIGBEE_PARAMETERS(e0) :15 44 26 5B 0C AE 48 
NETWORK_STATUS(e1) :01 00 
ZIGBEE_PARAMETERS(e0) :15 44 26 5B 0C B0 50 
OTID000Status_f8f8(70) :00 00 00 00 00 00 00 00 
OTID005FaultCode_f8u8(72) :00 00 05 C0 00 00 05 00 
OTID127SVersion_u8u8(7a) :01 AC 7F C0 00 00 7F 80 
OTBoilerLostLinkStatus(6f) :00 
CHRelayStates(38) :00 00 
others Command(40) :00 00 F4 01 00 00 00 04 
GatewayStatus(20) :00

I think that zigbee2mosquitto tried standard ZHA clusters (genBasic, hvacThermostat, msTemperatureMeasurement) and got 10-second timeouts on every single one. That’s because this device doesn’t implement any standard ZCL. The command IDs from the UART log, perhaps interpreted a bit, are, showing aborted pairing (to my network) and then successfully re-pairing to the Halo:

Cmd ID Name Value & Notes
0x10 CURRENT_HEATING_SET_POINT 0x01F4 = 500 → 5.00 °C (frost protection default)
0x1a Temperature_Calibration 0 (no offset)
0x1b SYSTEMmode/hold 04 00 00 00 = mode 4
0x31 FrostProtectionSetpoint 5.00°C
0x60 PinCode 46 46 46 46 = ASCII “FFFF” – default installer PIN is “not set”
0x66/0x67 Setpoint limits 5.00°C – 30.00°C
0x88 SET_WEEKDAY_SCHEDULE 06:00 → 21°C, 08:00 ->1°C, 16:00 → 21°C, 22:00 → 1°C
0xa0 Keepalive/heartbeat 0x20 every ~10s
0xa8 PRT_JOIN_NETWORK_RESPONSE 00 00 01 00 00 = join accepted
0xe0 ZIGBEE_PARAMETERS channel, PAN, RSSI, LQI
0xe1 NETWORK_STATUS 00 00=no net, 01 00=joined

I am a bit disappointed by this. I think one could write a connector and see if you could drive the boiler and convince it to stay joined to your network (it disconnected and went to the thermostat “automagically”) with the heartbeat keep-alives, but I had naively hoped for a beautiful open network where I could ultimately get the wifi thermostat to join my network and inspect / set on either device (giving me the abilitly to turn the heating off remotely if I forget and leave it on).

The actual Halo thermostat seems quite well secured. There isn’t publically available firmware for it, and they seem to keep the binaries on AWS only available via an API that requires a valid serial number to download a version specific for that device. I’d quite like to buy (or better yet, be given!) a set of header pins to actually look at siliconlab’s PTI interface (if that is what it is) with my logic analyser. The android app is obsfucated, has a ton of junk from facebook and a few advertising companies on it, and yet they have done this side of things competently.

A perhaps easier solution as outlined above is buy an opentherm interposer board (e.g. like https://otgw.tclcode.com/) that would sit between the thermostat and the boiler itself. I may (or may not) get around to doing this.

Hope this is helpful to someone else.

(Also, I know this is literally years later, but how did you get the front off the halo? I don’t want to damage it and it seemed pretty clearly fixed on. Does yours have the two sets of two rows of header pins by the battery compartment on the back too?)

Sorry for the life of me I can’t remember, I’ve dumped it long ago and went down an Opentherm gateway approach to the bolier and SAT on HA controlling.

I fitted an opentherm gateway but i only had the boiler data which wasnt what i was hoping for.I had no info from the wall stat room temp etc. I ended up using claude to assist with sniffing data and built a cloud based API HA intergration. Its basic atm but is working for heating and dhw control in Home Assistant. when you change the temp in home assistant it is reflected on the stat with it been cloud based.Down side is no local control but its a next best soution for now.