ZoneTouch 3 by Polyaire

I’ve searched around and can’t find any support for ZoneTouch 3 in Home Assistant or home bridge.

Plugged into a Daikin Ducted AC unit to control zones.

I think it should be familiar similar to the “Air Touch” units as it’s made by the same company.

Anyone else would like to see support for this?

Seems like someone might be giving this a go. No updates since April though.

So I went looking for someone who had worked on this problem and maybe got an API like the Airtouch 4 has, just not a HA integration maybe. I did not find that, what did I find was your post with my avatar staring back at me :sweat_smile:

I have been able to interpret the communication protocol for the ZoneTouch3 so far but have been unable to actually program a HA integration. I am by no means a programmer, just good at solving problems and have some knowledge of Python and TCP networking. I’ll put together what I have on Github so if anyone is so inclined they can help turn my python program into a HA integration.

I have tried using the Airtouch 3 (from HACS) and Airtouch 4 integrations with no real success and that doesn’t surprise me too much tbh, their communication, while similar, is not identical.

Here is a link to a PDF for the protocol that the support gave me. Hope it helps

I was able to run your python script with success on my ZoneTouch 3 controllers which is a great start.

This is miles easier than decoding the packet captures which is the method I’ve been using. No one at support was able to provide me anything like this so I’m glad you’ve got it. I’ll see how I go now I’ve got this.

Great to hear the program is working for you, can I ask how many zones you have? I have 7 and so if you have any different amount that would be good for testing.

Has anyone got anywhere with this yet?

I’ve tried to start creating an interactive NodeRed module for it - so that way it populates the data into HomeAssistant - but when calculating the CRC for it, its screwing with my datastream - and I don’t know why…

This is the current function I’m using:
https://lamp.crc.id.au/paste/1Ccfd8a81aCCBd07E9dAD7fDF0/

The percentage is in the msg.newValue attribute.

The CRC function returns either a decimal, or in the way I have it, something like 64FD when it needs to return [ 0x64, 0xFD ] to then add to the message to then be sent to the unit…

My Javascript code skills are horrible though :smiley:

EDIT: The full nodered flow to give the idea is here:

