Midea XYE direct communication

Greetings!

I have spent an ridiculous amount of time getting useable data from my Midea rebranded central-air mini-split air-handler unit (AHU) via its XYE terminal. This project is similar to many others but so far I have not found any direct XYE protocol implementation for Node Red so I thought I should open a thread to share my findings and maybe help someone else. Everything would have come together much faster except I have to use a different CRC calculation then previously mentioned in the XYE protocol references.

I primarily referenced this Codeberg page for the XYE protocol
Beyond that, the AI scraped the internet for any information it could find. :grinning_face:

edit
I should include the make/model of my heat pump. It's a Senville SENDC-24HF with 5kw electric Aux heat. I believe it's a rebranded Midea DLFSABH24XB3.

And I'm also using the KJR-120N 2-wire thermostat (with wifi) that connects to the HA/HB terminals in the AHU.

Hardware Connection

I call it direct XYE comms because I'm not using an ESP or WiFi dongle device but rather a RS485 Ethernet Serial Server (Hexin 2108E) in conjunction with a TCP request node to communicate with my AHU. The Hexin is connected via a db9-screw terminal adapter to the AHU's XYE terminals.

Hexin 2108E to XYE Wiring Connection

Heat Pump XYE Wire Color Hexin 2108E Terminal
X (Data+) White T+/B
Y (Data-) Red T-/A
E (Ground) Shield GND

Hexin 2108E Configuration

Network Settings:

  • IP Address: according to your network

  • Port: 1024 (anything suitable, must match your TCP request node)

  • Mode: TCP Server

Serial Settings:

  • Baud Rate: 4800

  • Data Bits: 8

  • Parity: NONE

  • Stop Bits: 1

Function

The Hexin 2108E acts as an RS-485 to Ethernet bridge, converting between:

  • RS-485 (physical layer to heat pump XYE bus)

  • TCP/IP (network layer to Node-RED or other automation systems)

Notes

  • Shield wire should only be grounded at ONE end (at the Hexin)

  • Use shielded twisted pair cable for best results

  • If communication doesn't work, try swapping X and Y wires (polarity can vary by manufacturer)

  • The XYE bus uses RS-485 at 4800 baud, 8N1 with the XYE protocol


CRC Calculations

As previously hinted at, I have to calculate the CRC a bit differently. I use bytes 1 to n-2 (2nd byte up to and including the 3rd last byte in the packet, query/cmd packet is 16 bytes, reply is 32 bytes). So that’s all the bytes excluding the preamble, CRC & postamble bytes. Once my Query 0xC0 has the proper CRC then the reply packet has useful data. This doesn’t surprise me but this is the first time I’ve see this specific combination of bytes mentioned for Midea XYE CRC calculations. I am surprised though that the AHU replies at all with an incorrect Query CRC. I'm using what I understand is a 8-bit additive inverse checksum or two's complement checksum. So similar to the Codeberg XYE protocol except targeting selective bytes.

let sum = 0;
for (let i = 1; i <= 13; i++) {  // Only bytes 1-13
    sum += frame[i];
}
frame[14] = (255 - (sum % 256) + 1) & 0xFF;

And that's as far as I've come. I thought it best to get this typed out for future reference before I forget the details.

The next step is to capture reply packets for each mode/fan combination to decipher the payload bytes as I've already noticed some more discrepancies with the Codeberg protocol.

Here's my latest flow now including the 0xC3 Set command.

