## 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;
...
bluebirdlboro