RS-485 A/C protocol (the bus reference)

This page is the reference for the serial bus between the Wi-Fi module and the A/C’s indoor-unit mainboard: the frame envelope, the message classes, the link bring-up, and the byte maps of the status and command frames. It is the page the firmware’s codec (hisense_protocol.h, hisense_protocol.cpp) is checked against. Where the two disagree, the disagreement is written down here.

The disassembly that backs the envelope and the stock module’s behaviour is in 10-stock-fw-init-and-comms.md. What is still unsettled is collected in 14-open-questions.md.

How each fact is known

Every table below says where a value comes from. Four sources exist:

Label Meaning
firmware Read from the vendor module’s firmware by disassembly. The dump is not in the repository. Addresses are in doc 10.
bus, with a date Seen on a real unit of the owner’s: a tap on the stock module’s transceiver, a remote button pressed while the status frame was logged, or a reply logged by this project’s firmware.
community Taken from an earlier project, pslawinski/esphome_airconintl or deiger/AirCon. NOTICE.md lists what came from each.
inferred Follows from a rule that holds for other values. Not seen on the wire.

The units measured are wall-mounted Hisense splits that report device type 01 01. Offsets are absolute frame offsets, counted from the first F4 as byte 0, after the doubled F4 bytes have been removed.

Physical layer

Item Value Source
Bus RS-485, half duplex, through a UM3352E transceiver on the module board (01-hardware.md)
Line settings 9600 baud, 8 data bits, no parity, 1 stop bit firmware (serial_baud(0x2580), serial_format(8, None, 1), doc 10 §2.1)
Module pins UART0, TX PA_14, RX PA_13 firmware (doc 10 §2.1)
Direction line PA_17 drives the transceiver’s DE: high to send, low to receive firmware (doc 10 §1.3), then hardware 2026-07-06
DE timing of the stock module DE high, 5 ms, send, 1 ms, DE low, under a mutex firmware (doc 10 §4.4)
Receive interrupt driven into a buffer, one frame complete on F4 FB firmware (doc 10 §4.3)
Reply window the vendor module: 500 ms at bring-up, 3000 ms in steady state (doc 10 §8). This project’s firmware: 1000 ms (replies arrive after about 230 ms, once seen at 400 ms) firmware, capture of 2026-10-15

The firmware also carries the string baudrate parameter must be B9600 %d, B115200 %d. The stock module probes both rates at start-up, alternating until the A/C answers the device-type request (doc 10 §4.1). Every unit seen here answers at 9600.

An earlier version of this page said the transceiver switches direction by itself and that no direction pin exists. That was wrong. It came from a first static pass that missed the PA_17 set-up, and it cost several days: bytes reached the transceiver’s DI pin and never the bus. The record of that pass is firmware/docs/03-ac-uart-pins.md.

What this project’s firmware does with these facts (DE held 5 ms before the first byte and 25 ms after the last, a 1000 ms bus cycle, a 1000 ms window) is in hisense_bus.h. The first driver’s timing rules are in its README.

Frame envelope

Source: firmware. The parser is at runtime 0x9b6f2e00 (file 0x22e00) with the image mapped at base 0x9b6d0000, and the builder sites are in doc 10 §3.2.

F4 F5 | DIR | CTRL | LEN | 00 00 | T1 T2 | FE 01 | 00 00 | CLASS | payload ... | CK_HI CK_LO | F4 FB
 0  1    2     3      4    5  6    7  8     9 10   11 12    13      14 ...        n-4   n-3    n-2 n-1
Offset Field Value
0, 1 start tag F4 F5
2 direction 00 module to A/C, 01 A/C to module. The stock parser rejects a received frame whose byte 2 is not 01
3 control 40 on every frame seen. Bits 6 and 7 select the checksum width (00 1 byte, 40 2, 80 2, C0 4). Bit 5 would make LEN a 16-bit big-endian value. Bit 0 would put one extra byte in front of LEN
4 LEN the whole frame is LEN + 9 bytes long
5, 6 reserved 00 00
7, 8 device type and sub-type 00 00 in the first frame of a session, then the pair the A/C reported (see Link bring-up)
9, 10 marker FE 01
11, 12 pad 00 00
13 class see Message classes
14 onwards payload class specific. Payload index N of doc 10 is frame byte 13 + N
n-4, n-3 checksum 16-bit sum, high byte first
n-2, n-1 end tag F4 FB

