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.