IR aircon control when the universal library can't read your remote

An ESP32 and a BC7215A IR board put a Mitsubishi Heavy split in Home Assistant, with my own frame code after the vendor's universal library failed to pair.

Difficulty
Moderate
Parts cost
An ESP32-C3 board and a BC7215A IR module
Build time
An afternoon
Skills
Soldering, ESPHome, Home Assistant

The problem

The living-room split is a Mitsubishi Heavy, and its only way in is the infrared (IR) remote, model RLA502A700R. I wanted it in Home Assistant like everything else, so I could set it from there without hunting for the remote.

There’s a neat open-source project for exactly this: an ESP32 plus a BC7215A “universal AC control” IR board, running ESPHome. You point your remote at it once, it pairs, and you’re done. Except mine never paired. “Pair failed”, every time.

So I kept the hardware and threw out the part that didn’t work. The BC7215A still does the IR sending and receiving, but the code that understands the Mitsubishi Heavy remote is my own: a small ESPHome component called mhi_bc7215. If your remote is a Mitsubishi Heavy one in the same family, you can use it as is. If it’s another brand the universal library can’t handle, the same approach will work, you’ll just need a different byte map.

How it works

How the IR aircon controller works Home Assistant asks for a mode, temperature and fan speed. The XIAO ESP32-C3 takes the last frame it heard from the real remote, rewrites the power, mode, temperature and fan bytes, keeps everything else, and checks every byte pair. The BC7215A board sends that frame with the remote's own timings to the aircon. The rest of the time the board listens, so a press on the real remote updates Home Assistant and becomes the new template. Home Assistant "cool, 24 °C, fan low" XIAO ESP32-C3 my mhi_bc7215 component • starts from the last remote frame • rewrites power + mode (bytes 5–6) • rewrites temperature (bytes 7–8) • rewrites fan speed (bytes 9–10) • keeps the rest (swing, extras) • checks every byte pair still matches send remote heard BC7215A IR board sends it with the remote's own timings, and listens the rest of the time The aircon only if the LED points straight at it Press the real remote and the board hears it too: Home Assistant updates, and that frame becomes the new template.

An aircon remote doesn’t send “temperature up”. Every press sends the whole state in one frame: power, mode, temperature, fan, swing, the lot. That makes life easy. The ESP keeps a copy of the last frame it heard from the real remote, and when Home Assistant asks for a change, it rewrites only the bytes for power, mode, temperature and fan, and leaves everything else exactly as the remote had it.

The BC7215A board does the hard IR work. When it hears the remote, it hands the ESP the frame’s bytes plus a “format packet”, which is the carrier and pulse timings. To send, the ESP gives it back new bytes with that same format packet, so the aircon sees something that looks exactly like its own remote.

There’s no pairing step. The board is always listening, and every valid frame from the remote becomes the new template and updates Home Assistant. The catch with IR: it’s one-way. The aircon never answers, so Home Assistant shows what was last sent or heard, not what the unit is actually doing.

What you need

  • Seeed XIAO ESP32-C3. The original project’s author recommends the XIAO over the cheap C3 Super Mini boards for Wi-Fi reception.
  • BC7215A “Universal AC Control” USB dev board. Mine came from AliExpress. It has a CP2102N USB-serial chip on it, a USB-C socket, a PWR SELECT jumper and a six-pin header (Vcc, GND, TX, RX, BUSY, MOD).
  • Six short hook-up wires.
  • A 5 V USB charger for the XIAO. A second one for the BC7215A board if you do the stronger-LED change in the wiring section.
  • An aircon with an IR remote. For my component as written, that means a Mitsubishi Heavy remote that sends the “ZM” style frame (see below for how to check). The RLA502A700R is the only one I’ve tested.
  • ESPHome on a computer, for the first flash over USB.

The original project, which this builds on: https://github.com/timj-code/bc7215_ac_esphome. It has 3D-printable case files in its 3d/ folder, but they’re for the Arduino-style BC7215 board, not this USB one, so the top half needs changing to fit.

Wiring

