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 |
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 (
Mfirmware, e.g.WCBN4612L_M_029.bin) is network plumbing. It base64-decodes theairconStatblob and hands the raw bytes to the RL78. It never interprets a single status bit.[FW] - The RL78 side (
Cfirmware, 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 theairconStatblob.[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).
- mDNS service
_beaver._tcp[FW][HW]- instance name: MAC address without colons, lower case (
348e89c5a137) - host:
<mac>.local - port:
51443 - TXT record: empty
- instance name: MAC address without colons, lower case (
- No UDP broadcast protocol, no discovery endpoint.
| 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.
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]
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]
Both are enforced in firmware and both produce confusing failures if you get them wrong.
-
airconIdis mandatory in the body of every relevant call.[FW]json_command_getAirconStatand…putAirconStatcheck, in order:contentsis non-NULL →contentsis a JSON object →get_string(contents,"airconId")is non-NULL and non-empty. Any failure aborts before anything happens. Newer firmware answersHTTP 400withresult: 2. Older firmware happens to tolerate its absence, which is why some clients omit it and "work". -
setAirconStatadditionally validatesoperatorId.[FW]It is compared against the up-to-four accounts stored in flash (table stride0x68) and against the literal stringaws. An unknownoperatorIdaborts the handler. So: callupdateAccountInfoonce with your own UUID before you try to write anything. Reading works without registration.The
awsescape hatch is real:operatorId: "aws"was accepted forsetAirconStaton aWF-RAC-HTTPS 025device without registering anything[HW]. Handy for one-off diagnostics — it consumes no account slot — but register properly for anything permanent.remoteListdoes not let you discover the registered ids: a device withnumOfAccount: 2returned["", "", "", ""].[HW]It lists only accounts registered withremote: 1, and emits a singlenullwhen none qualify;numOfAccountcounts 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 isdeleteAccountInfo. 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.
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.
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 onoperatorId. Two phones sharing one Smart M-Air account lock each other out exactly as an unrelated client would. - Reads are free.
getAirconStatneither takes nor tests the lock. - A read-only
setAirconStatstill 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
expiresnorupdatedBymoves 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.
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]
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 |
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.
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]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]
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.
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:
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]
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) == 0x04Mode 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].
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.
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-familyBit 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.
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] & 0x02is per indoor unit — does this unit want the compressor.- Code
0x11is 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.
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]
This is the part the official app barely uses, and the most valuable part of the interface.
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 = 0writes into the AC main board through a path nobody outside MHI has mapped. If you are exploring, send0xFFonly.
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.)
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
firmTypeWCBN4612Lbehaves 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 thatcommand[2]bit0x01(the power value, whose set-bit is0x02) 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 forWF-RACandWCBN4612Lare 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 copiescommand[2..]into its staging area and lets the unit decide.So the divergence is in the indoor unit, and
firmTypenames 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] & 3against the unit'sDB0 & 3to 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
0xFFsentinel, 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 with0xc0/0x40exactly 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 ownOP1says 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: 0means the unit knows the code,result: 11means 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.
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.
0x10and0x20follow the same indoor/outdoor convention as air temperature;0x0Dand0x32answer with values outside that pattern and are not understood. -
0x05is 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 whatENHANCED_RESOLUTIONin 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. -
0x1Eand0x1Fanswer 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 withDB6 = 0xc0for the indoor variant[EXT]- the one byte the bridge fixes to something else (§5.3). Confirmed by trying: putting0x10or0x20in the request'sOP1does not stand in for that, it only malforms the request (§5.1), while a single-unit code such as0x11answers normally in the same session.[HW]The bridge sends the constant0x80there, 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,0x1Fby the unit bit in the answer'sDB6and0x1Eby theOP1nibble — 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. -
0x34is the only code that usedOP3, 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]So0x11does 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, itsOU-EEV1), while this bridge'sOP2is 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. -
0x13on 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 % ofc8, 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 readc8, the idle value above.[UR](4 × SRK25ZS-WF on an SCM80, #328) -
0x85belowOP2 = 0x12is 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 isOP2 / 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,0x85between 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 under0x12as "no reading", not as a cold pipe. -
The two indoor coil sensors are per indoor unit;
0x82is shared. Over a night with one indoor unit cycling and the other cooling continuously, both units'0x81collapsed when their own compressor demand was on, while0x82read the same on both (paired correlation 0.89).0x87follows0x81per 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 onlyRsis specific to the coil.[INF] -
Rsmeasured, 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, whileoutdoorTempList[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] -
0x82is the same sensor behind a different resistor. MHI's diagram covers the outdoor coil too, and a five-hour standstill puts it onRs ≈ 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. -
0x82is 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,0x11and0x90(compressor frequency, operating current) have been reported to return a constant 0 even with the compressor confirmed running, while0x85and0x13return real, unit-specific values on those same units.[EXT]Both formulas produce exactly0.0forOP1 = 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". -
0x87is 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.
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:
0xAEtracks the indoor air temperature. It moved with0x80/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.0xADis 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 constant0x1E(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.0xD2sat at0x01through every pass. Nothing distinguishes a constant from a parameter that simply did not change while the unit was off.0xDDis silent operation. It answers with selector0x80, a value that appears nowhere else in any reply, and carries the state in bit0x20ofOP2:80 20 00on,80 00 00off. The same layout the CNS projects read asC0/DD[EXT]. It is written with the segment21 01 ff ff(on) or21 00 ff ff(off) in a set-bit-free frame, and the module answers that withresult: 11or a body that is not valid UTF-8 — yet the unit applies it, on both indoor units tested. Only reading0xDDback 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.
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.
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].
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.
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.)
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.
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.
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.
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]
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]
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) == 1always holds → the left/right vane position instate[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) == 0x04can 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.
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
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).
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.
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 OKand does nothing.[FW]
| 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. |
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.
| 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.