Protocol Overview
A developer’s orientation to the Hisense A/C RS-485 bus protocol: what the frames look like, which
mistakes have already been made, and where the full reference is. This page is a summary. The
byte reference is reverse-engineering/docs/03-rs485-ac-protocol.md (“doc 03” below), and the
vendor firmware’s bus handling is in reverse-engineering/docs/10-stock-fw-init-and-comms.md
(“doc 10”).
The protocol is hardware-validated: trust the repo values
Framing, checksum, and every byte offset were sniff-confirmed against the real W41H1 bus
(command + status) by remote-button correlation. The offsets originally borrowed from the
older AEH-W4A1 reference project were wrong in several places and are corrected in this
repo. Do not re-derive or “fix” them from reference or other-model docs. The executable
source of truth is the codec in the ESPHome component,
firmware/esphome/components/hisense_ac/hisense_protocol.cpp, and the hardware-validated
reference driver it is tested against, firmware/test/reference-driver/hisense_rs485.cpp
(hisense_parse_status / hisense_build_command).
Physical layer
| Transport | RS-485, 9600 8N1 (auto-probed against 115200 by stock; the link is 9600) |
| Pins (module) | UART0 TX = PA_14, RX = PA_13; DE = PA_17 |
| Role | Wi-Fi module is bus master / sole initiator; the A/C mainboard is a pure slave/responder |
Two facts about DE differ between the RE docs, and both are correct in context. Doc 03 notes the transceiver on the stock module is effectively auto-direction (no
gpio_* in the UART path it first analysed), while the deeper stock-firmware trace (doc 10)
proves the stock driver does bring up a DE line on PA_17 and toggles it around every TX
(5 ms pre-guard, 1 ms post-guard). This project’s firmware drives DE on every board: 5 ms of
settle before the first byte, and the frame’s wire time plus 25 ms before release.
Frame envelope
The module↔A/C frame is F4 F5 … F4 FB delimited. The full envelope the stock firmware
uses (doc 10 §3.2):
F4 F5 | DIR | CTRL | LEN | 00 00 | seqHi seqLo | FE 01 | 00 00 | CLASS | payload… | CKSUM | F4 FB
| Field | Meaning |
|---|---|
F4 F5 / F4 FB |
start / end markers |
DIR |
0x00 module→A/C, 0x01 A/C→module (RX rejects !=1) |
CTRL |
0x40 normal. bit6 → 2-byte checksum; bit5 → 16-bit BE length; bit0 → extra pre-LEN byte |
LEN |
payload count; total frame = LEN + 9 (this fixed the old fixed-82 assumption that dropped every 160-byte frame) |
seqHi/Lo |
the A/C’s device-type / sub-type, 00 00 until the DevType reply answers, that unit’s code after (below) |
CLASS |
message class (below) |
CKSUM |
big-endian running SUM over the payload, width from CTRL & 0xC0 (0x40 → 2 bytes). Not XOR, not a CRC. |
Checksum + byte-stuffing: all ac_* frames use CTRL=0x40 → 2-byte checksum. A checksum
byte equal to the 0xF4 marker is escaped by doubling it (… F4 → … F4 F4 …). The
builder stuffs on TX; the bus task un-stuffs on RX. A frame whose checksum never contains
0xF4 never exercises this path, a known test trap.
The seqHi/Lo bytes are a device-type, not a session token
Read this before touching the framing. Getting it wrong once cost a working A/C.
Those two envelope bytes look like a per-session token, and the stock disassembly can be read that way. They are the attached unit’s device-type / sub-type: a static identifier for that model. The module sends 00 00 until the A/C answers the DevType (0x0A) handshake, then sends the code that reply carries, on every later frame.
Read the code from the DevType reply’s inner payload [3]/[4] (frame bytes [16]/[17]). Do not read that reply’s envelope [9]/[10].
Measured on a live unit, both values from the same 0x0A reply:
device-type inner [3]/[4] : 01 01 <- send this
session tok envel [9]/[10]: 00 00 <- NOT this
One build stamped the envelope [9]/[10] instead. It read 00 00, treated that as “no token yet”, and sent 00 00 on every frame after the handshake. The A/C rejected all of them: the link never came up, no status arrived, and the unit needed a firmware update to recover.
A 0x66 status reply does carry 01 01 at [9]/[10], which is what makes the token reading look right. The DevType reply is the one that differs, and it is the frame that matters.
01 01 is one model’s code. A different Hisense model answers with a different code, which is why the driver learns it rather than hardcoding it. Details and the per-model capability map: reverse-engineering/docs/11-model-capability-map.md.
Message classes
| Class | Name | Purpose |
|---|---|---|
0x0A |
DevType | First handshake; identify device type, open session. Must complete before status/command frames are honored. |
0x07 |
Version | Exchange MCU/proto version; gates legacy-compat fields |
0x1E (num30) |
Link/status heartbeat | ~1 Hz. Module reports its Wi-Fi/commissioning state; A/C replies with request bits; this is the “77” channel (below) |
0x66 sub 00 |
Status poll | Live A/C state → the 160-byte status reply |
0x66 sub 40 |
ProductType | Model code + decoded feature bit-flags |
0x65 |
Control / set | The command write (fan/mode/temp/…) |
0x1D (num29) |
Reconfig / OTA | A/C demands re-provisioning / OTA reboot |
0x67 |
not decoded | The stock module sends it after each ProductType exchange, about once a minute. Seen only on a bench against a synthetic A/C (issue #110). Our driver never sends it and ignores it |
Provenance: only the envelope is firmware-confirmed from the stock binary. The stock module treats temps/fan/compressor as a bit-packed + transparent block it forwards to cloud without field-decoding. The byte-per-field maps below were confirmed a different way: on the live bus, by toggling each control and diffing the reply. Both are in doc 03.
Status vs command maps (summary)
Two directions, two different frames. Full tables (with the confirmed hex per field) in doc 03.
Status: 160-byte class-0x66 reply (hardware-confirmed by remote-button diffing):
| Byte | Field |
|---|---|
| 16 | fan speed (six speeds, more than the reference’s three) |
| 18 | mode: three bits, 4 to 6 (AUTO = 5 in status). Bit 7 is not part of the mode; the parsers mask it off |
| 19 | setpoint °C (direct integer) |
| 20 | room temp °C (direct integer, not the W4A1 (raw-32)*0.5556) |
| 35 | flags1: vswing / hswing / aux-heat / eco / turbo |
| 36 | flags2: purify / mute |
| 41–45 | compressor freq + outdoor/coil temp (telemetry) |
Command: 50-byte class-0x65 frame (DI-tap-confirmed on the stock dongle):
| Byte | Field | Note |
|---|---|---|
| 16 | fan | ref’s 0x05/07/09 were WRONG; fan*2+1 |
| 18 | mode, and power in the low nibble | AUTO = 0x90 in command, which differs from status value 5. The stock module ORs power-on 0x0C into the same byte (0x5C cool, 0x3C heat), so power and mode travel in one frame |
| 19 | temp | 2n+1, whole °C |
| 23 | beeper | bit 2 (0x04), the stock t_beep. Set: the unit beeps when it takes this frame. Every builder sets it; the Beeper switch clears it |
| 32 | vertical swing | 0xC0 swing / 0x40 fixed |
| 33 | eco (0x30/0x10) / turbo (0x0C/0x04) |
ref values were wrong |
Power and mode in one frame. The firmware sends a mode request to a unit that is off as
one frame with 0x0C in byte 18, as the stock module does. 0x5C and 0x3C are in the stock
image. The values for auto, dry and fan-only (0x9C, 0x7C, 0x1C) follow from the same field
rule and are marked VERIFY in the source. If the unit is still off at the check, the node falls
back to a power-on frame followed by a mode frame. Evidence and file offsets: doc 10 section 5b-2.
Horizontal swing is N/A: no motor on this unit; the bit toggles but nothing moves. Only
vertical swing is offered by default (supports_horizontal_swing turns the other on for a variant
that has the motor).
Entity ↔ RS-485 mapping
The component translates climate, switch and select writes to A/C fields and status frames back
to entity state. The mapping and every decision around it (shadow sync, confirm and retry, preset
planning) are pure functions in the component’s headers under
firmware/esphome/components/hisense_ac/ (hisense_mapping.h, hisense_special.h,
hisense_confirm.h, hisense_entities.h), all host-tested.
| Home Assistant | A/C concept |
|---|---|
| climate mode, off | mode field (cool / heat / auto / dry / fan), power bit |
| current temperature | indoor temp sensor |
| target temperature | setpoint, whole degrees, 16 to 32 °C, cool and heat only |
| fan mode, swing mode | six-step fan ladder, vertical swing |
| eco / turbo / quiet / sleep presets and switches | the special-mode bytes, sent 10 s apart |
The “77” request
There is no dedicated reset frame. The remote’s Horizon Airflow ×6 → “77” is a
remote→mainboard key sequence; the mainboard signals the module by setting a request bit in
its reply to the ~1 Hz 0x1E heartbeat. Static RE inferred bit3 (0x08); the hardware
truth is that “77” pulses payload[4] = 0x20 (bit5, smart-config) for a single ~1 Hz
frame, then clears. Conversely, the A/C lights “77” iff the module reports prov_status in its
outbound 0x1E, so the display tracks the module’s pairing state. Both directions are
proven on hardware. Full bitfields and the outbound builder are in doc 03, section
Recommission ("77"): the request bits in the 0x1E reply.
The vendor firmware uses the request to reset Wi-Fi and start pairing. This firmware has no
pairing step. Its component reports the request as the on_setup_request trigger and can light
“77” with the hisense_ac.set_setup_indicator action, and the shipped configuration binds the two
to Wi-Fi setup mode: the node opens its hotspot and captive portal, and “77” stays on the panel
until setup mode ends (Wi-Fi Setup Mode). 0x20 is taken at once, 0x08 has
to hold for three replies, and a second request within 10 s is ignored. The reference driver
under firmware/test/reference-driver/ still carries the first handler (edge-triggered on
0x08|0x20, with a re-entry lockout).
Bring-up ordering on Realtek’s SDK
This applies to firmware written directly on Realtek’s SDK for the stock module. Under ESPHome
the platform does the bring-up. Initialise the DE GPIO (PA_17) and the UART from a task, after
the RTOS scheduler and the SDK HAL are up, never from a static constructor. Doing it early faults
through unpopulated HAL tables, which was a boot fault in an early build. The vendor firmware’s
exact ordering (mutex first, a persistent gpio_t, the DE toggle sequence) and the root-cause
analysis are in doc 10.
The two references
reverse-engineering/docs/03-rs485-ac-protocol.md: the full frame format, the checksum, every confirmed byte map, the telemetry block, the timer encoding and the “77” bitfields.reverse-engineering/docs/10-stock-fw-init-and-comms.md: the vendor firmware’s init, bus bring-up, the DE pin, the link state machine, the transaction primitive, and the analysis of the DE pin against the flash pins.