[{"id":"8fdc4e97.ed6b5","type":"inject","z":"18a1c78c.638ef8","name":"Query 0xC0","props":[{"p":"commandCode","v":"0xC0","vt":"num"},{"p":"commandName","v":"Query 0xC0","vt":"str"}],"repeat":"2","crontab":"","once":true,"onceDelay":"2","topic":"","x":150,"y":340,"wires":[["b5298440.55041"]]},{"id":"b5298440.55041","type":"function","z":"18a1c78c.638ef8","name":"Build XYE Query","func":"// CORRECT XYE Protocol with FIXED CRC\n// CRC calculated from bytes 1-13 only (excluding preamble, CRC byte itself, and suffix)\n\nconst DEBUG_ENABLED = false;  // Change to true for detailed hex output\n\nconst PREAMBLE = 0xAA;\nconst SUFFIX = 0x55;\nconst COMMAND = msg.commandCode || 0xC0;\n\nconst DEVICE_ID = 0x00;  // Device ID (0x00 for first unit)\nconst MASTER_ID = 0x80;  // Master ID\n\nconst frame = [\n    PREAMBLE,              // Byte 0: 0xAA (NOT in CRC)\n    COMMAND,               // Byte 1: Command (IN CRC)\n    DEVICE_ID,         // Byte 2: Destination (IN CRC)\n    MASTER_ID,              // Byte 3: Source (IN CRC)\n    0x80,                  // Byte 4: From master flag (IN CRC)\n    MASTER_ID,              // Byte 5: Source repeated (IN CRC)\n    // Payload - 7 bytes, all zeros for query/lock/unlock (IN CRC)\n    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n    0,                     // Byte 13: Command check (IN CRC)\n    0,                     // Byte 14: CRC placeholder (NOT in CRC)\n    SUFFIX                 // Byte 15: 0x55 (NOT in CRC)\n];\n\n// Calculate command check: 255 - command code\nframe[13] = (255 - COMMAND) & 0xFF;\n\n// Calculate CRC: ONLY bytes 1-13 (excluding preamble, CRC itself, and suffix)\n// Formula: 255 - sum(bytes 1-13) % 256 + 1\nlet sum = 0;\nfor (let i = 1; i <= 13; i++) {  // Only bytes 1-13\n    sum += frame[i];\n}\nframe[14] = (255 - (sum % 256) + 1) & 0xFF;\n\nmsg.payload = Buffer.from(frame);\n\n// Format output like Parse function\nconst labels = {\n    0:  \"preamble\",\n    1:  \"command (C0:Query, C3:Set, CC:Lock, CD:Unlock)\",\n    2:  \"destination, device id\",\n    3:  \"source, master id\",\n    4:  \"from master flag\",\n    5:  \"source repeated, master id\",\n    6:  \"payload byte 0, mode for Set\",\n    7:  \"payload byte 1, fan for Set\",\n    8:  \"payload byte 2, setTemp for Set\",\n    9:  \"payload byte 3, flags for Set\",\n    10: \"payload byte 4, timer start for Set\",\n    11: \"payload byte 5, timer stop for Set\",\n    12: \"payload byte 6, reserved\",\n    13: \"command check, 255 - cmd\",\n    14: \"CRC\",\n    15: \"suffix\"\n};\n\nconst formattedHex = Array.from(frame).map((b, i) => {\n    const hex = '0x' + b.toString(16).padStart(2, '0');\n    const label = labels[i] || `byte_${i}`;\n    const paddedI = i.toString().padStart(2);\n    return `${paddedI} ${hex} (${label})`;\n}).join('\\n');\n\nif (DEBUG_ENABLED) node.warn(`${msg.commandName} - 16-BYTE QUERY:\\n${formattedHex}`);\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":250,"y":400,"wires":[["863b7fe1.d6b34"]]},{"id":"863b7fe1.d6b34","type":"tcp request","z":"18a1c78c.638ef8","server":"192.168.12.89","port":"1024","out":"time","splitc":" ","name":"Hexin XYE","x":370,"y":340,"wires":[["ca2c2dee.712d88"]]},{"id":"ca2c2dee.712d88","type":"function","z":"18a1c78c.638ef8","name":"Parse XYE Response","func":"// Parse XYE Response - 40MUAA Specific Decoding\n// Updated with discovered protocol mappings\n\n// ============================================================\n// DEBUG CONTROL - Set to false to suppress detailed output\n// ============================================================\nconst DEBUG_ENABLED = false;  // Change to true for detailed hex output\n\nif (!msg.payload || msg.payload.length !== 32) {\n    node.error(`Invalid response length: ${msg.payload ? msg.payload.length : 0}`);\n    return null;\n}\n\nconst buffer = msg.payload;\n\n// Verify CRC - ONLY bytes 1-29 (excluding preamble, CRC itself, and suffix)\nlet sum = 0;\nfor (let i = 1; i <= 29; i++) {\n    sum += buffer[i];\n}\nconst calculatedCRC = (255 - (sum % 256) + 1) & 0xFF;\nconst receivedCRC = buffer[30];\nconst crcValid = (receivedCRC === calculatedCRC);\n\n// ALWAYS show CRC errors (critical alert)\nif (!crcValid) {\n    node.error(`⚠️ CRC INVALID: received=0x${receivedCRC.toString(16)} calculated=0x${calculatedCRC.toString(16)}`);\n}\n\n// ============================================================\n// DECODING FUNCTIONS - 40MUAA Specific\n// ============================================================\n\n// Operating Mode (Byte 8) - 40MUAA uses different encoding than standard XYE\nfunction getOperMode(byte8) {\n    const modes = {\n        0x00: 'off',\n        0x80: 'aux',      // Auxiliary/Emergency heat only\n        0x81: 'fan_only',\n        0x82: 'dry',\n        0x84: 'heat',\n        0x88: 'cool',\n        0x91: 'auto'\n    };\n    return modes[byte8] || `unknown_0x${byte8.toString(16)}`;\n}\n\n// Fan Speed (Byte 9) - MODE DEPENDENT!\nfunction getFanSpeed(byte9, operMode) {\n    // OFF mode - fan is off\n    if (operMode === 'off') {\n        return 'off';\n    }\n    \n    // HEAT/AUX modes\n    if (operMode === 'heat' || operMode === 'heat_aux') {\n        if (byte9 === 0x80) return 'auto';          // Idle/starting\n        if (byte9 === 0x81) return 'auto';          // Active/high demand\n        if (byte9 === 0x82) return 'auto';          // Steady state/cruising\n        if (byte9 === 0x00) return 'high';\n        if (byte9 === 0x01) return 'high';\n        if (byte9 === 0x02) return 'mid';\n        if (byte9 === 0x04) return 'low';\n    }\n    \n    // COOL/DRY/FAN/AUTO modes\n    if (byte9 === 0x84) return 'auto';\n    if (byte9 === 0x81) return 'auto';\n    if (byte9 === 0x82) return 'auto';\n    if (byte9 === 0x01) return 'high';\n    if (byte9 === 0x02) return 'mid';\n    if (byte9 === 0x04) return 'low';\n    \n    return `unknown_0x${byte9.toString(16)}`;\n}\n\n// Set Temperature (Byte 10) - FAHRENHEIT on 40MUAA\nfunction getSetTemp(byte10) {\n    return {\n        fahrenheit: byte10,\n        celsius: Math.round(((byte10 - 32) * 5 / 9) * 10) / 10\n    };\n}\n\n// Sensor Temperatures (Bytes 11-14) - XYE formula\nfunction getSensorTemp(byteValue) {\n    if (byteValue === 0xFF) {\n        return null;  // No data\n    }\n    return Math.round(((byteValue - 0x30) / 2.0) * 10) / 10;\n}\n\n// Aux Heat Active (Byte 20, Bit 1)\nfunction hasAuxHeat(byte20) {\n    return (byte20 & 0x02) !== 0;\n}\n\n// Water Pump (Byte 21, Bit 2)\nfunction hasWaterPump(byte21) {\n    return (byte21 & 0x04) !== 0;\n}\n\n// Unit Locked (Byte 21, Bit 7)\nfunction isLocked(byte21) {\n    return (byte21 & 0x80) !== 0;\n}\n\n// ============================================================\n// PARSE RESPONSE\n// ============================================================\n\nconst operMode = getOperMode(buffer[8]);\nconst fanSpeed = getFanSpeed(buffer[9], operMode);\nconst setTemp = getSetTemp(buffer[10]);\n\nconst data = {\n    // Frame info\n    commandSent: msg.commandName || 'Query',\n    crcValid: crcValid,\n    responseCode: buffer[1],\n    deviceId: buffer[4],\n    \n    // Capabilities (constant for this unit)\n    capabilities1: buffer[6],\n    capabilities2: buffer[7],\n    \n    // Operating state\n    operMode: operMode,\n    operModeRaw: buffer[8],\n    fanSpeed: fanSpeed,\n    fanSpeedRaw: buffer[9],\n    \n    // Temperatures\n    setTempF: setTemp.fahrenheit,\n    setTempC: setTemp.celsius,\n    t1Temp: getSensorTemp(buffer[11]),      // Outdoor (or duplicate)\n    t1TempRaw: buffer[11],                   // Raw byte value\n    t2aTemp: getSensorTemp(buffer[12]),     // Indoor/Return Air ⭐ ACTUAL INDOOR\n    t2aTempRaw: buffer[12],                  // Raw byte value\n    t2bTemp: getSensorTemp(buffer[13]),     // Coil temp (hot end/discharge)\n    t2bTempRaw: buffer[13],                  // Raw byte value\n    t3Temp: getSensorTemp(buffer[14]),      // Outdoor (primary)\n    t3TempRaw: buffer[14],                   // Raw byte value\n    \n    // Power\n    current: buffer[15] === 0xFF ? null : buffer[15],\n    unknown16: buffer[16],\n    \n    // Timers\n    timerStart: buffer[17],\n    timerStop: buffer[18],\n    running: buffer[19],\n    \n    // Status flags\n    auxHeatActive: hasAuxHeat(buffer[20]),\n    modeFlags: buffer[20],\n    waterPump: hasWaterPump(buffer[21]),\n    locked: isLocked(buffer[21]),\n    operFlags: buffer[21],\n    \n    // Errors/Protection\n    error1: buffer[22],\n    error2: buffer[23],\n    protect1: buffer[24],\n    protect2: buffer[25],\n    ccmCommError: buffer[26],\n    \n    // Raw bytes for debugging\n    rawOperMode: `0x${buffer[8].toString(16)}`,\n    rawFanSpeed: `0x${buffer[9].toString(16)}`,\n    rawModeFlags: `0x${buffer[20].toString(16)}`,\n    rawOperFlags: `0x${buffer[21].toString(16)}`\n};\n\n// ============================================================\n// DEBUG OUTPUT - DETAILED HEX (only if DEBUG_ENABLED)\n// ============================================================\n\nif (DEBUG_ENABLED) {\n    // Map labels based on XYE/Midea protocol documentation\n    const labels = {\n        0:  \"preamble\",\n        1:  \"response code\",\n        2:  \"to master\",\n        3:  \"dest (master id)\",\n        4:  \"source (device id)\",\n        5:  \"dest repeat\",\n        6:  \"cap1\",\n        7:  \"cap2\",\n        8:  \"OPER MODE\",\n        9:  \"FAN SPEED\",\n        10: \"SET TEMP (°F)\",\n        11: \"T1 (outdoor/dup)\",\n        12: \"T2A (INDOOR ⭐)\",\n        13: \"T2B (coil hot)\",\n        14: \"T3 (outdoor)\",\n        15: \"CURRENT\",\n        16: \"unk16\",\n        17: \"timer_start\",\n        18: \"timer_stop\",\n        19: \"running\",\n        20: \"MODE FLAGS\",\n        21: \"OPER FLAGS\",\n        22: \"err1\",\n        23: \"err2\",\n        24: \"prot1\",\n        25: \"prot2\",\n        26: \"ccm_err\",\n        27: \"unk27\",\n        28: \"unk28\",\n        29: \"unk29\",\n        30: \"CRC\",\n        31: \"suffix\"\n    };\n\n    const detailedHex = Array.from(buffer).map((b, i) => {\n        const hex = '0x' + b.toString(16).padStart(2, '0');\n        const label = labels[i];\n        const paddedI = i.toString().padStart(2);\n        // Highlight key bytes\n        const important = [8, 9, 10, 15, 20, 21].includes(i) ? \"***\" : \"   \";\n        return `${paddedI} ${hex} ${important} ${label}`;\n    }).join('\\n');\n\n    node.warn(`RAW 32-BYTE RESPONSE:\\n${detailedHex}`);\n\n    // Summary line\n    node.warn(`MODE: ${data.operMode} | FAN: ${data.fanSpeed} | SETPOINT: ${data.setTempF}°F | AUX: ${data.auxHeatActive ? 'ON' : 'OFF'}`);\n\n    if (data.t1Temp !== null) {\n        node.warn(`TEMPS: Indoor=${data.t2aTemp}°C Outdoor=${data.t3Temp}°C Coil=${data.t2bTemp}°C`);\n    }\n\n    if (data.current !== null) {\n        node.warn(`CURRENT: ${data.current}A`);\n    }\n}\n\n// ALWAYS show communication errors (alerts)\nif (data.ccmCommError > 0) {\n    node.error(`⚠️ CCM Communication Error: ${data.ccmCommError}`);\n}\n\n// ALWAYS show system errors (alerts)\nif (data.error1 !== 0 || data.error2 !== 0) {\n    node.error(`⚠️ System Error: E1=0x${data.error1.toString(16)} E2=0x${data.error2.toString(16)}`);\n}\n\n// ALWAYS show protection codes (alerts)\n/*if (data.protect1 !== 0 || data.protect2 !== 0) {\n    node.error(`⚠️ Protection Active: P1=0x${data.protect1.toString(16)} P2=0x${data.protect2.toString(16)}`);\n}*/\n\n// ============================================================\n// OUTPUT\n// ============================================================\n\nmsg.xye = data;\nmsg.payload = data;\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":500,"y":400,"wires":[["91449075.d62c6"]]},{"id":"91449075.d62c6","type":"function","z":"18a1c78c.638ef8","name":"Publish to MQTT","func":"// Publish to MQTT - 40MUAA Specific Topics\n// Includes ALL bytes 6-26 for comprehensive monitoring\n// Removed: current (not reported), outdoor temp (not available), t1 (duplicate/unavailable)\n\nconst data = msg.xye;\nconst baseTopic = 'farm/house/hvac/upstairs/hp';\nconst messages = [];\n\n// Only publish if we have valid data\nif (!data || !data.crcValid) {\n    node.warn('Skipping MQTT publish - invalid or missing data');\n    return null;\n}\n\n// Calculate effective mode\nlet effectiveMode = data.operMode;\nif (data.operMode === 'heat' && data.auxHeatActive) {\n    effectiveMode = 'heat_with_aux';  // or 'aux_heat' or whatever you prefer\n}\n\n// Set aux_heat_mode true when it's in Aux only mode,\n// for some reason it only flags aux mode when it's \"Aux & Heat\"\nlet auxHeatMode = data.auxHeatActive;\nif (effectiveMode === 'aux') {\n    auxHeatMode = 'true';\n}\n\n// Create individual MQTT messages\nconst topics = {\n    // Operating state\n    'state/mode': effectiveMode, //data.operMode,\n    'state/fan': data.fanSpeed,\n    'state/setpoint_f': data.setTempF,\n    'state/setpoint_c': data.setTempC,\n    \n    // Temperature sensors (all sensors with scaled values)\n    'sensor/t1_temp': data.t1Temp !== null ? data.t1Temp : 'unavailable',       // T1 - outdoor duplicate\n    'sensor/t2a_temp': data.t2aTemp !== null ? data.t2aTemp : 'unavailable',     // T2A - Indoor/Return Air\n    'sensor/t2b_temp': data.t2bTemp !== null ? data.t2bTemp : 'unavailable',     // T2B - Coil hot end\n    'sensor/t3_temp': data.t3Temp !== null ? data.t3Temp : 'unavailable',       // T3 - Outdoor primary\n    \n    // Status flags\n    'state/aux_heat_active': auxHeatMode,\n    'state/water_pump': data.waterPump,\n    'state/locked': data.locked,\n    \n    // ALL RAW BYTES 6-26 (for comprehensive monitoring)\n    'raw/byte_06_cap1': data.capabilities1,\n    'raw/byte_07_cap2': data.capabilities2,\n    'raw/byte_08_oper_mode': data.operModeRaw,\n    'raw/byte_09_fan_speed': data.fanSpeedRaw,\n    'raw/byte_10_set_temp': data.setTempF,\n    'raw/byte_11_t1': data.t1TempRaw || 0,\n    'raw/byte_12_t2a': data.t2aTempRaw || 0,\n    'raw/byte_13_t2b': data.t2bTempRaw || 0,\n    'raw/byte_14_t3': data.t3TempRaw || 0,\n    'raw/byte_15_current': data.current !== null ? data.current : 255,\n    'raw/byte_16_unknown': data.unknown16 || 0,\n    'raw/byte_17_timer_start': data.timerStart,\n    'raw/byte_18_timer_stop': data.timerStop,\n    'raw/byte_19_running': data.running || 0,\n    'raw/byte_20_mode_flags': data.modeFlags,\n    'raw/byte_21_oper_flags': data.operFlags,\n    'raw/byte_22_error1': data.error1,\n    'raw/byte_23_error2': data.error2,\n    'raw/byte_24_protect1': data.protect1,\n    'raw/byte_25_protect2': data.protect2,\n    'raw/byte_26_ccm_error': data.ccmCommError,\n    \n    // Diagnostics\n    'debug/crc_valid': data.crcValid,\n    'debug/device_id': data.deviceId\n};\n\n// Create message for each topic\nfor (const [topic, value] of Object.entries(topics)) {\n    messages.push({\n        topic: `${baseTopic}/${topic}`,\n        payload: value.toString(),\n        retain: true\n    });\n}\n\n// Add a JSON summary topic for easy Home Assistant integration\nmessages.push({\n    topic: `${baseTopic}/state`,\n    payload: JSON.stringify({\n        mode: data.operMode,\n        fan: data.fanSpeed,\n        setpoint_f: data.setTempF,\n        setpoint_c: data.setTempC,\n        aux_heat: data.auxHeatActive,\n        t1_temp: data.t1Temp,        // Outdoor duplicate\n        t2a_temp: data.t2aTemp,      // Indoor/Return Air\n        t2b_temp: data.t2bTemp,      // Coil hot end\n        t3_temp: data.t3Temp,        // Outdoor primary\n        available: true\n    }),\n    retain: true\n});\n\n// Add complete raw bytes as single JSON topic\nmessages.push({\n    topic: `${baseTopic}/raw/all_bytes`,\n    payload: JSON.stringify({\n        byte_06: data.capabilities1,\n        byte_07: data.capabilities2,\n        byte_08: data.operModeRaw,\n        byte_09: data.fanSpeedRaw,\n        byte_10: data.setTempF,\n        byte_11: data.t1TempRaw || 0,\n        byte_12: data.t2aTempRaw || 0,\n        byte_13: data.t2bTempRaw || 0,\n        byte_14: data.t3TempRaw || 0,\n        byte_15: data.current !== null ? data.current : 255,\n        byte_16: data.unknown16 || 0,\n        byte_17: data.timerStart,\n        byte_18: data.timerStop,\n        byte_19: data.running || 0,\n        byte_20: data.modeFlags,\n        byte_21: data.operFlags,\n        byte_22: data.error1,\n        byte_23: data.error2,\n        byte_24: data.protect1,\n        byte_25: data.protect2,\n        byte_26: data.ccmCommError\n    }),\n    retain: true\n});\n\nnode.status({\n    fill: data.operMode === 'off' ? 'grey' : 'green',\n    shape: 'dot',\n    text: `${data.operMode} ${data.setTempF}°F`\n});\n\n// Store current state in flow context (nested under common name)\nflow.set('upstairs_heat_pump', {\n  mode: effectiveMode,\n  fan: data.fanSpeed,\n  setpoint_f: data.setTempF,\n  setpoint_c: data.setTempC,\n  aux_heat_active: auxHeatMode\n});\n\nreturn [messages];","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":630,"y":340,"wires":[["baa17f3d.f16888"]]},{"id":"baa17f3d.f16888","type":"mqtt out","z":"18a1c78c.638ef8","name":"MQTT Broker","topic":"","qos":"0","retain":"true","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"ad7b7ee9.73295","x":760,"y":400,"wires":[]},{"id":"31fd2279.416e06","type":"comment","z":"18a1c78c.638ef8","name":"XYE Interface with Indoor AHU","info":"","x":170,"y":160,"wires":[]},{"id":"17041e93.6ce761","type":"mqtt in","z":"18a1c78c.638ef8","name":"CMD Mode","topic":"farm/house/hvac/upstairs/hp/cmd/mode","qos":"1","datatype":"utf8","broker":"ad7b7ee9.73295","nl":false,"rap":true,"rh":0,"x":130,"y":200,"wires":[["35ff8cd0.4b92ac"]]},{"id":"9d62dbf3.31ca8","type":"mqtt in","z":"18a1c78c.638ef8","name":"CMD Fan","topic":"farm/house/hvac/upstairs/hp/cmd/fan","qos":"1","datatype":"utf8","broker":"ad7b7ee9.73295","nl":false,"rap":true,"rh":0,"x":120,"y":240,"wires":[["35ff8cd0.4b92ac"]]},{"id":"3ca3128e.4b2b06","type":"mqtt in","z":"18a1c78c.638ef8","name":"CMD Setpoint °C","topic":"farm/house/hvac/upstairs/hp/cmd/setpoint_c","qos":"1","datatype":"auto","broker":"ad7b7ee9.73295","nl":false,"rap":true,"rh":0,"x":140,"y":280,"wires":[["35ff8cd0.4b92ac"]]},{"id":"35ff8cd0.4b92ac","type":"function","z":"18a1c78c.638ef8","name":"Merge & Validate Command","func":"// Get current state\nconst currentState = flow.get('upstairs_heat_pump') || {\n    mode: 'off',\n    fan: 'auto',\n    setpoint_f: 73,\n    setpoint_c: 22.8\n};\n\n// Parse incoming command based on topic\nconst topic = msg.topic;\nconst payload = msg.payload;\n\nlet desiredState = Object.assign({}, currentState);\n\nif (topic.includes('/cmd/mode')) {\n    // Validate mode\n    const validModes = ['off', 'heat', 'heat_with_aux', 'aux', 'cool', 'fan_only', 'dry', 'auto'];\n    if (!validModes.includes(payload)) {\n        node.error('Invalid mode: ' + payload);\n        return null;\n    }\n    desiredState.mode = payload;\n    \n} else if (topic.includes('/cmd/fan')) {\n    // Validate fan\n    const validFans = ['auto', 'low', 'mid', 'high'];\n    if (!validFans.includes(payload)) {\n        node.error('Invalid fan: ' + payload);\n        return null;\n    }\n    desiredState.fan = payload;\n    \n} else if (topic.includes('/cmd/setpoint_c')) {\n    // Convert Celsius to Fahrenheit (round to nearest degree F)\n    const tempC = parseFloat(payload);\n    if (isNaN(tempC) || tempC < 10 || tempC > 35) {\n        node.error('Invalid temperature: ' + payload + '°C');\n        return null;\n    }\n    const tempF = Math.round(tempC * 9 / 5 + 32);\n    desiredState.setpoint_c = tempC;\n    desiredState.setpoint_f = tempF;\n}\n\nmsg.desiredState = desiredState;\nmsg.commandName = 'Set 0xC3';\n\nnode.status({\n    fill: 'blue',\n    shape: 'dot',\n    text: desiredState.mode + ' ' + desiredState.fan + ' ' + desiredState.setpoint_f + '°F'\n});\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":220,"wires":[["cba6275b.a3aae8"]]},{"id":"cba6275b.a3aae8","type":"function","z":"18a1c78c.638ef8","name":"Build Set 0xC3 Command","func":"// Build XYE Set Command (0xC3)\nconst desired = msg.desiredState;\n\nconst DEBUG_ENABLED = false;\n\nconst PREAMBLE = 0xAA;\nconst SUFFIX = 0x55;\nconst COMMAND = 0xC3;\nconst DEVICE_ID = 0x00;\nconst MASTER_ID = 0x80;\n\n// ===== ENCODE MODE (Byte 6) =====\nfunction encodeMode(mode) {\n    const modes = {\n        'off': 0x00,\n        'aux': 0x80,\n        'heat_with_aux': 0x80,\n        'fan_only': 0x81,\n        'dry': 0x82,\n        'heat': 0x84,\n        'cool': 0x88,\n        'auto': 0x91\n    };\n    return modes[mode] || 0x00;\n}\n\n// ===== ENCODE FAN (Byte 7) - MODE DEPENDENT! =====\nfunction encodeFan(fan, mode) {\n    // For heat/aux modes\n    if (mode === 'heat' || mode === 'heat_with_aux' || mode === 'aux') {\n        if (fan === 'auto') return 0x80;\n        if (fan === 'low') return 0x04;\n        if (fan === 'mid') return 0x02;\n        if (fan === 'high') return 0x01;\n    }\n    \n    // For cool/dry/fan/auto modes\n    if (fan === 'auto') return 0x84;\n    if (fan === 'low') return 0x04;\n    if (fan === 'mid') return 0x02;\n    if (fan === 'high') return 0x01;\n    \n    return 0x84;\n}\n\nconst modeByte = encodeMode(desired.mode);\nconst fanByte = encodeFan(desired.fan, desired.mode);\nconst tempByte = desired.setpoint_f;\n\nconst frame = [\n    PREAMBLE,\n    COMMAND,\n    DEVICE_ID,\n    MASTER_ID,\n    0x80,\n    MASTER_ID,\n    modeByte,\n    fanByte,\n    tempByte,\n    0x00,\n    0x00,\n    0x00,\n    0x00,\n    0,\n    0,\n    SUFFIX\n];\n\n// Calculate command check: 255 - command code\nframe[13] = (255 - COMMAND) & 0xFF;\n\n// Calculate CRC: bytes 1-13 only\nlet sum = 0;\nfor (let i = 1; i <= 13; i++) {\n    sum += frame[i];\n}\nframe[14] = (255 - (sum % 256) + 1) & 0xFF;\n\nmsg.payload = Buffer.from(frame);\n\nif (DEBUG_ENABLED) {\n    const hex = [];\n    for (let i = 0; i < frame.length; i++) {\n        hex.push('0x' + frame[i].toString(16).padStart(2, '0'));\n    }\n    node.warn('SET CMD: Mode=' + desired.mode + '(0x' + modeByte.toString(16) + ') Fan=' + desired.fan + '(0x' + fanByte.toString(16) + ') Temp=' + tempByte + '°F');\n    node.warn('FRAME: ' + hex.join(' '));\n}\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":410,"y":280,"wires":[["863b7fe1.d6b34"]]},{"id":"ad7b7ee9.73295","type":"mqtt-broker","name":"local mosquitto","broker":"192.168.12.253","port":"1883","clientid":"node-red-local","usetls":false,"compatmode":true,"keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","closeTopic":"","closeQos":"0","closePayload":"","willTopic":"","willQos":"0","willPayload":""}]

I'm not sure I completely "believe" the reported T1, T2A, T2B & T3 temperatures. I wonder if the raw conversion is wrong as none of the reported temps match any other sensors I have installed in the air return, plenum and outdoors.

An update to my XYE parsing, the reported temperatures are all in Fahrenheit instead of Celsius. Also I have visually confirmed the location of the sensors in the indoor air handler and updated the notes in the flow.

[{"id":"8fdc4e97.ed6b5","type":"inject","z":"18a1c78c.638ef8","name":"Query 0xC0","props":[{"p":"commandCode","v":"0xC0","vt":"num"},{"p":"commandName","v":"Query 0xC0","vt":"str"}],"repeat":"2","crontab":"","once":true,"onceDelay":"2","topic":"","x":150,"y":340,"wires":[["b5298440.55041"]]},{"id":"b5298440.55041","type":"function","z":"18a1c78c.638ef8","name":"Build XYE Query","func":"// CORRECT XYE Protocol with FIXED CRC\n// CRC calculated from bytes 1-13 only (excluding preamble, CRC byte itself, and suffix)\n\nconst DEBUG_ENABLED = false;  // Change to true for detailed hex output\n\nconst PREAMBLE = 0xAA;\nconst SUFFIX = 0x55;\nconst COMMAND = msg.commandCode || 0xC0;\n\nconst DEVICE_ID = 0x00;  // Device ID (0x00 for first unit)\nconst MASTER_ID = 0x80;  // Master ID\n\nconst frame = [\n    PREAMBLE,              // Byte 0: 0xAA (NOT in CRC)\n    COMMAND,               // Byte 1: Command (IN CRC)\n    DEVICE_ID,         // Byte 2: Destination (IN CRC)\n    MASTER_ID,              // Byte 3: Source (IN CRC)\n    0x80,                  // Byte 4: From master flag (IN CRC)\n    MASTER_ID,              // Byte 5: Source repeated (IN CRC)\n    // Payload - 7 bytes, all zeros for query/lock/unlock (IN CRC)\n    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n    0,                     // Byte 13: Command check (IN CRC)\n    0,                     // Byte 14: CRC placeholder (NOT in CRC)\n    SUFFIX                 // Byte 15: 0x55 (NOT in CRC)\n];\n\n// Calculate command check: 255 - command code\nframe[13] = (255 - COMMAND) & 0xFF;\n\n// Calculate CRC: ONLY bytes 1-13 (excluding preamble, CRC itself, and suffix)\n// Formula: 255 - sum(bytes 1-13) % 256 + 1\nlet sum = 0;\nfor (let i = 1; i <= 13; i++) {  // Only bytes 1-13\n    sum += frame[i];\n}\nframe[14] = (255 - (sum % 256) + 1) & 0xFF;\n\nmsg.payload = Buffer.from(frame);\n\n// Format output like Parse function\nconst labels = {\n    0:  \"preamble\",\n    1:  \"command (C0:Query, C3:Set, CC:Lock, CD:Unlock)\",\n    2:  \"destination, device id\",\n    3:  \"source, master id\",\n    4:  \"from master flag\",\n    5:  \"source repeated, master id\",\n    6:  \"payload byte 0, mode for Set\",\n    7:  \"payload byte 1, fan for Set\",\n    8:  \"payload byte 2, setTemp for Set\",\n    9:  \"payload byte 3, flags for Set\",\n    10: \"payload byte 4, timer start for Set\",\n    11: \"payload byte 5, timer stop for Set\",\n    12: \"payload byte 6, reserved\",\n    13: \"command check, 255 - cmd\",\n    14: \"CRC\",\n    15: \"suffix\"\n};\n\nconst formattedHex = Array.from(frame).map((b, i) => {\n    const hex = '0x' + b.toString(16).padStart(2, '0');\n    const label = labels[i] || `byte_${i}`;\n    const paddedI = i.toString().padStart(2);\n    return `${paddedI} ${hex} (${label})`;\n}).join('\\n');\n\nif (DEBUG_ENABLED) node.warn(`${msg.commandName} - 16-BYTE QUERY:\\n${formattedHex}`);\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":250,"y":400,"wires":[["863b7fe1.d6b34"]]},{"id":"863b7fe1.d6b34","type":"tcp request","z":"18a1c78c.638ef8","server":"192.168.12.89","port":"1024","out":"time","splitc":" ","name":"Hexin XYE","x":370,"y":340,"wires":[["ca2c2dee.712d88"]]},{"id":"ca2c2dee.712d88","type":"function","z":"18a1c78c.638ef8","name":"Parse XYE Response","func":"// Parse XYE Response - 40MUAA Specific Decoding\n// Updated with discovered protocol mappings\n\n// ============================================================\n// DEBUG CONTROL - Set to false to suppress detailed output\n// ============================================================\nconst DEBUG_ENABLED = false;  // Change to true for detailed hex output\n\nif (!msg.payload || msg.payload.length !== 32) {\n    node.error(`Invalid response length: ${msg.payload ? msg.payload.length : 0}`);\n    return null;\n}\n\nconst buffer = msg.payload;\n\n// Verify CRC - ONLY bytes 1-29 (excluding preamble, CRC itself, and suffix)\nlet sum = 0;\nfor (let i = 1; i <= 29; i++) {\n    sum += buffer[i];\n}\nconst calculatedCRC = (255 - (sum % 256) + 1) & 0xFF;\nconst receivedCRC = buffer[30];\nconst crcValid = (receivedCRC === calculatedCRC);\n\n// ALWAYS show CRC errors (critical alert)\nif (!crcValid) {\n    node.error(`⚠️ CRC INVALID: received=0x${receivedCRC.toString(16)} calculated=0x${calculatedCRC.toString(16)}`);\n}\n\n// ============================================================\n// DECODING FUNCTIONS - 40MUAA Specific\n// ============================================================\n\n// Operating Mode (Byte 8) - 40MUAA uses different encoding than standard XYE\nfunction getOperMode(byte8) {\n    const modes = {\n        0x00: 'off',\n        0x80: 'aux',      // Auxiliary/Emergency heat only\n        0x81: 'fan_only',\n        0x82: 'dry',\n        0x84: 'heat',\n        0x88: 'cool',\n        0x91: 'auto'\n    };\n    return modes[byte8] || `unknown_0x${byte8.toString(16)}`;\n}\n\n// Fan Speed (Byte 9) - MODE DEPENDENT!\nfunction getFanSpeed(byte9, operMode) {\n    // OFF mode - fan is off\n    if (operMode === 'off') {\n        return 'off';\n    }\n    \n    // HEAT/AUX modes\n    if (operMode === 'heat' || operMode === 'heat_aux') {\n        if (byte9 === 0x80) return 'auto';          // Idle/starting\n        if (byte9 === 0x81) return 'auto';          // Active/high demand\n        if (byte9 === 0x82) return 'auto';          // Steady state/cruising\n        if (byte9 === 0x00) return 'high';\n        if (byte9 === 0x01) return 'high';\n        if (byte9 === 0x02) return 'mid';\n        if (byte9 === 0x04) return 'low';\n    }\n    \n    // COOL/DRY/FAN/AUTO modes\n    if (byte9 === 0x84) return 'auto';\n    if (byte9 === 0x81) return 'auto';\n    if (byte9 === 0x82) return 'auto';\n    if (byte9 === 0x01) return 'high';\n    if (byte9 === 0x02) return 'mid';\n    if (byte9 === 0x04) return 'low';\n    \n    return `unknown_0x${byte9.toString(16)}`;\n}\n\n// Set Temperature (Byte 10) - FAHRENHEIT on 40MUAA\nfunction getSetTemp(byte10) {\n    return {\n        fahrenheit: byte10,\n        celsius: Math.round(((byte10 - 32) * 5 / 9) * 10) / 10\n    };\n}\n\n// Sensor Temperatures (Bytes 11-14) - FAHRENHEIT on 40MUAA (not standard XYE!)\nfunction getSensorTemp(byteValue) {\n    if (byteValue === 0xFF) {\n        return null;  // No data\n    }\n    // 40MUAA reports temps in Fahrenheit, convert to Celsius\n    const fahrenheit = byteValue;\n    return Math.round(((fahrenheit - 32) * 5 / 9) * 10) / 10;\n}\n\n// Aux Heat Active (Byte 20, Bit 1)\nfunction hasAuxHeat(byte20) {\n    return (byte20 & 0x02) !== 0;\n}\n\n// Water Pump (Byte 21, Bit 2)\nfunction hasWaterPump(byte21) {\n    return (byte21 & 0x04) !== 0;\n}\n\n// Unit Locked (Byte 21, Bit 7)\nfunction isLocked(byte21) {\n    return (byte21 & 0x80) !== 0;\n}\n\n// ============================================================\n// PARSE RESPONSE\n// ============================================================\n\nconst operMode = getOperMode(buffer[8]);\nconst fanSpeed = getFanSpeed(buffer[9], operMode);\nconst setTemp = getSetTemp(buffer[10]);\n\nconst data = {\n    // Frame info\n    commandSent: msg.commandName || 'Query',\n    crcValid: crcValid,\n    responseCode: buffer[1],\n    deviceId: buffer[4],\n    \n    // Capabilities (constant for this unit)\n    capabilities1: buffer[6],\n    capabilities2: buffer[7],\n    \n    // Operating state\n    operMode: operMode,\n    operModeRaw: buffer[8],\n    fanSpeed: fanSpeed,\n    fanSpeedRaw: buffer[9],\n    \n    // Temperatures\n    setTempF: setTemp.fahrenheit,\n    setTempC: setTemp.celsius,\n    t1Temp: getSensorTemp(buffer[11]),      // Indoor Return Air\n    t1TempRaw: buffer[11],                  // Raw byte value\n    t2aTemp: getSensorTemp(buffer[12]),     // Indoor Coil Midpoint\n    t2aTempRaw: buffer[12],                 // Raw byte value\n    t2bTemp: getSensorTemp(buffer[13]),     // Indoor Coil Gas/Hot end\n    t2bTempRaw: buffer[13],                 // Raw byte value\n    t3Temp: getSensorTemp(buffer[14]),      // Outdoor Air\n    t3TempRaw: buffer[14],                  // Raw byte value\n    \n    // Power\n    current: buffer[15] === 0xFF ? null : buffer[15],\n    unknown16: buffer[16],\n    \n    // Timers\n    timerStart: buffer[17],\n    timerStop: buffer[18],\n    running: buffer[19],\n    \n    // Status flags\n    auxHeatActive: hasAuxHeat(buffer[20]),\n    modeFlags: buffer[20],\n    waterPump: hasWaterPump(buffer[21]),\n    locked: isLocked(buffer[21]),\n    operFlags: buffer[21],\n    \n    // Errors/Protection\n    error1: buffer[22],\n    error2: buffer[23],\n    protect1: buffer[24],\n    protect2: buffer[25],\n    ccmCommError: buffer[26],\n    \n    // Raw bytes for debugging\n    rawOperMode: `0x${buffer[8].toString(16)}`,\n    rawFanSpeed: `0x${buffer[9].toString(16)}`,\n    rawModeFlags: `0x${buffer[20].toString(16)}`,\n    rawOperFlags: `0x${buffer[21].toString(16)}`\n};\n\n// ============================================================\n// DEBUG OUTPUT - DETAILED HEX (only if DEBUG_ENABLED)\n// ============================================================\n\nif (DEBUG_ENABLED) {\n    // Map labels based on XYE/Midea protocol documentation\n    const labels = {\n        0:  \"preamble\",\n        1:  \"response code\",\n        2:  \"to master\",\n        3:  \"dest (master id)\",\n        4:  \"source (device id)\",\n        5:  \"dest repeat\",\n        6:  \"cap1\",\n        7:  \"cap2\",\n        8:  \"OPER MODE\",\n        9:  \"FAN SPEED\",\n        10: \"SET TEMP (°F)\",\n        11: \"T1 (return air)\",\n        12: \"T2A (coil midpoint)\",\n        13: \"T2B (coil inlet/hot gas)\",\n        14: \"T3 (outdoor air)\",\n        15: \"CURRENT\",\n        16: \"unk16\",\n        17: \"timer_start\",\n        18: \"timer_stop\",\n        19: \"running\",\n        20: \"MODE FLAGS\",\n        21: \"OPER FLAGS\",\n        22: \"err1\",\n        23: \"err2\",\n        24: \"prot1\",\n        25: \"prot2\",\n        26: \"ccm_err\",\n        27: \"unk27\",\n        28: \"unk28\",\n        29: \"unk29\",\n        30: \"CRC\",\n        31: \"suffix\"\n    };\n\n    const detailedHex = Array.from(buffer).map((b, i) => {\n        const hex = '0x' + b.toString(16).padStart(2, '0');\n        const label = labels[i];\n        const paddedI = i.toString().padStart(2);\n        // Highlight key bytes\n        const important = [8, 9, 10, 15, 20, 21].includes(i) ? \"***\" : \"   \";\n        return `${paddedI} ${hex} ${important} ${label}`;\n    }).join('\\n');\n\n    node.warn(`RAW 32-BYTE RESPONSE:\\n${detailedHex}`);\n\n    // Summary line\n    node.warn(`MODE: ${data.operMode} | FAN: ${data.fanSpeed} | SETPOINT: ${data.setTempF}°F | AUX: ${data.auxHeatActive ? 'ON' : 'OFF'}`);\n\n    if (data.t1Temp !== null) {\n        node.warn(`TEMPS: Indoor=${data.t2aTemp}°C Outdoor=${data.t3Temp}°C Coil=${data.t2bTemp}°C`);\n    }\n\n    if (data.current !== null) {\n        node.warn(`CURRENT: ${data.current}A`);\n    }\n}\n\n// ALWAYS show communication errors (alerts)\nif (data.ccmCommError > 0) {\n    node.error(`⚠️ CCM Communication Error: ${data.ccmCommError}`);\n}\n\n// ALWAYS show system errors (alerts)\nif (data.error1 !== 0 || data.error2 !== 0) {\n    node.error(`⚠️ System Error: E1=0x${data.error1.toString(16)} E2=0x${data.error2.toString(16)}`);\n}\n\n// ALWAYS show protection codes (alerts)\n/*if (data.protect1 !== 0 || data.protect2 !== 0) {\n    node.error(`⚠️ Protection Active: P1=0x${data.protect1.toString(16)} P2=0x${data.protect2.toString(16)}`);\n}*/\n\n// ============================================================\n// OUTPUT\n// ============================================================\n\nmsg.xye = data;\nmsg.payload = data;\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":500,"y":400,"wires":[["91449075.d62c6"]]},{"id":"91449075.d62c6","type":"function","z":"18a1c78c.638ef8","name":"Publish to MQTT","func":"// Publish to MQTT - 40MUAA Specific Topics\n// Includes ALL bytes 6-26 for comprehensive monitoring\n// Removed: current (not reported), outdoor temp (not available), t1 (duplicate/unavailable)\n\nconst data = msg.xye;\nconst baseTopic = 'farm/house/hvac/upstairs/hp';\nconst messages = [];\n\n// Only publish if we have valid data\nif (!data || !data.crcValid) {\n    node.warn('Skipping MQTT publish - invalid or missing data');\n    return null;\n}\n\n// Calculate effective mode\nlet effectiveMode = data.operMode;\nif (data.operMode === 'heat' && data.auxHeatActive) {\n    effectiveMode = 'heat_with_aux';  // or 'aux_heat' or whatever you prefer\n}\n\n// Set aux_heat_mode true when it's in Aux only mode,\n// for some reason it only flags aux mode when it's \"Aux & Heat\"\nlet auxHeatMode = data.auxHeatActive;\nif (effectiveMode === 'aux') {\n    auxHeatMode = 'true';\n}\n\n// Create individual MQTT messages\nconst topics = {\n    // Operating state\n    'state/mode': effectiveMode, //data.operMode,\n    'state/fan': data.fanSpeed,\n    'state/setpoint_f': data.setTempF,\n    'state/setpoint_c': data.setTempC,\n    \n    // Temperature sensors (all sensors with scaled values)\n    'sensor/t1_temp': data.t1Temp !== null ? data.t1Temp : 'unavailable',       // T1 - outdoor duplicate\n    'sensor/t2a_temp': data.t2aTemp !== null ? data.t2aTemp : 'unavailable',     // T2A - Indoor/Return Air\n    'sensor/t2b_temp': data.t2bTemp !== null ? data.t2bTemp : 'unavailable',     // T2B - Coil hot end\n    'sensor/t3_temp': data.t3Temp !== null ? data.t3Temp : 'unavailable',       // T3 - Outdoor primary\n    \n    // Status flags\n    'state/aux_heat_active': auxHeatMode,\n    'state/water_pump': data.waterPump,\n    'state/locked': data.locked,\n    \n    // ALL RAW BYTES 6-26 (for comprehensive monitoring)\n    'raw/byte_06_cap1': data.capabilities1,\n    'raw/byte_07_cap2': data.capabilities2,\n    'raw/byte_08_oper_mode': data.operModeRaw,\n    'raw/byte_09_fan_speed': data.fanSpeedRaw,\n    'raw/byte_10_set_temp': data.setTempF,\n    'raw/byte_11_t1': data.t1TempRaw || 0,\n    'raw/byte_12_t2a': data.t2aTempRaw || 0,\n    'raw/byte_13_t2b': data.t2bTempRaw || 0,\n    'raw/byte_14_t3': data.t3TempRaw || 0,\n    'raw/byte_15_current': data.current !== null ? data.current : 255,\n    'raw/byte_16_unknown': data.unknown16 || 0,\n    'raw/byte_17_timer_start': data.timerStart,\n    'raw/byte_18_timer_stop': data.timerStop,\n    'raw/byte_19_running': data.running || 0,\n    'raw/byte_20_mode_flags': data.modeFlags,\n    'raw/byte_21_oper_flags': data.operFlags,\n    'raw/byte_22_error1': data.error1,\n    'raw/byte_23_error2': data.error2,\n    'raw/byte_24_protect1': data.protect1,\n    'raw/byte_25_protect2': data.protect2,\n    'raw/byte_26_ccm_error': data.ccmCommError,\n    \n    // Diagnostics\n    'debug/crc_valid': data.crcValid,\n    'debug/device_id': data.deviceId\n};\n\n// Create message for each topic\nfor (const [topic, value] of Object.entries(topics)) {\n    messages.push({\n        topic: `${baseTopic}/${topic}`,\n        payload: value.toString(),\n        retain: true\n    });\n}\n\n// Add a JSON summary topic for easy Home Assistant integration\nmessages.push({\n    topic: `${baseTopic}/state`,\n    payload: JSON.stringify({\n        mode: data.operMode,\n        fan: data.fanSpeed,\n        setpoint_f: data.setTempF,\n        setpoint_c: data.setTempC,\n        aux_heat: data.auxHeatActive,\n        t1_temp: data.t1Temp,        // Outdoor duplicate\n        t2a_temp: data.t2aTemp,      // Indoor/Return Air\n        t2b_temp: data.t2bTemp,      // Coil hot end\n        t3_temp: data.t3Temp,        // Outdoor primary\n        available: true\n    }),\n    retain: true\n});\n\n// Add complete raw bytes as single JSON topic\nmessages.push({\n    topic: `${baseTopic}/raw/all_bytes`,\n    payload: JSON.stringify({\n        byte_06: data.capabilities1,\n        byte_07: data.capabilities2,\n        byte_08: data.operModeRaw,\n        byte_09: data.fanSpeedRaw,\n        byte_10: data.setTempF,\n        byte_11: data.t1TempRaw || 0,\n        byte_12: data.t2aTempRaw || 0,\n        byte_13: data.t2bTempRaw || 0,\n        byte_14: data.t3TempRaw || 0,\n        byte_15: data.current !== null ? data.current : 255,\n        byte_16: data.unknown16 || 0,\n        byte_17: data.timerStart,\n        byte_18: data.timerStop,\n        byte_19: data.running || 0,\n        byte_20: data.modeFlags,\n        byte_21: data.operFlags,\n        byte_22: data.error1,\n        byte_23: data.error2,\n        byte_24: data.protect1,\n        byte_25: data.protect2,\n        byte_26: data.ccmCommError\n    }),\n    retain: true\n});\n\nnode.status({\n    fill: data.operMode === 'off' ? 'grey' : 'green',\n    shape: 'dot',\n    text: `${data.operMode} ${data.setTempF}°F`\n});\n\n// Store current state in flow context (nested under common name)\nflow.set('upstairs_heat_pump', {\n  mode: effectiveMode,\n  fan: data.fanSpeed,\n  setpoint_f: data.setTempF,\n  setpoint_c: data.setTempC,\n  aux_heat_active: auxHeatMode\n});\n\nreturn [messages];","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":630,"y":340,"wires":[["baa17f3d.f16888"]]},{"id":"baa17f3d.f16888","type":"mqtt out","z":"18a1c78c.638ef8","name":"MQTT Broker","topic":"","qos":"0","retain":"true","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"ad7b7ee9.73295","x":760,"y":400,"wires":[]},{"id":"31fd2279.416e06","type":"comment","z":"18a1c78c.638ef8","name":"XYE Interface with Indoor AHU","info":"","x":170,"y":160,"wires":[]},{"id":"17041e93.6ce761","type":"mqtt in","z":"18a1c78c.638ef8","name":"CMD Mode","topic":"farm/house/hvac/upstairs/hp/cmd/mode","qos":"1","datatype":"utf8","broker":"ad7b7ee9.73295","nl":false,"rap":true,"rh":0,"x":130,"y":200,"wires":[["35ff8cd0.4b92ac"]]},{"id":"9d62dbf3.31ca8","type":"mqtt in","z":"18a1c78c.638ef8","name":"CMD Fan","topic":"farm/house/hvac/upstairs/hp/cmd/fan","qos":"1","datatype":"utf8","broker":"ad7b7ee9.73295","nl":false,"rap":true,"rh":0,"x":120,"y":240,"wires":[["35ff8cd0.4b92ac"]]},{"id":"3ca3128e.4b2b06","type":"mqtt in","z":"18a1c78c.638ef8","name":"CMD Setpoint °C","topic":"farm/house/hvac/upstairs/hp/cmd/setpoint_c","qos":"1","datatype":"auto","broker":"ad7b7ee9.73295","nl":false,"rap":true,"rh":0,"x":140,"y":280,"wires":[["35ff8cd0.4b92ac"]]},{"id":"35ff8cd0.4b92ac","type":"function","z":"18a1c78c.638ef8","name":"Merge & Validate Command","func":"// Get current state\nconst currentState = flow.get('upstairs_heat_pump') || {\n    mode: 'off',\n    fan: 'auto',\n    setpoint_f: 73,\n    setpoint_c: 22.8\n};\n\n// Parse incoming command based on topic\nconst topic = msg.topic;\nconst payload = msg.payload;\n\nlet desiredState = Object.assign({}, currentState);\n\nif (topic.includes('/cmd/mode')) {\n    // Validate mode\n    const validModes = ['off', 'heat', 'heat_with_aux', 'aux', 'cool', 'fan_only', 'dry', 'auto'];\n    if (!validModes.includes(payload)) {\n        node.error('Invalid mode: ' + payload);\n        return null;\n    }\n    desiredState.mode = payload;\n    \n} else if (topic.includes('/cmd/fan')) {\n    // Validate fan\n    const validFans = ['auto', 'low', 'mid', 'high'];\n    if (!validFans.includes(payload)) {\n        node.error('Invalid fan: ' + payload);\n        return null;\n    }\n    desiredState.fan = payload;\n    \n} else if (topic.includes('/cmd/setpoint_c')) {\n    // Convert Celsius to Fahrenheit (round to nearest degree F)\n    const tempC = parseFloat(payload);\n    if (isNaN(tempC) || tempC < 10 || tempC > 35) {\n        node.error('Invalid temperature: ' + payload + '°C');\n        return null;\n    }\n    const tempF = Math.round(tempC * 9 / 5 + 32);\n    desiredState.setpoint_c = tempC;\n    desiredState.setpoint_f = tempF;\n}\n\nmsg.desiredState = desiredState;\nmsg.commandName = 'Set 0xC3';\n\nnode.status({\n    fill: 'blue',\n    shape: 'dot',\n    text: desiredState.mode + ' ' + desiredState.fan + ' ' + desiredState.setpoint_f + '°F'\n});\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":220,"wires":[["cba6275b.a3aae8"]]},{"id":"cba6275b.a3aae8","type":"function","z":"18a1c78c.638ef8","name":"Build Set 0xC3 Command","func":"// Build XYE Set Command (0xC3)\nconst desired = msg.desiredState;\n\nconst DEBUG_ENABLED = false;\n\nconst PREAMBLE = 0xAA;\nconst SUFFIX = 0x55;\nconst COMMAND = 0xC3;\nconst DEVICE_ID = 0x00;\nconst MASTER_ID = 0x80;\n\n// ===== ENCODE MODE (Byte 6) =====\nfunction encodeMode(mode) {\n    const modes = {\n        'off': 0x00,\n        'aux': 0x80,\n        'heat_with_aux': 0x80,\n        'fan_only': 0x81,\n        'dry': 0x82,\n        'heat': 0x84,\n        'cool': 0x88,\n        'auto': 0x91\n    };\n    return modes[mode] || 0x00;\n}\n\n// ===== ENCODE FAN (Byte 7) - MODE DEPENDENT! =====\nfunction encodeFan(fan, mode) {\n    // For heat/aux modes\n    if (mode === 'heat' || mode === 'heat_with_aux' || mode === 'aux') {\n        if (fan === 'auto') return 0x80;\n        if (fan === 'low') return 0x04;\n        if (fan === 'mid') return 0x02;\n        if (fan === 'high') return 0x01;\n    }\n    \n    // For cool/dry/fan/auto modes\n    if (fan === 'auto') return 0x84;\n    if (fan === 'low') return 0x04;\n    if (fan === 'mid') return 0x02;\n    if (fan === 'high') return 0x01;\n    \n    return 0x84;\n}\n\nconst modeByte = encodeMode(desired.mode);\nconst fanByte = encodeFan(desired.fan, desired.mode);\nconst tempByte = desired.setpoint_f;\n\nconst frame = [\n    PREAMBLE,\n    COMMAND,\n    DEVICE_ID,\n    MASTER_ID,\n    0x80,\n    MASTER_ID,\n    modeByte,\n    fanByte,\n    tempByte,\n    0x00,\n    0x00,\n    0x00,\n    0x00,\n    0,\n    0,\n    SUFFIX\n];\n\n// Calculate command check: 255 - command code\nframe[13] = (255 - COMMAND) & 0xFF;\n\n// Calculate CRC: bytes 1-13 only\nlet sum = 0;\nfor (let i = 1; i <= 13; i++) {\n    sum += frame[i];\n}\nframe[14] = (255 - (sum % 256) + 1) & 0xFF;\n\nmsg.payload = Buffer.from(frame);\n\nif (DEBUG_ENABLED) {\n    const hex = [];\n    for (let i = 0; i < frame.length; i++) {\n        hex.push('0x' + frame[i].toString(16).padStart(2, '0'));\n    }\n    node.warn('SET CMD: Mode=' + desired.mode + '(0x' + modeByte.toString(16) + ') Fan=' + desired.fan + '(0x' + fanByte.toString(16) + ') Temp=' + tempByte + '°F');\n    node.warn('FRAME: ' + hex.join(' '));\n}\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":410,"y":280,"wires":[["863b7fe1.d6b34"]]},{"id":"ad7b7ee9.73295","type":"mqtt-broker","name":"local mosquitto","broker":"192.168.12.253","port":"1883","clientid":"node-red-local","usetls":false,"compatmode":true,"keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","closeTopic":"","closeQos":"0","closePayload":"","willTopic":"","willQos":"0","willPayload":""}]