[{"id":"94a7559abc579a34","type":"tab","label":"TouchZone 3","disabled":false,"info":"","env":[]},{"id":"e16e78a66e5b49fa","type":"debug","z":"94a7559abc579a34","name":"debug 3","active":true,"tosidebar":true,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"payload","statusType":"auto","x":700,"y":180,"wires":[]},{"id":"d8aa6dc266f98ce5","type":"ha-number","z":"94a7559abc579a34","name":"Zone 1 Flow","version":1,"debugenabled":false,"inputs":0,"outputs":1,"entityConfig":"6354824e36dec33d","mode":"listen","value":"payload","valueType":"msg","outputProperties":[{"property":"newValue","propertyType":"msg","value":"","valueType":"value"},{"property":"previousValue","propertyType":"msg","value":"","valueType":"previousValue"}],"x":110,"y":60,"wires":[["83b2071c939e21a0"]]},{"id":"f9893c1599fcbe7e","type":"ha-number","z":"94a7559abc579a34","name":"Zone 2 Flow","version":1,"debugenabled":false,"inputs":0,"outputs":1,"entityConfig":"fd4e0f68ccef0a46","mode":"listen","value":"payload","valueType":"msg","outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"value"},{"property":"previousValue","propertyType":"msg","value":"","valueType":"previousValue"}],"x":110,"y":120,"wires":[[]]},{"id":"83b2071c939e21a0","type":"function","z":"94a7559abc579a34","name":"Set Flow (Zone 1)","func":"var Header = [0x55, 0x55, 0x55, 0xAA];\nvar SetZone1 = [\n    0x80, 0xB0,                 // Address\n    0x01,                       // ID\n    0xC0,                       // Type\n    0x00, 0x0C,                 // Length\n    0x20, 0x00,                 // Sub Type\n    0x00, 0x00,                 // Common Data Length\n    0x00, 0x01,                 // Repeat Data Count\n    0x00, 0x04,                 // Repeat Data Length\n    0x01, 0x02, msg.newValue, 0x00,     // Data (Zone, Set Percent, (percent) , Zero)\n];\n\n// Work out the CRC\nvar decimal = crc16(SetZone1);\nmsg.hexString = decimal.toString(16).padStart(2,'0');\n\n// Create the payload...\nvar message = Header.concat(SetZone1);\nmsg.payload = message;\nreturn msg;\n\nfunction crc16(buffer) {\n  var crc = 0xFFFF;\n  var odd;\n\n  for (var i = 0; i < buffer.length; i++) {\n    crc = crc ^ buffer[i];\n\n    for (var j = 0; j < 8; j++) {\n      odd = crc & 0x0001;\n      crc = crc >> 1;\n      if (odd) {\n        crc = crc ^ 0xA001;\n      }\n    }\n  }\n\n  return crc;\n};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":310,"y":60,"wires":[["e16e78a66e5b49fa"]]},{"id":"8ce8ad0e3b2f8a7e","type":"tcp request","z":"94a7559abc579a34","name":"","server":"172.31.1.251","port":"7030","out":"time","ret":"buffer","splitc":"1000","newline":"","trim":false,"tls":"","x":740,"y":60,"wires":[["e16e78a66e5b49fa"]]},{"id":"6354824e36dec33d","type":"ha-entity-config","server":"adf8b17c.f6a7c","deviceConfig":"1b5da8be9761f562","name":"Zone1 Flow","version":"6","entityType":"number","haConfig":[{"property":"name","value":"Zone1 Flow"},{"property":"icon","value":""},{"property":"entity_picture","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":""},{"property":"unit_of_measurement","value":""},{"property":"min_value","value":0},{"property":"max_value","value":100},{"property":"step_value","value":5},{"property":"mode","value":"slider"}],"resend":false,"debugEnabled":false},{"id":"fd4e0f68ccef0a46","type":"ha-entity-config","server":"adf8b17c.f6a7c","deviceConfig":"","name":"Zone 2 Flow","version":"6","entityType":"number","haConfig":[{"property":"name","value":""},{"property":"icon","value":""},{"property":"entity_picture","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":""},{"property":"unit_of_measurement","value":"%"},{"property":"min_value","value":0},{"property":"max_value","value":100},{"property":"step_value","value":5},{"property":"mode","value":"slider"}],"resend":false,"debugEnabled":false},{"id":"adf8b17c.f6a7c","type":"server","name":"Home Assistant","addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":"","connectionDelay":false,"cacheJson":true,"heartbeat":true,"heartbeatInterval":"30","areaSelector":"friendlyName","deviceSelector":"friendlyName","entitySelector":"friendlyName","statusSeparator":"","enableGlobalContextStore":true},{"id":"1b5da8be9761f562","type":"ha-device-config","name":"","hwVersion":"","manufacturer":"Node-RED","model":"","swVersion":""}]

@CRCinAU , thank you for putting together that Node Red Flow, it addressed all of my inabilities to make this solution work in Node Red. I’ve solved the issue you had with the CRC, I asked ChatGPT to create a Javascript equivalent of my CRC function from the python script on the repo and then modified the program from there. I now have the ability to turn on/off a zone and set the zone’s percentage.

Actually reading back the values into Home Assistant is not functional yet and is left more as an exercise to the reader.

Here is the flow I have setup:

[{"id":"94a7559abc579a34","type":"tab","label":"ZoneTouch 3","disabled":false,"info":"","env":[]},{"id":"8ce8ad0e3b2f8a7e","type":"tcp request","z":"94a7559abc579a34","name":"","server":"REDACTED","port":"7030","out":"time","ret":"buffer","splitc":"1000","newline":"","trim":false,"tls":"","x":700,"y":300,"wires":[[]]},{"id":"8b458bf0531dead8","type":"function","z":"94a7559abc579a34","name":"Set Flow (Zone 1)","func":"var zoneNumber = 0x01 //The number of the zone (or 'group') in Hex, starts at 0x00\nvar percentage = msg.payload //The input msg.payload must be a decimal number\nmsg.percentage = percentage\n\nvar Header = [0x55, 0x55, 0x55, 0xAA];//This is standard, do not modify\n\nvar SetZone1 = [\n    0x80, 0xB0,                 // Address\n    0x12,                       // ID\n    0xC0,                       // Type\n    0x00, 0x0C,                 // Length\n    0x20, 0x00,                 // Sub Type\n    0x00, 0x00,                 // Common Data Length\n    0x00, 0x04,                 // Repeat Data Count\n    0x00, 0x01,               // Repeat Data Length\n    zoneNumber, 0x80, percentage, 0x00 // Data (Zone, Set Percent, (percent) , Zero)\n    //for state ^, 0x03 indicates on, 0x02 off and, 0x80 percentage\n];\n\n// Work out the CRC\nvar decimal = crc16Modbus((SetZone1));\nvar hexString = decimal.toString(16);\nvar fullMessage = Header.concat(SetZone1);\nvar crcFirstByte = hexString.substring(0, 2);\nvar crcSecondByte = hexString.substring(2, 4);\n\nfullMessage = fullMessage.concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte,16));\n\nvar fullMessage = Header.concat(SetZone1).concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte, 16));\nvar dataBuffer = Buffer.from(fullMessage);\nmsg.payload = dataBuffer;\nreturn msg;\n\nfunction crc16Modbus(dataHex) {\n    function calcCRC16(data, poly = 0xA001) {\n        let crc = 0xFFFF;\n        for (let i = 0; i < data.length; i++) {\n            crc ^= data[i];\n            for (let j = 0; j < 8; j++) {\n                if (crc & 0x0001) {\n                    crc = (crc >> 1) ^ poly;\n                } else {\n                    crc >>= 1;\n                }\n            }\n        }\n        return crc;\n    }\n\n    // Convert hex string to byte array\n    let dataBytes = Buffer.from(dataHex, 'hex');\n\n    // Calculate CRC16 checksum\n    let crc = calcCRC16(dataBytes);\n    \n    //Return the hex value\n    return crc;\n};\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":370,"y":260,"wires":[["8ce8ad0e3b2f8a7e"]]},{"id":"bffeee23cd53426b","type":"ha-switch","z":"94a7559abc579a34","name":"Zone 1 HA Switch","version":0,"debugenabled":false,"inputs":1,"outputs":2,"entityConfig":"e98f891eba6562b8","enableInput":true,"outputOnStateChange":true,"outputProperties":[{"property":"outputType","propertyType":"msg","value":"state change","valueType":"str"},{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"}],"x":130,"y":320,"wires":[["77ec815cbd2e0643"],["377daf51f1972036"]]},{"id":"ef4227872bc786b0","type":"ha-number","z":"94a7559abc579a34","name":"","version":1,"debugenabled":false,"inputs":0,"outputs":1,"entityConfig":"f54a6f2b4d1c1fed","mode":"listen","value":"payload","valueType":"msg","outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"value"},{"property":"previousValue","propertyType":"msg","value":"","valueType":"previousValue"}],"x":90,"y":260,"wires":[["8b458bf0531dead8"]]},{"id":"77ec815cbd2e0643","type":"function","z":"94a7559abc579a34","name":"Turn on (Zone 1)","func":"var zoneNumber = 0x01 //The number of the zone (or 'group') in Hex, starts at 0x00\n\nvar Header = [0x55, 0x55, 0x55, 0xAA];//This is standard, do not modify\n\nvar SetZone1 = [\n    0x80, 0xB0,                 // Address\n    0x12,                       // ID\n    0xC0,                       // Type\n    0x00, 0x0C,                 // Length\n    0x20, 0x00,                 // Sub Type\n    0x00, 0x00,                 // Common Data Length\n    0x00, 0x04,                 // Repeat Data Count\n    0x00, 0x01,               // Repeat Data Length\n    zoneNumber, 0x03, 0x00, 0x00 // Data (Zone, Set Percent, (percent) , Zero)\n    //for state ^, 0x03 indicates on, 0x02 off and, 0x80 percentage\n];\n\n// Work out the CRC\nvar decimal = crc16Modbus((SetZone1));\nvar hexString = decimal.toString(16);\nvar fullMessage = Header.concat(SetZone1);\nvar crcFirstByte = hexString.substring(0, 2);\nvar crcSecondByte = hexString.substring(2, 4);\n\nfullMessage = fullMessage.concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte,16));\n// Create the payload...\n\nvar fullMessage = Header.concat(SetZone1).concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte, 16));\nvar dataBuffer = Buffer.from(fullMessage);\nmsg.payload = dataBuffer;\nreturn msg;\n\nfunction crc16Modbus(dataHex) {\n    function calcCRC16(data, poly = 0xA001) {\n        let crc = 0xFFFF;\n        for (let i = 0; i < data.length; i++) {\n            crc ^= data[i];\n            for (let j = 0; j < 8; j++) {\n                if (crc & 0x0001) {\n                    crc = (crc >> 1) ^ poly;\n                } else {\n                    crc >>= 1;\n                }\n            }\n        }\n        return crc;\n    }\n\n    // Convert hex string to byte array\n    let dataBytes = Buffer.from(dataHex, 'hex');\n\n    // Calculate CRC16 checksum\n    let crc = calcCRC16(dataBytes);\n    \n    //Return the hex value\n    return crc;\n};\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":370,"y":300,"wires":[["8ce8ad0e3b2f8a7e"]]},{"id":"377daf51f1972036","type":"function","z":"94a7559abc579a34","name":"Turn on (Zone 1)","func":"var zoneNumber = 0x01 //The number of the zone (or 'group') in Hex, starts at 0x00\n\nvar Header = [0x55, 0x55, 0x55, 0xAA];//This is standard, do not modify\n\nvar SetZone1 = [\n    0x80, 0xB0,                 // Address\n    0x12,                       // ID\n    0xC0,                       // Type\n    0x00, 0x0C,                 // Length\n    0x20, 0x00,                 // Sub Type\n    0x00, 0x00,                 // Common Data Length\n    0x00, 0x04,                 // Repeat Data Count\n    0x00, 0x01,               // Repeat Data Length\n    zoneNumber, 0x02, 0x00, 0x00 // Data (Zone, Set Percent, (percent) , Zero)\n    //for state ^, 0x03 indicates on, 0x02 off and, 0x80 percentage\n];\n\n// Work out the CRC\nvar decimal = crc16Modbus((SetZone1));\nvar hexString = decimal.toString(16);\nvar fullMessage = Header.concat(SetZone1);\nvar crcFirstByte = hexString.substring(0, 2);\nvar crcSecondByte = hexString.substring(2, 4);\n\nfullMessage = fullMessage.concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte,16));\n// Create the payload...\n\nvar fullMessage = Header.concat(SetZone1).concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte, 16));\nvar dataBuffer = Buffer.from(fullMessage);\nmsg.payload = dataBuffer;\nreturn msg;\n\nfunction crc16Modbus(dataHex) {\n    function calcCRC16(data, poly = 0xA001) {\n        let crc = 0xFFFF;\n        for (let i = 0; i < data.length; i++) {\n            crc ^= data[i];\n            for (let j = 0; j < 8; j++) {\n                if (crc & 0x0001) {\n                    crc = (crc >> 1) ^ poly;\n                } else {\n                    crc >>= 1;\n                }\n            }\n        }\n        return crc;\n    }\n\n    // Convert hex string to byte array\n    let dataBytes = Buffer.from(dataHex, 'hex');\n\n    // Calculate CRC16 checksum\n    let crc = calcCRC16(dataBytes);\n    \n    //Return the hex value\n    return crc;\n};\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":370,"y":340,"wires":[["8ce8ad0e3b2f8a7e"]]},{"id":"e98f891eba6562b8","type":"ha-entity-config","server":"4e161a78ecbd3dda","deviceConfig":"1b5da8be9761f562","name":"Zone 1 HA Switch","version":"6","entityType":"switch","haConfig":[{"property":"name","value":"Zone 1"},{"property":"icon","value":""},{"property":"entity_picture","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":""}],"resend":false,"debugEnabled":false},{"id":"f54a6f2b4d1c1fed","type":"ha-entity-config","server":"4e161a78ecbd3dda","deviceConfig":"1b5da8be9761f562","name":"","version":"6","entityType":"number","haConfig":[{"property":"name","value":"Zone 1 Percentage"},{"property":"icon","value":""},{"property":"entity_picture","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":""},{"property":"unit_of_measurement","value":"%"},{"property":"min_value","value":0},{"property":"max_value","value":100},{"property":"step_value","value":1},{"property":"mode","value":"auto"}],"resend":false,"debugEnabled":false},{"id":"4e161a78ecbd3dda","type":"server","name":"Home Assistant","version":5,"addon":false,"rejectUnauthorizedCerts":false,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true,"heartbeat":false,"heartbeatInterval":"30","areaSelector":"friendlyName","deviceSelector":"friendlyName","entitySelector":"friendlyName","statusSeparator":": ","statusYear":"hidden","statusMonth":"short","statusDay":"numeric","statusHourCycle":"default","statusTimeFormat":"h:m","enableGlobalContextStore":false},{"id":"1b5da8be9761f562","type":"ha-device-config","name":"Zone Controller","hwVersion":"","manufacturer":"Node-RED","model":"","swVersion":""}]

I think I have redacted all my IPs :man_shrugging:t2:

In terms of the script itself this is what the function I put together looks like now (explanation provided after):

var zoneNumber = 0x01 //The number of the zone (or 'group') in Hex, starts at 0x00
var percentage = msg.payload //The input msg.payload must be a decimal number
msg.percentage = percentage

var Header = [0x55, 0x55, 0x55, 0xAA];//This is standard, do not modify

var SetZone1 = [
    0x80, 0xB0,                 // Address
    0x12,                       // ID
    0xC0,                       // Type
    0x00, 0x0C,                 // Length
    0x20, 0x00,                 // Sub Type
    0x00, 0x00,                 // Common Data Length
    0x00, 0x04,                 // Repeat Data Count
    0x00, 0x01,               // Repeat Data Length
    zoneNumber, 0x80, percentage, 0x00 // Data (Zone, Set Percent, (percent) , Zero)
    //for state ^, 0x03 indicates on, 0x02 off and, 0x80 percentage
];

// Work out the CRC
var decimal = crc16Modbus((SetZone1));
var hexString = decimal.toString(16);
var fullMessage = Header.concat(SetZone1);
var crcFirstByte = hexString.substring(0, 2);
var crcSecondByte = hexString.substring(2, 4);

fullMessage = fullMessage.concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte,16));

var fullMessage = Header.concat(SetZone1).concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte, 16));
var dataBuffer = Buffer.from(fullMessage);
msg.payload = dataBuffer;
return msg;