The trap here is that the XIAO’s printed D-numbers are not GPIO numbers. ESPHome wants GPIO numbers. Use the right-hand column.

BC7215A header XIAO ESP32-C3 pin GPIO
Vcc 3V3 (not 5V) –
GND GND –
TX (BC7215 output) D3 GPIO5
RX (BC7215 input) D4 GPIO6
BUSY D5 GPIO7
MOD D6 GPIO21

With this wiring, set the board’s PWR SELECT jumper to Vin. Vcc goes to the XIAO’s 3.3 V, never its 5 V pin: the BC7215’s TX line follows its supply, and 5 V on TX would go straight into the ESP32-C3, which is a 3.3 V part.

The stronger-LED change

Wired as above, the board’s single IR LED runs from 3.3 V, and it’s weak. It only works if it’s pointed straight at the aircon (see the lessons). From the board’s schematic, the PWR SELECT jumper feeds both the CP2102N and the IR LED, and the CP2102N’s own 3.3 V regulator is what powers the BC7215 chip. So:

  1. Move PWR SELECT to USB.
  2. Remove the Vcc wire between the header and the XIAO. Keep GND and the four signal wires.
  3. Plug the board’s own USB-C into a 5 V charger. Before reconnecting the XIAO, check with a meter: the header’s Vcc pin should read about 3.3 V, and the 220 µF capacitor (labelled C3 on the board, not to be confused with the ESP32-C3) should have about 5 V across it.
  4. Power the XIAO from its own 5 V USB. The board’s little regulator can’t run the ESP’s Wi-Fi as well.

That way the LED gets 5 V (roughly 185 mA peak instead of about 100 mA) while the BC7215 chip stays at 3.3 V logic. A wide-angle 940 nm IR LED (±30° or wider, rated for 500 mA pulses or more) in place of the stock one, same way round, should help further. These are the changes I’d make for more range; I haven’t measured how much each one helps yet.

Don’t “fix” the weak LED by feeding 5 V into the header Vcc pin. That’s the chip’s supply, and at 5 V the chip expects at least 3.5 V for a logic high, which the ESP’s 3.3 V can’t give it, and its TX line would put 5 V into the ESP.

Firmware and config

My component sits on top of the original project, which pulls in the BC7215 maker’s library as a git submodule. The universal AC library in there isn’t used at all. I only borrow the low-level BC7215 driver, which talks to the chip over its serial line.

The folder layout ends up like this:

bc7215_ac_esphome/            # the original project, cloned with --recursive
├── deps/bc7215_ac_lib/       # the BC7215 maker's library (git submodule)
├── components/
│   ├── bc7215_ac/            # the original universal component (not used)
│   └── mhi_bc7215/           # mine: __init__.py, climate.py, mhi_bc7215.h/.cpp, mhi_frame.h
├── aircon.yaml               # the config below
└── secrets.yaml

The mhi_bc7215 folder: (code download coming).

The ESPHome config

# Mitsubishi Heavy aircon over IR: XIAO ESP32-C3 + BC7215A board.
# The vendor's universal AC library is not used; the mhi_bc7215 component builds the frames.
substitutions:
  bc7215_dir: "./deps/bc7215_ac_lib"

esphome:
  name: living-room-aircon
  friendly_name: Living Room Aircon
  includes:
    # Low-level BC7215 driver only
    - ${bc7215_dir}/examples/ESP-IDF/components/bc7215/bc7215.cpp
    - ${bc7215_dir}/examples/ESP-IDF/components/bc7215/include/bc7215.hpp
    - ${bc7215_dir}/bc7215_ac_lib/bc7215_lib.c
    - ${bc7215_dir}/bc7215_ac_lib/bc7215_lib.h
    - ${bc7215_dir}/bc7215_ac_lib/bc7215_types.h
    - ${bc7215_dir}/examples/ESP-IDF/main/include/bc7215_lib_config.h

esp32:
  variant: esp32c3
  framework:
    type: esp-idf

external_components:
  - source:
      type: local
      path: ./components
    components: [mhi_bc7215]

