Can you paste the TX message that you send to the device to put it in auto?
And also, the response to the query message later?
It depends on if
If you mean that you want to gather data but not manipulate anything…yes you can do that, but you’d need a different version of the code that only listens. I have that somewhere.
If your KJR-120W is using HA/HB, you can have the RS485 on there as well, I think. Not 100% sure of that though.
-Matt
@Oscar_Calvo are you doing anything special to control outdoor compressor speed for better temperature control, or all you’re doing is sending whatever your temperature sensor is reporting?
I wasn’t super happy with temperature regulation with my thermostat when we had a cold snap, where the outdoor compressor often ran too low of speed and fell behind, and then tried to play catch up only to hit frosting issues due to humidity. I am toying with the idea of “faking” temperature info to control compressor speed, I am curious if anyone has done anything like that.
Hey all; following this thread with great interest. I’m currently running
- wtahler’s yaml on an esp32 connected directly to my air handler board
- i am also running a kjr120 tstat in the main living area for the follow-me temperature (for now)
- i utilize the data from the esp32 primarily to track defrost cycles (if the outdoor temp measured at the coil suddenly is much higher than ambient and the condenser is still running, it’s defrosting)
- at some point I’d like to ditch the KJR and run everything through the esp32 but there are a few sharp edges preventing me from doing so (updating the firmware power cycles the unit??)
One question I have for the group is: which xye github/yaml file is “latest?” I see that there’s this new homeops repo which might be the newest, but based on the convos in this thread, mdrobnak seems to really know what’s going on. Unfortunately, from a consumer’s perspective who isn’t in the weeds, it’s kinda hard to tell which repo/yaml is the most up to date.
Could you explain what connection your using along with your KJR-120W wired controller, You must be using the 4 wire interface, to the air handler?
I’m not actually familiar with a 4-wire connection for the wired controller. Typically, you’re either using the 2-wire HA/HB connection to leverage communication between the stat, AHU, and outdoor condenser, or you’re running non-communicating 24V, and I’d imagine a heat pump would need more than just 4 wires. In my case I’m using HA/HB.
just actually got my esp32 logging through the KJR-120W wired controller today:) I have to be different,I guess. Am very catious as i dont want to kill the controller.
I connected the esp32 to the air handler board, not through the tstat. Personally, I wouldn’t want to connect up there because it’s not obvious to me how, at least for my setup, the XYE terminals would be active. Also, I’d seen setups where folks did it down in the air handler proper, so I opted to emulate what I saw.
I can’t speak to how safe it is to stick the esp up @ the stat itself, but in terms of how to power, there’s really only one solution to this: you power the esp how it wants to be powered. In my case, there’s a series of outlets near my air handler so I just plugged the microusb into an adapter.
I finally have some time…
I’m going to leave the rest of the Farenheit support in (even though I’m not using it) but put the setpoint back to the C0 frame.
Also I re-forked my repo - this time from esphome/esphome - so the two people who star’d the repo…you’ll have to do that again. Sorry. But now I’m actually tracking the correct upstream, rather than a fork.
This should work fine now. Though I do otice a 0.5 degree diffference from where I started…my thermostat show at 71.5 degree target instead of 72 now. :: shrug ::
Please let me know if this fixes anyone else having issues with the C0 vs C4 frame. (I included the masking out of 0x40 as well)
Have you tried the code that
Have you tried the code in my repo? Mine definitely does not do that. I’ve been using this code for well over a year, and while far from perfect, I think it works pretty well overall.
There’s also a flag for defrost, no need to use logic. When the protect flags is “2” that means a defrost is in progress. (Edit: Fixed flag reference)
My code has been updated to work with the latest ESPHome releases, and is what I am runnig myself. I’m doing my best to try and keep on top of things these days.
I think I posted my full yaml a bit earlier but I can do again if needed.
external_components:
- source: github://mdrobnak/esphome@delays_updated
components: [midea_xye]
is the setup for my latest code.
-Matt
I do this with my IR-sent “follow me” temp that is collected via another ESP device. I’ve found that, at least in my case with a Fahrenheit-based unit, being creative with rounding gets pretty close. This is my sophisticated algorithm:
if(isCooling){
//In cooling mode
if(isRunning){
rf = floor(f);
}else{
rf = ceil(f); //Not Running (round up)
}
}else if(isHeating){
if(isRunning){
rf = floor(f); //was round
}else{
rf = floor(f); //Not running, round down to heat sooner
}
}
Basically, it has a tendency to allow too much swing when it’s not running, and it overshoots AC and undershoots heat while running. This evens out some of those deltas.
The one thing I haven’t solved is making it smarter when it gets really cold. In that case, it’s clear the unit is losing the battle as the temp creeps down, but it waits too long to kick into turbo gear (around -3F from setpoint.) By that point, the outdoor temp affects output and it won’t be able to catch up to setpoint. It’s never been more than -3F off (and I have an unrealistic 70F set temp), but it seems like I could modify this to take outdoor temp and time of day to more aggressively heat. For example, if the unit has been running for X hours, the temp keeps falling, it’s cold outside, and it’s a few hours away from the coldest time (early morning, 4-5AM or so), it should crank it up to max to build up a buffer.
Hysteresis? Most climate integrations include it.
Ooh exciting! I’ll swap over to your firmware tomorrow. I came up with a rather hacky way of tracking defrosts (I have an emporia vue so if condenser_is_running and outdoor_temp_at_the_coils is really_high then is_defrosting = True sorta deal) but getting that for free is obviously better. I also saw your posts about follow me temps and I’d love to provide the temp from my more-accurate temp sensor in my living room.
Cheers to you and everyone else who’s mucking around in this stuff. It’s wildly fascinating and hugely helpful from an HA perspective!
Y’all probably know this and I imagine it may’ve come up in this thread already, but these Midea units don’t just use the follow me temp when you enable that functionality. My understanding is that it gets incorporated in the math the AHU is doing to decide how much heat/cool to call for. My understanding is that the actual temperature the unit is trying to attain is a mixture of the follow me temp, the temp of the air going into the air handler, the temp @ the coils, etc. Basically a side effect of the fact that the firmware was written for a mini split, where the temp at the output is pretty close to the room temp.
Also another thing you probably know but just to mention it, the unit can show you farenheit, but it really “thinks” in celsius. In order to get tighter drift for the set point, I played around a bunch with the offset temperature measured @ the thermostat, while also adjusting the set point. So now, my room will actually be 68, but I have the thermostat set for 66. For me in my situation, that seems to give me the smallest drift, even if it’s super cold out.
Depends on the unit. Some can work with Farenheit natively.
But, yes, it’s 70% weighted to the follow-me temp, 30% intake air temp on my AHU.
And yes, the temperature swings more than most Americans like ![]()
Hopefully I fixed the issue people had with it showing 120 degrees now…
-Matt
Just bumping this to the top in case others want to try @mdrobnak 's setup. This is a reply to the entire yaml they are referencing.
@mdrobnak what was the cause of the 120F issue? I am currently trying to run this XYE setup without providing a follow-me sensor, with a TL-04 thermostat (for now). Since TL-04 is not supported in Midea-AC-LAN integration, I am trying to use XYE to control the TL-04 thermostat. Changing temp works just fine, but I was seeing the 120F issue when I changed modes using XYE.
@brianHa Yep that’s more or less what I am also thinking about doing. Maybe even take it further and fake a lower follow me temp to make the compressor spin up earlier. I thought I saw someone on this thread who said they were doing something like this. I wonder how well that works.
Out of curiosity, why are you using IR? Why not XYE?
I’m not certain - I do not experience this. That said, it’s either a 0x40 bit being set, or the value in 0xC4 is not correct vs 0xC0. Either way, both of those issues should have been addressed with the latest push to the delays_updated branch.
-Matt
Right, can never blindly trust what the chat bot says.
Yes, I have it working while my t-stat is still connected. I’m seeing changes propagate back & forth between both interfaces. The key was tweaking the CRC calcs in the query cmd, once I got that the XYE interface responded with valid data. Then I had to also switch to Fahrenheit only as my IDU sends all temps in raw F integers.
This is an excellent document! It appears to be much more comprehensive then what I was using. I think the only part you’re missing that I’m aware of at this point is that defrost mode is indicated in byte 24 as a value of 0x02. Also my CRC calcs are a little different then yours.
Here’s my current Node-Red flow in case anyone is curious. My next step is to expand it with the query_extended and follow_me cmds to see if they work.
[{"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":"5","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 inDefrost: buffer[24] === 0x02, // Defrost cycle detection\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 'state/in_defrost': data.inDefrost,\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 in_defrost: data.inDefrost,\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 cut over to your version and wow; this is fantastic. Using an actually accurate temp sensor for follow me has been a revelation. Significantly better performance.
Re protect flags, what does a protect flag of 1 mean? Is there documentation somewhere that indicates what other flags there are and what they mean? Very happy to I can remove my kludgy helper I’ve been using to track defrost cycles haha.
Edit: Oh; my flag of 1 was just HA showing me an average haha. Are there any protect flags other than 2?
Don’t forget the heating offset configured via the DIP switches (or via special remote commands) that applies to the T1 (return air) temp.
Overall, the default algorithm seems to be optimized for efficiency, not closely tracking the set point. It tries to run at as low of a compressor frequency (output power) as it can for as long as it can, even if it results in up to a ±3F delta from set point.
I had my unit ESP controlled before the ‘follow me’ temp protocol was documented over RS-485. The original protocol work from the Codeberg repo was from a CCM controller, which doesn’t support sending temp. I tried it over XYE, and I saw the same thing rymo did (post 38). My unit won’t respond to C4 messages over the XYE terminals. It probably would if I connected to the RS485 (4-wire) pins, but I haven’t been motivated to bother trying, since my unit is in the attic and the IR works well for me.