function crc16Modbus(dataHex) {
    function calcCRC16(data, poly = 0xA001) {
        let crc = 0xFFFF;
        for (let i = 0; i < data.length; i++) {
            crc ^= data[i];
            for (let j = 0; j < 8; j++) {
                if (crc & 0x0001) {
                    crc = (crc >> 1) ^ poly;
                } else {
                    crc >>= 1;
                }
            }
        }
        return crc;
    }

    // Convert hex string to byte array
    let dataBytes = Buffer.from(dataHex, 'hex');

    // Calculate CRC16 checksum
    let crc = calcCRC16(dataBytes);
    
    //Return the hex value
    return crc;
};

The above example shows setting the percentage of a zone (‘group’). We get the percentage from the input (in this case the HA Number entity) and provide it as is in decimal form. The percentage value and zone number get passed to the SetZone array which then goes through the crc16Modbus function. I don’t pretend to understand how it works, I didn’t write the original python for it a friend did (thank you friend), all I know is that this black box produces correct CRCs. We then assemble the header, SetZone and, CRC into a final array that is loaded into a buffer to then be passed to the TCP function. The buffer part is important. Also note that I have swapped the common data length and common data count values, the protocol says it should be 0x04 and 0x01 respectively but that just doesn’t work for my system. Swapping them does though :man_shrugging:t2:.