logger:
  hardware_uart: USB_SERIAL_JTAG
  level: INFO   # remote frames log at INFO, Home Assistant -> aircon sends at WARN

api:
  encryption:
    key: !secret api_key

ota:
  platform: esphome
  password: !secret ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none
  fast_connect: false   # if several access points share one network name, scan and pick the nearest
  output_power: 20dB
  ap:
    ssid: "Aircon Fallback"
    password: !secret fallback_password

captive_portal:

climate:
  - platform: mhi_bc7215
    id: ac
    name: "Aircon"
    esp_tx_pin: 6    # XIAO D4 -> BC7215 RX
    esp_rx_pin: 5    # XIAO D3 <- BC7215 TX
    busy_pin: 7      # XIAO D5 <- BUSY
    mod_pin: 21      # XIAO D6 -> MOD
    # uart_num: 1    # optional, defaults to 1

button:
  - platform: restart
    name: "Restart"
  - platform: template
    name: "Relearn Remote"
    icon: mdi:remote
    entity_category: config
    on_press:
      - lambda: id(ac).relearn();

text_sensor:
  - platform: template
    name: "Status"
    update_interval: 1s
    lambda: return {id(ac).status_message()};

sensor:
  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: 60s

preferences:
  flash_write_interval: 5s   # save the learned frame soon after it's heard

And secrets.yaml next to it:

wifi_ssid: "your-wifi-name"
wifi_password: "your-wifi-password"
api_key: "generate one, see below"
ota_password: "something long"
fallback_password: "something long"

ESPHome’s docs page for the api: component has a button that generates a fresh encryption key.

The frame, byte by byte

This is the heart of it. The Mitsubishi Heavy “ZM” frame starts with a fixed five-byte signature, and from byte 5 on, every byte is followed by its complement (the same byte with every bit flipped). That pairing is a free error check: if any pair doesn’t add up, it’s not a good frame.

Bytes What’s in them
0–4 Signature: 52 AE C3 1A E5
5 / 6 Mode and power, then its complement. Cool F6, heat F3, dry F5, fan F4, auto F7. Bit 3 set means off.
7 / 8 Temperature in the low four bits: byte 8 holds (temp − 17), byte 7 its complement. 18–30 °C.
9 / 10 Fan: auto FF, then FE, FD, FC, FB for speeds 1 to 4.
11 onwards Swing, presets, the display light and so on. Kept as the remote last sent them.

The standard ZM frame is 19 bytes. My RLA502A700R sends 29 (232 bits): the standard 19 plus a 10-byte tail. That longer frame is what I think the universal library choked on. My code doesn’t care about the tail, it just copies it.

I worked out the map from two existing open-source decoders (mattbyte’s MHI-IR-Climate and the MitsubishiHeavyZM code in arduino-heatpumpir), then checked it against captures from my own remote.

The codec lives in one header, mhi_frame.h, with no ESPHome or ESP32 code in it at all, so it compiles and runs on a PC. That’s how I tested it before going near the aircon. Here it is in full:

#pragma once
// Mitsubishi Heavy "ZM / ZSA" IR frame codec (remote RLA502A700R family).
// Pure C++ with no ESPHome/ESP-IDF dependencies so it can be unit-tested on a PC.

#include <cstdint>
#include <cstring>

