Midea A/C via local XYE

Looks like my unit uses frames C0 and C3 byte positions 28 and 29 as the EEV Low and EEV High. You get a better picture of what the unit is doing with byte position 19, when the unit is active vs idle.

To save some typing Reverse Engineering Senville/Midea SComms - #18 by MidATRIX

I hope this is standard but I don't think it will be since I recently learned that mini-splits typically do not have an indoor EEV. I have both an indoor EEV and a outdoor EXV (This is how my manuals document them and we all know how accurate the manuals are)

I can try and help decode if yours is different.

Can you detail the connections you've made to the back of your wired controller? I have the same model and I'm likewise trying to tap into XYE while keeping the controller functional on HAHB.

Slightly adjacent, not the same protocol, but maybe useful for people working on local Midea/ESPHome/RS485 integrations.

I published an ESPHome Modbus RTU package for a Mitsui-branded Midea-platform hydronic heat pump:

This is not XYE / midea_xye. It is RS485 Modbus RTU on a hydronic heat pump using a Waveshare ESP32-S3-RS485-CAN board.

Current scope:

  • read-only runtime/configuration/diagnostic sensors
  • guarded write for the validated water setpoint register
  • no cloud dependency
  • tested on real hardware

Sharing it here only because the general theme is similar: local Midea integration via ESPHome and wired bus instead of cloud/app-only control.

For Clivet / Riello / Immergas / Airwell / Hyundai / Mundoclima and other Midea-platform hydronic units, start read-only and validate the register map against the wired controller before enabling writes.

One pin carries a 500Hz clock signal and the other carries data. It must be actively driven, otherwise the clock and data pin sit idle at 5V.

I bought one of those inverter testers from Aliexpres - that helps a lot with reverse engineering (even though the display is in Chinese).

Here is what I have so far: https://medium.com/@florian.mckee/reverse-engineering-mideas-odu-diagnostic-port-af603e159053

Nice! That looks like I2C communication. Which makes sense as the inverter tool manual labels the pins as 5V, GND, SCL, SDA i.e. latter two refer to I2C.

I spent a little time digging into the HA/HB thermostat interface on my KJR-120W/MBF. It uses Home Bus System (HBS) signalling, via MM1192 interface chip. So, not at all standard RS-485.

Since I don’t have any HBS equipment, I tapped the upstream pins on the interface chip, captured it with my scope at 48000 baud (double the actual rate) and fed it into Claude for analysis.

Bottom line: HA/HB does use the XYE protocol. Sort of. They’ve added 4 bit framing, where every byte is transmitted as 10 bits with a couple of extra zeroes stuffed in, presumably for improved clock recovery.

I captured one previously unknown packet, a 32-byte status broadcast (command code 0xD0) containing essentially everything you see on the thermostat screen. For my own purposes, I don’t see any advantage to using HA/HB over the XYE interface on my air handler.

Hi Everyone,

Do I need some speficifc hardware to connect to Midea MO1SE-H36B-2A controller board which is 16023000016273? I have esp32 and breadboards. Can I not use those to connect directly?

Do I need something like this along with ESP32?

Hi all. I have a Carrier 38MURA/40MUAA that I am hoping to collect some more information on, and maybe drive directly through XYE rather than the 24v ecobee thermostat my clueless installer insisted on. Currently, my system is wired 24v thermostat <> air handler, and also 24v air handler <> outdoor unit.
I have an esp32 with a UART <> RS485 adapter with flow control. Hooked up to the XYE terminals on my board, I see no RX light flashing, and I seemingly get no data. I’ve flipped TX/RX, and also X/Y. I see ~5V on one of the terminals. Is there a combination of dip switches or something else I need to do to enable communication on this port?

Huge thanks again to everyone doing the heavy lifting on the XYE interface. I have a bit of a breakthrough on the concealed-duct unit I described in post #97, and I wanted to put the full result back here instead of just saying “it works.”

For context, this is an ACiQ/Midea 24K concealed-duct AHU on a 6-zone condenser. I have an M5Stack Atom + Tail485 on XYE at 4800 baud, while the KJR-120N wired controller is still attached on its separate controller bus.

What I’ve proved

  • The head reports capabilities 0x14, so it does not advertise the EXTERNAL_TEMP capability (0x80).
  • The ESPHome component sends the C6 Follow-Me frame successfully, but this particular head ignores it.
  • I heated the wired thermostat with a heat gun. Its reported temperature climbed to about 89°F while the independent living-room sensor stayed around 72°F, and the unit continued regulating against the wired thermostat temperature.
  • Setpoint writes sent over XYE are accepted, echoed back, and remain authoritative even with the wired controller attached.