Hope this does what you were seeking @PaintTheNight and @CRCinAU. Let me know if there are any issues, I’ll take a look. Thanks again CRC for starting me down this path and Paint for the Zone Touch Protocol document.

Damn dude, that is pretty sweet.

I did some more hacking on it… Now you can feed it any number between 0 to 100, and it’ll also turn the zone on when going from 0 → any other value.

// We send two packets in the ON case. An ON and a percentage.
// On the OFF case, we just send one.
var states = [];
var outputMsgs = [];

if ( msg.payload == "off") {
    states.push(0x02);
} else if ( msg.payload == "on" ) {
    states.push(0x03);
} else {
    if ( msg.previous == 0 ) {
        states.push(0x03);
    }
    states.push(0x80);
}

msg.percentage = msg.payload;

for ( var state in states ) {
    var Header = [0x55, 0x55, 0x55, 0xAA]; //This is standard, do not modify
    var SetZone = [
        0x80, 0xB0,       // Address
        0x12,             // ID
        0xC0,             // Type
        0x00, 0x0C,       // Length
        0x20, 0x00,       // Sub Type
        0x00, 0x00,       // Common Data Length
        0x00, 0x04,       // Repeat Data Count
        0x00, 0x01,       // Repeat Data Length
        msg.zone,         // Zone (starts at 0x00 - supply in msg.zone)
        states[state],    // State - 0x03 = on, 0x02 = off, 0x80 = percentage
        msg.percentage,
        0x00
    ];

    // Work out the CRC
    var decimal = crc16Modbus((SetZone));
    var hexString = decimal.toString(16);
    var fullMessage = Header.concat(SetZone);
    var crcFirstByte = hexString.substring(0, 2);
    var crcSecondByte = hexString.substring(2, 4);

    fullMessage = fullMessage.concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte,16));

    var fullMessage = Header.concat(SetZone).concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte, 16));
    var dataBuffer = Buffer.from(fullMessage);
    msg.payload = dataBuffer;
    node.send(msg);
}
return null;

function crc16Modbus(dataHex) {
    function calcCRC16(data, poly = 0xA001) {
        let crc = 0xFFFF;
        for (let i = 0; i < data.length; i++) {
            crc ^= data[i];
            for (let j = 0; j < 8; j++) {
                if (crc & 0x0001) {
                    crc = (crc >> 1) ^ poly;
                } else {
                    crc >>= 1;
                }
            }
        }
        return crc;
    }

    // Convert hex string to byte array
    let dataBytes = Buffer.from(dataHex, 'hex');

    // Calculate CRC16 checksum
    let crc = calcCRC16(dataBytes);
    
    //Return the hex value
    return crc;
};

When you set up the Zone entities in NodeRed, add the msg.zone attribute to the zone number in hex (ie 0x00 = Zone 1, 0x01 = Zone 2) etc…

It seems to work fine on my setup - except for some reason, Zone 4 never goes to 100% via this - but does from both the control panel, and the ZoneTouch3 app on my phone. It’ll adjust 0-95% and anywhere inbetween, but 100% just doesn’t seem to happen. No idea why…

So, this is my setup:

Each zone configured as its own entity as follows:

Added to the Entities Card in HA, I see:
image

Just to add some more - I’ve started trying to figure out how to parse the reply to a command, and also get the status of all the configured zones.

// Only process Group Status responses.
if ( msg.payload[10] == 0x21 ) {
    msg.zone_status = [];
    msg.zone_percentage = [];
    msg.zone_count = msg.payload[17];

    // Get the status for each zone.
    // Bit 8-7 = State. 00 = Off, 01 = On, 11 = Turbo
    // Bit 6-1 = Group Number
    for (var i = 0; i < msg.zone_count; i++) {
        var byte = 18 + (i * 8);
        msg.zone_status.push(msg.payload[byte]);
        byte = byte + 1;
        msg.zone_percentage.push(msg.payload[byte]);
    }
}
return msg;

Right now, this just grabs the percentage of each zone, and stuffs it in the message.

I haven’t done any parsing (ugh, bitwise operators required) and its past 2am now - but we should be able to update the node-red created entities with values that come from this status message.

Also, if we poll it every minute or so, it should allow us to keep nodered / ha updated with changes made via other methods too (ie the control panel etc)

My current flow:

Output example:
image

Needs more thought and I need sleep though :smiley:

EDIT: Oh, in theory, an extended query could also get the zone names to update the Friendly Name according to what’s in the controllers config too… I haven’t even looked at that yet though…

I’ve got you man! I’ve got a function that parses the response from the zone controller whenever you send it a command, the extended ones are a breeze from memory I already have them parsed in a Python file somewhere, I’ll take a look.

I’ve taken a different approach which is somewhat less elegant than yours but allows for the same control scheme in Home Assistant as afforded by the ZT3 app. That is the ability to control zones on/off independent of the percentage:

I have it update every entity after the user makes a change to ensure that HA and the device are always in sync.

An aside about TCP connectivity

What I never did figure out when trying to turn my python scripts into a HA integration was the asynchronous functionality, that is being able to hold open a TCP connection to the device without taking up too much CPU time, I don’t know if Node Red has this functionality.

This can cause an issue with the updates Node Red makes triggering the Node Red flow again creating a loop. To solve this after an action there is a 1s lockout of communication to the ZT3 to prevent this looping.

This is my code for that Update entities function:

// Assume msg.payload contains the buffer
var buffer = msg.payload;

// Convert the buffer to an array of hex strings
var hexArray = [];
for (let i = 0; i < buffer.length; i++) {
    // Convert each byte to a hexadecimal string (padding with zero if necessary)
    hexArray.push(buffer[i].toString(16).padStart(2, '0'));
}

// Store the hex strings array in msg.payload for further use
msg.payload = hexArray;
msg.zone0 = hexArray[18].substring(0,1) === "4";
msg.zone0Percentage = (msg.zoneNumber === 0) ? msg.enduringValue :  parseInt(hexArray[21],16)

msg.zone1 = hexArray[26].substring(0, 1) === "4";
msg.zone1Percentage = (msg.zoneNumber === 1) ? msg.enduringValue :  parseInt(hexArray[29],16)

msg.zone2 = hexArray[34].substring(0, 1) === "4";
msg.zone2Percentage = (msg.zoneNumber === 2) ? msg.enduringValue :  parseInt(hexArray[37],16)

msg.zone3 = hexArray[42].substring(0, 1) === "4";
msg.zone3Percentage = (msg.zoneNumber === 3) ? msg.enduringValue :  parseInt(hexArray[45],16)

msg.zone4 = hexArray[50].substring(0, 1) === "4";
msg.zone4Percentage = (msg.zoneNumber === 4) ? msg.enduringValue :  parseInt(hexArray[53],16)

msg.zone5 = hexArray[58].substring(0, 1) === "4";
msg.zone5Percentage = (msg.zoneNumber === 5) ? msg.enduringValue :  parseInt(hexArray[61],16)

msg.zone6 = hexArray[66].substring(0, 1) === "4";
msg.zone6Percentage = (msg.zoneNumber === 6) ? msg.enduringValue : parseInt(hexArray[69],16)

flow.set("lockout", true);
// Return the message object to pass to the next node
return msg;

Edit: I just realised that the ternary operators I’m using in each percentage assignment may not make sense without the full Node Red Flow context (I’m sorry, I can’t go through redacting all of the flow to share at the moment). What I’m doing is; for each Zone when the number entity produces a change (and therefore an input to the Node Red flow) it will put the value through on msg.payload but also msg.enduringValue. The msg.payload is modified by functions between the left side number entity inputs to the flow and the right side outputs to the flow. The reason I do this is because in my testing the response from the ZT3 tells me the previous state of the zone, not the current state, but only in the case of percentage. I may be reading the wrong value for percentage but I doubt it.

The other functions simply set or get the flow variable "lockout", for example the Lockout messages function will pass on a message exactly as it is given unless the "lockout" flow variable is true in which case it does nothing and stops the flow there.

if (flow.get("lockout")){
    return null;
}
return msg;

In terms of HA, my lovelace card looks like this:

I may make the percentage bars conditional on the zone being on but I’m undecided on that yet, it’s more aesthetic than functional anyways.

Edit 2: To save you the trouble @CRCinAU I do have the decoding for the extended messages down, just need to make it into JS. I’ll send it over when I get home.

Ah, nice - thanks.

The bit I did was so you could use the same function to use both ON / OFF and percentage stuff - and have it do the logic as well.

I noticed that you can’t set a percentage when a zone is OFF - unless you turn it on first.

This kinda combines it all into a single functional block of code.

I was also going to use the ‘On Start’ side of things to grab the zone names and try and set them on the entities Friendly Name. That way, its still a single function block.

It still needs a recurring timer to sync controller → HA entities though, as there doesn’t seem to be an event based method to stay connected and receive - at least that I’ve noticed as yet.

NodeRed does allow you to stay connected to the device as well - and we just get to send a message, then parse the reply.

I’d be more than happy if you wanted to port some of your stuff into my function - as it’d probably cut out a lot of complexity from what you already has - as it’ll handle both switches and sliders without duplicating code blocks…

EDIT: As for the lockout, you can always just use the NodeRed rate limiter. That way, you don’t have to manually track this.

So my response parser is based on the full extended message that is sent as a response to initial queries by the Zone Touch 3 phone app to the controller and it gets ALL the information for every zone. I’ve found the structure of the message is a bunch of front loaded stuff I’m not sure the meaning of but the zones start around byte 124 (123 if we talk zero based arrays). The query I use to get the full information response is:
55 55 55 AA 90 B0 07 1F 00 02 FF F0 AD 8C
or as a decimal buffer you can use in the inject function of Node Red:
[85,85,85,170,144,176,7,31,0,2,255,240,173,140]

The response is then parsed by the following code:

// Assume msg.payload contains the buffer
var buffer = msg.payload;

// Convert the buffer to an array of hex strings
var message = [];
for (let i = 0; i < buffer.length; i++) {
    // Convert each byte to a hexadecimal string (padding with zero if necessary)
    message.push(buffer[i].toString(16).padStart(2, '0'));
}
//msg.fullHexArray = message; Debugging
var entry = 123; //This is the location of the first byte of the first zone
msg.zone0 = message[entry].substring(0, 1) === "4"; //return true if zone is ON
msg.zone0Percentage = parseInt(message[entry+1],16);
msg.zone0SpillState = message[entry+6].substring(1,2) === "4"; //Checking if a zone has spill enabled
//var zoneTurboState = message[entry+6].substring(0,1) === "8"; UNTESTED, I don't use turbo zones

entry = entry + 22; //The length of each zone entry is 22 bytes
/*8 bytes for the status message, 2 bytes for zone number and 12 bytes for the zone name
The 2 zone number bytes represent the first zones depending on the place of a high bit in a 8 bit binary sequence:
00001000 indicates zone 3 and in hex is represented as 8. 
I assume the second byte allows for systems with 9+ zones to be represented.
I have no confirmation for any of this speculation aside my observations
*/
msg.zone1 = message[entry].substring(0, 1) === "4";
msg.zone1Percentage = parseInt(message[entry+1],16);
msg.zone1SpillState = message[entry+6].substring(1,2) === "4";

entry = entry + 22;
msg.zone2 = message[entry].substring(0, 1) === "4";
msg.zone2Percentage = parseInt(message[entry + 1], 16);
msg.zone2SpillState = message[entry + 6].substring(1, 2) === "4";

entry = entry + 22;
msg.zone3 = message[entry].substring(0, 1) === "4";
msg.zone3Percentage = parseInt(message[entry + 1], 16);
msg.zone3SpillState = message[entry + 6].substring(1, 2) === "4";

entry = entry + 22;
msg.zone4 = message[entry].substring(0, 1) === "4";
msg.zone4Percentage = parseInt(message[entry + 1], 16);
msg.zone4SpillState = message[entry + 6].substring(1, 2) === "4";

entry = entry + 22;
msg.zone5 = message[entry].substring(0, 1) === "4";
msg.zone5Percentage = parseInt(message[entry + 1], 16);
msg.zone5SpillState = message[entry + 6].substring(1, 2) === "4";

entry = entry + 22;
msg.zone6 = message[entry].substring(0, 1) === "4";
msg.zone6Percentage = parseInt(message[entry + 1], 16);
msg.zone6SpillState = message[entry + 6].substring(1, 2) === "4";

flow.set("lockout", true);

return msg;

To get the names out as well (I won’t be dynamically updating them for my setup) you can pull the 12 bytes starting from position 11 of each entry (message[entry+10]…). It uses only ASCII characters so I assume casting the decimal value to char would yield the correct character? I don’t use JS much but I know this would work in VBA.

I won’t be changing the system to combine the on/off functions with percentage as I don’t want to lose the percentage setting on the controller when a zone is turned off. Thanks for the offer though! Also the 1 second lockout seems to achieve what I need for now but I’ll have a look at rate limiting it or just making the triggering logic smarter.

I have included in the full update function (above) the ability to get whether spill is active on that zone, there is also scope for detecting turbo but I don’t fully understand this function so can’t really implement it.

Note I have a 7 zone system but the way the above function is coded you just add or remove code if you need more or less. It works on each entry being 22 bytes long.

