Skip to content

Latest commit

 

History

History
1421 lines (1167 loc) · 77.4 KB

wf-rac-module-reference.md

File metadata and controls

1421 lines (1167 loc) · 77.4 KB

Mitsubishi Heavy Industries WF-RAC WLAN module — external interface reference

Audience: anyone writing software that talks to an MHI WF-RAC WLAN module over the network, and anyone building a CNS/SPI replacement (ESP32, ESP8266) who wants to know what the stock module actually does on the bus.

Purpose: interoperability. This describes the interfaces a client needs in order to talk to the module correctly - nothing here is a guide to modifying, reflashing or circumventing anything in it, and the sections that touch write paths say so explicitly.

Direction of travel: outside in. Network → HTTP → airconStat blob → CNS/SPI frame. The module's own internals appear only where they explain a behaviour you can observe from outside; the raw evidence is in Appendix A.

Reading the confidence tags. Every non-obvious claim carries one. Do not silently upgrade them.

Tag Meaning
[HW] Observed on real hardware (SRK20ZS-WF and SRK35ZS-WF on one multi-split, firmware WF-RAC-HTTPS 025/200)
[FW] Read out of a firmware image (module or bridge MCU), static analysis
[APP] Read out of the official Smart M-Air app (version 1.4.009)
[EXT] External project documentation, chiefly MHI-AC-Trace / MHI-AC-Ctrl
[UR] Reported by a user on hardware I do not have. One report unless it says otherwise
[INF] Inference from the above. Plausible, not tested

1. What the module is

The WF-RAC module is two processors, not one:

        ┌──────────────────────── WF-RAC module ───────────────────────┐
 Wi-Fi  │  Realtek RTL8710C (Ameba-Z2)        Renesas RL78             │   CNS
════════╪══ FreeRTOS, mbedTLS 2.4.0  ══UART══ protocol bridge  ════════╪══════ AC
 HTTPS  │  HTTP/MQTT server, TLS,             airconStat ⇄ SPI frames  │  SPI   main
 :51443 │  base64, OTA, Wi-Fi                 (acts as the remote      │        board
        │  "M" firmware                        control) "C" firmware   │
        └──────────────────────────────────────────────────────────────┘
  • The Realtek side (M firmware, e.g. WCBN4612L_M_029.bin) is network plumbing. It base64-decodes the airconStat blob and hands the raw bytes to the RL78. It never interprets a single status bit. [FW]
  • The RL78 side (C firmware, e.g. WF-RAC-HTTPS_C_200) is the interesting one: it is the CNS/SPI remote control, i.e. exactly the role an ESP32 running MHI-AC-Ctrl takes over when you unplug the module. It receives MOSI frames, sends MISO frames, and translates both directions into the airconStat blob. [FW]

Consequence for ESP32 developers: the WF-RAC network protocol is not a separate protocol. It is the MHI CNS/SPI frame wrapped in base64 and JSON, with a fixed set of bits stripped out. Everything MHI-AC-Trace documents about DB0…DB14 applies, with a known offset (§4).

Consequence for network developers: anything the AC's main board knows but the CNS bus doesn't carry is unreachable, and anything the bridge chooses not to forward is unreachable — but the request channel is generic and largely unused by the official app (§5).


2. Layer 1 — discovery and the local HTTP API

2.1 Finding the device

  • mDNS service _beaver._tcp [FW] [HW]
    • instance name: MAC address without colons, lower case (348e89c5a137)
    • host: <mac>.local
    • port: 51443
    • TXT record: empty
  • No UDP broadcast protocol, no discovery endpoint.

2.2 Transport

Property Value
Port 51443 (all firmware branches) [HW]
Scheme https:// on the WF-RAC-HTTPS and WCBN4612L branches, plain http:// on the older WF-RAC branch [HW]
Method POST only
Path /beaver/command/<commandName>
Body JSON, see below

TLS is ancient. The module links mbedTLS 2.4.0 (2016) [FW]. Modern clients fail the handshake with SSLV3_ALERT_HANDSHAKE_FAILURE unless you drop the security level and allow legacy renegotiation. The certificate is self-signed and its CN does not match the IP, so hostname verification must be off. If you do not know the branch, probe: try one scheme, fall back to the other.

Some modules answer with a valid JSON body under the wrong Content-Type (text/plain) [HW]. Parse the body regardless of the declared type, and parse it on non-2xx statuses too — error responses carry useful JSON.

2.3 Request envelope

Every request, without exception:

{
  "apiVer": "1.0",
  "command": "getAirconStat",
  "deviceId": "<your client id, free-form string>",
  "operatorId": "<your operator UUID>",
  "timestamp": 1754400000,
  "contents": { "airconId": "348e89c5a137" }
}

All five envelope fields are mandatory and validated before the command is dispatched. [FW] The parser reads them in this order and returns an error on the first failure, so a missing envelope field looks exactly like a malformed request:

Field JSON type Checked how
command string present, non-empty; then dispatched
apiVer string present, non-empty — the value is never compared. The literal "1.0" exists in the image only for emitting responses. Send anything non-empty and it passes
operatorId string present, non-empty here; setAirconStat additionally validates it against the stored accounts (§2.5)
deviceId string present, non-empty; logged (deviceId_value=%s) and passed on to the command handler
timestamp number present and non-zero
contents object required by every handler except getDeviceInfo

timestamp sets the module's clock. [FW] The parsed 64-bit value is written straight into the module's time global. The module has no RTC, so your client is its time source — which is where account expires values and the MQTT aggTm stamp come from. Send a real Unix timestamp in seconds; sending garbage will skew the device's notion of time.

Responses carry command, apiVer: "1.0", result and contents. [FW]

2.4 Endpoints

Seven handlers are registered in the module firmware; there are no others. [FW]

Command contents required Notes
getDeviceInfo (none — body is not evaluated at all) [FW] Always answers airconId, macAddress, apMode. Use this to bootstrap: it is the only call that works before you know the airconId.
getAirconStat airconId Returns the current state blob
setAirconStat airconId, airconStat (base64) Returns the new state blob
updateAccountInfo accountId, airconId, remote (0/1), timezone Registers your operator id on the device
deleteAccountInfo accountId, airconId Frees a slot
setNetworkInfo ssid, netPass Wi-Fi provisioning (AP mode) [FW]
updateFirmware (not documented here) Firmware updating is the app's job — see §8 for why you do not want to drive this yourself

Additionally, the MQTT (cloud) side knows setTopicId, putAirconStat and reportAirconStat. These are not reachable locally. [FW]

2.5 The two hard rules

Both are enforced in firmware and both produce confusing failures if you get them wrong.

  1. airconId is mandatory in the body of every relevant call. [FW] json_command_getAirconStat and …putAirconStat check, in order: contents is non-NULL → contents is a JSON object → get_string(contents,"airconId") is non-NULL and non-empty. Any failure aborts before anything happens. Newer firmware answers HTTP 400 with result: 2. Older firmware happens to tolerate its absence, which is why some clients omit it and "work".

  2. setAirconStat additionally validates operatorId. [FW] It is compared against the up-to-four accounts stored in flash (table stride 0x68) and against the literal string aws. An unknown operatorId aborts the handler. So: call updateAccountInfo once with your own UUID before you try to write anything. Reading works without registration.

    The aws escape hatch is real: operatorId: "aws" was accepted for setAirconStat on a WF-RAC-HTTPS 025 device without registering anything [HW]. Handy for one-off diagnostics — it consumes no account slot — but register properly for anything permanent.

    remoteList does not let you discover the registered ids: a device with numOfAccount: 2 returned ["", "", "", ""]. [HW] It lists only accounts registered with remote: 1, and emits a single null when none qualify; numOfAccount counts every occupied slot. [FW] A list that looks empty next to a non-zero count is therefore normal, not a fault.

    Four account slots exist, and a device paired with several phones can be full. [FW] Nothing about them is automatic: entries never expire, and a full table refuses the new client rather than displacing an existing one, so registering cannot push anyone out. The only way a slot is ever freed is deleteAccountInfo. Re-registering an id you already hold costs nothing and takes no second slot — it updates the entry in place.

    A fifth client can still read everything; it is writing that a full table denies it.

2.6 Rate limiting

The module is single-threaded about connections and says so: the firmware contains 429 Too Many Requests, Connection rate exceeded, device lock timeout: %d and send_message_unlock_semaphore_timer. [FW]

Practical rule, confirmed in the field: one connection at a time, at least 1 second between requests, per device. [HW] Violating it produces dropouts that look like a flaky network.

2.7 The 60-second write lock — the one that catches everyone

An applied setAirconStat grants its sender 60 seconds of exclusive write access. Everyone else is refused for the duration. [FW] The check sits at the head of the handler:

