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
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:
- Move PWR SELECT to USB.
- Remove the Vcc wire between the header and the XIAO. Keep GND and the four signal wires.
- 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.
- 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__.pyandclimate.py: tell ESPHome about the newmhi_bc7215climate platform and its options (esp_tx_pin,esp_rx_pin,busy_pin,mod_pin, anduart_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
Statussensor in Home Assistant.
Step by step
- 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 - Put the
mhi_bc7215folder in itscomponents/folder (code download coming). - Save the config above as
aircon.yamlin the project folder, and make yoursecrets.yaml. - Wire the XIAO to the BC7215A board as in the table. Double-check the GPIO column, not the D-numbers.
- 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. - In Home Assistant, the new ESPHome device should be discovered under Settings → Devices &
services. Add it and paste in the same
api_key. - 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.
- 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 likeRemote: ON COOL 24C fan 2 [52 AE C3 1A E5 …]. If nothing appears at INFO, set the logger to DEBUG. A line sayingNot an MHI framemeans 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.