What I do know about the controller is that if the app (or in our instance Node Red) is maintaining an active connection then any change on the controller or by another person using an app will cause it to send out an update to all connected clients. I’m reticent to have an always open connection though as I’m unsure the overhead for Node Red and the controller, I think your timer idea is probably best until we can figure out overhead.

Well, I’ve managed to parse the replies, and then do a new message to set the status of every zone.

As it reads the number of zones from the reply, it should be possible to just create more number.zone_X entries in the HA flow, and it all ‘just works’…

Function code:

// Only process Group Status responses.
if ( msg.payload[10] == 0x21 ) {
    msg.zone_count = msg.payload[17];

    // Get the status for each zone.
    // Bit 8-7 = State. 00 = Off, 01 = On, 11 = Turbo
    // Bit 6-1 = Group Number
    for (var i = 0; i < msg.zone_count; i++) {
        var byte = 18 + (i * 8);

        // Set number.zone_X to the retrieved value.
        var zone_entity = "number.zone_" + ( i + 1 );
        var zone_status = msg.payload[byte] - i;
        var percent = msg.payload[byte + 1];

        // zone_status == 0 means OFF
        if ( zone_status == 0 ) {
            percent = 0;
        }

        // Create a new message to pass to the set-value service.
        var NewMsg = { };
        NewMsg.entity_id = zone_entity;
        NewMsg.payload = percent;
        node.send(NewMsg);
    }
}
return null;

The full flow:

The call-service node after the Parse Reply function is set as:

I don’t quite have it set to interpret the status as yet - I might be lazy and not do a bitwise comparison to get that status… Just gotta look at the numbers a bit better… In my case, I’ll just set OFF = 0%, ON = a value returned in Percent.

EDIT: I just updated the above code to check the status. It now works for OFF = 0%, ON = anything else.

I have NodeRed always keep the connection open. I believe that when using the app, it instantly sends a data string back to the NodeRed connection - as updates seem instantanious.

Of course, right now, I’m still polling every 30 seconds as well - as not only will this help keep the connection open, it’ll also make sure we have a max delta of 30 seconds if we don’t get an instant update.

I’ve had a bit more of a play - and I decided to get a bit smart (which has partly backfired)…

Now, I can create the entities in HA dynamically - based on the number of zones that the ZT3 reports. This means an entity like: number.zonetouch3_zone_0 etc will be created for each reported zone.

This makes the configuration MUCH easier - as you don’t need to create any HA entities in either NR or HA…

The entire flow (bugs and all) is:
Working:
- Updates from the panel → HA
- Updates from the ZT3 app → HA

Not working:
- Adjustments from HA make the entity unavailable
- Need to set the Friendly Name somehow.

It’s a work in progress - but that’s where I’m at so far.

The timed Get Status node works fine - and as we stay connected to the ZT3, any updates on it or the app propagate instantly.

The Get Zone Names part sends the right command, but doesn’t function in setting the Friendly Name of the auto-generated entity. A work in progress. You can always set these in HA as a customisation anyway.

EDIT: All superseded, see below.

More progress…

Right now, I’ve abandoned the idea of importing the zone names - as that’s adding more complexity for the moment.

Now things mostly work, with one caveat - when you change the value in HA, the entity will likely flash “Unavailable” for a moment before updating with the correct value.

It’s not ideal, but unless this can be done properly within HA, this is probably as good as it’ll get for a while.

It will currently detect the number of zones you have, and configure the entities in HA accordingly. I use it with Auto Entites card as follows:

type: custom:auto-entities
card:
  type: entities
  title: Zone Touch 3
filter:
  include:
    - entity_id: number.zonetouch3*
  exclude:
    - state: 'off'
sort:
  method: entity_id
show_empty: false

The current NodeRed flow is below: Superseded, see below.

Change the IP address of your ZoneTouch 3 controller in the tcp: block.

It looks like you’ve got a really comprehensive solution there. My development has forked a bit as I’ve settled for a less automatic solution. You can automatically get the names of the zones and create such entities in HA however you need to do so using the REST API for HA and the created entities are not persistent, they will get wiped out by a reboot.

With this in mind I’ve settled on something more permanent, less automatic. Not brilliant for others who want to use my solution but I think you’ve got that covered.

I’ve got updating from extended messages every 30 seconds (whether they need to be extend or if I could just request all group status updates is honestly much of a muchness I think). I’ve also solved the loop issue I have when updating entities from command responses, I check that the values have actually changed prior to triggering the assemble message function. I now have binary sensors for detecting if spill is active, I haven’t and won’t be implementing turbo.

My full flow is available here (my message is over HA forum max if I include it in the body)

I may see how far I can get with a proper integration for HA though because now I have a proper template for how it will look. If I end up doing this I’ll post progress here.

Hahaha - its actually been totally re-written from the above…

It seems a lot of the things that were tripping me up are directly related to not using the HA websocket directly…

There’s a couple of minor things I’m still trying to fix, but its here:

