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: