Close
0%
0%

Hacking Mitsubishi Heavy AC IR with ESP32

IRremoteESP8266 said UNKNOWN — so I reverse-engineered it: raw timings → 8-byte protocol → ESP32 → Home Assistant. Code included.

Similar projects worth following
My Mitsubishi Heavy Industries SRK-series air conditioner works
fine with its remote. It works with nothing else.

IRremoteESP8266 — the standard library for IR on ESP32 — reports
"Protocol: UNKNOWN" when it sees the signal. Neither the
MITSUBISHI_HEAVY_152 nor the MITSUBISHI_HEAVY_88 variant matches.
The library's catalogue simply doesn't include this unit.

So I reversed the protocol from scratch. I captured raw IR
timings, pressed every button combination while recording the
resulting 8-byte hex frames, and decoded the byte layout by
watching which fields changed with each knob. The result is a
compact custom encoder/decoder running on an ESP32 that integrates
with Home Assistant over MQTT — and stays in sync when someone
uses the physical remote too.

Hardware: ESP32 (IR TX/RX) · ESP8266 + BME280 (ambient sensor) ·
Raspberry Pi 2B (MQTT broker + Home Assistant)

Code: github.com/bluebirdlboro/mitsubishi-ac-ir

## Hardware

Three nodes on the same WiFi network:

**ESP32 — IR control**
- IR receiver: VS1838B, signal pin → GPIO 14, VCC 3.3 V, GND
- IR emitter: 940 nm IR LED driven by a 2N2222 NPN transistor.
  GPIO 4 → 100 Ω → transistor base. LED between 3.3 V (via 100 Ω
  current-limit) and collector. Emitter to GND.

**ESP8266 — ambient sensor**
- BME280 on I2C (SDA → GPIO 4, SCL → GPIO 5). Publishes
  temperature, humidity, and pressure every 30 s via MQTT with
  Home Assistant auto-discovery.

**Raspberry Pi 2B — hub**
- Runs Mosquitto MQTT broker and Home Assistant. Both ESP modules
  receive OTA firmware updates over the network.

## Why not use the library?

IRremoteESP8266 ships with decoders for `MITSUBISHI_HEAVY_152`
(152-bit frame) and `MITSUBISHI_HEAVY_88` (88-bit frame). I wired
up the VS1838B, pointed the factory remote, and got:

```
Protocol: UNKNOWN   Raw length: 131
```

131 transitions → 1 header mark/space + 64 payload bits + 1
trailer mark/space = **8 bytes**, not 19 or 11. This unit speaks
an undocumented variant. No amount of tweaking `kCaptureBufferSize`
or `kTimeout` changed that.

## Raw timings

`IRrecv` always records raw mark/space durations even when no
decoder claims the signal. From a single button press the
structure was immediately clear:

| Symbol  | Mark    | Space   |
|---------|---------|---------|
| Header  | 5950 µs | 7475 µs |
| Bit 1   | 508 µs  | 3454 µs |
| Bit 0   | 508 µs  | 1496 µs |
| Trailer | 508 µs  | 7422 µs |

Encoding is **space-length**: mark duration is constant; space
duration carries the bit value. Bits are LSB-first within each
byte. Every command is sent **three frames** back-to-back with a
50 ms gap between frames.

## Decoding by variation

The methodology: change one knob on the remote, observe exactly
which byte changes. Repeat for every setting.

| Setting      | B0 | B1 | B2 | B3 | B4 | B5 | B6 | B7 |
|--------------|----|----|----|----|----|----|----|-----|
| Cool 26 Med  | FF | 00 | BF | 40 | 66 | 99 | 2A | D5 |
| Cool 27 Med  | FF | 00 | BF | 40 | 56 | A9 | 2A | D5 |
| Cool 26 High | FF | 00 | FF | 00 | 66 | 99 | 2A | D5 |
| Cool 26 Low  | FF | 00 | 9F | 60 | 66 | 99 | 2A | D5 |
| Heat 26 Med  | FF | 00 | BF | 40 | 63 | 9C | 2A | D5 |
| Off          | FF | 00 | BF | 40 | 6E | 91 | 2A | D5 |

Patterns that emerged:

- **B0, B1, B6, B7** never change → fixed header (`FF 00`) and
  trailer (`2A D5`). Standard frame markers.

- **B2/B3** move in complementary pairs (`FF/00`, `BF/40`,
  `9F/60`): `B3 = 0xFF - B2`. Classic one-byte XOR checksum to
  catch transmission errors. Encodes fan speed: `FF` = high,
  `BF` = medium, `9F` = low.

- **B4** packs mode (low nibble) and temperature (high nibble):
  `B4 = ((32 - temp) & 0xF) << 4 | mode_nibble`.
  Mode nibbles: `6` = cool, `5` = dry, `4` = fan, `3` = heat.
  Power-off sets bit 3: `B4 |= 0x08`.