[{"id":"e8d1cb207f57eaf8","type":"tab","label":"ZoneTouch 3","disabled":false,"info":"","env":[]},{"id":"26205ec1423be98e","type":"websocket in","z":"e8d1cb207f57eaf8","name":"WebSocket In","server":"","client":"06e5bb654365913d","x":90,"y":100,"wires":[["329a59e8437373bc"]]},{"id":"f10acb738a5b6496","type":"websocket out","z":"e8d1cb207f57eaf8","name":"","server":"","client":"06e5bb654365913d","x":250,"y":320,"wires":[]},{"id":"899fe2e93679dd5a","type":"switch","z":"e8d1cb207f57eaf8","name":"","property":"payload.type","propertyType":"msg","rules":[{"t":"eq","v":"auth_required","vt":"str"},{"t":"eq","v":"event","vt":"str"},{"t":"eq","v":"result","vt":"str"},{"t":"else"}],"checkall":"true","repair":false,"outputs":4,"x":370,"y":100,"wires":[["d3cd01a673e31966"],["526afd2893a4b343"],[],[]]},{"id":"329a59e8437373bc","type":"json","z":"e8d1cb207f57eaf8","name":"","property":"payload","action":"obj","pretty":false,"x":250,"y":100,"wires":[["899fe2e93679dd5a"]]},{"id":"d3cd01a673e31966","type":"change","z":"e8d1cb207f57eaf8","name":"Add Access Token Here","rules":[{"t":"delete","p":"_session","pt":"msg"},{"t":"set","p":"access_token","pt":"msg","to":"ABC123","tot":"str"},{"t":"set","p":"payload","pt":"msg","to":"{\"type\": \"auth\", \"access_token\": access_token}","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":590,"y":80,"wires":[["fa75f5242683a341"]]},{"id":"fa75f5242683a341","type":"json","z":"e8d1cb207f57eaf8","name":"","property":"payload","action":"str","pretty":false,"x":770,"y":80,"wires":[["d18adb16ca69fa20"]]},{"id":"9b16dab46faa2008","type":"function","z":"e8d1cb207f57eaf8","name":"create discovery","func":"const i = msg.zone;\nconst messageId = (flow.get(\"messageId\") ?? 0) + 1;\n\n// create messageId to zoneId table\n// table is for when a message is recieved about value change\n// we know what zone it is with\nconst lookup = flow.get(\"lookup\") ?? {};\nconst alreadyRegisteredZones = Object.values(lookup);\n\n// default payload \nmsg.payload =  { \n    id: messageId,\n    type: \"nodered/entity\",\n    server_id: `zonetouch3_zone_${i}`,\n    node_id: `zonetouch3_zone_${i}`,\n    state: msg.percent\n};\n\n// if messageId already in lookup no need to resend discovery just update value\nif(!alreadyRegisteredZones.includes(i)) {\n    lookup[messageId] = i;\n\n    msg.payload = {\n        ...msg.payload,\n        type: \"nodered/discovery\",\n        component: \"number\",\n        attributes: {\n            name: `zonetouch3_zone_${i}`,\n            min: 0,\n            max: 100,\n            step: 5,\n            unit_of_measurement: \"%\",\n            mode: \"slider\",\n            icon: \"mdi:hvac\",\n        }\n    };\n}\n\nflow.set(\"lookup\", lookup);\nflow.set(\"messageId\", messageId);\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":920,"y":220,"wires":[["3ee9dfb96e70b1a0"]]},{"id":"8a7856865551d6a4","type":"status","z":"e8d1cb207f57eaf8","name":"","scope":["f10acb738a5b6496"],"x":80,"y":440,"wires":[["9db6481343ed39a6"]]},{"id":"526afd2893a4b343","type":"function","z":"e8d1cb207f57eaf8","name":"Value changed from HA","func":"const messageId = msg.payload.id;\nconst value = msg.payload.event.value;\n\nconst lookup = flow.get(\"lookup\") ?? {};\nconst zoneId = lookup[messageId];\nif(zoneId === undefined) {\n    node.error(\"Message ID not found in lookup table\");\n    return null;\n}\n\nmsg.zone = zoneId;\nmsg.value = value;\ndelete msg._session;\n\n// We send two packets in the ON case. An ON and a percentage.\n// On the OFF case, we just send one.\nvar states = [];\n\nif (msg.value == 0) {\n    states.push(0x02);\n} else {\n    states.push(0x03);\n    states.push(0x80);\n}\n\nfor (var state in states) {\n    var Header = [0x55, 0x55, 0x55, 0xAA]; //This is standard, do not modify\n    var SetZone = [\n        0x80, 0xB0,       // Address\n        0x12,             // ID\n        0xC0,             // Type\n        0x00, 0x0C,       // Length\n        0x20, 0x00,       // Sub Type\n        0x00, 0x00,       // Common Data Length\n        0x00, 0x04,       // Repeat Data Count\n        0x00, 0x01,       // Repeat Data Length\n        msg.zone,         // Zone (starts at 0x00 - supply in msg.zone)\n        states[state],    // State - 0x03 = on, 0x02 = off, 0x80 = percentage\n        msg.value,\n        0x00\n    ];\n\n    // Work out the CRC\n    var decimal = crc16Modbus((SetZone));\n    var hexString = decimal.toString(16);\n    var fullMessage = Header.concat(SetZone);\n    var crcFirstByte = hexString.substring(0, 2);\n    var crcSecondByte = hexString.substring(2, 4);\n\n    fullMessage = fullMessage.concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte, 16));\n\n    var fullMessage = Header.concat(SetZone).concat(parseInt(crcFirstByte, 16)).concat(parseInt(crcSecondByte, 16));\n    var dataBuffer = Buffer.from(fullMessage);\n    msg.payload = dataBuffer;\n    node.send(msg);\n}\nreturn null;\n\nfunction crc16Modbus(dataHex) {\n    function calcCRC16(data, poly = 0xA001) {\n        let crc = 0xFFFF;\n        for (let i = 0; i < data.length; i++) {\n            crc ^= data[i];\n            for (let j = 0; j < 8; j++) {\n                if (crc & 0x0001) {\n                    crc = (crc >> 1) ^ poly;\n                } else {\n                    crc >>= 1;\n                }\n            }\n        }\n        return crc;\n    }\n\n    // Convert hex string to byte array\n    let dataBytes = Buffer.from(dataHex, 'hex');\n\n    // Calculate CRC16 checksum\n    let crc = calcCRC16(dataBytes);\n\n    //Return the hex value\n    return crc;\n};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":590,"y":120,"wires":[["a7e0376d89163621"]]},{"id":"9db6481343ed39a6","type":"switch","z":"e8d1cb207f57eaf8","name":"","property":"status.event","propertyType":"msg","rules":[{"t":"eq","v":"connect","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":230,"y":440,"wires":[["d14c2aba8e317cd0"]]},{"id":"d14c2aba8e317cd0","type":"change","z":"e8d1cb207f57eaf8","name":"reset message id and subscription lookup","rules":[{"t":"delete","p":"messageId","pt":"flow"},{"t":"delete","p":"lookup","pt":"flow"}],"action":"","property":"","from":"","to":"","reg":false,"x":480,"y":440,"wires":[[]]},{"id":"a7e0376d89163621","type":"tcp request","z":"e8d1cb207f57eaf8","name":"","server":"172.31.1.251","port":"7030","out":"sit","ret":"buffer","splitc":" ","newline":"","trim":false,"tls":"","x":520,"y":220,"wires":[["2a74605e38f0d1d3"]]},{"id":"6fc966c7341f947e","type":"function","z":"e8d1cb207f57eaf8","name":"Query Packet","func":"msg.payload = Buffer.from([0x55, 0x55, 0x55, 0xAA, 0x80, 0xB0, 0x01, 0xC0, 0x00, 0x08, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA4, 0x31 ]);\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":300,"y":220,"wires":[["a7e0376d89163621"]]},{"id":"ec9358d227636c16","type":"inject","z":"e8d1cb207f57eaf8","name":"Get Status","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"30","crontab":"","once":true,"onceDelay":"1","topic":"","payload":"","payloadType":"date","x":110,"y":220,"wires":[["6fc966c7341f947e"]]},{"id":"2a74605e38f0d1d3","type":"function","z":"e8d1cb207f57eaf8","name":"Parse Reply","func":"var NewMsg = {};\n\n// Only process Group Status responses.\nif ( msg.payload[10] == 0x21 ) {\n    msg.zone_count = msg.payload[17];\n\n    // Get the status for each zone.\n    // Bit 8-7 = State. 00 = Off, 01 = On, 11 = Turbo\n    // Bit 6-1 = Group Number\n    for (var i = 0; i < msg.zone_count; i++) {\n        var byte = 18 + (i * 8);\n\n        // Set number.zone_X to the retrieved value.\n        NewMsg.zone = i;\n        NewMsg.zone_status = msg.payload[byte] - i;\n        NewMsg.percent = msg.payload[byte + 1];\n\n        // zone_status == 0 means OFF\n        if ( NewMsg.zone_status == 0 ) {\n            NewMsg.percent = 0;\n        }\n        node.send(NewMsg);\n    }\n}\nreturn null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":730,"y":220,"wires":[["9b16dab46faa2008"]]},{"id":"9e5d406b701633c6","type":"inject","z":"e8d1cb207f57eaf8","name":"Delete HA Entities","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":130,"y":620,"wires":[["726cf4943fc9c028"]]},{"id":"726cf4943fc9c028","type":"ha-get-entities","z":"e8d1cb207f57eaf8","name":"","server":"adf8b17c.f6a7c","version":1,"rules":[{"property":"entity_id","logic":"starts_with","value":"number.zonetouch3_zone_","valueType":"str"}],"outputType":"split","outputEmptyResults":false,"outputLocationType":"msg","outputLocation":"payload","outputResultsCount":1,"x":330,"y":620,"wires":[["2df2274420584663"]]},{"id":"feb42266baf2ea80","type":"debug","z":"e8d1cb207f57eaf8","name":"debug 6","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":240,"y":760,"wires":[]},{"id":"2df2274420584663","type":"function","z":"e8d1cb207f57eaf8","name":"create remove","func":"const entity_id = msg.payload.entity_id;\nvar entity = entity_id.split(\".\")[1];\nentity.replace(\"nodered_\", \"\");\n\n// default payload \nmsg.payload =  { \n    type: \"nodered/discovery\",\n    server_id: entity,\n    node_id: entity,\n    component: \"number\",\n    remove: true,\n};\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":520,"y":620,"wires":[["f2157194d9403979"]]},{"id":"4cf60e0955916c37","type":"catch","z":"e8d1cb207f57eaf8","name":"","scope":null,"uncaught":false,"x":80,"y":760,"wires":[["feb42266baf2ea80"]]},{"id":"237283aa3101c2df","type":"comment","z":"e8d1cb207f57eaf8","name":"Send to HA WebSocket","info":"","x":120,"y":280,"wires":[]},{"id":"71e4f526aea3b09b","type":"link in","z":"e8d1cb207f57eaf8","name":"link in 7","links":["d18adb16ca69fa20","3ee9dfb96e70b1a0","f2157194d9403979"],"x":35,"y":320,"wires":[["f10acb738a5b6496"]]},{"id":"d18adb16ca69fa20","type":"link out","z":"e8d1cb207f57eaf8","name":"link out 5","mode":"link","links":["71e4f526aea3b09b"],"x":875,"y":80,"wires":[]},{"id":"3ee9dfb96e70b1a0","type":"link out","z":"e8d1cb207f57eaf8","name":"link out 6","mode":"link","links":["71e4f526aea3b09b"],"x":1045,"y":220,"wires":[]},{"id":"7acd81d42c67cbae","type":"comment","z":"e8d1cb207f57eaf8","name":"Maintenance Only","info":"","x":110,"y":580,"wires":[]},{"id":"32e6a7eea6b7c96b","type":"comment","z":"e8d1cb207f57eaf8","name":"Timed Trigger for sync","info":"","x":120,"y":180,"wires":[]},{"id":"b59c226ed03b2f66","type":"comment","z":"e8d1cb207f57eaf8","name":"Data in from HA WebSocket","info":"","x":140,"y":60,"wires":[]},{"id":"f2157194d9403979","type":"link out","z":"e8d1cb207f57eaf8","name":"link out 7","mode":"link","links":["71e4f526aea3b09b"],"x":655,"y":620,"wires":[]},{"id":"fb7a844de702beb0","type":"comment","z":"e8d1cb207f57eaf8","name":"Access Token is Important!","info":"","x":590,"y":40,"wires":[]},{"id":"a99f0e23682e9772","type":"comment","z":"e8d1cb207f57eaf8","name":"Clean up on a fresh connect","info":"","x":140,"y":400,"wires":[]},{"id":"06e5bb654365913d","type":"websocket-client","path":"ws://localhost:8123/api/websocket","tls":"","wholemsg":"false","hb":"0","subprotocol":""},{"id":"adf8b17c.f6a7c","type":"server","name":"Home Assistant","addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":"","connectionDelay":false,"cacheJson":true,"heartbeat":true,"heartbeatInterval":"30","areaSelector":"friendlyName","deviceSelector":"friendlyName","entitySelector":"friendlyName","statusSeparator":"","enableGlobalContextStore":true}]

It needs a Long Lived Token to be added into the flow for auth, but other than that, it mostly works.

The maintenance parts are not working yet… but most of the other stuff is.

Much of the credit to zachowj - the main guy behind the NodeRed WebSocket / Companion stuff:

@generically_named

Can you please try sending these two buffers to your unit?

[85,85,85,170,128,176,18,192,0,12,32,0,0,0,0,4,0,1,3,3,100,0,225,83]
[85,85,85,170,128,176,18,192,0,12,32,0,0,0,0,4,0,1,3,128,100,0,154,2]

One bug that I can’t seem to figure out is that when I set zone 0x03 to 100% (0x64), then it never accepts that setting. I can change it to every other percentage, and its fine. There’s just something wrong with 100%.

It only seems to happen on this zone as well. All others work.

I’m not sure if we’re hitting an edge case with an invalid checksum, or something really weird.

As a comparison, this sets zone 0x01 to 100% and works fine:
[85,85,85,170,128,176,18,192,0,12,32,0,0,0,0,4,0,1,1,3,100,0,89,82]
[85,85,85,170,128,176,18,192,0,12,32,0,0,0,0,4,0,1,1,128,100,0,177,163]

This is interesting, I am getting no response from the ZT3 when I try to set zone 3 to 100% as well. Not just using your buffers but through my program as well.

I’m investigating what the app sends in this situation, give me a minute.

There is no difference in the way the app is sending the data and the way we are. If I account for the different message ID the app uses and recalculate the CRC it comes out exactly the same as the packet capture from the app.

A workaround if we can’t fix this would be a two step message specifically for this case that sets the zone to 95% then uses the increment zone command (bits 8-6 of byte 2 of the message, set as 011) to increase it to 100%. That’s a bodge if I’ve ever seen one but I don’t understand why we’re encountering this issue?