So native C6 Follow-Me is a dead end on this head, but XYE setpoint control gives us another path.

The fallback that is working

Every 60 seconds the ESP32 calculates:

head setpoint = desired room target + (wired thermostat temperature - independent room temperature)

Example: if I want the room at 70°F, the wall controller sees 73°F, and the room sensor sees 71°F, the ESP32 sends a 72°F setpoint to the head. As those two measured temperatures converge, the bias disappears.

The loop is on the ESP32, so it does not depend on a Home Assistant automation continuing to run. It exits when the climate is off, skips invalid sensor values, clamps the command to 65–78°F, and has a switch that disables room tracking immediately. A live result looked like this:

target=70.0F wall=22.8C room=22.4C -> setpoint=70.7F

The unit echoed the setpoint and held it. The independent room sensor was also within about 0.4°F of a separate handheld reading.

I am not claiming the underlying offset math is new control theory. What may be useful/novel here is applying a continuously recalculated two-sensor bias on-device as a software Follow-Me fallback for an XYE head that transmits and receives normally but ignores C6. I found the static-offset discussion in #38, the fake Follow-Me idea in #140 and #147, and manual setpoint-offset tuning in #150, but I did not find this exact fallback. If somebody already published it, please point me there.

Important caveats

  • This is validated on one ACiQ concealed-duct head, primarily in cooling. Different unit families and controller-bus topologies may behave differently.
  • I still want to add a minimum-change threshold and an explicit stale-room-sensor timeout before calling the loop finished.
  • The working component is currently a local vendor: larsonm-personal@62a3047 for the raw-Fahrenheit/C0 behavior, plus the WAIT_DATA recovery from HomeOps PR #136. The raw-Fahrenheit/C0 work is still represented publicly by HomeOps PR #137, so the upstream pieces are split at the moment.
  • The YAML below therefore references a local components/midea_xye directory; it is the complete configuration I am running, but that local component directory is also required to compile it.

If anyone has a head without EXTERNAL_TEMP, or sees valid C6 frames being sent but ignored, I would really like another data point.

Full working ESPHome configuration
substitutions:
  name: livingroom-hvac
  friendly_name: "Living Room HVAC"
  comment: "ESPHome flashed M5Stack Atom with Tail485 attached to livingroom/kitchen AHU in attic"

esphome:
  name: ${name}
  name_add_mac_suffix: false
  friendly_name: ${friendly_name}
  comment: ${comment}

esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging (but not via UART)
logger:
  #baud_rate: 0
  #logs:
  #  component: ERROR

# Enable Home Assistant API
api:
  encryption:
    key: !secret api_key
#  services:
#    - service: follow_me
#      variables:
#        temperature: float
#      then:
#        midea_ac.follow_me:
#          temperature: !lambda "return temperature;"
#          beeper: false

# Enable wifi
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  use_address: 192.168.1.236

# url: https://github.com/exciton/esphome
external_components:
  # #942 (2026-07-11): switched from the pinned git source to a LOCAL vendored
  # copy of the larsonm-personal @62a3047 (ch_concealed) midea_xye component so
  # we could patch the WAIT_DATA deadlock. That build has the correct C&H/ACiQ
  # raw-Fahrenheit decode (temperature_encoding: RAW + target_temperature_source:
  # C0; wire-proven 0x46=70degF) but NO recovery from a missed/short/over-long
  # reply — one glitched response permanently strands the poll loop (all bus
  # telemetry silently freezes; current_temperature/target go null while the ESP
  # stays healthy). This regressed the bring-up ~2 days after it went live.
  # The vendored copy under components/midea_xye/ backports HomeOps upstream
  # main's recovery path into climate_midea_xye.cpp's no-reply branch (see the
  # #942 comment there). Everything else is byte-identical to @62a3047.
  #
  # History: mdrobnak@delays_updated (original deadlock, dead 100ms timeout) ->
  # larsonm-personal @62a3047 (raw-degF decode, deadlock still present) ->
  # this local vendored + patched copy (raw-degF decode + deadlock recovery).
  #
  # UPSTREAM EXIT: when the raw-Fahrenheit decode (temperature_encoding: RAW /
  # target_temperature_source: C0) lands in a HomeOps release that ALSO carries
  # the recovery path, delete homeassistant/esphome/components/midea_xye/ and
  # repoint this block to that release tag.
  - source:
      type: local
      path: components
    components: [midea_xye]

