Your Air Conditioner Has Features Its Remote Doesn’t
I decoded the Mitsubishi Mr Slim infrared protocol to get local control of my bedroom AC — and found an airflow mode that no button on the remote can reach
The bedroom air conditioner was the last thing in the house that only obeyed a plastic stick. I had a cheap Tuya infrared blaster sitting on the shelf, so I plugged it in and expected a ten-minute job: pair the blaster, pick “Mitsubishi” from a list, done.
Home Assistant said the remote was unsupported. Fine — that’s what the vendor cloud is for. I paired it in the Tuya app, found the right codeset (“Mitsubishi Mr Slim”, remote index 2997), and got working control of the AC through the cloud.
It was worse than the remote.
The cloud codeset exposes four fan speeds. The physical remote has six — the sixth is a “silent” mode quieter than speed 1, which is the only one you’d want running at night in a bedroom. The cloud gives you 16–30 °C; the remote goes to 31. The cloud has no concept of the louvers at all, so the vane positions — the thing that decides whether the AC blows on your face for eight hours — simply weren’t addressable. I had automated my air conditioner into a downgrade.
Replaying buttons gets you buttons
The obvious fix is to teach the blaster the remote’s codes directly. Every one of these devices has a learning mode: point the remote at it, press a button, get back a base64 blob you can replay later.
This works, and for most appliances it’s enough. It was not enough here, and the reason is worth stating plainly because it’s the whole point of what follows: replay gives you exactly the buttons you pressed, and nothing else. An air conditioner’s state is not a set of buttons. It’s a tuple — power, mode, temperature, fan speed, vertical vane, horizontal vane, boost — and the remote transmits the entire tuple on every press. There is no “temperature up” code. There’s a code that means cool, 24 degrees, fan speed 2, vanes at position 3, and a different one for every other combination.
Six fan speeds × sixteen temperatures × five vane positions × four modes is several thousand captures. Nobody is pressing a button that many times.
So the choice was to either accept a crippled subset, or work out what the bytes actually mean and generate frames myself.
Fifty-five presses
I wrote a capture harness that keeps the blaster’s study mode armed between presses, then sat in front of the AC and walked the remote through a deliberately structured sweep: every temperature at a fixed everything-else, then every fan speed, then every vane position, then the modes. Fifty-five captures. Structured, because the whole decoding strategy is change one thing and see which byte moves.
Then I converted each base64 blob back into a pulse train and started looking.
The first thing that falls out is the frame boundary. Infrared protocols are marks (carrier on) and spaces (carrier off) measured in microseconds, and Mitsubishi’s are distinctive: a 3426 µs header mark, a 1701 µs header space, then bit marks of 431 µs where the following space carries the value — 431 µs for a zero, 1286 µs for a one. Split the train on any space longer than 5000 µs and you get two identical transmissions per press. The remote just sends everything twice.
Each transmission decoded to 18 bytes. The first five were 23 CB 26 01 00 on every single capture — a fixed preamble, which is the reassuring sign that you’re reading the frame correctly and not off by a bit somewhere.
Change one thing
With a structured capture set, most of the fields fall over quickly. Byte 7 counted up in lockstep with the temperature and sat at 8 when the remote said 24 °C, which makes it temperature − 16. Byte 5 was 0x20 on every capture except the power-off ones, where it was zero. Byte 6 took four distinct values across the four modes.
Byte 9 was the interesting one, because two things were encoded in it. The low three bits tracked the fan speed 1–4. Selecting silent didn’t produce a fifth distinct field — it produced the value 5 in those same three bits, which tells you Mitsubishi thinks of silent as a speed below 1, not as a separate flag. Bits 3–5 moved with the vertical vane. Bit 7 came on for auto fan.
That left the last byte, which changed on every capture and correlated with nothing. It’s a checksum, and it’s the plain sum of the preceding seventeen bytes masked to a byte. That took one line of Python to confirm across all forty-seven frames that decoded cleanly.
Which is the moment the whole thing tips over, because once you can compute the checksum you’re no longer reading the protocol. You’re writing it.
def build(power=True, mode="cool", temp=24, fan="auto",
vvane="auto", hvane="1", fast=False) -> bytes:
b = bytearray(18)
b[0:5] = bytes([0x23, 0xCB, 0x26, 0x01, 0x00])
b[5] = 0x20 if power else 0x00
b[6] = MODES[mode]
b[7] = max(16, min(31, int(temp))) - 16
b[8] = (HVANE[str(hvane)] << 4) | MODE_B8[mode]
v = VVANE[str(vvane)] & 0x07
b[9] = 0x80 | (v << 3) if fan == "auto" else \
0x40 | (v << 3) | (FAN[str(fan)] & 0x07)
if fast:
b[15] = 0x08
b[9] = 0x40 | (v << 3) # FAST overrides the fan speed
b[17] = sum(b[:17]) & 0xFF
return bytes(b)
Proving it before trusting it
An encoder that produces plausible-looking bytes is not an encoder that works, and infrared is one-way — the AC never answers. So the check has to come from the captures themselves: feed the encoder the settings each capture was taken at, and demand the output match the captured bytes exactly.
Forty of forty-seven matched byte-for-byte. The seven that didn’t were all the same benign disagreement: when you select auto fan the remote emits 0x80 with the speed bits cleared, but frames sent while auto is already active carry 0x85 — the auto flag plus the speed it remembers. Both come from the real remote and the unit treats them identically. That’s not an encoder bug, it’s the remote having a small amount of state.
But rebuilding captured frames only proves you can reproduce inputs you’ve seen. The test that actually matters is a frame that never existed. I had never captured 21 °C — the sweep skipped it. So I built one, transmitted it, and the air conditioner went to 21 degrees.
That’s the difference between a recording and a model.
The mode that isn’t on the remote
Byte 8 packs two things: the low nibble tracks the mode, and the high nibble is the horizontal vane — 1 through 5 for fixed positions, 0xC for swing. Five positions, five buttons on the remote, tidy.
Except a nibble holds sixteen values and the remote only ever emitted six of them. So I walked the rest.
Most did nothing — 0x0, 0x6 and 0x7 are silently ignored by the unit. But 0x8 did something I hadn’t seen the AC do: it split the louvers, half aimed left and half aimed right. There is no button for this. It exists in the hardware, the firmware honours it, and the remote Mitsubishi ships gives you no way to ask for it.
I would never have found that by replaying captures, because you cannot capture a code the remote refuses to send. It’s only reachable if you’re synthesizing frames — and it’s the single best argument for doing the harder version of this work.
What it cost, and what it bought
| Vendor cloud | Local frames | |
|---|---|---|
| Fan speeds | 4 | 6 (including silent) |
| Temperature | 16–30 °C | 16–31 °C |
| Vertical vane | ✗ | auto, 1–5, swing |
| Horizontal vane | ✗ | 1–5, swing, split |
| Boost | ✗ | ✓ |
| Needs internet | yes | no |
| Round trip | cloud | ~0.3 s on the LAN |
The blaster now takes frames straight over the local network. No vendor account is in the path, which means the AC keeps working when the internet doesn’t, and it keeps working when the vendor decides to retire the codeset.
Four things that cost me time
Roughly one capture in five is truncated. The learning mode sometimes returns a partial pulse train. Early on I assumed a short decode meant I’d misread the format. The fix is unglamorous: decode every segment in the capture and keep the longest well-formed one.
The blaster reports null when you ask its status. The infrared data point is write-only, so a status read tells you nothing about whether the device is alive. It does echo writes back, which is the only liveness signal you get.
The transmit payload needs a literal "1" prefixed to the base64. Not documented anywhere I could find, easy to get wrong by hand, and the failure mode is a silent no-op.
The AC has no display. This one shapes everything about how you test. You cannot read back the temperature to confirm a frame landed, so every verification has to key on something observable — the louvers physically moving, or the fan audibly changing speed. I burned an evening early on “confirming” temperature changes that I had no way of actually seeing, which is to say I confirmed nothing at all. Pick an observable before you start, or you will convince yourself of things that aren’t true.
Get it
The protocol writeup and a Home Assistant integration built on it are on GitHub at ha-mitsubishi-ir — installable through HACS, sends locally, no cloud account required. It’s built against a Mitsubishi GR 22 VF, a cool-only unit, so the heat mode is untested; if you have a Mr Slim that heats, the capture harness is in the repo and I’d welcome the frames.
The broader point isn’t really about air conditioners. A surprising amount of the hardware in a house is running firmware more capable than the interface it shipped with, and the gap between the two is usually eighteen bytes and an afternoon.