namespace mhi {

static const uint8_t kSig[5] = {0x52, 0xAE, 0xC3, 0x1A, 0xE5};
static const uint8_t kMinBytes = 19;   // ZM 152-bit
static const uint8_t kMaxBytes = 40;
static const int kMinTemp = 18;
static const int kMaxTemp = 30;

enum Mode : uint8_t { MODE_AUTO = 7, MODE_COOL = 6, MODE_DRY = 5, MODE_FAN = 4, MODE_HEAT = 3 };
// Fan index 0 = auto, 1..4 = remote fan steps 1 (lowest) .. 4 (highest).
static const uint8_t kFanCodes[5] = {0xFF, 0xFE, 0xFD, 0xFC, 0xFB};

struct State {
  bool power;
  uint8_t mode;  // Mode
  int temp;      // Celsius
  uint8_t fan;   // 0..4, or 0xFF if the remote sent a code we don't map (eco/boost)
};

// True if `d` (n bytes) is a well-formed MHI ZM-family frame.
inline bool valid(const uint8_t *d, uint16_t n) {
  if (n < kMinBytes || n > kMaxBytes || (n % 2) == 0) return false;
  if (std::memcmp(d, kSig, sizeof(kSig)) != 0) return false;
  for (uint16_t i = 5; i + 1 < n; i += 2)
    if (static_cast<uint8_t>(~d[i]) != d[i + 1]) return false;
  return true;
}

inline bool decode(const uint8_t *d, uint16_t n, State &out) {
  if (!valid(d, n)) return false;
  out.power = (d[5] & 0x08) == 0;
  out.mode = d[5] & 0x07;
  out.temp = (d[8] & 0x0F) + 17;
  out.fan = 0xFF;
  for (uint8_t i = 0; i < 5; ++i)
    if (d[9] == kFanCodes[i]) out.fan = i;
  return out.mode >= MODE_HEAT && out.mode <= MODE_AUTO;
}

// Write `s` into a copy of the learned frame `base` (n bytes) -> `out`. Returns false on bad input.
inline bool encode(const uint8_t *base, uint16_t n, const State &s, uint8_t *out) {
  if (!valid(base, n)) return false;
  if (s.mode < MODE_HEAT || s.mode > MODE_AUTO) return false;
  int t = s.temp < kMinTemp ? kMinTemp : (s.temp > kMaxTemp ? kMaxTemp : s.temp);
  std::memcpy(out, base, n);
  out[5] = static_cast<uint8_t>(0xF0 | s.mode | (s.power ? 0x00 : 0x08));
  out[6] = static_cast<uint8_t>(~out[5]);
  const uint8_t code = static_cast<uint8_t>(t - 17);
  out[8] = static_cast<uint8_t>((base[8] & 0xF0) | code);
  out[7] = static_cast<uint8_t>(~out[8]);
  if (s.fan <= 4) {
    out[9] = kFanCodes[s.fan];
    out[10] = static_cast<uint8_t>(~out[9]);
  }
  return valid(out, n);
}

}  // namespace mhi

The rest of the component

The other files are the glue between that codec, the BC7215 driver and ESPHome. If you’re adapting this to another brand, these are the bits you’d keep, and mhi_frame.h is the bit you’d rewrite.

  • __init__.py and climate.py: tell ESPHome about the new mhi_bc7215 climate platform and its options (esp_tx_pin, esp_rx_pin, busy_pin, mod_pin, and uart_num, which defaults to 1).
  • Start-up: starts the BC7215 driver, loads the saved template from flash, toggles the chip’s MOD line (send, then receive) to wake it, and leaves it listening in its “data plus format packet” mode.
  • Receiving: when the chip has a frame, it waits about 60 ms for the format packet to follow it, reads both, and flips every bit if the chip flagged the frame as inverted. If decode() accepts it, the frame and format packet become the new template, Home Assistant is updated, and both are saved to flash. Anything that isn’t a Mitsubishi Heavy frame is ignored (and logged at DEBUG level).
  • Sending: Home Assistant’s mode, temperature and fan are mapped onto a State, encode() rewrites the template, and a small step-by-step routine switches the chip to send mode, loads the format packet, sends the bytes, waits for the BUSY line to drop (giving up after 3 seconds), then switches back to listening. It’s done in steps from ESPHome’s main loop rather than with long waits, so nothing blocks.
  • Mode mapping: Home Assistant’s “heat/cool” is the aircon’s auto, so you can still set a target in auto. Fan modes auto, quiet, low, medium and high are the remote’s auto and speeds 1 to 4. If the remote sends an eco or boost code, the fan display stays on the last real speed.
  • Status text: “Ready”, “Learned the remote — ready”, “Sent”, “Send timed out”, or a hint about what to do next. That’s the Status sensor in Home Assistant.