#i2c settings for dfrobot MiCS-4514 gas sensor
i2c:
  sda: GPIO25
  scl: GPIO21
  scan: true
  id: bus_a

# UART settings for RS485 converter dongle (required)
uart:
  tx_pin: GPIO26
  rx_pin: GPIO32
  baud_rate: 4800
  rx_buffer_size: 1024
  # #942 diagnostics: log both directions, chunked at the XYE prologue 0x55.
  # Capture C0-RX byte 10 (setpoint) + 11 (T1) vs known values to settle the
  # raw-Fahrenheit variant (PROTOCOL.md Temperature Encoding; HomeOps#137).
  debug:
    direction: BOTH
    after:
      delimiter: [0x55]
#  debug: #If you want to help reverse engineer
#    direction: BOTH
    #dummy_receiver: true
    #after:
    #  delimiter: [0x55]

# Enable esphome OTA updates
ota:
  password: !secret ota_password
  id: my_ota
  platform: esphome

# Main settings
climate:
  - platform: midea_xye
    name: Living Room HVAC
    id: midea_ac
    period: 1s                  # Optional. Defaults to 1s
    timeout: 300ms              # #942: honored on HomeOps; generous vs ~67ms frame time
    beeper: false               # Optional. Beep on commands.
    #default_target_temperature_low: 18°C
    #default_target_temperature_high: 24°C
    #custom_auto: true
    use_fahrenheit: true
    temperature_encoding: RAW       # #942: unit sends raw degF bytes (wire-proven)
    target_temperature_source: C0   # #942: unit lacks C4 (all-0xFF sentinel)
    compressor_aware_action: true   # #942 batch: hvac_action reflects real compressor state
    sync_fan_mode_from_device: true # #942 batch: head-unit fan changes reflect into HA
    # #1021: native XYE follow-me is INERT on this head — the heat-gun test proved
    #   it regulates to the WALL THERMOSTAT (internal_current_temperature) and
    #   ignores external follow-me (capability 0x14, no EXTERNAL_TEMP). Room-based
    #   control is done on-device via the SETPOINT-BIAS loop (see the number:/
    #   switch:/interval: blocks below), so the follow_me_sensor binding is removed.
    visual:                     # Optional. Example of visual settings override.
      min_temperature: 65 °F    # min: 17
      max_temperature: 78 °F    # max: 30
      temperature_step: 0.5 °F  # min: 0.5
#    visual:                     # Optional. Example of visual settings override.
#      min_temperature: 17 °C    # min: 17
#      max_temperature: 30 °C    # max: 30
#      temperature_step: 0.5 °C  # min: 0.5
    supported_modes:            # Optional. 
      - FAN_ONLY
      - HEAT_COOL              
      - COOL
      - HEAT
      - DRY
    supported_presets:      # Optional
      - BOOST
    # outdoor_temperature: DISABLED (#942 cleanup) — this AHU never populates
    # the field (C4 response is all-0xFF sentinel; the value rendered as
    # -4degF pre-RAW and ~0degF post-RAW, both meaningless). Re-enable only
    # if the unit ever gains an ODU on the bus.
    temperature_2a:             # Optional. Inside coil temperature
      name: Inside Coil Inlet Temp
    temperature_3:
      name: Outside Coil Temperature
    timer_start:                # Optional. On timer duration
      name: Timer Start
    timer_stop:                 # Optional. Off timer duration
      name: Timer Stop
    error_flags:                # Optional.
      name: Error Flags
    protect_flags:              # Optional. 
      name: Protect Flags
    static_pressure:
      name: Static Pressure
      min_value: 0
    # #942 batch (2026-07-09): duty/diagnostic surface
    fan_speed:
      name: Fan Speed
    internal_current_temperature:   # = the WALL THERMOSTAT reading the head
      name: Internal Current Temperature   # regulates to (heat-gun-proven, #1021);
      id: head_wall_temp                    # °C internally (HA shows °F). Bias ref.
    internal_target_temperature:
      name: Internal Target Temperature
    defrost:
      name: Defrost
    compressor_active:              # the duty-cycle signal (A/B-correlation class)
      name: Compressor Active

button:
  # #942: manual unwedge affordance (and future automation hook if the
  # component ever freezes again — restart clears any wedged state).
  - platform: restart
    name: Living Room HVAC Restart