This is the layout of a frame from the module. A frame from the A/C has the same length rule, class byte, checksum and end tag, and its bytes 5 to 12 are laid out differently: the status reply opens F4 F5 01 40 97 01 00 FE 01 01 01 01 00 66, with FE at byte 7. Use the absolute offsets of the byte maps below for A/C frames and do not derive them from the table above.

The checksum is the sum of bytes 2 to n-5 inclusive (direction through the last payload byte), truncated to 16 bits. It is a plain sum, not an XOR and not a CRC (firmware: the Thumb loop ldrb r2,[r3,#1]!; add r1,r2, width helper at 0x9b6f0958).

Frame lengths seen, all LEN + 9 (bus):

Frame LEN Length
link bring-up requests 0x0A, 0x07, ProductType request 0B 20
status poll 0C 21
device-type reply 0D 22
0x1E heartbeat and its reply 13 28
ProductType reply 24 45
command 0x65 29 50
status reply 97 160

The first status decoder here assumed the 82-byte status frame of the community project and dropped every frame. The W41H1’s status frame is 160 bytes.

Parser facts

From the stock parser’s disassembly:

  • A frame of length 3 or less is rejected.
  • Bytes 0 and 1 have to be F4 F5, otherwise the log says “invalid frame”.
  • The end tag is read at the position LEN gives. If it is not F4 FB the log says “no end tag”.
  • A checksum mismatch logs “crc error” and the frame is dropped before any handler sees its payload.
  • A received frame has to carry 01 at byte 2, otherwise “link resp error”.

Byte stuffing

A F4 inside the frame, between the direction byte and the checksum, is sent twice. The end tag is the only F4 followed by FB (firmware, stuffing helper at 0x9b6f7854). The first check of this rule here was a sample frame of the community project: its 16 °C frame has the checksum 01 F4, which is on the wire as 01 F4 F4 F4 FB. The component’s builders double a F4 checksum byte, and its receiver removes any doubled F4.

Message classes

Source: firmware for the builders and handlers (doc 10 §3.3), bus where noted.

Class Name Direction Purpose
0A device type request and reply first frame of a session. The A/C reports what it is
07 version request and reply the mainboard’s protocol version. The stock module gates some bytes on it (doc 10 §6.6)
1E link heartbeat request about once a second, and reply the module reports its Wi-Fi state. The A/C’s reply carries request bits (the “77” request)
66 sub 00 status poll and 160-byte reply the unit’s state
66 sub 40 ProductType request and 45-byte reply capability flags (11-model-capability-map.md)
65 command module to A/C, and a reply the stock module does not read set power, mode, setpoint, fan and the rest (the reply)
1D mainboard image request A/C to module the A/C can answer the device-type request with it; the module then runs its mainboard OTA engine (“[DOTA]” in its logs) to send the A/C the image it holds, and queues a fault code if it has none (doc 13). Not seen on a unit here
67 device ID request and 60-byte reply payload 67 00 00. Sent by the stock module once after each ProductType exchange; the reply carries a 23-character device id (below)

The stock firmware’s error strings name three reply kinds, link resp, net resp and trans resp. They are nested checks on the reply’s sub-header, not three classes.

On 67: an earlier statement in doc 10 that this class never appears on the bus was wrong and is corrected there (doc 10 §3.3). The component does not send it, and the units run normally without it. What a real unit answers has not been captured; the layout the stock module expects is below.

The device-ID exchange (class 67)

Source: the stock image of 2026-08-19, read with rizin (dumps/issue-110-class-0x67-analysis.md, local only). The module’s init routine uart_ctl_process_main sends 67 00 00 right after the ProductType query, with a 3 s window and one retry, and ignores the result. The reply handler, handle_deviceID_cmd_result (named by its own log string), accepts a reply only when:

Frame byte Content
whole frame 60 bytes, LEN 33 (inner payload of 43 bytes from byte 13)
13 class 67
16, 17 10 24
31 to 53 the device id, 23 ASCII bytes. The module folds A to Z to lowercase
54, 55 16-bit sum of bytes 16 to 53, high byte first (a second checksum, inside the payload)
56, 57 the frame checksum

Anything else is logged (“invalid device id length or code format”, “device id checksum mismatch”) and dropped. The id goes to the module’s cloud identity (g_device_id, set_device_id_to_flash). firmware/test/virtual_ac.py answers the query in this layout (--device-id), so a stock module on the bench sees the same exchange as on a unit. Bytes 5 to 12 of that reply copy the status reply; a real unit’s reply has not been seen and may differ there.

The module is the only initiator. The A/C sends nothing unasked.

The A/C ignores the status poll until the module has brought the link up: a device-type request, a version request, then a 0x1E heartbeat about once a second.

The three request frames were captured from the stock module’s transmit line (the transceiver’s DI pin) on 2026-07-06. The heartbeat was byte-identical in 22 frames in a row, so it has no counter.

0A  F4 F5 00 40 0B 00 00 00 00 FE 01 00 00 0A 04 00 01 58 F4 FB
07  F4 F5 00 40 0B 00 00 01 01 FE 01 00 00 07 01 00 01 54 F4 FB
1E  F4 F5 00 40 13 00 00 01 01 FE 01 00 00 1E 00 00 B0 80 20 00 00 00 40 00 03 02 F4 FB

The device-type exchange

The request is the 0A frame above: bytes 7 and 8 are 00 00 because nothing is known yet.

A real reply, captured on 2026-10-12 from a unit driven by this project’s firmware on an ESP32-C3 board:

F4 F5 01 40 0D 01 00 FE 01 00 00 01 00 0A 04 01 01 01 01 60 F4 FB
Offset Bytes Meaning
2 01 from the A/C
4 0D LEN 13, so 22 bytes in all
9, 10 00 00 not the device type. See below
13 to 15 0A 04 01 class, sub-type, and 01 for “reply”
16, 17 01 01 device type and sub-type
18, 19 01 60 checksum: the bytes 2 to 17 add up to 0x0160
20, 21 F4 FB end tag

So this reply follows the same length rule and the same checksum rule as every other frame. Until this capture the repository held no real device-type reply, and its length and checksum were derived from the stock parser. The same capture holds a 0x1E reply of 28 bytes with the checksum 02 BD and a status frame of 160 bytes with the checksum 05 4E. Both verify. Their full bytes are not reproduced here.

The stock handler checks payload 0A 04 01, then stores the device type (frame byte 16) and the sub-type (frame byte 17). Every later frame from the module carries that pair in bytes 7 and 8. That is why the captured 07 and 1E frames above carry 01 01 there.

The pair has to come from bytes 16 and 17 of the reply. Bytes 9 and 10 of the same reply are 00 00. A build that stamped those instead (2026-07-16) had every frame rejected by the A/C and never got a status frame. Status replies do carry 01 01 at bytes 9 and 10, which is why an earlier look at them suggested the two were the same. The full account is in doc 10 §4.5.

Device types the stock firmware knows: 00 and 01 (air conditioner), 15 (dehumidifier), 36 and 37 (A/C variants). See 11-model-capability-map.md.

What the firmware does on receive

This section describes the component, not the vendor module. The code is FrameAssembler and the parsers in hisense_protocol.cpp, and BusScheduler::frame_received_ in hisense_bus.cpp.

  • The assembler accepts a frame only when byte 1 is F5, byte 2 is 01 (which also drops the echo of the node’s own frames), LEN fits the 200-byte buffer, and the frame ends F4 FB at LEN + 9. It removes doubled F4 bytes.
  • A byte that breaks a frame is looked at again as a possible start, and a later F4 F5 inside a frame that turns out to be cut short is followed as a second candidate. Noise in front of a reply does not cost the reply.
  • The checksum is judged once per frame, in the scheduler. A frame that fails is counted in the “Checksum errors” sensor, logged at WARN, and not parsed.
  • A device-type reply is adopted only when its checksum is good and the pair is not 00 00. A damaged reply is a failed try and the request goes out again 500 ms later. It does not count as having heard the unit.
  • A parser reads a payload byte only when that byte lies in front of the 4-byte trailer (checksum and end tag). The minimum lengths that follow are 49 bytes for a status frame, 40 for a ProductType reply (44 for its extended tier) and 22 for a device-type reply. In a shorter frame the same offsets hold checksum bytes or the end tag.
  • Only a status frame with a good checksum keeps the link alive. Five status polls in a row without one and the link is declared lost.
  • The request bits of a 0x1E reply are read only from a frame with a good checksum.

Status frame: byte map (class 66 sub 00, 160 bytes)

Source: bus. On 2026-07-07 and the days after, each control was toggled on the unit’s remote while the 160-byte status reply was logged, and the bytes that changed were noted. The first offsets came from the community project. Several were wrong for this unit and are corrected here. The parser is parse_status() in the codec.

Byte Field Encoding Notes
16 fan speed 01 auto, 02 quiet, 0A 0C 0E 10 12 low, medium-low, medium, medium-high, high The community project had three speeds
17 sleep profile 00 off, 02 General, 04 Old, 06 Young, 08 Kids (profile × 2) All four seen by rotating the profile on the remote, 2026-08-19 (doc 10 §7.4c)
18 power and mode bits 2 and 3: run, non-zero when the unit is on. Bits 4 to 6: mode The stock capability table defines the mode as three bits. Until 2026-10 the driver read four and took a frame with bit 7 set for an unknown mode. Auto is reported as 5 or 6. Smart mode changes the low bits. What bit 7 carries is unknown
19 setpoint whole degrees in the panel’s unit 16 hex is 22 °C
20 room temperature whole degrees in the panel’s unit Not the community project’s (raw - 32) × 0.5556. Seen together with byte 19 on a capture: cool, set 22, room 21
26 flags bit 7: smart mode (the remote’s SMART button). Bit 1: the panel shows Fahrenheit The Fahrenheit bit was confirmed on 2026-07-19 by switching a remote to °F
30 on timer relative countdown See Timer
31 off timer relative countdown, 00 when none  
35 flags 1 bit 7 vertical swing, bit 6 horizontal swing, bit 4 auxiliary heater relay, bit 2 eco, bit 1 turbo Eco is bit 2 here where the community project had bit 3. Turbo was not in the community project
36 flags 2 bit 2 quiet (mute). Purify: see below Quiet toggled on the bench
37 display bit 7 follows the remote’s DIMMER button Bit 3 is the filter indicator per the stock capability table (doc 10 §7.4a), not seen set
39, 40, 64, 66 fault bytes one bit per fault Decoded from the stock capability table, two bits confirmed by induced faults on 2026-07-22 (doc 10 §7.3 to §7.6)

Horizontal swing: the test units have no horizontal louvre motor. The status bit toggles and nothing moves (confirmed 2026-07-07). The component has the option supports_horizontal_swing, off by default, for a unit that has one.

Auxiliary heater relay: the bit read 0 during normal heat-pump heating (compressor at 50 Hz, warm air). It is expected in cold weather or defrost and could not be provoked at 33 °C outside. The position is from the bus correlation and the meaning from the stock firmware’s names.

Buttons that leave no trace in the status frame, checked by pressing them while logging: iFeel (the remote sends its own temperature reading to the unit, not over this bus) and Clock.

The purify bit: the documents and the code disagree

Two positions are on record for the status bit that reports purify, and no unit with a purifier was available to settle it.

Position Where it comes from
byte 36 bit 5 (0x20) The community project’s map, carried into the first version of this table and into 05-esp32-replacement.md. On the test units the bit is always 0
byte 36 bit 7 (0x80) The stock capability table: the t_purify record decodes to status byte 36 bit 7 (doc 10 §7.4d). The same decode rule reproduces six positions that were confirmed on the bus

The codec reads bit 7 (purify_on in parse_status()) and marks it VERIFY. Bit 7 has the better evidence. Neither has been seen set. The ProductType flag ac_purify reads 0 on the units measured (doc 11 §5.1), so the feature is absent there. A status capture from a unit with a purifier, taken with purify on and off, settles it.

Telemetry bytes

Source: bus, 2026-07-07, by comparing three states: cooling hard (compressor at 60 Hz), off, and heating hard (50 Hz). A byte that reverses between cooling and heating is a coil temperature. One that goes to zero when the compressor stops is a load reading.

Byte Field Cool, off, heat
41, 43 compressor frequency commanded 60, 0, 55
42 compressor frequency actual 60, 0, 50
44 outdoor temperature, °C confirmed against a weather station
45 outdoor coil temperature, °C 33, 25, 17
50 bus voltage, about 220 218, 222, 218
55 (repeated at 145) current reading A 18, 0, 22
56 (repeated at 144) current reading B 12, 0, 38
60 current or discharge reading 44, 0, 54
71 a counter or current seen only when cooling 2, 0, 0

Bytes 144 and 145 repeat bytes 56 and 55. The current bytes are not calibrated to amperes. How the firmware turns byte 55 into a power estimate is in firmware/docs/09-energy-monitoring.md.

Right after mains power returns, bytes 44 and 45 both read EC (-20 °C) until the unit has real readings. This was seen once, on one unit, on 2026-10-10. The firmware’s rule for it is in hisense_telemetry.h.

Timer (bytes 30 and 31)

The remote’s timer is relative. The remote works out the time remaining against its own clock and sends that. The same target time gave different byte values when set at different times of day, so the A/C never knows the time of day. The value shows in the status frame for about three polls when the timer is confirmed on the remote, and then both bytes return to 00. Byte 30 is the on timer and byte 31 the off timer.

The encoding of the minutes is not known. A one-hour on timer read 0B in byte 30 and 01 in byte 31, and an earlier mixed test read 8B and 15. A sweep of known durations would settle it. The firmware does not bridge the timer: Home Assistant automations do the same job.

Command frame: byte map (class 65, 50 bytes)

The layout of this frame and its first values came from the community project (esphome_airconintl, messages.h). On 2026-07-08 a tap on the stock module’s DI pin, while the vendor app drove the unit, corrected several of them. The stock firmware’s capability table later explained every value by one rule (doc 10 §7.4b):

value = ((logical << 1) | 1) << (shift - 1)        00 = leave this field alone

The low bit of each field says “this field is being set”, which is why the command values are odd-looking.

Byte Field Values Source
16 fan 01 auto, 0B low, 0D medium-low, 0F medium, 11 medium-high, 13 high (index × 2 + 1) bus, DI tap 2026-07-08. The community values 05 07 09 were wrong here
16 fan, quiet 03 The DI tap recorded it. Sent as a fan value by this project it put the unit on high. See below
17 sleep profile × 2 + 1: 03 05 07 09, off 01 bus, 2026-08-19 (doc 10 §7.4c)
18 mode (index × 2 + 1) in the high nibble: fan 10, heat 30, cool 50, dry 70, auto 90 community for the numbering, auto 90 from the DI tap
18 power bits 2 and 3: 0C switch on, 04 switch off, 00 leave alone firmware (doc 10 §5b-2), see below
19 setpoint degrees × 2 + 1 community, matched
23 beeper bit 2 set: the unit beeps when it takes the frame firmware, then heard on a unit 2026-10-10
23 display unit 05 Celsius, 07 Fahrenheit, in the stock Matter path firmware (doc 10 §5b-2)
31 marker 01 in every command bus, 2026-08-19: a frame without it is accepted and ignored
32 vertical swing C0 swing, 40 hold community, matched
33 eco 30 on, 10 off bus, DI tap 2026-07-08. The first driver sent 34
33 turbo 0C on, 04 off bus, DI tap 2026-07-08. The first driver sent 5C
35 quiet (mute) 30 on, 10 off bus, DI tap 2026-07-08
36 panel display C0 on, 40 off, 00 leave alone bus, bench 2026-07-19

Status and command disagree on auto: the command is index 4 (90) and the status reports 5 or 6.

Where the three literal frames come from

Three frames are sent byte for byte as the community project published them: power on (on[]), power off (off[]) and the status poll (request_status[]). No capture of these three from the owner’s bus is on record. They are in use because every unit this firmware runs on answers the poll and switches on and off on them. The stock firmware explains the 16-byte header and the poll field by field, and its power values agree with the two power frames (next section). Several bytes of the power-off frame are not understood, so it is kept as published. NOTICE.md has the list.

What was captured on the owner’s bus: the three link frames from the stock module’s DI pin (2026-07-06), the command corrections of the table above (DI tap, 2026-07-08), the status byte map (remote buttons, from 2026-07-07), and the device-type reply (2026-10-12).

Power travels in the mode byte

The stock module puts power-on (0C) into the same byte as the mode: 5C is cool and on, 3C is heat and on, 04 is off (firmware, doc 10 §5b-2). It never sends a power frame followed by a mode frame. The component sends power and mode this way since 2026-10. For auto, dry and fan the same rule gives 9C, 7C and 1C. The stock Matter path never sends those three, and they have not been compared with a capture.

Quiet is the mute flag, not a fan speed

The DI tap of 2026-07-08 lists 03 at byte 16 for quiet. When this project sent 03 there as a fan speed, the unit went to high. Quiet is reached through the mute flag at byte 35, after which the status reports fan 02. The codec keeps FAN_SPEED_QUIET with a VERIFY marker and the component never sends it from Home Assistant.

Single-field frames

Quiet and the sleep profile go out as a minimal frame: the header, 04 at byte 23, 01 at byte 31, and the one field. Only the named byte is confirmed. On 2026-08-19 it was found that byte 17 in the combined frame cancels sleep and cannot select a profile, and that the minimal frame selects every profile once byte 31 is 01. The account is in doc 10 §7.4c.

Horizontal swing

The community encoding (byte 32 bits 4 and 5, plus 14 at byte 37) has never moved anything on the test units, which have no motor for it. It is kept for a unit that has one. Vertical and horizontal in one frame is not confirmed.

Which comfort modes can be on together

Source: bus, 2026-07-23, through this project’s firmware (command sent, status read back) and checked against the vendor app on the same unit. The A/C enforces this itself: for a pair that cannot coexist, the mode switched on second wins and the first reads back 0.

  quiet sleep turbo
eco yes (eco 1, mute 1, fan 02) yes no (byte 33 carries one of them)
quiet   no no
sleep     no

Commands that arrive close together are dropped. Quiet needed about 6 s to take. A 3 s gap, or quiet followed at once by eco, lost a command. Turbo followed by quiet can leave both off for a moment. The component sends special-mode frames 10 s apart for this reason.

After quiet is switched on and off again, the fan is left at 0A (low) and not 01 (auto).

The reply to a command frame (class 65)

Source: firmware, the stock dump of 2026-08-19, read 2026-10-15 (file offsets in that image). Nothing here is from the bus: no reply to a 65 frame has been captured.

The echo rule that doc 10 §4.2 describes (reply repeats the command’s first two payload bytes and carries 01 in the third) lives in the verified transaction wrapper (file 0x20cc4) and is applied only to frames sent through it with the verify flag: the 0x1E heartbeat, 07 01 00, 67 00 00 and the 0x1C probe. A 65 command never goes through that wrapper [PROVEN]: the builder matter_pack_devType_cmd (0x205c8) wraps its own frame and has one caller, which puts the frame on the command queue (uart_process_handle_cmd, 0x24c18). The queue is drained by uart_ctl_process_main (0x268e4 onwards), which sends through uart_process_cmd (0x24a50):

Step What the stock module does Site
send writes the frame, waits up to 3000 ms for the RX interrupt’s end-of-frame signal. Bytes that arrived after the window closed still count as a reply on the next pass 0x24a50
no bytes at all logs, marks the queue node failed, frees it, sleeps 1 s, takes the next command. The command is not re-sent 0x27098 to 0x270d0
any bytes logs “CMD process success”, records reply[2] != 01 as the only verdict (frame byte 2, the direction byte), base64-encodes the raw reply. No class compare, no echo compare, no length gate; the frame decoder (device_package_get_cmd, 0x20768) is not run on it 0x26a2a, 0x26fc4
after any reply sends one 0x1E heartbeat at once and decodes that reply as usual 0x24e66
result send_cloud_server_result (0x1bbcc). Cloud commands (queue kind 0xc9): the base64 reply and the byte-2 flag go into the result JSON. Matter commands (kind 0xa8): no case, the reply is logged and freed 0x1bbcc, 0x1bc60 to 0x1bca2

So the stock module does not confirm a command from its reply, and does not retry a command whose reply is missing or wrong. Confirmation comes from the status frames that follow, which is also what this component does (confirm_decision in hisense_confirm.h).

Two things the image does say about layout, for whoever captures the reply: the decoder places the payload of an A/C frame at frame byte 13 (class byte), so a reply that followed the echo rule would carry the command’s class and byte 14 at bytes 13 and 14 and 01 at byte 15. That is what CMD_REPLY_ACK_OFFSET in hisense_bus.h assumes, and it stays VERIFY: the stock image never checks it for a 65, so the layout is a guess until a capture backs it. The component logs its verdict and decides nothing on it. What it does differently from stock is the re-send of a frame that got no reply at all (COMMAND_SENDS_MAX), which stock does not do.

ProductType (class 66 sub 40)

The A/C answers the ProductType request with 45 bytes of capability flags. The flag positions are in doc 10 §5a, and what they mean for a unit is in 11-model-capability-map.md.

Recommission (“77”): the request bits in the 0x1E reply

On the remote, the horizontal airflow button pressed six times makes the unit beep and show “77” on its panel. The vendor manual describes it as the way to reset the module’s Wi-Fi. No frame class exists for it. The mainboard sets a request bit in its reply to the module’s heartbeat, and the panel shows “77” because the module says it is provisioning.

These bits are in use today. The component’s setup-request tracker reads them, and the wifi_setup_mode component opens the node’s Wi-Fi hotspot on a request. Today’s behaviour is described in firmware/esphome/README.md. Setup mode and the gesture have not been run on a unit with the ESPHome firmware yet. The bus facts below were measured with the earlier firmwares.

Inbound: what the A/C asks for

The stock handler handle_num30_cmd_result (0x9b6f2778) reads payload byte 4 of the reply, which is frame byte 17 (firmware):

Bit Mask What the stock module does
5 20 smart-config pairing, then a reboot (0x9b6f27de)
3 08 reset and reconfigure: hs_reset_wifi, enter_provisioning_reset, 2 s, reboot (0x9b6f27f4)
0 01 store: the A/C hands the module 8 or 2 bytes of a binding token
6 40 an action code

A reply of class 1D in place of 1E is the A/C asking the module for a mainboard image (the module’s “[DOTA]” engine answers it; doc 13). The handler also reads payload bytes 5 and 11 (doc 10 §6.5).

Bus, 2026-07-20: the gesture sets bit 5, not bit 3. The reply carries 20 at byte 17 for one reply, about one second, and then 00 again. Each press that completes the gesture makes one pulse. Bit 3 has not been seen from a unit.

enter_provisioning_reset (0x9b6f2358) writes the module’s state as follows and the caller then reboots (firmware):

wifi_set_connect_status(3)      disconnected
wifi_set_control_status(0)
wifi_set_wifi_prov_status(1)    provisioning
wifi_set_work_status(5)
wifi_set_wifi_route_status(0)
report_restart()                the new state goes out in the next heartbeat

Outbound: what the module reports

The module reports its own state in every heartbeat (firmware, builder 0x9b6f225c):

frame 16 = 0x80 | (scan not finished << 6) | (connect << 4) | (control << 2)
frame 17 = 0x80 | (self test << 5) | (scan << 4) | (provisioning << 3)
frame 18 = (work << 5) | (route << 1)        only when the A/C's protocol version allows it
frame 22 |= 0x40

While pairing, frame 17 has bit 3 set and frame 16 has bits 4 and 5 set (connect is 3). Once the module has an address, frame 16 is B0, frame 17 bit 3 is clear, frame 18 is 22 and frame 22 has 40. That is the “connected” heartbeat captured from the stock module, shown above.

Bus, 2026-07-09: the panel shows “77” exactly while frame 17 bit 3 is set in the module’s heartbeat. Setting the bit lit “77” and clearing it cleared the panel. Nothing else has to be sent. So the panel is driven by the module, and the same bit position means “reconfigure” in one direction and “I am provisioning” in the other.

How this project’s firmware uses the bits

The component takes bit 5 at once, because one reply is all a unit sends. Bit 3 has to hold for three replies in a row. After a request is taken, and after the “77” indication is switched off, bit 5 is ignored for 10 s, because the press that ends the mode makes a pulse of its own. The code is link_request_bits() and the SetupRequestTracker in the codec, and the action hisense_ac.set_setup_indicator sets the outbound bit.

History: the Matter firmwares and “77”

Retired. This section describes how the project’s two Matter firmwares used the “77” request. They were retired on 2026-10-12, and the last tree with them is the tag matter-final. See docs/guide/Matter-Firmware-Retired.md. The current use of the request is in firmware/esphome/README.md.

The Matter firmwares reacted to either request bit by opening a Matter commissioning window (OpenBasicCommissioningWindow, discriminator 3840) while staying commissioned. The fabric was not wiped. Repeated pulses were collapsed into one window. Horizontal swing was not advertised over Matter, because the units have no motor for it (the RockSupport attribute was 0x02, vertical only). This was proven on hardware. Two things were never checked: replacing the fabric on a real re-pair, and whether the “exit 77” frame of the first driver clears the panel after the window times out.

The outbound bit followed the commissioning window. Until 2026-07-27 it was set only on the remote’s gesture, so a window opened in another way (at first boot with no fabric, or through the Administrator Commissioning cluster) left the panel dark while the device could be paired. From that date the firmware drove the bit from the commissioning window manager’s open and close notifications, with one sync at driver start because the first-boot window is already open by then.

Verified on hardware on 2026-07-27, on one unit running the AmebaZ2 Matter firmware 1.3.31, for a window opened through the Administrator Commissioning cluster:

Step The firmware’s link console command reported Panel
no window OUTBOUND prov_status=0 off
window open OUTBOUND prov_status=1 shows 77
window closed prov_status=0 again clears

For how the vendor firmware’s own Matter interface is commissioned, see 02-matter-local-control.md.

Capturing frames

Tap the UM3352E on the stock module (pinout): pin 4 (DI) carries what the module sends, pin 1 (RO) what the A/C answers, pin 5 is ground. A USB RS-485 adapter on the A and B lines sees both directions. The module has to be plugged into the A/C, because the transceiver is powered from the A/C’s 5 V. If the logic level at RO or DI is 5 V, bring it down to 3.3 V with a divider before a 3.3 V adapter.

tools/sniff.py logs frames with timestamps, tools/decode_ac_frames.py checks and decodes them, and tools/map_diff.py reports which status bytes changed while one control was toggled. The tools are described in tools/README.md.

History: how this page started

The first version of this page was written before anything had been captured. These notes are kept because they show what was known from the firmware’s strings alone.

  • The strings aircond get cmd crc error and aircond get cmd error: invalid frame showed that frames have a header, a length and a checksum. BC_cmd_task handles the board communication, and dev_ota_uart_process_cmd shows that an update of the mainboard can go over the same UART.
  • The firmware names these capability fields: ac_cool_heat, ac_fan_mute, ac_infinite_fan_speed, ac_humidity, ac_purify, ac_power_save, ac_power_display, ac_q_display, ac_swing_direction_8, ac_swing_follow, ac_8heat, ac_enable_8heat, ac_ai, ac_dr, ac_trans_102_64. They turned out to be the ProductType flags (doc 10 §5a). ac_trans_102_64 was first read as a hint at block sizes of 102 and 64 bytes. It is the flag that selects the generic profile 199 (doc 11 §4).
  • The vendor firmware bridges its Matter thermostat to the bus: SystemMode to the mode and power, LocalTemperature from the room temperature, OccupiedCoolingSetpoint and OccupiedHeatingSetpoint to the setpoint. What it sends for each is in doc 10 §5b-2.
  • The plan then was to flash one of the community ESPHome components (pslawinski/esphome_airconintl, akrabi/hisense_ac_esphome, zoffypal/esphome_hisence_ac), which target the older AEH-W4A1 module on the same kind of bus, and to reconcile the differences with captures. The project wrote its own component in the end. The community tables are discussed in 05-esp32-replacement.md.
  • The first driver (kept as a frozen test reference in firmware/test/reference-driver/) had open items that were listed here. They are closed: the explicit eco and turbo off values are sent (10, 04), the display has the three values of the table above, sleep goes out as a single-field frame, and the 10 s poll interval inherited from a sensor stub became the 1 s bus cycle of the stock module.

Back to top