if (last_writer && expires >= now && strcmp(deviceId, last_writer) != 0)
        abort;                          /* answers result 1 or 12 */
...                                     /* otherwise apply, then: */
expires     = now + 60;
last_writer = deviceId;

Four things follow, and each of them has bitten a client:

  • It keys on deviceId, not on operatorId. Two phones sharing one Smart M-Air account lock each other out exactly as an unrelated client would.
  • Reads are free. getAirconStat neither takes nor tests the lock.
  • A read-only setAirconStat still takes it. A status request for operation data (§5.3) applies nothing, and renews the lock all the same. Poll it once a minute and you own the unit permanently — the official app included will be unable to control it. Leave at least one full lock duration free in every cycle.
  • A refused write is invisible afterwards. Neither expires nor updatedBy moves on the collision path, so there is no way to find out that somebody was locked out — only that somebody got through.

expires in the status response is that deadline, and it can be compared against your own clock directly: the module has no RTC and takes its time from the timestamp of every request it receives (§2.3), so your own request sets the clock its answer is measured against. A rise in expires between two polls means a write happened in between — yours if you sent one, someone else's if you did not. That is the only reliable way to notice another client, and it only works while you let the lock lapse often enough for them to get a write in.

The result codes do not identify this case. [FW] The handler's internal return value is mapped to the JSON result through one small table (-1 → 2, 0 → 0, 1 → 1, 2 → 12, 3 → 11), and a bridge-MCU error on the normal path arrives at the very same values as the collision path does. So 1/11/12 mean "refused", not "refused by someone else's lock". Treat them as a reason to check expires and retry once, not as a diagnosis.

2.8 Response fields (getAirconStat / setAirconStat)

In the order the firmware emits them: [FW]

Field Meaning
airconId MAC-derived device id
airconStat base64 state blob — the actual payload, see §3
logStat bit 2 of an internal flag byte
updatedBy deviceId of the last successful writer — but reported as the literal local whenever that writer's account carries remote: 0, which every locally paired client does [FW]
expires when the last writer's 60-second exclusive write access lapses (§2.7) — not an account expiry
autoHeating frost-protection flag (see §6.3)
firmType hard-coded in the image, e.g. WCBN4612L
timezone
highTemp, lowTemp 0xFF is emitted as JSON null; in practice constant "AB"/"66" (see §6.3)
wireless.firmVer %03d-formatted counter, e.g. 025
mcu.firmVer bridge MCU version, e.g. 200
remoteList registered accounts
numOfAccount

updatedBy does not distinguish IR remote from Wi-Fi changes — it is constant local in all recordings. [HW]

2.9 The complete field inventory

The WCBN4612L module image contains exactly these JSON key strings and no others [FW]. This is closed for that branch only. The WF-RAC and WF-RAC-HTTPS images are encrypted and could not be inventoried — and a live WF-RAC-HTTPS 025 device returns two fields that are not in this list, ledStat (integer) and mcu (object, {"firmVer": "200"}) [HW]. ledStat is the unit's own "LED ON" display-light toggle from the app's Option Settings screen (gated by the led_light capability), not a Wi-Fi module status flag — a constant 1 just means the setting has never been switched off [APP]. Expect per-branch additions:

command, apiVer, operatorId, deviceId, timestamp, contents, result, airconId, airconStat, accountId, macAddress, apMode, logStat, updatedBy, expires, autoHeating, firmType, highTemp, lowTemp, wireless, firmVer, remoteList, numOfAccount, timezone, remote, firmUrl, cksum, ssid, netPass, topicId, aggTm.

Four of them have a known transport but an unverified effect — the module demonstrably reads and forwards them, but nobody has confirmed that writing them changes anything on the AC. Treat them as unknown, not as features:

Field What is certain What is not
logStat (request) read on setAirconStat, value forced below 2, stored in an internal flag bit, echoed in the response [FW] what the bit switches on
autoHeating, highTemp, lowTemp (request) placed into the UART frame header to the bridge MCU, at header bytes [3] bit 0, [4], [5] [FW] whether the AC acts on them. Responses are constant 0 / "AB" / "66" on every device measured, and the official app never writes them locally (§6.3)
deviceId (value) mandatory, logged, handed to the command handler [FW] whether any handler evaluates it, or whether it only has to be present
timezone stored per account, echoed back [FW] what the module uses it for — expires formatting and aggTm are the obvious candidates, neither traced

3. Layer 2 — the airconStat blob

Base64. Decoded, it is two independent blocks back to back, each with its own CRC16: [APP] [FW]

┌─────────────── COMMAND block ──────────────┬─────────────── RECEIVE block ──────────────┐
│ 18 B state │ 1 B segCount │ segCount×4 B │ 2 B CRC │ 18 B state │ 1 B segCount │ … │ 2 B CRC │
└────────────────────────────────────────────┴────────────────────────────────────────────┘
  • COMMAND block = what you want the AC to do (and, in a device response, an echo of the last command it accepted). This is the block the bridge MCU translates into MISO frames.
  • RECEIVE block = what the AC currently is. This is the block you parse.

3.1 Locating the RECEIVE block

The COMMAND block is variable-length, so:

seg_count      = data[18]                 # segment count of the COMMAND block
receive_start  = seg_count * 4 + 21       # 18 state + 1 count + n*4 + 2 CRC
state          = data[receive_start : receive_start + 18]
rx_seg_count   = data[receive_start + 18]
segments       = data[receive_start + 19 : receive_start + 19 + rx_seg_count*4]

3.2 CRC16

CRC16-CCITT, appended little-endian to each block: [APP]

Parameter Value
Polynomial 0x1021
Init 0xFFFF
Reflect in / out no / no
Final XOR none
Covers the block's own bytes only (state + count + segments)

The bridge MCU verifies the COMMAND block's CRC before doing anything with it. [FW]

3.3 The "nothing to send" sentinel

When it has no variable data to transmit, the official app appends [0x01, 0xFF, 0xFF, 0xFF, 0xFF] — segment count 1, then one segment whose code byte is 0xFF. [APP] The bridge treats code 0xFF as "no request" and skips the whole request path. [FW] Use this as your default trailer.


4. Layer 3 — the 18-byte state block is the CNS/SPI frame

This is the key insight for anyone coming from the ESP32 side.

The bridge MCU copies the state block straight out of the MOSI frame: [FW]

state[0..9]   = MOSI raw byte  1..10
state[10..17] = MOSI raw byte 20..27

Since the MHI frame is SB0 SB1 SB2 DB0 DB1 …, that gives:

4.1 Byte map

state[i] SPI Contents Confidence
0 SB1 0x80 | ModelNr — the signature byte doubles as a model id, read it as state[0] & 0x7F [FW] [HW]
1 SB2 constant 0x04, no known use [FW]
2 DB0 power / mode / up-down swing [HW]
3 DB1 fan speed / vane position [HW]
4 DB2 setpoint [HW]
5 DB3 room temperature in use, (raw−61)/4 °C [EXT] [HW]
6 DB4 error code [HW]
7 DB5 bit 0x10 echoes command[8] from the last command frame — not a device state (§5.3) [HW]
8 DB6 operation-data type/echo; bit 0x08 = "cool/hot judge" off [HW]
9 DB7 bit 0x02 = compressor running (see §4.6) [HW]
10 raw 20 bit0 occupancy/Vacant, bit2 self-clean reset [HW]
11 raw 21 left-right vane position (low 5 bits) [HW]
12 raw 22 3D-auto/Entrust, LR-vane enable, self-clean flags — forced to 1 on legacy-signature units, see §6.7 [HW] [FW]
13–14 raw 23–24 unknown —
15 raw 25 bit0 self-clean operation (see §6.4) [HW]
16–17 raw 26–27 unknown —

state[10..17] live beyond the classic 20-byte frame. WF-RAC-capable units send extended frames; this is where left/right vanes and 3D-auto are carried. MHI-AC-Ctrl reaches part of the same area as its DB16/DB17. [EXT]

4.2 Decoding the state block (read path)

Operation      = (state[2] & 0x03) == 1              # power on
OperationMode  = {0x00:'auto', 0x04:'dry', 0x08:'cool', 0x0C:'fan', 0x10:'heat'}[state[2] & 0x3C]
SwingUD_on     = (state[2] & 0xC0) == 0x40
AirFlow        = {0x07:'auto', 0x00:1, 0x01:2, 0x02:3, 0x06:4}[state[3] & 0x0F]
VaneUD         = (state[3] & 0xF0) >> 4              # 0..3 -> position 1..4, ignored if swinging
PresetTemp     = state[4] / 2.0                      # °C
IndoorTemp_raw = state[5]                            # see §4.5 — but prefer the pushed segment
ErrorCode      = state[6]                            # see §4.4
CoolHotJudge   = (state[8] & 0x08) == 0
Vacant         = (state[10] & 0x01) != 0
VaneLR         = (state[11] & 0x1F) + 1              # not reported if (state[12] & 0x03) == 1, see §6.7
Entrust3D      = (state[12] & 0x0C) == 0x04