number:
  # #1021 setpoint-bias: the DESIRED ROOM temperature (°F). HA / your schedules
  # set this one value; the interval loop below drives the head's setpoint so the
  # ROOM converges here. Plain number (no device_class) so HA does not unit-convert
  # it — the value is °F as typed and the loop converts to °C.
  - platform: template
    name: "Living Room Room Target"
    id: room_target
    unit_of_measurement: "°F"
    min_value: 65
    max_value: 78
    step: 0.5
    initial_value: 72
    restore_value: true
    optimistic: true
    mode: box

switch:
  # #1021 setpoint-bias: room-tracking on/off. OFF = climate behaves normally
  # (setpoint is whatever you/the wall set). ON = the interval loop overrides the
  # head's setpoint every 60s to track Room Target. Default OFF until validated.
  - platform: template
    name: "Living Room Room Tracking"
    id: room_tracking
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF

interval:
  # #1021 setpoint-bias loop. Every 60s, when Room Tracking is ON and the head is
  # not OFF:  head_setpoint = Room Target + (wall_thermostat − room_sensor)
  # so the head cools/heats until ITS reference (the wall thermostat) hits the
  # biased target, at which point the ROOM reads Room Target. wall_thermostat =
  # internal_current_temperature (heat-gun-proven; °C in ESPHome). All math in °C;
  # set_target_temperature() takes °C. FAIL-SAFE: skip if any input is NaN (e.g.
  # the room sensor went stale) — the head just holds its last setpoint. Clamp to
  # the head's safe range (65–78 °F).
  - interval: 60s
    then:
      - lambda: |-
          if (!id(room_tracking).state) return;
          if (id(midea_ac).mode == climate::CLIMATE_MODE_OFF) return;
          float wall_c = id(head_wall_temp).state;                     // wall thermostat (°C)
          float room_c = id(room_temp_c).state;                        // room sensor (°C)
          float tgt_c  = (id(room_target).state - 32.0f) * (5.0f/9.0f);// desired room °F→°C
          if (isnan(wall_c) || isnan(room_c) || isnan(tgt_c)) return;  // fail-safe
          float sp_c = tgt_c + (wall_c - room_c);                      // biased setpoint (°C)
          const float lo = (65.0f - 32.0f) * (5.0f/9.0f);              // 65 °F
          const float hi = (78.0f - 32.0f) * (5.0f/9.0f);              // 78 °F
          if (sp_c < lo) sp_c = lo;
          if (sp_c > hi) sp_c = hi;
          auto call = id(midea_ac).make_call();
          call.set_target_temperature(sp_c);
          call.perform();
          ESP_LOGI("setpoint_bias", "target=%.1fF wall=%.1fC room=%.1fC -> setpoint=%.1fC (%.1fF)",
                   id(room_target).state, wall_c, room_c, sp_c, sp_c * 9.0f/5.0f + 32.0f);

sensor:
  # #1021 setpoint-bias: the operator-installed room sensor, imported from HA
  # (°F) and converted to °C — the "room" term of the on-device bias loop.
  # internal:true — it's a loop input, not a new HA entity (the original HA
  # sensor sensor.living_room_temperature_temperature stays the display).
  - platform: homeassistant
    entity_id: sensor.living_room_temperature_temperature
    id: room_temp_c
    internal: true
    filters:
      - throttle: 15s
      - heartbeat: 60s
      - debounce: 2s
      - lambda: return (x - 32.0) * (5.0/9.0);   # °F → °C (ESPHome climate is °C)
  - platform: wifi_signal
    name: Living Room HVAC Wi-Fi Signal
    update_interval: 60s

  - platform: uptime
    name: "Uptime"
    id: uptime_sec
    internal: true

  - platform: template
    name: Living Room HVAC Uptime Days
    lambda: |-
      return (id(uptime_sec).state/60)/60/24;
    icon: mdi:clock-start
    unit_of_measurement: days
    update_interval: 60s

  - platform: mics_4514
    i2c_id: bus_a
    address: 0x75
    nitrogen_dioxide:
      name: Nitrogen Dioxide
    carbon_monoxide:
      name: Carbon Monoxide
    hydrogen:
      name: Hydrogen
    ethanol:
      name: Ethanol
    methane:
      name: Methane
    ammonia:
      name: Ammonia
    update_interval: 60s

@mdrobnak I have this same wiring diagram on my carrier 40MUAA air handler. Are you using the CN3 pins, and if so, do you also have a hardwired (ha/hb) controller in the mix? Did you have to disconnect your 24v thermostat for it to work (beyond changing the dipswitchs on SW1)? I have so far not been able to receive traffic of any kind (no RX light either) from the CN3 port shown in your photo.