- **B5 = 0xFF - B4** — same XOR trick applied to the combined
  mode+temperature byte.

## Full byte map

| Byte | Meaning |
|------|---------|
| B0   | `0xFF` — header |
| B1   | `0x00` — header |
| B2   | Fan speed: `0xFF` high · `0xBF` medium · `0x9F` low |
| B3   | `0xFF - B2` (checksum) |
| B4   | `((32 - temp) << 4) \| mode_nibble` ; OR `0x08` if off |
| B5   | `0xFF - B4` (checksum) |
| B6   | `0x2A` — trailer |
| B7   | `0xD5` — trailer |

## Encoder

```cpp
void sendMitsubishiCustom(bool power, uint8_t mode_code,
                           uint8_t temp, uint8_t fan_b2) {
    uint8_t mc = mode_code;
    if (!power) mc |= 0x8;

    uint8_t b4 = ((32 - temp) & 0xF) << 4 | mc;
    uint8_t b5 = 0xFF - b4;
   ...

Read more »

mitsubishi-ac-ir-main.zip

the code...

Zip Archive - 15.90 kB - 06/12/2026 at 14:44

Download

  • 1 × ESP32 Board with integrated IR receiver and IR emitter LED WiFi-enabled, captures and replays infrared signals
  • 1 × Raspberry Pi 2B MQTT broker and Home Assistant host for smart home integration
  • 1 × ESP8266 with BME280 sensor temperature, humidity and pressure monitoring via MQTT
  • 1 × USB-to-UART adapter CP2102/CH340 serial adapter for ESP flashing and debug
  • 1 × USB WiFi dongle network connectivity for Raspberry Pi 2B

  • 8-byte frame fully decoded — integration live

    bluebirdlboro16 hours ago 0 comments

    One long evening of pressing buttons and copying hex. Every button
    combination on the remote, 8 bytes recorded each time, change one
    setting and watch which byte moves. The full picture:

    B0/B1 = fixed header (FF 00). B6/B7 = fixed trailer (2A D5).

    B2 encodes fan speed, and B3 is always 0xFF - B2 — an XOR
    checksum to detect noise on a one-way channel. FF/00 = high,
    BF/40 = medium, 9F/60 = low.

    B4 packs temperature and mode into a single byte:
    high nibble = (32 - temp) & 0xF, low nibble = mode
    (6=cool, 5=dry, 4=fan, 3=heat). Power-off is not a separate
    command; it just sets bit 3 of the low nibble: B4 |= 0x08.
    B5 is 0xFF - B4, same trick as B2/B3.

    That's the whole protocol. The encoder is a straightforward
    implementation of the table above. Sent Cool 26 Medium — the AC
    started blowing cold air. Sent Off — it stopped.

    The decoder runs in reverse for incoming frames from the physical
    remote. Header, trailer, and both checksums must validate; the AC
    silently ignores anything malformed (no NAK, no error output),
    which made early debugging painful. Once both encoder and decoder
    were working I could cross-check them against each other instead
    of relying on the AC's silence.

    Home Assistant integration is live. MQTT Discovery registers the
    device as a climate entity automatically. The dashboard, HA
    automations, and the physical remote all stay in sync.

  • Protocol: UNKNOWN — the library doesn't know this unit

    bluebirdlboro20 hours ago 0 comments

    Wired up the VS1838B, pointed the factory remote at the ESP32,
    and got the one line you don't want to see:

        Protocol: UNKNOWN   Raw length: 131

    IRremoteESP8266 has decoders for MITSUBISHI_HEAVY_152 and
    MITSUBISHI_HEAVY_88. Neither matched. I tried adjusting
    kCaptureBufferSize, extending kTimeout, and replaying raw captures
    from the library's own test data. Nothing.

    131 raw transitions means: 1 header mark/space + 64 payload bits
    (128 mark/space pairs) + 1 trailing mark/space = 8 bytes. The
    library knows 19-byte (152-bit) and 11-byte (88-bit) frames. This
    unit sends 8. No published spec covers it.

    The raw timing buffer is there even when the decoder returns
    UNKNOWN. So instead of finding the right decoder, I stopped
    looking for one and started reading the timings directly:

    - Header: ~5950 µs mark, ~7475 µs space
    - Bit marks: uniform ~508 µs
    - Bit spaces: either ~1496 µs (zero) or ~3454 µs (one)
    - Trailer: 508 µs mark, ~7422 µs space

    Space-length encoding, LSB-first, 8 bytes, three repeated frames
    per command with 50 ms gaps. The shape of the protocol is now
    known. Next: figure out what the bytes mean.

View all 2 project logs

Enjoy this project?

Share

Discussions

Does this project spark your interest?

Become a member to follow this project and never miss any updates