Mode bit layout is DB0[4:2] and the fan bits are DB1[1:0], exactly as in the SPI documentation [EXT]; the tables above are the WF-RAC-observed values [HW]. The mode values are also the digits MHI's service mode blinks out for the operation mode — AUTO 0, DRY 1, COOL 2, FAN 3, HEAT 4 — and the value of operation-data code 0x02 (§5.4) [EXT].

4.3 Building the COMMAND block (write path) — and the set-bit trap

The COMMAND block is copied byte-for-byte into the MISO frame with the same offset: command[i] → MISO raw byte i+1, so command[2] → DB0, and so on. [FW]

On the MHI bus, writing a field means setting its accompanying set-bit. A value alone is ignored. Those set-bits are:

Field Value bits Set-bit In command[]
Power DB0[0] DB0[1] command[2] |= 0x02 (+0x01 for on)
Mode DB0[4:2] DB0[5] command[2] |= 0x20
Vane up/down swing DB0[6] DB0[7] command[2] |= 0x80
Vane up/down position DB1[5:4] DB1[7] command[3] |= 0x80
Fan DB1[1:0] DB1[3] command[3] |= 0x08
Setpoint DB2[6:0] DB2[7] command[4] = temp*2 | 0x80
Room temperature DB3[7:0] (none) command[5], 0xFF = leave internal sensor

This is why the WF-RAC command tables look "off by a constant" compared with the receive tables — e.g. mode auto is 0x20 when writing and 0x08 when reading. The extra bits are the set-bits.

A complete, safe command block: start from [0,0,0,0,0,0xFF,0,0,0,0,0,0,0,0,0,0,0,0], set only the fields you want to change, append the sentinel trailer of §3.3, CRC it, then append a RECEIVE block built the same way (the official app sends both; the device ignores the receive half). [APP]

Never leave command[5] at anything other than 0xFF unless you mean it — see §5.5.

4.4 Error code (state[6])

code = state[6] & 0x7F
if state[6] & 0x80:  label = f"M{code:02d}"   # indoor/self-diagnosis family
elif code == 0:      label = "00"             # no error
else:                label = f"E{code}"       # E-family

Bit 7 selects the family, and the bit-7 test must come before the zero test. [APP] Getting this wrong is a well-worn bug: a naive implementation makes the E branch dead code.

4.6 state[9] & 0x02 — compressor demand

Not documented anywhere else. It carries the distinction between powered on and actually calling for the compressor — the AC reports hvac_action: cooling either way, and this bit costs nothing to read, since it rides along in every ordinary status poll with no operation-data request needed.

Correlated against operation-data code 0x11 (compressor frequency) across a multi-day recorder history of two indoor units on one outdoor unit [HW]:

Situation state[9] & 0x02 Code 0x11
Unit calling for compressor 1 > 0 Hz
Unit satisfied, sibling unit also idle 0 0.0 Hz
Unit satisfied, sibling still calling 0 > 0 Hz

Both directions are covered. Where no sibling was demanding, the bit and the frequency moved together within a single poll: five clean 1 → 0 transitions, each followed by the frequency reading 0.0 Hz two seconds later, and the matching 0 → 1 transitions with the frequency picking up in the same poll.

The apparent counterexamples are the important part. In four further cases the bit dropped to 0 while the frequency stayed at 20–35 Hz. Every one of them lines up with the other indoor unit still demanding at that moment. That is the relationship worth remembering:

  • state[9] & 0x02 is per indoor unit — does this unit want the compressor.
  • Code 0x11 is outdoor-unit-level (§5.4) — is the shared compressor turning, for whoever asked.

On a single-split system the two are interchangeable. On a multi-split they are not, and the bit is the one that answers "is this room being served".

state[7] & 0x10 stayed set in every sample, at 0 Hz and at full load alike, so whatever it means, it is not compressor state.

4.5 Temperatures — one byte, one table

state[5] is DB3, the room temperature the controller is currently working with. It is the same raw byte the pushed indoor-temperature segment (§5.2) carries — verified on two units over nine consecutive frames, state[5] equal to the segment's data byte every time [HW] — and it decodes through the same 256-entry thermistor table (indoorTempList, 0.1 °C steps, −30…52 °C) [APP]. Over the living range (16…31 °C) that table is linear at T = (raw − 59) / 4 within 0.05 K, i.e. 0.25 °C steps; outside it, it bends like the thermistor curve it is, so copy the table rather than fit a line.

The SPI-bus projects decode this byte as T = (raw − 61) / 4 [EXT]. Against the manufacturer's own table that reads half a kelvin low across the whole living range. It is very likely the "< 0.5 °C" discrepancy between room and return-air temperature that MHI-AC-Trace notes without explanation, and it was the constant this project encoded with until September 2026 — which is where the apparent 0.5 K "echo" on injected values came from (§5.6).

The 0xFF convention runs the other way. 0xFF means "no external value" in the write direction (command[5], §5.6); in the read direction the AC reports the temperature it is actually using.

Which is literal: with an external room temperature injected, state[5] carries back exactly the value that was written, and so does the pushed indoor-temperature segment below — neither is a reading of the room any more. See §5.6. [HW]