Step by step

  1. Clone the original project with its submodule (a ZIP download won’t include it): git clone --recursive https://github.com/timj-code/bc7215_ac_esphome.git
  2. Put the mhi_bc7215 folder in its components/ folder (code download coming).
  3. Save the config above as aircon.yaml in the project folder, and make your secrets.yaml.
  4. Wire the XIAO to the BC7215A board as in the table. Double-check the GPIO column, not the D-numbers.
  5. Plug the XIAO into your computer and flash it: esphome run aircon.yaml. The first flash has to be over USB. After that, updates go over Wi-Fi.
  6. In Home Assistant, the new ESPHome device should be discovered under Settings → Devices & services. Add it and paste in the same api_key.
  7. The Status sensor will say “Press any button on the remote, aimed at the box, to learn it”. Point the real remote at the board and press any button. It should change to “Learned the remote — ready”, and the climate card should jump to whatever the remote shows.
  8. Find the box a home where its IR LED points straight at the aircon’s receiver window. If you also want remote presses to sync back, the original project’s author suggests bending the board’s IR receiver upright so it faces into the room while the LED faces the aircon.

If the template ever gets out of whack, press Relearn Remote and then any button on the remote.

Testing it

  • Check your remote first. Watch the logs (esphome logs aircon.yaml) and press a button on the real remote. A Mitsubishi Heavy frame shows up as a line like Remote: ON COOL 24C fan 2 [52 AE C3 1A E5 …]. If nothing appears at INFO, set the logger to DEBUG. A line saying Not an MHI frame means the board hears your remote fine, but you’ll need the original universal firmware (its author says it pairs with most aircons) or your own byte map.
  • Remote to Home Assistant: change the temperature on the real remote. The climate card should follow.
  • Home Assistant to aircon: change one thing at a time from Home Assistant and watch the unit. On mine I tried temperature up and down, fan speed, off and on, and cool to heat. All of them worked, and every send ended with Status “Sent”.
  • Don’t trust “Sent” on its own. It means the board finished transmitting, not that the aircon heard it. Look at the unit.

A basic dashboard card for it, with your own entity ID in place of the example:

type: thermostat
entity: climate.living_room

Lessons learnt

The universal library would not pair with my remote. “Pair failed”, every time. To be sure it wasn’t my wiring, I compiled the library on my laptop and fed it the real capture from my remote. It still failed. My remote sends a 29-byte frame, longer than the standard one, and the library simply doesn’t support it. Lesson: once you’ve proven the library is the problem, stop poking at it. The BC7215 board is still a good IR transceiver; you only need to replace the part that understands the frame.

XIAO pin labels are not GPIO numbers. My first wiring followed a table for the C3 Super Mini. The BC7215’s TX ended up on a pin the ESP was driving as an output and holding high. The BUSY LED on the board flashed, so it looked alive, but zero bytes ever reached the ESP. If you see BUSY flash and nothing arrives, check that TX goes to an ESP input.

“Sent” doesn’t mean the aircon heard it. IR is one-way, so neither the box nor Home Assistant can know. With the board’s LED a little off-axis, the aircon silently ignored it, while Home Assistant cheerfully showed the new setting and Status said “Sent”. The real remote is far more forgiving. Aim the LED straight at the receiver window, and when you test, look at the unit, not the dashboard.

Don’t fix a weak IR LED by raising the chip’s supply. Putting 5 V into the header’s Vcc pin is tempting, but that’s the BC7215 chip’s own supply. At 5 V it wants a higher logic level than a 3.3 V ESP can give, and its TX would put 5 V into the ESP. Give the LED 5 V through the board’s USB side instead, and leave the chip on 3.3 V.

Write the frame code so it runs on a PC. Because mhi_frame.h has no ESP code in it, I could test it on the laptop: decode and re-encode the real captures from my remote and check they came back identical, and compare it against 200 states from mattbyte’s MHI-IR-Climate encoder. By the time I sent anything to the aircon, I already knew my frames matched the real remote’s.