The pushed variable segments (§5.2) remain the place to read temperatures from: the indoor one carries this same byte, the outdoor one has a table of its own (−50…43 °C), and both arrive unsolicited. The tables are non-linear and are not derivable from a formula — copy them (the ones in this project live in pywfrac's utils.py). [APP]


5. Layer 4 — variable segments: the operation-data channel

This is the part the official app barely uses, and the most valuable part of the interface.

5.1 Segment format

Every variable segment is 4 bytes, and it is literally DB9…DB12 of an SPI frame: [FW]

[ Code, OP1, OP2, OP3 ]  ==  [ DB9, DB10, DB11, DB12 ]
OP1 Meaning
0x10 (16) status report — this segment carries a value
0xFF (255) request — "tell me this"
0x00 (0) command — "set this"

OP2/OP3 are the value, or the sub-selector, depending on the code.

In a request, all three of OP1, OP2 and OP3 are 0xFF. They are the answer's fields, not yours: the AC fills them in when it replies. [EXT] A request on the bus carries two things, DB6 and the code in DB9 - the bus-level capture in MHI-AC-Trace shows ff ff ff behind them, and one ESPHome implementation hands its request builder a buffer that ends before DB10 altogether. Putting anything else in those three bytes does not add information to the request, it malforms it: asking with OP1 = 0x10 here returned a body that was not even valid UTF-8, and OP1 = 0x20 got the segment dropped without an answer. [HW]

Which unit an answer came from is read off OP1 in the reply: & 0x30 is 0x20 for the indoor sensor and 0x10 for the outdoor one, the same convention as the air temperatures in §5.2. [EXT]

Safety note. OP1 = 0 writes into the AC main board through a path nobody outside MHI has mapped. If you are exploring, send 0xFF only.

5.2 What arrives unsolicited

The bridge keeps a cache of exactly three slots, hard-coded in ROM [FW]:

Code OP1 Meaning Decoding
0x80 0x10 outdoor temperature outdoorTempTable[OP2], 0.1 °C
0x80 0x20 indoor temperature indoorTempTable[OP2], 0.1 °C
0x94 0x10 energy meter (OP3<<8 | OP2) × 0.25 kWh

These appear in the RECEIVE block's trailer on ordinary polls, without being asked for. [HW] They are not pushed by the AC of its own accord, though: the bridge requests them itself, cycling a three-entry ROM table of (code, unit) pairs - 80 01, 80 00, 94 01 at 0x3025 - and setting the bus-level unit selector per entry, 0xc0 for the indoor variant and 0x40 for the outdoor one. [FW] That is why 0x80 arrives twice with different OP1 values: it is asked twice. It is also the only place in the firmware where that selector is set from anything other than a constant - see §5.3.

The energy counter is per run, not lifetime: it counts up in 0.25 kWh steps while the indoor unit is running, holds its last value while the unit is off, and is cleared to 0 at the next power-on — the value read right after an off→on transition is 0. [HW] (Six resets across two units, each one within a minute of an off→on transition, including one initiated from the unit itself rather than over the network; no reset ever occurred without a power-on, and ordinary setAirconStat commands do not clear it.) Treat it as a counter that may restart at any time: accumulate its upward steps yourself if you want a lifetime figure, and never read a low value as a fault.

What the 0.25 kWh resolution costs. Only completed steps are reported, so whatever a run consumes above its last completed step never appears in the counter at all, and the next power-on clears it — a lifetime figure built from this source is therefore always low. The shortfall is a fixed amount per run, about 0.125 kWh on average, no matter how long the run is [INF]: a unit cycling every half hour totals noticeably low, one running for hours at a time is off by little. A run consuming less than 0.25 kWh reads 0 throughout and contributes nothing [HW]. There is no finer counter in the protocol — an external energy meter is the only accurate option for a frequently cycling unit.

On a multi-split the counter is per indoor unit, not a shared outdoor-unit total, so the values of two units may be summed. [HW] (Verified on a two-unit multi-split: one unit cleared its counter at its own power-on while the sibling's kept running unchanged; the two accumulated at different rates over the same interval; and one kept counting while the other was off for seven hours. Do not conclude the opposite from a single side-by-side run — two units cooling in parallel accumulate at nearly the same rate and look like one shared meter.)

5.3 Requesting anything else — a parameter channel, used generically

This channel was not designed as an operation-data API. It is the parameter pipe for Home Leave Mode, and the official app puts exactly one code in it: 248, in segments shaped (248, mode, sub-code, value) where OP1 is 0xFF to read and 0x00 to write. With nothing to send the app still emits one filler segment, (0xFF, 0xFF, 0xFF, 0xFF) [APP], which the bridge recognises and discards without queueing anything [FW].

What makes it generally useful is that the bridge does not interpret what it carries. It copies the COMMAND block's variable segments verbatim and unfiltered into a request queue, emits one per MISO frame with the operation-data type bit set in DB6, matches incoming MOSI answers by code byte, and appends the matches to the RECEIVE block's trailer. [FW]

There is no whitelist on this path. The only special case is the matching step: for code 248 the sub-code in OP2 is compared as well, because that code alone would not identify which of six answers came back.

That matching is also why you cannot ask for a unit. The bridge pairs an answer with a pending request by the code byte alone, so two answers sharing a code would be indistinguishable to it — precisely what a unit selector for 0x1E or 0x1F would produce, and precisely why 248 needed a second discriminator bolted on. The channel is code-addressed by construction, not by oversight. [FW]

Recipe — verified end to end on real hardware [HW]:

1. build a COMMAND block with NO set-bits: all zeros, command[5] = 0xFF,
   command[8] as usual (see below - do not send the current state)
2. trailer = [1] + [code, 0xFF, 0xFF, 0xFF]        # one request
3. CRC, base64, POST setAirconStat
4. the answer is ALREADY in that POST's own response:
   receive trailer -> [code, sel, value, value2]

Send an empty command block, not the current state. The obvious approach — fill the block from the last status you read — makes the request a full write of that status, and any change made at the unit since you read it is undone. It is unnecessary: a value applies only together with its set-bit (§4.3), so a block with none of them set changes nothing while still carrying the trailer. Measured on two indoor units [HW]: result: 0, the complete operation-data trailer in the response, and power, mode, fan speed, setpoint and both vane axes unchanged — with the unit running and with it switched off.

Not on every unit. One module reporting firmType WCBN4612L behaves the opposite way: the indoor unit switches off the moment the block arrives, and again on every repetition, until the operation-data sensor asking for it is turned off. [UR] The reading that fits is that command[2] bit 0x01 (the power value, whose set-bit is 0x02) is applied without its set-bit, so an all-zero byte means "off" rather than "leave alone". Whether the other fields are applied from their zeros too is untested — a unit that is off hides most of them.

The module is not what differs. [FW] It is tempting to write this off as a firmware branch, and that is wrong. The bridge-MCU images for WF-RAC and WCBN4612L are byte-identical, and against the third image (WF-RAC-HTTPS) the command path is identical too: frame builder and the pending-command comparison match instruction for instruction apart from one relocated RAM variable, and the ROM mask that clears bits before sending is the same 18 bytes. The MCU does not interpret the set-bits at all on the way out — it copies command[2..] into its staging area and lets the unit decide.

So the divergence is in the indoor unit, and firmType names the module, not the unit. Do not gate behaviour on it; it is a label that happens to correlate with one report.

What the MCU does do is compare command[2] & 3 against the unit's DB0 & 3 to decide whether its command has been taken, and it keeps re-sending while they differ [FW]. A block with no set-bits therefore never confirms on a unit that ignores it — harmless, but it stays pending and is re-sent. On a unit that applies it, the unit goes off and the block then "confirms", which is exactly the reported behaviour.

One consequence either way: where this happens, operation data cannot be polled at all, and neither can anything else riding on the same frame — for this integration that includes writing a room temperature into command[5], which has no frame of its own.

Carry command[8] as you would in a real command, though. It is the one field with no set-bit of its own, and dropping it clears the unit's echo of it in DB5 bit 0x10 — harmless in itself, but it makes that bit look like a state change when it is only an echo of your own last frame. [HW]

Read the answer out of the setAirconStat response. The round trip over the CNS bus finishes inside the HTTP request, so the reply block you get back already carries the extra segment. It is transient: the bridge clears its response cache after handing it over, and a getAirconStat ~2.5 s later shows the usual three segments and nothing else. [HW] If any other client polls the device in that window, it — not you — gets the answer.

Two things that do not work as one might expect [HW]:

  • The command-block echo does not reflect your trailer. It stays at the 0xFF sentinel, so it cannot be used as an "was my request accepted" channel. The answer itself is the only confirmation.
  • The only field that steers a request is one you cannot write. Of the four segment bytes only the code reaches the AC as an input. The unit is selected in DB6, and on the path that carries your segments the bridge writes it as (DB6 & 0x3f) | 0x80 - a constant, regardless of what you sent. [FW] The firmware does know how to set that selector properly, and does so with 0xc0/0x40 exactly as the CNS implementations do [EXT] - but only for the three requests of its own in §5.2, driven by a table in flash. There is no path from the trailer to it. The reply's own OP1 says which sensor answered (§5.1), but that is the answer's field, not a way to ask.

An unknown code is refused, not ignored — and it takes the whole trailer with it. If the indoor unit does not know one of the codes you asked for, the response carries result: 11 and none of that request's segments are answered, including the valid ones. [HW] (On the meaning of 11 in general, see §2.7: it says "refused", not why.)

Two consequences, and the second is the one that bites:

  • You can probe the code space without decoding anything. result: 0 means the unit knows the code, result: 11 means it does not. Sweeping the whole space on that basis is what §5.4 does. Be ready for a third outcome: a refusal arrives as an unreadable response body often enough to matter (§5.4), so retry once before recording a code as unknown.
  • Batching is useless for exploration. One unknown code voids the request, so a batch of candidates reports only that the batch failed, never which member was at fault. Ask one code per request while you are mapping unknown territory; batch only codes you have already confirmed.

Limits. The bridge does not clamp the segment count and copies count × 4 bytes into a queue with room for 22 entries [FW] — but 22 is not the limit you will hit. The HTTP path in front of that queue gives out much earlier, measured on an SRK20ZS-WF running WF-RAC-HTTPS 025/200 [HW]:

Segments Result
1–11 clean. result: 0, every segment answered (6 of 6 runs at 11)
13 unreliable. Three runs gave one malformed body, one result: 11, one clean
14 and above never clean — either result: 11 with nothing answered, or a response body that is not valid UTF-8

"Malformed body" is the same failure mode as a malformed request field (§5.1): the module stops producing valid JSON at all. No lasting damage was observed — mode, setpoint and sensor readings were unchanged after every failure — but the request is lost.

Keep the count at 10 or below. That leaves a step of headroom under the first observed wobble. And note that a failure contaminates the next request: immediately after a malformed response, a request was refused that went through six times in a row when retried on its own. If you probe near the boundary, pause and repeat before believing the result.

Timing. SPI frames run at roughly 20/s [EXT], one request per frame, so a handful of requests resolve in well under a second. Anything slower than that is your own poll interval, not the device.

5.4 Operation-data codes

The bridge firmware contains per-code plausibility rules for these codes [FW]. All fifteen were requested over the WF-RAC HTTP path and all fifteen answered (SRK20ZS-WF, WF-RAC-HTTPS 025/200) [HW]. Measured twice: once with the compressor idle, once under load (setpoint forced 6 K below room temperature). Where a value moved sensibly between the two, that is noted — those readings are consistent with the MHI-AC-Ctrl formulas [EXT], but two operating points are not a calibration, so the formulas stay [INF].

This table is the decoded subset. The whole code space has since been swept and 49 codes answer in all — the rest are mapped at the end of this section.

Code Name Formula Measured (compressor idle)
0x11 Compressor frequency [Hz] (sel − 0x10) × 25.6 + 0.1 × OP2 idle 00 ⇒ 0.0 Hz · load e6 ⇒ 23.0 Hz
0x90 Current [A] OP2 × 14 / 51 idle 00 ⇒ 0.0 A · load 04 ⇒ 1.10 A (≈250 W)
0xAD Relative humidity [%] OP2; 30 ⇒ no sensor (or range floor), 0 ⇒ broken sensor wire, 0xFF ⇒ no value 10 1e ff ⇒ 30 on an SRK20ZS-WF, which has no humidity sensor
0xB1 TDSH [°C] OP2 / 2 00 in both — never moved
0x85 Discharge pipe TD [°C] OP2 / 2 + 32, only for OP2 >= 0x12 idle 14 ⇒ 42 °C · load 17 ⇒ 43.5 °C
0x13 Outdoor EEV [pulses] OP2 idle c8 ⇒ 200 · load 5f/66 ⇒ 95 / 102 (valve closes under load)
0x05 Internal setpoint [°C] OP2 / 2, sel = 0x13 2e ⇒ 23.0 °C while 22.5 °C was the value sent
0x82 THO-R1 outdoor heat exchanger, no known conversion idle 3f ⇒ 63 · load 49 ⇒ 73; tracks the outdoor unit, but not on the coil scale below
0x81 THI-R1 [°C] thermistor curve, see §5.4 20 5a ff ⇒ raw 90, sel = 0x20
0x87 THI-R3 [°C], gas header thermistor curve, see §5.4 10 5a ff ⇒ raw 90
0x1E Total run hours [h] OP2 × 100 ff ff ff ⇒ no value
0x1F Indoor fan speed OP1 & 0x0f, not OP2 [EXT] ff ff ff ⇒ no value
0x0D unknown — 00 ff ff, sel = 0x00
0x21 silent operation — the write code (see below); read, the state is likely OP1 & 0x0f [INF] — 10 ff ff ⇒ 0
0x32 unknown — 2e 04 ff, unusual sel
0x34 unknown — 11 01 04, uses OP3 — multi-byte value
0x35 unknown — 10 00 ff
0x80 Air temperature see §5.2 pushed unsolicited
0x94 Energy [kWh] see §5.2 pushed unsolicited

Notes on the shape of the answers [HW]:

  • The second byte is a selector, not a fixed status marker. 0x10 and 0x20 follow the same indoor/outdoor convention as air temperature; 0x0D and 0x32 answer with values outside that pattern and are not understood.

  • 0x05 is absent from the bridge's own code list and answers anyway, over the channel in §5.3, with the setpoint the unit is using internally. It is not always the value that was sent: 22.5 °C came back as 23.0, 22.0 °C as 22.0 (three and ten measurements, SRK20ZS-WF). The unit resolves whole degrees and rounds a half one up, so a half-degree step either lands a full degree away or nowhere — which is what ENHANCED_RESOLUTION in MHI-AC-Ctrl works around. [HW] A correction finer than a degree has to travel in the injected room temperature instead (§5.6); that field has a 0.25 K grid.

  • 0x1E and 0x1F answer with all-0xFF, and that is as far as this interface goes. Both exist on both units, and every implementation that reads them does so with DB6 = 0xc0 for the indoor variant [EXT] - the one byte the bridge fixes to something else (§5.3). Confirmed by trying: putting 0x10 or 0x20 in the request's OP1 does not stand in for that, it only malforms the request (§5.1), while a single-unit code such as 0x11 answers normally in the same session. [HW] The bridge sends the constant 0x80 there, which those implementations use for "no operating-data request at all" [EXT]. Why it never sends anything else is settled in the firmware rather than guessed: the request path cannot express a unit because the answer path could not tell the two apart (§5.3). The bridge is not short of the logic — its own poll disambiguates both codes, 0x1F by the unit bit in the answer's DB6 and 0x1E by the OP1 nibble — but that classifier sits behind a three-entry ROM table and never sees a code you asked for. [FW] Either way, reading indoor fan speed or run hours needs a device on the indoor unit's own connector; it is not available over this interface.

  • 0x34 is the only code that used OP3, so at least one value here is wider than one byte.

  • On a multi-split installation, 0x11/0x90/0x85 (compressor frequency, current, discharge temperature) read identically from every indoor unit sharing the outdoor unit — they are outdoor-unit-level values, not per-indoor. 0x13 (EEV) differs per indoor unit, since each has its own expansion valve. Confirmed live: one indoor unit cooling, the other idle, identical frequency/current/temperature on both, EEV 57 vs. 0. [HW] So 0x11 does not tell you whether this indoor unit is calling for cooling on a multi-split — state[9] & 0x02 (§4.6) does, and costs nothing.

  • 0x13's full-open pulse count is unknown. MHI-AC-Ctrl reads the same quantity as 16 bits (DB12<<8 | DB11, its OU-EEV1), while this bridge's OP2 is a single byte — likely a narrowed/derived value, not a raw pass of the same counter. No reference project (MHI-AC-Ctrl or its ESPHome port) converts it to a percentage either; both publish raw pulses. [EXT] Mapping the byte linearly onto 0–100 % is fine as a relative reading — idle against load, or one indoor unit against another — but do not present it as a calibrated valve opening.

  • 0x13 on an indoor unit without demand depends on the system's direction. While another indoor unit cools, it reads 0 and only the working unit's valve is open. [HW] While another one heats, it stays at roughly 16–24 % of c8, and a unit locked out of its own mode is treated like one in fan mode. With nothing running, all four units of that system read c8, the idle value above. [UR] (4 × SRK25ZS-WF on an SCM80, #328)

  • 0x85 below OP2 = 0x12 is not a temperature. MHI-AC-Trace states the conversion as two branches: below that byte the sensor only reports "30 °C or colder", above it the value is OP2 / 2 + 32. [EXT] Applying the second branch everywhere turns an idle outdoor unit's bytes 8–16 into a steady 36–40 °C, which looks like a running crankcase heater and was read as one (#288). Confirmed against 24 h of own data: compressor frequency 0 and current 0.00 throughout, 0x85 between bytes 8 and 15 while outdoor air swung 29.7 → 11.0 °C, on an outdoor unit with no crankcase heater. [HW] Both of the calibration points above sit above the threshold, so the low branch rests on that source alone — treat a byte under 0x12 as "no reading", not as a cold pipe.

  • The two indoor coil sensors are per indoor unit; 0x82 is shared. Over a night with one indoor unit cycling and the other cooling continuously, both units' 0x81 collapsed when their own compressor demand was on, while 0x82 read the same on both (paired correlation 0.89). 0x87 follows 0x81 per unit: identical while the compressor is off, higher while it runs — the difference is evaporator superheat. [HW]

  • Conversion for 0x81/0x87 — invert the divider, do not index a table:

    byte = G · Rs / (Rs + R(T)),   R(T) = R25 · exp(B · (1/T − 1/298.15))
    R25 = 5200 Ω,  B = 3900 K,  G = 367,  Rs = 1912 Ω
    

    MHI's service manuals print one characteristic for room air, indoor coil, outdoor coil and outdoor air, and the sensor is a ~5 kΩ NTC with B ≈ 3950 [EXT]. The channels differ only in their series resistor, which is why the app's two air tables (§5.2) map onto each other by a fractional-linear relation rather than an offset — and why no indexing of either table ever fit the coil. With the outdoor and indoor channels' own resistors (≈5.6 kΩ and ≈4.0 kΩ) the same formula reproduces both published tables to ~0.3 K, so only Rs is specific to the coil. [INF]

  • Rs measured, not assumed. Four infrared readings during one heating run plus a standstill reading against a room thermometer, RMS 0.25 K over 23–46 °C. [HW] The infrared thermometer sees a mix of fin and intake air, so the fin's share is a free parameter of the fit — and it is what settles the question: one constant share explains all four readings on this curve, while outdoorTempList[2 × OP2] extended upward would need that share to halve between cooling and heating. Sample values: byte 47 → 5.5 °C, 75 → 17.0, 129 → 34.1, 170 → 45.8, 186 → 50.4, 252 → 72.1. [HW] Measure on matt tape stuck to the fins: bare aluminium has an emissivity near 0.35 and reads several kelvin too high in cooling, low in heating.

  • Two caveats. Above byte 170 the curve is extrapolation — physically motivated, but no thermometer has been held against it, and byte 252 (seen on a 36 °C day) would put the coil at 72 °C, past the 63 °C heating overload cut-out that did not trigger. And at the cold end the curve reads ~5.5 °C where the manual's frost protection cuts out at 1.0 °C; that threshold is adjustable on the unit and what gets sampled is the value before the stop, so a direct reading at byte 75 (16.8 °C measured, 17.0 °C from the curve) was taken as the better evidence. [HW] [INF]

  • 0x82 is the same sensor behind a different resistor. MHI's diagram covers the outdoor coil too, and a five-hour standstill puts it on Rs ≈ 1230 Ω against a reference thermometer — the same channel type as the indoor coil (both far below the air channels' 4.0/5.6 kΩ, as a sensor that has to span frost to condensing temperature needs), but not the same value: at byte 80 the two curves are 4.5 K apart. [HW] [INF] Readings taken in afternoon sun run high on a west-facing unit, and no calibrated point exists yet, so it stays a raw byte. A defrost cycle would anchor it exactly: the outdoor coil sits at 0 °C while ice melts off it.

  • 0x82 is not on that scale — a resting outdoor coil reads far lower than a resting indoor coil at the same temperature. MHI-AC-Ctrl documents no formula for THO-R1 either. [EXT] Treat it as a raw byte.

  • Not every firmware branch answers usefully. On mcu131/wireless010, 0x11 and 0x90 (compressor frequency, operating current) have been reported to return a constant 0 even with the compressor confirmed running, while 0x85 and 0x13 return real, unit-specific values on those same units. [EXT] Both formulas produce exactly 0.0 for OP1 = 0x10, OP2 = 0, so a decoded zero cannot be told apart from a genuine one — keep the raw bytes if you need to distinguish "no data" from "not running".

  • 0x87 is the sensor on the indoor coil's gas header: the coil outlet while cooling, but the inlet of the hot gas while heating. Name it by position, not by flow direction. [EXT]

Two codes MHI-AC-Ctrl uses are absent from the bridge's list: 0x7C (protection number) and 0x0C (defrost). That absence says nothing about whether they work here. The list is the classifier for the bridge's own poll, and the channel in §5.3 does not consult it — 0x05 and 0x7C both answer while being absent from it. [FW] [HW] Judge a code by trying it, not by that list.

0x7C is requested alongside the rest, as Protection Number (raw) — it sits in the same operation-data address space. Batching it is safe because it is confirmed to answer, not because a miss would be harmless: a code the unit does not serve voids the whole request it rode in on (§5.3). It does answer, confirmed on three units across single- and multi-split [HW], but it only ever reads 0: a controlled test that reproduced a real overload clamp (see §5.7) left it unmoved.

Its values are not the stop codes. MHI's service manuals list it as operation-data item 33, the protection state of the compressor, with a table of its own [EXT]: 0 normal, 1/2 discharge temperature control/anomaly, 3/15 current safe on the inverter's primary/secondary current, 4/5 high-pressure control/anomaly, 6/7 low-pressure control/anomaly, 8 anti-frost control, 9 current cut, 10/11 power transistor control/overheat, 12 compression-ratio control, 14 dew prevention, 16/17 stop by rotor lock/start-up failure, 18 active-filter anomaly. The lowest number wins while several are active, and a frequency reduction requested by the indoor unit shows as 4 in heating and 8 in cooling. So the number covers frequency-reducing controls, not just stops — the raised speed floor of §5.7 is not on the list, though. On our units it read 0 even through a probable anti-frost stop, where the table says 8 [HW]: either these units do not fill it, or it needs the outdoor selector the bridge cannot send (§5.3).

0x0C answers, and the answer is the defrost flag. The segment comes back as 10 ff ff [HW], and that is not an empty answer: the flag is OP1 & 0x01, the bit MHI-AC-Ctrl reads it from, and a CNS capture of an idle unit shows the same 10 ff [EXT]. So 10 means "not defrosting". It held through cooling, standstill and six heating runs on two units. A defrost cycle should read 11 ff ff; we have not caught one yet.

The rest of the code space

Every code value was requested one at a time, using result: 0 vs. result: 11 (§5.3) as the oracle. That is 255 questions, not 256: 0xFF is the sentinel and cannot be asked for (see below). SRK20ZS-WF on WF-RAC-HTTPS 025/200, unit switched off throughout, two confirmation passes that returned the same 49 codes with the same bytes. [HW]

49 codes answer in total, and 28 of them have no known meaning — neither in the table above nor anywhere else in this section. Grouped by what they did at rest:

Behaviour Codes
carries a value at rest 0xAE (10 9c ff), 0xAD (10 1e ff, humidity), 0xD2 (10 01 ff)
reports 0 while the unit is off 0x10 0x15 0x22 0x2A 0x3E 0x7B 0x84 0x86 0x88 0x8D 0xA0 0xA1 0xA3 0xA4 0xA5 0xB0 0xD5
value in OP1's low nibble, OP2 unused 0x02 (12 = cool), 0x0C 0x14 0x1C 0x1D 0x23 0x45 (each 0)
answers all-0xFF 0x44 0xF1
answers with selector 0x80 0xDD (80 00 00) — silent operation, see below

Read this as a map of where something exists, not as a set of new sensors. A value of 0 from a unit that is standing still carries no information, and 17 of the 30 are in exactly that state — they have to be read again under load before any of them means anything. The three that do carry a value at rest are the ones worth chasing first, and one of them has a visible lead:

  • 0xAE tracks the indoor air temperature. It moved with 0x80/sel 0x20 (the return-air sensor, §5.2) across the sweep and the confirmation passes, on the same scale, never more than one 0.25 K step apart, and the two agreed exactly on the second pass. An unfiltered or differently-rounded read of the same sensor is the obvious reading, but it has only been seen on a still room — confirm it on a room whose temperature is actually moving before relying on it.
  • 0xAD is the relative humidity in percent (OP2), read at the indoor unit's return-air sensor. Whether a unit has the sensor depends on the series: MHI's service manuals show it only on the SRK35 and SRK50 in the ZS-WF series, but on every size in the ZSX and ZT-WF series. A unit without it answers a constant 0x1E (30), as the SRK20ZS-WF here did through every pass. No value below 30 has been seen, so 30 means either "no sensor" or the bottom of the range, and the integration reports it as unknown; so is 0, which the manuals give for a broken sensor wire. The sensor sits in the return air, so while heating the reading is lower than a sensor in the room would show.
  • 0xD2 sat at 0x01 through every pass. Nothing distinguishes a constant from a parameter that simply did not change while the unit was off.
  • 0xDD is silent operation. It answers with selector 0x80, a value that appears nowhere else in any reply, and carries the state in bit 0x20 of OP2: 80 20 00 on, 80 00 00 off. The same layout the CNS projects read as C0/DD [EXT]. It is written with the segment 21 01 ff ff (on) or 21 00 ff ff (off) in a set-bit-free frame, and the module answers that with result: 11 or a body that is not valid UTF-8 — yet the unit applies it, on both indoor units tested. Only reading 0xDD back tells whether a write took effect. [HW] The flag survives the unit being switched off and on, and status requests do not disturb it. [HW] The IR remote can change it as well, and a state set there may not clear over the bus [EXT]. Whether it changes anything measurable on a multi-split system is open: heating ran past the outdoor unit's documented silent limit with the flag set. [HW]

A refusal does not always arrive as JSON. Of the 206 codes that were refused, 127 came back as result: 11 and 79 came back as a response body that is not valid UTF-8 — the same malformed output an oversized request produces (§5.3). This is not a property of particular codes: the 79 were scattered across 47 separate runs of one to four consecutive codes, and not one of the 49 answering codes ever did it, across three passes. Treat a malformed body as a refusal you could not read, retry it once, and do not conclude anything about the code from it alone.

The device state was unchanged after every such failure (mode, setpoint and sensor readings all identical), so it is a serialisation fault in the module, not damage. The request is lost, and it can disturb the one that follows it (§5.3).

A refused code and an empty answer are different things. A refusal returns nothing at all in the trailer; a code the unit knows but has nothing for comes back as ff ff ff (0x1E, 0x1F, 0x44, 0xF1). So "no value" is itself information — it says the unit accepts the code — and it is only visible if you look at whether the segment arrived, not at its contents.

OP2 = 0xFF alone does not mean empty. For the seven nibble codes above the value is the low nibble of OP1: MHI-AC-Ctrl decodes mode, defrost and both fan speeds exactly there [EXT], and the bridge's own plausibility rules check only the high nibble of OP1 for 0x11 and 0x21, leaving the low one as payload [FW]. 0x02 reads 12, which is COOL in the mode coding of §4.2. The others read 0 in every state seen so far.

0xFF is the one value you cannot probe: as a trailer code it is the "nothing to send" sentinel (§3.3), and the bridge discards it without queueing anything.

5.5 Code 248 — the one the app does use

The official app's only use of the request channel is the "auto-heating / away-mode" settings screen. [APP]

Sub-code (OP2) Meaning
27 / 28 outdoor-temperature rule, cooling / heating
29 / 30 target room temperature, cooling / heating
31 / 32 airflow, cooling / heating
  • read: send [248, 0xFF, sub, 0x00] — six segments, one per sub-code
  • write: send [248, 0x00, sub, value]
  • answers come back as [248, 0x10, sub, value]
  • temperatures are value / 2 °C, exactly like the setpoint
  • airflow raw bytes map (0, 3, 5, 7, 14) → auto, 1, 2, 3, 4

Verified end to end against real hardware, including a write round-trip. [HW] That makes it the working reference implementation for §5.3: same mechanism, different code byte.

5.6 Injecting an external room temperature

command[5] reaches the bus as DB3. Writing a value below 0xFF makes the AC use it instead of its built-in sensor [EXT]. The encoding is the inverse of the indoor-temperature table of §4.5: send the index whose table entry is nearest to T, which over 16…31 °C is raw = round(T × 4) + 59. Not + 61 — with that constant every injected value arrives half a kelvin warm, the app shows 26.5 for a sent 26.0, and a cooling thermostat that stops a quarter to three quarters of a kelvin under the setting looks as if it stopped a full kelvin under it [HW].

The official app never writes this byte — it is fixed at 0xFF. [APP] The path is open on the WF-RAC interface [FW], and it works: verified on an SRK20ZS-WF (WF-RAC-HTTPS 025/200) in cooling [HW]. It is a real control command, not a query. This is the single most requested reason for replacing the module with an ESP32, and it does not require replacing anything.

Both read paths follow the injected value. 0x85 written into command[5] came back one poll cycle later as state[5] = 0x85 — exactly the byte written — and as 18.5 °C in the pushed indoor-temperature segment (§5.2), the same byte through the table, while the room was at 21.2 °C. Writing 0xFF again restored the real reading within one cycle, to 0.05 K of its previous value. So while an override stands, nothing on the wire still reports the unit's own sensor: a client that wants the room temperature has to keep its own source.

The AC adds nothing to an injected value on the way back. The 0.5 K gap between state[5] and the pushed segment that earlier captures seemed to show (22.50/23.0, 22.25/22.7, 20.75/21.2) was the − 61 decoding of state[5] held against the table's decoding of the very same byte — see §4.5.

The value has no set-bit of its own (§4.3), so it is written by whichever frame goes out next and reverted by any frame that leaves the byte at 0xFF — there is no way to write it once and leave it. A periodic request frame is the cheapest carrier for it; note that this makes such a frame a write, and that a running self-clean cycle does not survive one [HW].

5.7 Compressor overload protection (speed-limit clamps)

Separate from the stop codes in §4.4/error_codes.py, and from Protection Number (raw) in §5.4: this control never stops the compressor or reports a code, it only raises its lower speed limit for as long as the trigger condition holds. A controlled test — an outdoor unit's air-temperature sensor heated past the documented cooling threshold — reproduced the expected speed-floor rise while Protection Number (raw) stayed 0 throughout, confirming the code does not cover it [HW]. The only outside signal is the effect itself, on the Compressor Frequency sensor: the reading holds at or above the floor instead of modulating down.

The mechanism is documented in the SRK service manuals for each indoor series, keyed to outdoor air temperature with per-step hysteresis, and the numbers differ by series and — within a series — by indoor unit size [EXT]:

Series Step (outdoor air) Resets below Lower limit, SRK20-35 Lower limit, SRK50
ZS-WF, ZT-WF ≥ 41 °C 40 °C 30 rps 27 rps
ZS-WF, ZT-WF ≥ 47 °C 46 °C 45 rps 35 rps
ZSX-WF, ZR-WF ≥ 38 °C 37 °C 25 rps —
ZSX-WF, ZR-WF ≥ 41 °C 40 °C 30 rps —
ZSX-WF, ZR-WF ≥ 47 °C 46 °C 40 rps —

(—: not yet transcribed for SRK50/60 on the ZSX-WF/ZR-WF side.)

Heating has its own, separate table with more steps, and starts from a noticeably lower outdoor temperature per series — 22 °C for ZS-WF against 17 °C (indoor fan) / 13 °C (outdoor unit) for ZSX-WF [EXT] — but the exact speed floors for that direction have not been transcribed here yet.

Deliberately not a sensor. A client cannot tell from the wire which of these rows applies — the protocol carries a capability group, not the SRK series or size class [FW] [INF] — so an automatic binary sensor built on outdoor temperature would have to ask the user to pick their model and trust the answer, silently wrong for anyone who picks the wrong line. Multi-split adds a third case on top: the outdoor unit runs its own overload control there, so even a correctly-picked indoor table does not necessarily govern.

Where the series and size are known, a plain template sensor with hysteresis on the raw outdoor-temperature entity, comparing against the relevant row, covers this without needing it built in.


6. What you cannot get, and why

6.1 Set-bits are filtered out

Before handing the state block to the network side, the bridge masks it with an 18-byte ROM table. [FW] The mask is

00 00 A2 88 80 00 00 00 00 00 00 10 0A 00 00 00 00 00

and the bits it clears are precisely the MISO set-bits of §4.3 — DB0 bits 7/5/1, DB1 bits 7/3, DB2 bit 7 (plus two bits in state[11] and state[12]). You cannot see which field was last written, over any API.

(This mask is also the independent proof of the whole §4 mapping: an 18-byte table whose set bits land exactly on the documented SPI set-bits cannot be a coincidence.)

6.2 DB8–DB14 never leave the module

The state block takes MOSI bytes 1–10 and 20–27; bytes 11–19 are dropped. [FW] That includes DB13, the byte whose bits are believed to carry outdoor-unit status including compressor running/idle [EXT].

That is a real limit, but it does not mean compressor state is unreachable: it also rides in DB7 bit 1, which is forwarded — see §4.6. Do not generalise "DB13 is dropped" into "outdoor status is unavailable"; check the forwarded bytes first.

6.3 autoHeating / highTemp / lowTemp are not what they look like

These three are carried in the UART frame header between the Realtek and the RL78, not in the airconStat blob, and surface as top-level JSON fields. [FW]

Measured across four devices, three accounts and two firmware branches: highTemp and lowTemp come back as the constants "AB" and "66", and autoHeating is always 0. [HW] The official app never writes the local request fields either (they stay null); the only code that sets a highTemp/lowTemp pair does so on a cloud endpoint. [APP]

Treat them as inert unless you can prove otherwise on your unit.

6.4 Self-clean cannot be started remotely

No code path in the app sends "start self-clean"; only a reset/cancel bit exists (command[10] bit 0x04). [APP] The status bit state[15] & 0x01 does not track the real cycle: driven from the IR remote, it stayed 0 through a complete self-clean run while the module was polled throughout. [HW] It echoes the last value written over Wi-Fi, nothing more.

6.5 The cloud adds no data

The MQTT reporter and the local getAirconStat handler call the same builder for the contents object; the only difference is that the MQTT report appends aggTm. [FW] The cloud's hourly/daily/monthly "operation data" is a server-side aggregation of exactly what you can read locally. [APP]

6.6 The hourly reset is not the module's idea

The module resets its CPU from exactly two places: a one-shot 10 s timer used after provisioning, and a module_reset() whose only caller is the branch that handles "cpu reset received" — a bit in an incoming frame from the bridge. Every timer in the module firmware is in the seconds range; no hour-scale constant exists in the application area. [FW] So the periodic reset seen in the field is commanded from below, not by the Wi-Fi stack. Blocking the module's WAN access does not stop it. [HW]

6.7 Left/right vanes and 3D-auto, on units with the legacy bus signature

The bridge MCU implements two CNS protocols and picks one from the first MOSI signature byte the indoor unit sends. [FW] The choice is made once per frame and switches the whole receive/transmit path, not a single branch:

MOSI SB0 MISO SB0 Frame builder
0xC7 0x9B 0x5C7F
0x6C 0xA9 0x54B7
anything else 0xAA 0x54B7

0x6C with a 0xA9 MISO signature is the classic MHI bus as documented by MHI-AC-Trace. [EXT] And on exactly that path, after the §6.1 mask is applied, the module overwrites the byte unconditionally: [FW]

if (MOSI[0] == 0x6C) state[12] = 1;

So on a legacy-signature unit state[12] is not a device state at all. It is always 1, whatever raw byte 22 contained, which means:

  • (state[12] & 0x03) == 1 always holds → the left/right vane position in state[11] is never reported. A client following §4.2 will read "auto" forever, no matter what position the unit is actually in.
  • (state[12] & 0x0C) == 0x04 can never hold → Entrust (3D auto) always reads as off.

Writing still works — the command path is untouched. Only the read-back is gone, so a client must not confirm a horizontal-vane command by re-reading it.

How to tell which kind of unit you have: SB0 itself is not visible over the network, so test the symptom. Any reading of state[12] other than 1 proves the extended variant — the reference unit for this document reports 0x00 throughout [HW]. If it does read 1, move the horizontal vane from the infrared remote and poll again: on the extended variant state[12] and the position in state[11] follow, on the legacy one state[12] stays 1.

Do not use the app's per-model capability tables to decide this. Their windDirectionLR flag is set for four of the five model classes [APP], which says what the model line supports, not what this bus variant reports back.


7. Worked flows

7.1 Read state

POST /beaver/command/getDeviceInfo          -> airconId
POST /beaver/command/getAirconStat {airconId}
  -> contents.airconStat (base64)
     decode, skip COMMAND block (§3.1), parse 18 state bytes (§4.2)
     parse trailer segments (§5.2) for temperatures and kWh

7.2 Change the setpoint to 22.0 °C

1. read current state (7.1)                     # you must send a full state
2. command = current 18 bytes re-encoded
3. command[4] = int(22.0 * 2) | 0x80            # value + set-bit
4. trailer  = [0x01, 0xFF,0xFF,0xFF,0xFF]       # sentinel
5. blob = crc16(command + trailer) + crc16(receive + trailer)
6. POST setAirconStat {airconId, airconStat: base64(blob)}

Registering your operatorId via updateAccountInfo is a prerequisite (§2.5).

7.3 Ask for compressor frequency

1. build a no-op command block (current state, no set-bits)
2. trailer = [0x01, 0x11, 0xFF, 0xFF, 0xFF]
3. POST setAirconStat
4. sleep 1 s
5. POST getAirconStat, scan the RECEIVE trailer for a segment whose code is 0x11
   -> Hz = (OP1 - 0x10) * 25.6 + 0.1 * OP2

Untested [INF]. If it works, the same shape works for every code in §5.4.


8. Firmware versions

Three parallel version lines exist. Version numbers are only comparable within one firmType — a higher number in another branch means nothing. This trips people up constantly when comparing notes across units.

firmType Wireless (M) MCU (C)
WF-RAC 010 131
WF-RAC-HTTPS 025 200
WCBN4612L 029 999

Both numbers are reported by getDeviceInfo as wireless/mcu, alongside firmType. Quote all three when reporting a problem — the pair alone is ambiguous.

Updating is the app's job and is deliberately not documented here. Two things are worth knowing before anyone goes looking:

  • The bridge MCU update path is fire and forget — no progress reporting, no rollback, no second bank. A failed write does not leave you with a device to retry on.
  • The device rejects downgrades: if the requested version is not newer than the running one, the handler returns 200 OK and does nothing. [FW]

9. Quick answers to the usual questions

Question Answer
Can I run an ESP32 on CNS and keep the WF-RAC module? No. Two active slaves on the bus conflict. A purely passive sniffer that never drives MISO is fine. [EXT]
Is there a local MQTT broker? No. MQTT is cloud-only, endpoint fetched from iot.smartmair.com, mutual TLS with a device certificate in flash. [FW]
Can I get compressor/current/hours without opening the unit? Yes, via §5.3 — verified end to end, all fifteen codes in §5.4 answer. Watch the firmware caveat there.
Can I feed an external room temperature without opening the unit? Yes, via §5.6, verified on hardware. It also replaces the room temperature the unit reports back.
Does the module do anything smart with the state bytes? No. It is a base64 pipe. All semantics live in the RL78 and the AC. [FW]
Why does the energy counter keep dropping to zero? It is a per-run counter, not a lifetime one (§5.2). Nothing is broken.
Why is my accumulated energy total lower than my electricity meter? The counter reports whole 0.25 kWh steps only, and each power-on discards the unreported remainder — about 0.125 kWh per run (§5.2).
How do I tell "unit is on" from "compressor is actually running"? state[9] & 0x02 (§4.6), free in every poll. On a multi-split it answers per indoor unit, unlike compressor frequency.
Why does my client work on one unit and fail on another? Almost always airconId missing (§2.5), TLS too modern (§2.2), or the four account slots being full.
Why do my commands get refused right after someone used the app? The 60-second write lock (§2.7). Wait for expires and send again.
Why can nobody else control the unit while my client runs? Same lock, from the other side: you are renewing it. Anything you send on a cadence — a status request for operation data included — has to leave it lapse.

Appendix A — bridge MCU internals (provenance)

Only needed if you want to re-derive or extend the above. Architecture: Renesas RL78, 64 KiB image, unencrypted, ~36 KiB occupied. Confirmed with a real rl78-elf-objdump built from GNU binutils. Addresses are RAM in the 0xF____ segment.

Address Contents
0xE236 18-byte COMMAND state, as received from the Wi-Fi side
0xE248 COMMAND segment count; segments from 0xE249
0xE1DE request queue, count in 0xFE3B6, room for 22 segments — the count from 0xE248 is not bounds-checked. This is not the limit a client hits: the HTTP path in front of it gives out around 12 segments (§5.3)
0xE186 response cache, count in 0xFE3B9, 22 segments
0xE17A unsolicited-push cache, 3 slots; ROM table at 0x3025 = 80 01, 80 00, 94 01
0xE2F5 18-byte RECEIVE state (what §4 calls state[0..17])
0xE307 RECEIVE segment count; segments from 0xE308
0xE3BA raw 18 bytes lifted out of the MOSI stream, pre-mask
0xE3CF MOSI frame, 33 bytes (SB0 at +0, DB0 at +3, DB9..DB12 = 0xE3DB..0xE3DE)
0xE3F0 live MOSI receive buffer, 33 bytes; 0x52DB promotes it into 0xE3CF
0xE411 MISO frame (DB6 = 0xE41A, DB9..DB12 = 0xE41D..0xE420)
0xE3B7 request state machine, 0…5
0x3034 18-byte ROM mask (§6.1)
Function Role
0x9B28 unpack COMMAND block from the Wi-Fi buffer (0xDE85), verify CRC16
0x6AB7 fill the request queue from 0xE248/0xE249; state 1→2
0x5679 emit one queued segment per MISO frame into DB9..DB12, set the opdata bit in DB6; when drained, state→3
0x53BB copy MOSI frame → raw state 0xE3BA, then call the two below
0x58B1 handle one received segment: push-cache check, then match against the request queue; all matched ⇒ state 4
0x591A the push cache's own classifier — 17 operation-data codes, including indoor/outdoor disambiguation for 0x1E/0x1F. Reached only for codes in the 0x3025 table, so all but 0x80/0x94 are unreachable
0x5B27 build the RECEIVE trailer from push cache + response cache
0x680F build the RECEIVE state: state[i] = raw[i] & ~mask[i], then force state[12] = 1 on the legacy signature (§6.7)
0x58A2 pick the MISO signature byte from the MOSI one — the protocol-variant switch (§6.7)
0x5C7F the other frame builder, used on the 0xC7 variant
0x9A44 pack RECEIVE state + trailer + CRC16 into the Wi-Fi buffer
0x54B7 translate the COMMAND state into a MISO frame (command[i] → MISO byte i+1), legacy variant

The Realtek module image was worked through with a Capstone Thumb-2 pass.

Do not flash the bridge MCU. The update path has no rollback, no progress reporting and no second bank, and the official app does not even offer the file. Static analysis only.


Appendix B — sources

Source Used for
WCBN4612L_M_029.bin (unencrypted Realtek image, log macros keep file names, line numbers and function names in clear text) HTTP handlers, MQTT, reset paths, rate limiting, airconId/operatorId validation
WF-RAC-HTTPS_C_200.bin (64 KiB RL78 image) everything in §4, §5, §6.1, §6.2, Appendix A
Smart M-Air Android app 1.4.009 blob layout, CRC, code 248, capabilities, thermistor tables
MHI-AC-Trace SPI.md, MHI-AC-Ctrl SPI frame layout, set-bit semantics, operation-data codes and formulas
Live captures against an SRK20ZS-WF and an SRK35ZS-WF on WF-RAC-HTTPS 025/200 every [HW] claim

On the reach of the [FW] claims about the module. They all come from that one WCBN4612L image, because it is the only module firmware published in the clear — the WF-RAC and WF-RAC-HTTPS branches ship theirs encrypted end to end (uniform 8.00 bits/byte, no readable region anywhere in either file). So none of it can be re-derived on another branch by reading, only confirmed by observation. Where a [FW] claim about the module carries an [HW] claim beside it, that observation was made on WF-RAC-HTTPS 025/200 and the two branches agreed.

Independent client implementations worth reading: homebridge-mhi-wfrac, mqtt2mhi-wf-rac, ioBroker.woso_mitsu_aircon_rac, node-red-aircon-rac-wf, and this project's own protocol library pywfrac.