Hisense AEH-W41H1 Stock Firmware: Initialization & A/C-Bus Communication (Unified Reference)

Generated by a 6-way parallel disassembly of dumps/w41h1_dump1.bin + synthesis, hardware-validated 2026-07-06 (v5), see “HIL validation” at the end.

Image: /home/you/hisense-w41h1/dumps/w41h1_dump1.bin. SoC: Realtek RTL8710C (AmebaZ2). XIP/link base 0x9b6d0000runtime = file_offset + 0x9b6d0000; raw 4 MB dump so file offset = physical flash offset. Build banner “May 26 2025 14:17:28” at 0x9b70882e.

Each load-bearing claim is tagged [PROVEN] (specific opcodes/addresses from disassembly) or [INFERRED] (deduced, reconciled across analyses, or ESPHome/reference-derived). Contradictions between the six source analyses are resolved inline under ⚖ Resolved.


1. Boot & hardware-init sequence

1.1 Call chain to the A/C driver [PROVEN]

The A/C driver is brought up late, in RTOS task context, never from a pre-scheduler hook or C++ static constructor. Every edge below is a verified BL/veneer xref:

app-main task            0x9b70882c   ; prints "May 26 2025 14:17:28" banner, then:
 └─ bl 0x9b74a890                      ; "start-everything" init sequence
     ├─ bl 0x9b70d1a8                  ; subsystem init
     ├─ bl 0x9b735b7c                  ; init (two RAM-image code ptrs)
     ├─ bl 0x9b749468 → 0x9b7437bc(0xbb8) ; delay 3000 ms
     ├─ bl 0x9b714360                  ; init
     ├─ bl 0x9b749488 → 0x9b6f0624     ; "example_hs_link" / HS-core start
     │        ├─ bl 0x9b6f749c  hs_driver_init   ◄── FIRST statement (0x9b6f0626)
     │        ├─ bl 0x9b6fa218
     │        └─ bl 0x9b745db0  xTaskCreate(entry=0x9b00818d, "matter_nett_main",
     │                                       stack=0x100 words, prio 3)
     └─ bl 0x9b746288                  ; creates "IDLE" task

The only caller of hs_driver_init in the entire image is 0x9b6f0626 inside 0x9b6f0624. hs_driver_init runs after the SDK/HAL/board init and after the scheduler is live. [PROVEN]

1.2 Ordering inside hs_driver_init (0x9b6f749c) [PROVEN]

step addr call effect
1 0x9b6f74a274c6 0x9b744ec4 ×4 (xSemaphoreCreateMutex) 4 sync objects → [0x1000bf14], [0x100095d0], [0x1000bf20], tx_mutex [0x1000b3c4]
2 0x9b6f74c8 0x9b748bfc pin/pad HAL bring-up, indirects through HAL adapter vtable (ldr r7,=0x0005ec60; ldr r3,[r7,#0x88]; blx r3 @ 0x9b748c0c); also unregisters pinmux fn 0x10 on PA_0..PA_4 (pin table @ 0x9b822028)
3 0x9b6f74cc 0x9b6f6aa4 init_sector_data: flash/NVM config-sector load
4 0x9b6f74d0 0x9b6f7470 (DE setup) see §1.3, brings up PA_17 before the UART
5 0x9b6f74dc+ serial_init(uart@0x1000b248, TX=0x0e, RX=0x0d) 0x9b748698; serial_baud(9600); serial_format(8,N,1); recv-stream; serial_irq (ISR 0x9b00d07d, other bank) UART0 + RX IRQ
6 0x9b744a9c(prio 3) spawns uart_ctl_process_main control task

1.3 DE GPIO bring-up 0x9b6f7470 [PROVEN]

0x9b6f7472  ldr  r4,=0x1000b3c8        ; DE gpio_t object (persistent SRAM)
0x9b6f7474  movs r1,#0x11              ; pin = PA_17
0x9b6f7478  bl   0x9b748464            ; gpio_init(obj, PA_17)
0x9b6f747e  movs r1,#1  ; bl 0x9b7484fc ; gpio_dir(obj, OUTPUT=1)
0x9b6f7486  movs r1,#0  ; bl 0x9b7484c0 ; gpio_mode(obj, PullNone=0)
0x9b6f748e  movs r1,#0  ; b.w 0x9b748518 ; gpio_write(obj, 0)  (idle DE low = RX)

1.4 GPIO-subsystem prerequisites [PROVEN]

There is no manual clock / SYSON / pinmux power-on anywhere on this path. mbed gpio_init (0x9b748464) self-bootstraps:

0x9b748466  ldr  r6,=0x10028a34        ; "gpio comm initialised" flag (SRAM)
0x9b74846c  ldr  r3,[r6]; cbnz r3,skip ; skip if already done
0x9b748470  ldr  r3,=0x0005f040        ; &hal_gpio_stubs (ROM function table)
0x9b748472  ldr  r0,=0x10028a38        ; &gpio_comm adapter object
0x9b748474  ldr  r3,[r3,#0x10]; blx r3 ; hal_gpio_comm_init(&adapter)  (lazy, once)
0x9b748478  movs r3,#1; str r3,[r6]    ; latch flag
0x9b74847c  bl   0x9b748e58            ; gpio_init_pin → hal_pinmux_register(pin,0x70) via 0x9b7fc348
            ; on failure: memset(obj,0,24); obj[0]=0xFF  (marks obj invalid → dir/mode/write no-op)
  • Global GPIO prerequisite (hal_gpio_comm_init) is done lazily by gpio_init itself, guarded by flag 0x10028a34, through ROM table hal_gpio_stubs @ 0x0005f040.
  • Pinmux registration happens inside gpio_init_pin with function-ID 0x70: no separate pinmux call is needed. [PROVEN]
  • gpio_write (0x9b748518) is a bare register store: r2=[obj+0x10] (DOUT-set) or [obj+0xc] (DOUT-clear), r3=[obj+4] (mask), str r3,[r2]. No clock touched at write time. [PROVEN]
  • The gpio_t is a 24-byte struct (movs r2,#0x18 @ 0x9b74849e) and must be a persistent object. [PROVEN]

⚖ Resolved (two distinct init pieces, not a contradiction): the boot-init thread’s “lazy hal_gpio_comm_init via ROM 0x5f040” and the state-machine thread’s “0x9b748bfc pad-HAL vtable via 0x5ec60[+0x88]” are different subsystems both required before a PA_17 write: 0x9b748bfc (step 2 of hs_driver_init) populates the pad/HAL adapter, and gpio_init’s guarded [0x5f040+0x10] call brings up the GPIO-comm adapter. Both must have run, in order, before any gpio_write. [PROVEN]


2. Definitive pin / pinmux / flash map

2.1 A/C-path pins [PROVEN]

Signal PinName Encoded Evidence
RS-485 DE (transceiver direction) PA_17 0x11 movs r1,#0x11 @ 0x9b6f7474
UART0 TX → transceiver DI PA_14 0x0e serial_init arg1 @ 0x9b6f74de
UART0 RX ← transceiver RO PA_13 0x0d serial_init arg2 @ 0x9b6f74de
SPI flash (SCLK/CS/D0–D3) dedicated SPIC pads, NOT PA_13/14/17 §2.3

0x11 = PA_17 two independent ways: (a) AmebaZ2 PIN_NAME(port,pin)=(port<<5)|pinPA_17=(0<<5)|17=0x11; (b) Port-A PinNames are linear and the sibling UART pins are the known PA_14=0x0e / PA_13=0x0d, so 0x11=17. 0x11<0x20 ⇒ cannot be Port-B. UART fixed 9600 8N1 (serial_baud(0x2580), serial_format(8,None,1)). [PROVEN]

2.2 Pinmux write path [PROVEN]

The stock never writes a pad register inline. gpio_init → ROM GPIO adapter (0x5f040) + RAM HAL (bl 0x9b748e58hal_pinmux(pin, 0x70) via SRAM thunk 0x9b7fc348; 0x70 = GPIO function ID). The literal pad-register STR lives in mask ROM, not in the flash dump, so the exact PA_17 pad register/bitfields cannot be quoted from this image. [PROVEN that it’s ROM-resident; the register offset itself is INFERRED / not recoverable here.]

Hardware bases that are dump-proven (bound any direct-register approach): flash XIP/SPIC window 0x98000000 (ctrl/cal ≈ 0x98001000); peripheral/SYSON 0x40000000; G-timer (DelayUs) 0x40003800; AON domain 0x48000000 (AmebaZ2 pad/pinmux control lives here, exact PA_17 offset not confirmable from the dump). [PROVEN bases; PA_17 pad offset INFERRED.]

2.3 Flash map & the PA_17-vs-flash question: RESOLVED: no collision in stock [PROVEN]

The flash runs a calibrated Quad (multi-IO) mode. The SPIC bootloader is a separate sub-image relocated to SRAM (header @ file 0x40a0: len=0x7e00, load=0x10036100). spic_init @ SRAM 0x10037408 reads its per-mode calibration table from memory-mapped flash:

0x10037438  add.w r2,r1,#-0x68000000   ; = r1 + 0x98000000 (flash data window)
0x1003743c  add.w r2,r2,#0x1040        ; table @ flash 0x1040 + 8*idx
0x10037442  ldr   r2,=0x98001044       ; confirms window base 0x98000000
            ; reject 0xFFFFFFFF; entry valid only if word1 == ~word0

Exactly one slot is programmed, idx 4 (file 0x1060 = 0x01001202 / 0xFEFFEDFD, a valid complement pair; all others 0xFFFF…) = the Quad-IO index. Corroborated by spic_config_auto_mode’s quad case (orr …,#0x10 into SPIC [r4,#0x120]) invoking the Quad-Enable ROM routine 0x10037070, and the string "…cannot set QE bit!" (file 0xb302). [PROVEN]

Why this settles it definitively: the stock drives PA_17 as a GPIO output while continuously XIP-executing from that same quad flash. gpio_init(PA_17) repoints the PA_17 pad to the GPIO function; if SPIC needed PA_17 as a flash lane, the first gpio_init would kill instruction fetch instantly. It doesn’t. Therefore PA_17 is not a flash lane in the running configuration: the GD25Q32’s WP/HOLD (D2/D3) are on dedicated SPIC pads, and the SDK option FLASH_D2_PIN_SEL2 = PA_17 is unused on this board. [PROVEN]

The one way to manufacture a collision (check on the Matter build): if your bootloader / SPIC init selects the SEL2 group (D2→PA_17) with quad enabled, SPIC drives PA_17 every fetch and your app’s gpio_init(PA_17) tears that down → boot fault. Confirm your SPIC IO-mode + D2/D3 pin-sel match stock (D2/D3 must not be on PA_17). If they already match, the fault is not flash, see §7. [INFERRED]


3. A/C-bus protocol taxonomy

3.1 Bus role model [PROVEN]

The Wi-Fi module is bus master / sole initiator; the A/C mainboard is a pure slave/responder. There is no class-demux on RX: a full scan of 0x1f000–0x2a000 found zero cmp against 0x0A/0x07/0x1E/0x66 on the UART path (the only cmp #0x66/#0x67/#0x65 hits, 0x9b6ef1b4/b8/bc, are in CloudJson_processMsg, the TCP cloud channel, unrelated). Responses are matched to requests by the transaction primitive, not decoded by class. Direction is hard-gated: RX frames must have byte[2]==1 or the decoder rejects them (“link resp error”). [PROVEN]

3.2 Universal frame envelope [PROVEN]

F4 F5 | dir | CTRL | LEN | 00 00 | seqHi seqLo | FE 01 | 00 00 | CLASS | payload… | CKSUM | F4 FB

off field value / semantics source
0 STX1 0xF4 0x9b6f0a24
1 STX2 0xF5 0x9b6f0a24
2 DIR 0x00 M→AC, 0x01 AC→M (RX gate ==1 @ 0x9b6f2e56) 0x9b6f0a48
3 CTRL 0x40 normal. bit6(0x40)→2-byte cksum; bit5(0x20)→16-bit BE LEN; bit0→extra pre-LEN byte. Width helper 0x9b6f0958: 0x40→2, 0x80→2, 0xC0→4, 0x00→1
4 LEN 8-bit payload count (16-bit BE if CTRL bit5). total_frame = LEN+9 0x9b6f2e92
5,6 rsvd 0x00 0x00 0x9b6f09dc
7,8 link_seq hi/lo 00 00 on first handshake frame, 01 01 after (session token, §4.4) 0x9b6f09dc reads 0x10009687/86
9,10 markers 0xFE 0x01 (fixed sub-header terminator) 0x9b6f09ee/f2
11,12 pad 0x00 0x00 0x9b6f2a76
13 CLASS message class (§3.3) inner[0]
14.. payload class-specific; inner[1] usually subtype/len
N CKSUM width bytes, big-endian running SUM over bytes [2 .. 5+LEN) decoder 0x9b6f2f6c; encoder 0x9b6f097c
N+1,2 ETX 0xF4 0xFB 0x9b6f2aea/ec

Byte-stuffing: a literal 0xF4 inside dir…checksum is escaped F4 F4; the end tag is a lone F4 FB (0x9b6f7854). Checksum verified byte-exact on all four captured templates. The A/C→module sub-header is laid out differently (e.g. status response header 01 40 97 01 00 FE 01 01 01 01 00 66… puts FE at byte[7]); the decoder walks that variable RX sub-header via nested net resp / trans resp byte-==1 checks (0x9b6f2f94, 0x9b6f2fe8). [PROVEN]

3.3 Class taxonomy [PROVEN builders/handlers; subtype semantics as noted]

Class @byte13 Name Dir Builder / Handler Payload Purpose
0x0A DevType M↔AC build 0x9b6f2b5c, tmpl 0x9b805506=0A 04 00; parse 0x9b6f2194 0A 04 00 01 First handshake; identify device type, open session (link 00 00)
0x07 Version (VerResp) M↔AC parse 0x9b6f0bb8 07 01 00 01 Exchange MCU/proto version + checksum; capability follow-up after DevType
0x1E (num30) Link/network-status heartbeat M→AC ~1 Hz, AC→M build 0x9b6f225c/0x9b6f2c10; parse 0x9b6f2778 11 B: 1E 00 00 b3 b4 b5 00 00 00 40 00 Module reports its own Wi-Fi/cloud/prov state; A/C replies with request bits (§5, §6)
0x66 sub 00 Status poll M→AC poll, AC→M large telemetry build 0x9b6f2bac (“StatusCmd”), from loop 0x9b6f9682 req 66 00 00 00 01 Live A/C state read every loop
0x66 sub 40 ProductType / feature-flag status M→AC, AC→M build 0x9b6f2b0c; parse 0x9b6f0c4c 66 40 … Product/model code + decoded feature bit-flags (§5)
0x65 Control / set M→AC pack 0x9b6f2c60 (“DevTypeCMD1”), emit 0x9b6f8658 65 + 33 B body Device-type-gated control write
0x1D (num29) Reconfig / OTA request AC→M handled 0x9b6f4108; reconfig 0x9b6f3bf8 A/C answers DevType with 0x1D to demand re-provisioning / OTA reboot

Not bus classes: 0x65/0x66/0x67 at 0x9b6ef158/1b4/1b8/1bc are cloud-JSON opcodes; 0x67 never appears on the A/C bus. handle_deviceID_result (0x9b6f0aa2) matches sub-header bytes (byte[3]==0x10 && byte[4]==0x24), not a top-level class. [PROVEN]

⚖ Resolved (0x66 subtypes): the bus-protocol thread’s two-subtype model (66 00=status poll, 66 40=ProductType) is authoritative, two distinct builders (0x9b6f2bac emits 66 00, 0x9b6f2b0c emits 66 40). The field-maps thread’s flag table (§5a) is the parser for the 66 40 response (handle_producttype_cmd_result guards payload[1]==0x40 @ 0x9b6f0c74); the 66 00 poll response is consumed inline (f_electricity/f_ecm/f_power_display). Both large responses carry a transparent tail the module forwards to cloud without decoding. [PROVEN builders; INFERRED that the two large responses share the transparent-tail structure.]


Runs as uart_ctl_process_main (0x9b6f8dd8), called repeatedly by the matter_nett_main task (task body in the other flash bank, so no in-image caller). One pass = the sequence below. State vars: baud [0x10000988]; session token [0x10009686/87]; linked flag [0x1000bcf3]; already-inited flag [0x1000bcc2]; stop/stream flag [0x10009602]; device-type [0x10009687]. [PROVEN]

4.1 State sequence [PROVEN]

S0, BAUD SET (0x9b6f8e62): copy DevType tmpl; toggle baud 9600(0x2580) ↔ 115200(0x1c200) (ite eq @ 0x9b6f8e7c); serial_baud; line-status readback (0x9b7487d0, [hal+0x50]&0x3f); if busy → delay, retry.

S1, DEVTYPE PROBE (0x9b6f8f1c): zero RX counters; transaction_primitive(devtype, 3, rx, verify=0, mode=1, 500ms).

  • fail (≤0) → S0 with the other baud (this is the entire 9600/115200 auto-probe, ~1 attempt per 500 ms until a valid reply).
  • rx[0]==0x1D → reconfig/OTA preprocess 0x9b6f4108.
  • rx[0]==0x0Ahandle_devType_cmd_result (0x9b6f2194): latch device-type/id into [0x10009687/86]; if type==0x37 set [0x1002942c]=1.

S2, LINK CONFIRM (0x9b6f8fae): 0x9b6f8004 sends the 07 01 00 capability query (tmpl 0x9b807823, verify=1, 500 ms); set linked [0x1000bcf3]=1. If [0x1000bcc2]≠0 (already inited on a prior pass) → skip S3, jump to S4.

S3, INITIAL STATUS STREAM, 15 s (0x9b6f8fc2): deadline = now + 0x3a98(15000). Loop: gather A/C state via the seven Wi-Fi getters (§6), memset(buf,0,0x80), build num30(0x1E) (0x9b6f225c), transaction_primitive(num30, len, rx, verify=1, mode=1, 500ms); on success handle_num30_cmd_result; if the iteration took <1000 ms, vTaskDelay the remainder → ≈1 Hz poll.

S4, MODEL FINALISE: allocates + copies the model capability struct, keyed on device-type. Full map, struct layout, and the per-model feature tables: 11-model-capability-map.md. In brief: {0,1,0x36,0x37} → TEMPLATE_A 0x9b807e20 + malloc(0x33c)→[0x1000b8a8] (A/C); 0x15 → TEMPLATE_B 0x9b808144 + malloc(0xe4)→[0x1000b8b4] (dehumidifier); anything else allocates nothing (0x9b6f9638). Next pass short-circuits S3 via [0x1000bcc2] → steady state = repeated verified num30/0x66 polls.

Corrections to an earlier revision of this line (2026-07-16, all [PROVEN], see doc 11 §2):

  1. 0x9b6f9126 is NOT the dispatch head: it is bl 0x9b6f307c, the devtype getter (ldrb r0,[0x10009687]; cmp r0,#1; it lo; movlo r0,#1: maps 0→1).
  2. 0x36/0x37 do NOT get a distinct branch. 0x9b6f9130 is the TEMPLATE_A/0x33c branch (mov.w r0,#0x33c @ 0x9b6f9130), shared by all four A/C codes (beq.w #0x9b6f9130 @ 0x9b6f9728/0x9b6f9732).
  3. TEMPLATE_A is reached by an explicit ==1 test rather than an “else.” The real else (0x9b6f9638) allocates no struct at all.

(Off main path: 0x9b6f3480 enumerates slaves 1..8 via 0x9b6f3120, transaction timeout 3000 ms, 5 retries; 0x9b6f3418 resets 8×0x48-byte slave slots.)

4.2 Transaction primitive 0x9b6f335c [PROVEN]

int tx(u8* payload, int len, u8* rx_out, int verify_echo, u8 tx_mode, int timeout_ms):

  1. 0x9b6f2a14 wrapF4 F5 … frame into malloc buffer.
  2. 0x9b6f7f38 send_recvr8 = status.
  3. free wrapped; if send_recv≠0 → return 0.
  4. session-id capture (0x9b6f33a0): first reply only (globals==0) → rxbuf[9]→[0x10009687], rxbuf[10]→[0x10009686].
  5. 0x9b6f2e00 device_package_get_cmd → parsed transparent-payload length r4; if r4>0 && rx_out≠0 memcpy into caller buffer.
  6. echo verify (only if verify_echo≠0): require parsed_len>2, memcmp(payload, reply, 2)==0, reply[2]==1 (ACK/dir); mismatch → r4=0.

4.3 Send + listen 0x9b6f7f38 [PROVEN]

RX is interrupt-driven: uart_rx_start (0x9b6f7728); TX via framed sender 0x9b6f7880 (mode=1) or raw DEVWRITE/OTA path (mode=0); block on xSemaphoreTake(rx_sem@0x1000b894, timeout) (0x9b745404), pdTRUE→status 0 else status 7 (timeout); uart_rx_end; if RX count [0x1000b8bc]>0 → malloc + memcpy from ISR buffer 0x1000b8c0. The RX ISR (registered in hs_driver_init, handler 0x9b00d07d other bank) accumulates into 0x1000b8c0/0x1000b8bc and gives rx_sem on F4 FB frame completion. Every main call site uses a 500 ms (0x1f4) listen window. [PROVEN]

4.4 TX / DE gating chain [PROVEN]

0x9b6f7880 (F4-stuff → 0x9b6f7854) → hs_uart_send_buf 0x9b6f5228 (enable flags [0x1000b7fc]/[0x10009b20]) → tail-call byte-writer 0x9b6f51c8, which does: xSemaphoreTake(tx_mutex@0x1000b3c4) → DE=1 → DelayUs(5000) → serial_send → DelayUs(1000) → DE=0 → give. [PROVEN]: this is the exact DE toggle sequence to reproduce (5 ms pre-guard, 1 ms post-guard).

Two source threads appeared to conflict; they describe different byte origins:

  • Envelope bytes[7]/[8] (link_seq hi/lo). 0x9b6f09dc writes 00 00 while [0x10009687]==0, else the stored value. The transaction primitive echoes the A/C reply’s envelope bytes[9]/[10] into those globals (0x9b6f33ae), but for the DevType frame handle_devType_cmd_result overwrites them right after (see “Dual use” below), the device-type write is the one that survives. First frame (DevType) is sent 00 00; every later frame carries 01 01 = this model’s device-type/sub-type. [PROVEN] ⚠️ Read the “Dual use” warning before touching these bytes: they are a static per-model id, not a per-session token, treating them as a token breaks the link.
  • The wifi-coupling thread’s observation that the num30 inner payload never writes offsets [7]/[8] is about a different index origin (the 11-byte class payload, not the envelope), no contradiction. [PROVEN]

Dual use of [0x10009686/87]: the transaction primitive seeds these once from the raw reply envelope bytes[9]/[10] (session token), and handle_devType_cmd_result overwrites them with the DevType inner payload [3] (device-type code → 0x10009687) and [4] (sub-type → 0x10009686). Post-handshake the device-type meaning is authoritative (later gating reads 0x10009687 for 0x15/0x36/0x37). [PROVEN that both writes occur.]

⚠️ The two sources coincide on STATUS replies but NOT on the DevType reply, hardware, 2026-07-16. The inference that they always coincide was shipped as v10207, which stamped the reply envelope bytes[9]/[10] into outbound bytes[7]/[8], sampling first reply only (stock’s globals==0 gate), i.e. the 0x0A DevType reply. The A/C rejected every frame: link never came up, zero status, LocalTemperature null; recovered by OTA (v10208). Since stamping 01 01 is provably byte-identical (host goldens), the breakage proves the 0x0A reply’s envelope bytes[9]/[10] is NOT 01 01 on this unit.

MEASURED ON THE WIRE (2026-07-16, esp32 node 28 fw 1.0.6, instrumented token console command reporting both sources from the same 0x0A reply):

  device-type  inner [3]/[4] : 01 01  [learned from A/C]   <- what stock uses
  session tok  envel [9]/[10]: 00 00  [captured]           <- what v10207 stamped

The DevType reply’s envelope bytes[9]/[10] is 00 00, not 01 01. The claim above that “the A/C reply seeds the token to 01 01” is wrong for the 0x0A frame: that 01 01 comes only from handle_devType_cmd_result’s inner [3]/[4]. A 0x66 status reply does carry 01 01 at [9]/[10], measured separately, which is why an observe-only capture that re-reads every reply looked benign and seemed to confirm the token story.

This is the v10207 failure. It sampled first-reply-only (stock’s globals==0 gate = the 0x0A reply), read 00 00, rejected that as “not yet seeded”, and stamped its 00 00 seed onto every post-handshake frame in place of 01 01. The A/C rejected all of them.

Consequence: bytes[7]/[8] post-handshake carry the device-type/sub-type, a static per-model identifier. The 01 01 in the captured stock DI frames is this model’s device type. Read these bytes from handle_devType_cmd_result’s inner [3]/[4] on the class-0x0A reply (frame bytes [16]/[17]), never from that reply’s envelope [9]/[10]. See firmware/src/rs485-driver/hisense_rs485.cpp (hisense_devtype_from_reply).

4.6 Timing / retry summary [PROVEN]

  • Per-transaction listen: 500 ms. Timeout → status 7 → transaction returns 0.
  • Baud auto-probe: 9600↔115200, toggled every DevType attempt, back-to-back until a valid 0x0A reply.
  • Steady poll: num30/0x66 at ~1 Hz, 15 s initial burst; later passes skip the burst.
  • Verified transactions additionally require reply echo of cmd[0..1] + ACK [2]==1; the DevType probe is unverified.

5. Field maps: Status(0x66) / Command(0x65) / num30(0x1E) / DevType(0x0A)

Provenance correction [PROVEN by absence]: the ESPHome esphome_airconintl byte-per-field layout that the repo’s hisense_rs485.{h,cpp} and INTEGRATION.md assume (160-B 0x66 mode@18/setpoint@19/indoor@20/comp@42/outdoor@44; 50-B 0x65 fan@16/mode@18/temp@19) does not exist in any stock builder or parser: a scan of 0x1f000–0x2a000 for struct accesses ≥ offset 0x30 returned zero hits. The stock scheme is bit-packed + transparent-block: temps/compressor freq/fan RPM are never field-decoded by the module; they live in the transparent tail forwarded verbatim to Hisense cloud (flag ac_trans_102_64). Keep the ESPHome maps for the sniffer, but drop any “firmware-confirmed” provenance for the 160-/50-byte layouts, only the envelope is firmware-confirmed. [PROVEN]

All handlers receive payload starting at the class byte: payload[k] == frame byte[13+k].

5a. Status 0x66 sub 40: handle_producttype_cmd_result 0x9b6f0c4c [PROVEN]

Guard: payload[0]==0x66 (0x9b6f0c6a) and payload[1]==0x40 (0x9b6f0c74). Length gates len-2 ∈ {>0x14, >0x17, >0x18} enable higher fields. 15 feature flags (printf order into ac_* log 0x9b6f0ee0, format 0x9b804690):

Field payload off frame byte bits site
ac_cool_heat [5] 18 0x80 0x9b6f0ed8
ac_ai [0x0F] 28 0x40 0x9b6f0caa
ac_infinite_fan_speed [0x0C] 25 0x08 0x9b6f0cba
ac_power_save [0x0A] 23 0x40 0x9b6f0cce
ac_fan_mute [0x0B] 24 0x40 0x9b6f0ce0
ac_swing_direction_8 [0x0F] 28 0x10 0x9b6f0cae
ac_swing_follow [0x0D] 26 0x02 0x9b6f0cca
ac_power_display [0x0E] 27 bits6-7 (>>6) 0x9b6f0c94
ac_dr [0x16] 35 bits0-1 (&3) 0x9b6f0cf0
ac_humidity [0x13] 32 0x01 0x9b6f0ca0
ac_8heat [0x0D] 26 0x80 0x9b6f0ee6
ac_purify [0x0A] 23 0x08 0x9b6f0cd2
ac_q_display [0x1A] 39 0x40 0x9b6f0d0e
ac_trans_102_64 [0x19] 38 0x08 0x9b6f0cfe
ac_enable_8heat [0x1A] 39 0x04 0x9b6f0d0a

Also: payload[3] (frame 16) → raw global 0x100096c9 (bits1,3 feed a state machine); payload[4] (frame 17) bit3 → state machine; payload[5] (frame 18) read signed (ldrsb @ 0x9b6f0c86) into sp+0x64, branched ≥0/<0 @ 0x9b6f0f10: a signed selector byte, not a plain temperature. The partial overlap with ESPHome (frame16≈”fan”, frame18≈”mode”) is coincidental, semantics are bit-flags, not byte values. [PROVEN]

Correction (2026-07-16, [PROVEN], see doc 11 §4): an earlier revision said *“Whole commit gated on [0x10009687]==0x15"*. That is wrong. The ==0x15 test selects the **printf format string** and does not gate the commit. The real gate is **devtype <= 1** (ldrb r3,[0x10009687]; cmp r3,#1; bhi.w #0x9b6f16c4 @ 0x9b6f0d84), so the commit runs for **our** unit (devtype 1) and not for the dehumidifier alone. It commits supported bytes into the S4-allocated capability struct at [0x1000b8a8], plus a 3-digit **profile code** strcpy'd to 0x100096dc (getter 0x9b6f308c). ac_trans_102_64 set → profile **199** (the generic/transparent profile, beq.w @ 0x9b6f0ddc‘199’ @ 0x9b6f0de4`).

⚠️ The table above is authoritative. Our driver disagreed with it until 2026-07-16. hisense_parse_features() read the right bytes and mislabeled two: its q_display held [0x0A]&0x08 = ac_purify, and its purify held [0x0D]&0x80 = ac_8heat. True ac_q_display ([0x1A]&0x40) went unparsed until 2026-07-18, when the three [0x19]/[0x1A] flags (ac_q_display, ac_enable_8heat, ac_trans_102_64) were added behind a length gate (ext_valid, needs frame len > 39) mirroring stock’s own tiered gates. Confirmed against the stock printf arg order and renamed (heat_8c + purify); the byte reads were correct, so behaviour did not change.

5b. Command 0x65: matter_pack_devType_cmd 0x9b6f2c60 [PROVEN]

34-byte buffer, memset 0, payload[0]=0x65 (0x9b6f2c8a), wraps 33-byte payload. Body written only when [0x10009687] ∈ {0x36,0x37}: a device-registration/capability command selected by arg0(r6)/arg1(r5), not a plain climate setter:

payload off value / rule site
[0] 0x65 0x9b6f2c8a
[5] =arg1 (0–5); 0x5C if arg0==0, 0x3C if arg0==3 0x9b6f2cd0/2d84
[0x14] 0x30 if arg1==5 && devtype==0x37 0x9b6f2cdc
[4],[0x13],[0x14],[0x15],[0x1C] set/|= when [0x1000054c] ≤ 0x211 (legacy-compat) 0x9b6f2cec-2d0e
[0x0A] |=4 (or =5 if arg0==3 && arg1==0) 0x9b6f2d10/2d48

5c. num30 0x1E: builder 0x9b6f225c, handler 0x9b6f2778 [PROVEN] (full Wi-Fi coupling in §6)

Builder packs three flag structs into payload[3/4/5], sets payload[9]|=0x40, payload[0]=0x1E, [1]=[2]=0; returns 11. Handler parses A/C reply (payload[0]==0x1E && payload[1]==0, with 0x1D OTA sibling handled first) and reads request bits in payload[4], payload[5], payload[0xB] (§6.5).

5d. DevType 0x0A: builder 0x9b6f2b5c, handler 0x9b6f2194 [PROVEN]

Builder emits {0x0A,0x04,0x00,0x00}. Handler validates payload[0]==0x0A && [1]==0x04 && [2]==0x01, then:

payload off meaning dest
[2] 0x01 = A/C→module response
[3] device-type code 0x10009687
[4] sub-type / count 0x10009686

If type==0x37 latch [0x1002942c]=1. If payload==NULL[0x10009687]=[0x10009686]=0xFF, return −3. These globals gate the 0x66 parse (==0x15) and 0x65 body (∈{0x36,0x37}), DevType handshake must complete before status/command frames are honored. [PROVEN]


6. Wi-Fi / commissioning ↔ bus coupling [PROVEN]

6.1 Seven getters → backing globals → setters

getter reads field (__func__) setter
0x9b6f0228 0x100095fb wifi_set_control_status 0x9b6f0470
0x9b6f0234 0x10009600 wifi_set_connect_status (0=connected) 0x9b6f0424
0x9b6f028c 0x10009602 wifi_set_self_test_mode 0x9b6f0240
0x9b6f02e4 0x10009604 wifi_set_wifi_scan_status 0x9b6f0298
0x9b6f033c 0x10009603 wifi_set_wifi_prov_status 0x9b6f02f0
0x9b6f0394 0x10009605 wifi_set_work_status 0x9b6f0348
0x9b6f03ec 0x10009601 wifi_set_wifi_route_status 0x9b6f03a0

(Also wifi_set_cloud_config_status → word 0x10000544 via 0x9b6f04d4; unnamed word pair 0x9b6f04bc/04c80x10000548. Neither is packed into num30.)

6.2 Source of the globals

Driven by WLAN state machine wifi_server_main (0x9b6fc080–0x9b6fc520), polling hs_link_dev_is_net_ok(). Got-IP branch (0x9b6fc0b6): work=1,route=1,prov=0,connect=0. Link-down (0x9b6fc090/c508): connect=3,work=1,prov=0. Enter-provisioning reset 0x9b6f2358: connect=3,control=0,prov=1,work=5,route=0, then kicks commissioning task 0x9b6eef3c.

6.3 num30 wire-bit map (builder 0x9b6f225c)

Payload 1E 00 00 [b3] [b4] [b5] 00 00 00 40 00:

  • byte[3]: b7=const1; b6=(recv_num30_flag==0); b4=connect_status; b2=control_status.
  • byte[4]: b7=const1; b5=self_test_mode; b4=scan_status; b3=prov_status.
  • byte[5]: b5=work_status; b1=route_status. (**Emitted only if proto-ver==0   >0x214**, else error 5, byte[5]=0.)
  • byte[9]: |=0x40 unconditional.

Getters are shifted as full bytes, so multi-valued statuses spill (e.g. connect==3 → byte[3] bits4+5 = 0x30; net-OK clears them).

6.4 recv_num30_flag 0x1000098c

Feeds byte[3] bit6 as (flag==0)?1:0. Set to 1 exactly once in scan_thread_main @ 0x9b6f260c, right after wifi_set_self_test_mode(0) + “scan test finish”. So byte[3] bit6 = “scan-not-yet-ready”, asserted from boot until the first Wi-Fi scan completes.

6.5 A/C request bits → Wi-Fi actions (handle_num30_cmd_result 0x9b6f2778)

payload[0]==0x1D → OTA reboot (“receive 29#, reboot…”, 0x9b6fa510(6,1)). num30 body (payload[0]==0x1E):

  • payload[4] bit5(0x20) → smart-config reboot; bit3(0x08) → reset/reconfig (0x9b6f2358, 2 s delay, 0x9b6fa510(6,2)); bit0(0x01) → STORE: memcpy(0x100096d0, payload+3, N), N=(ver>0x214||ver==0)?8:2 (A/C hands module its binding/token bytes); bit6(0x40) → action code.
  • payload[5] bit7(0x80) → report-restart 0x9b6f7ce0 rc=5; bit6(0x40) → start factory test (wifi_set_self_test_mode(1)); bit5(0x20) → rc=7.
  • payload[0xB] &0xF0 (len>0xB) → “need update device ota info” (0x9b6f3480, 0x9b6f4598).

6.6 Proto-version gate 0x1000054c (threshold 0x214)

Set in class-0x07 handler 0x9b6f0bb8: payload[5..6] (BE u16) → 0x1000054c; payload[3..4]0x100096e4. Gates: num30 byte[5] emission (0x214), store length 8-vs-2 (0x214), 0x65 legacy-compat bytes (sibling threshold 0x211).

6.7 Coupling summary

Pure software reflection: wifi_server_main → seven byte globals → num30 builder → transaction 0x9b6f335c → DE-gated byte-writer 0x9b6f51c8. Nothing in the Wi-Fi/commissioning path initializes or gates the PA_17 DE GPIO: the status coupling runs entirely above the UART/DE layer and is not implicated in the boot fault. The authoritative “net-OK” signal on the wire is byte[9] bit6 (0x40) + byte[5] {work@b5, route@b1}. [PROVEN]


FIX FOR OUR v4 BOOT FAULT

Why driving PA_17 as GPIO at the Matter-app-init call site hard-faults [PROVEN root cause]

It is not a flash-lane collision and not a missing clock enable. §2.3 proves PA_17 is the DE net, not a flash lane, and the stock freely gpio_inits it while XIP-executing from quad flash. §1.4 proves no clock/SYSON/pinmux poke precedes PA_17 in stock.

The fault is execution context / ordering. mbed gpio_init (0x9b748464) does not touch a register directly, it dispatches through RAM/ROM function tables that are only valid after the SDK has populated them:

  1. Lazy GPIO-comm adapter. On its first call, gpio_init runs hal_gpio_comm_init via [hal_gpio_stubs+0x10] (ldr r3,=0x5f040; ldr r3,[r3,#0x10]; blx r3), guarded by flag 0x10028a34. [PROVEN @ 0x9b748474]
  2. Pad/HAL adapter vtable. hs_driver_init step 2 (0x9b748bfc) populates the pad-HAL adapter that gpio_init_pin/gpio_dir/gpio_mode dereference (ldr r7,=0x5ec60; ldr r3,[r7,#0x88]; blx r3 @ 0x9b748c0c). [PROVEN]
  3. Per-pin pinmux registration is a further indirect blx through hal_pinmux(pin, 0x70) via SRAM thunk 0x9b7fc348. [PROVEN]

If gpio_init(PA_17) is called from a C++ static constructor, from PlatformMgr().InitChipStack(), or any pre-scheduler/pre-SDK-HAL hook, these tables are unpopulated → the blx jumps through a stale/null function pointer → HardFault before UART TX and before the BLE beacon: exactly the observed symptom. [PROVEN mechanism; symptom-match INFERRED]

A second, independent fault mode sits just downstream: the DE byte-writer 0x9b6f51c8 does xSemaphoreTake(tx_mutex@0x1000b3c4) first, and gpio_write (0x9b748518) blindly stores through [obj+0x10]/[obj+0xc]. If the tx_mutex handle is null (mutex not yet created) or the gpio_t is stack-local / zero-init (so those DOUT pointers are 0), you fault on xSemaphoreTake(NULL) or str r3,[0]. Stock avoids both by creating the mutex first (step 1) and using a persistent global gpio_t at 0x1000b3c8. [PROVEN]

The correct bring-up for our custom driver [grounded in §1–§2, §4.4]

1. Move DE bring-up out of Matter/app-init into a task that starts after the scheduler and after SDK HAL/GPIO init. Put it exactly where the RS485 UART is initialized (our hs_driver_init equivalent), invoked from a task, never from a static ctor, InitChipStack, or any pre-scheduler hook. This is the single most important change and directly fixes the “no UART TX / no BLE beacon” symptom.

2. Use the SDK’s mbed gpio_init: do NOT hand-roll a clock/pinmux/PAD-register write. gpio_init self-runs the lazy hal_gpio_comm_init (guarded by 0x10028a34) and registers the pinmux (function-ID 0x70) internally. Adding your own SYSON/AON pad poke is unnecessary and, in the wrong order, is itself a fault source. Do not attempt raw HAL/PAD-register writes for PA_17: the literal pad register lives in mask ROM (not in the dump), and the AON pad offset/bitfields for PA_17 could not be confirmed from this image, a hand-written str to a guessed AON offset is strictly riskier than the mbed path the stock uses in production. Prefer the gpio_t wrapper.

3. Reproduce the stock order exactly, with a persistent gpio_t:

/* file-scope / static — persistent, NOT stack-local, matches stock 0x1000b3c8 */
static gpio_t de_gpio;                 /* 24-byte mbed gpio_t */
static SemaphoreHandle_t tx_mutex;     /* matches stock 0x1000b3c4 */

/* Runs from a task, AFTER scheduler + SDK board/HAL init are up */
void ac_driver_init(void)
{
    /* (1) create sync objects FIRST — the DE byte-writer takes tx_mutex before toggling DE */
    tx_mutex = xSemaphoreCreateMutex();
    /* ...create the other 3 stock sync objects if you mirror them... */

    /* (2) DE line — same order as stock 0x9b6f7470 */
    gpio_init(&de_gpio, PA_17);        /* 0x11; lazy comm-init + pinmux happen here */
    gpio_dir (&de_gpio, PIN_OUTPUT);   /* dir = 1 */
    gpio_mode(&de_gpio, PullNone);     /* pull = 0 */
    gpio_write(&de_gpio, 0);           /* idle DE low = RX */

    /* (3) ONLY NOW bring up the UART */
    serial_init(&uart0, PA_14, PA_13); /* TX=0x0e, RX=0x0d */
    serial_baud(&uart0, 9600);
    serial_format(&uart0, 8, ParityNone, 1);
    serial_irq_handler(...);           /* RX ISR → rx_sem on F4 FB */

    /* (4) spawn the uart control task (auto-baud DevType handshake) */
}

4. DE toggle around every TX must match 0x9b6f51c8 (§4.4), guarded by the mutex:

void ac_send(const uint8_t *frame, int len)
{
    xSemaphoreTake(tx_mutex, portMAX_DELAY);
    gpio_write(&de_gpio, 1);           /* DE high = drive bus */
    DelayUs(5000);                     /* stock pre-guard: 5 ms */
    serial_send(&uart0, frame, len);   /* blocking */
    DelayUs(1000);                     /* stock post-guard: 1 ms (flush last byte) */
    gpio_write(&de_gpio, 0);           /* DE low = release to RX */
    xSemaphoreGive(tx_mutex);
}

5. Verify the SPIC pin-sel before shipping (§2.3): confirm your bootloader/SPIC IO-mode and D2/D3 pin-select match stock, D2/D3 must not be on PA_17. If they already match stock (they should, stock uses quad on dedicated SPIC pads), the fault is purely the init-context issue above, and steps 1–4 resolve it.

Guardrails: keep the gpio_t a persistent global (never stack-local); ensure gpio_init actually ran and returned before any gpio_write (mbed marks a failed init with obj[0]=0xFF, which makes dir/mode/write safely no-op, so a clean late init is far safer than an early one that faults inside gpio_init); create the tx_mutex before the first DE-gated write.

In one line: the missing piece is context, not a register: bring DE up with plain mbed gpio_init/gpio_dir/gpio_mode/gpio_write on a persistent gpio_t, from a task that runs after the RTOS scheduler and SDK HAL are up (exactly where stock’s hs_driver_init sits), create the tx_mutex first, and stop initializing PA_17 during early/pre-scheduler Matter init.


HIL validation

The v4 boot fault was exactly the context/ordering fault analysed in “FIX FOR OUR v4 BOOT FAULT” above (DE gpio_init(PA_17) at pre-scheduler Matter-init faults through unpopulated HAL tables; before that, the earlier “no DE/RE” conclusion had left TX on DI but never on A/B). Fix as prescribed: DE (PA_17) + UART bring-up moved into hisense_hw_bringup(), called from the top of the bus task (task context, mirroring stock hs_driver_init).

Validated on hardware 2026-07-06 (v5, built-images/flash_rac-integrated-v5-defix.bin): boots (BLE Matter beacon, disc 3840); the A/C answers at ~1 Hz (0x1E heartbeat + 160-B 0x66 status); and the full loop closes end-to-end, commissioned onto the Matter fabric, LocalTemperature reads live °C, and Matter SystemMode writes emit the correct 0x65 (Cool→byte18=0x50, Heat→byte18=0x30). Standalone setpoint writes (OccupiedCooling/HeatingSetpoint) return IM 0x86 (a cluster deadband/limit constraint); the setpoint also rides the mode 0x65 at byte19. Build-by-build v3→v4→v5 debugging detail is in git history.

7. Cloud attribute table (t_* / f_* / f_e_*) [PROVEN, 2026-07-19]

The module carries the full Hisense cloud attribute namespace as a name blob plus capability tables. This is the vocabulary the ConnectLife app speaks, and the source of its “Self diagnostics” screen. Found by static analysis of dumps/w41h1_dump1.bin (local-only, gitignored). No bus tap required.

7.1 Load base [PROVEN]

base = 0x9b6d0000, so address = 0x9b6d0000 + file_offset.

Derived by taking every attribute-name string offset, scanning the image for 32-bit LE words that could point at them, and keeping the base with the most consistent hits: 77 of 77 names resolve under this base and no other. Cross-check against §5a holds: the ac_8heat getter 0x9b6f0ee6 maps to file 0x20ee6, inside the code region.

This base was not previously written down, and without it no static RE on this dump is reproducible.

7.2 Capability tables [PROVEN]

Two tables, 12-byte stride, entries [word0: u32][name_ptr: u32][desc: u32] (all LE):

table file offset records ends
TEMPLATE_A 0x137e20 67 0x138144 (= 0x137e20 + 67*12)
TEMPLATE_B 0x138144 15 0x1381f8, then plain ASCII (uart_server_clr …)

word0 byte +2 is a 0/1 “supported” gate. name_ptr points into the name blob at 0x13730a..0x1376ca.

A bit-width lookup table sits immediately before TEMPLATE_A at file 0x137e0c:

00 01 03 07 0f 1f 3f 7f ff        mask = (1 << width) - 1

Alignment warning. 0x137e34 is not a record boundary; it is 20 bytes past the base and not a multiple of the stride. Walking from there yields a table that looks plausible but is phase-shifted by one field, pairing every name with the previous record’s desc. An earlier pass in this repo did exactly that and drew the wrong conclusion from it (see §7.5). The literal 0x9b807e34 appears nowhere in the image, which is a quick way to confirm it was never a real boundary.

Note TEMPLATE_A[1] is named t_power only as an artifact: its name_ptr (0x9b813402) lands one byte inside an unrelated Bluetooth AT string, so that slot is reserved/invalid rather than a real capability.

7.3 The fault namespace: what “Self diagnostics” reads

27 f_e_* fault attributes exist. The app’s four categories are a bucketing of these (the bucketing is inferred from the names; the names are [PROVEN]).

desc byte +2 is a group and byte +3 a bit index, giving contiguous runs:

group bits (high to low)
0x18 0f intemp, 0e incoiltemp, 0d inhumidity, 0c waterfull, 0b upmachine / infanmotor, 0a dwmachine / arkgrille, 09 invzero, 08 incom
0x19 0f indisplay, 0e inkeys, 0d inwifi, 0c inele, 0b ineeprom
0x31 0e outeeprom, 0d outcoiltemp, 0c outgastemp, 0b outtemp
0x33 0c over_hot / over_cold
0x00 00 push (no position; likely not a wire bit)

Three genuine collisions survive correct alignment and are NOT artifacts: infanmotor/upmachine, arkgrille/dwmachine, over_cold/over_hot. Read these as one bit whose meaning depends on the unit type (a floor-standing grille versus a wall unit, hot versus cold protection). Any decoder must accept that some names alias one bit.

7.4 Settable attributes worth noting [PROVEN names]

The t_ prefix marks a settable attribute. Two matter for open issues:

  • t_8heat and t_8c_heater_onoff both exist. 8 C frost-guard heat therefore is commandable by the stock module. Its absence from the ConnectLife app is a UI choice, not a protocol limit.
  • t_dimmer exists alongside display on/off, the likely path to panel-brightness level (the 2-bit ac_power_display).

Supporting evidence for the group/bit reading: t_eco (desc=0a140512) and t_super (desc=09140312) share group 0x14 at adjacent bits 0x0a/0x09, which matches the hardware-confirmed adjacency of eco and turbo in status byte 35.

The tables also name capabilities this unit likely lacks: t_fresh_air, f_co2_value, f_co2_level, t_onekey_selfclean, t_indoor_selfclean, t_outdoor_selfclean, t_hp_lock_onoff, t_ht_lock_onoff, t_heat_control_logic, t_interlock_onoff, t_demand_response. Per docs/11 §5.1, gate on capability flags at runtime rather than deleting these paths.

7.4a The desc word decodes [CONFIRMED]

desc byte +2 is a payload offset and byte +3 packs width and bit position:

frame_byte = 15 + desc[2]
width      = widthtable[ desc[3] >> 3 ]     table at file 0x137e0c: 00 01 03 07 0f 1f 3f 7f ff
shift      = desc[3] & 7                     (mask = (1 << width_bits) - 1)

Confirmed by six fields this repo had already established on hardware, independently of the table:

attribute desc decodes to matches
t_up_down 0f140711 byte 35 bit 7, 1-bit vswing_on
t_left_right 0e140511 byte 35 bit 6, 1-bit hswing_on
t_eco 0a140512 byte 35 bit 2, 1-bit eco_on
t_super 09140312 byte 35 bit 1, 1-bit turbo_on
t_fan_mute 0a150514 byte 36 bit 2, 1-bit mute_on
t_temp 38040104 byte 19, 7-bit setpoint

The last one matters most: it is the only non-1-bit anchor, so it exercises the width table rather than just the shift.

Fields this newly locates, all previously unknown:

attribute desc frame position status
t_temp_type 090b0108 byte 26 bit 1, 1-bit C/F display unit. Both bench units read 0 (Celsius). Direction UNVERIFIED.
t_work_mode 1c030503 byte 18 bits 4-6, 3-bit matches the mode nibble we already decode
t_dimmer 0f160715 byte 37 bit 7, 1-bit explains the healthy 0x80 at byte 37
f-filter 0b160000 byte 37 bit 3, 1-bit filter-clean indicator

Byte 38 reads 0x05 identically on both bench units while state-dependent bytes differ, so it is structural, but no attribute in either template claims payload offset 0x17, so it stays unidentified.

7.4b The COMMAND encoding decodes too [CONFIRMED 16/16]

desc bytes +0 and +1 serve the command frame, mirroring +2/+3 on the status side:

command_byte = 15 + desc[0]                  same base as the status side
shift        = desc[1] & 7
value        = ((logical << 1) | 1) << (shift - 1)      0x00 = leave this field alone

The | 1 is a “this field is being set” marker, which is why every control on this bus has odd-looking values.

Verified against all 16 command values this repo had confirmed on hardware, with no exceptions:

field shift logical computed confirmed
display on / off 7 1 / 0 0xC0 / 0x40 0xC0 / 0x40
eco on / off 5 1 / 0 0x30 / 0x10 0x30 / 0x10
turbo on / off 3 1 / 0 0x0C / 0x04 0x0C / 0x04
setpoint 16 / 22 / 32 C 1 16 / 22 / 32 0x21 / 0x2D / 0x41 same
fan auto..high 1 0..9 0x01..0x13 same

This is the rule behind several things previously recorded as separate empirical facts: the setpoint’s value*2+1, the fan ladder, the eco/turbo values that disagreed with the reference project, and the display tri-state (0xC0 on, 0x40 off, 0x00 leave alone) that was established the hard way on the bench.

desc[0] is independently confirmed by nine attributes landing on command bytes this repo already drives: t_fan_speed -> 16, t_power -> 18, t_temp -> 19, t_swing_angle -> 31, t_up_down/t_left_right -> 32, t_eco/t_super -> 33, t_dimmer -> 36.

Predicted commands for controls we do not implement yet. All UNVERIFIED on hardware:

attribute command byte ON OFF
t_8heat (8 C frost-guard) 37 0x03 0x01
t_purify (ionizer) 34 0xC0 0x40
t_temp_type (C/F display unit) 23 0x03 0x01

7.4c t_sleep decodes, and the A/C ignores it anyway [CONFIRMED table, NEGATIVE on hardware]

Decoded from a stock dump taken 2026-08-19 (record at file 0x129ec0, desc=39020102):

side decode meaning
status desc[2]=0x02, desc[3]=0x39 byte 17, 7-bit, shift 1, so profile * 2
command desc[0]=0x02, desc[1]=0x01 byte 17, shift 1, so profile * 2 + 1

Both sides agree with what this repo already sent: General 0x03, Old 0x05, Young 0x07, Kids 0x09, off 0x01 at byte 17. The table therefore CONFIRMS the encoding rather than correcting it.

The A/C does not act on it. On a live CF35LR03G all five profiles were commanded, first as the driver’s single-field frame and then as the combined frame with byte 17 patched via hisense_build_command_override(). The frame was demonstrably accepted each time (the same frame cleared mute through byte 35), and sleep_raw never moved once. A bounded sweep of the 18 unknown payload bytes at 0x03, plus byte 17 with the status-side encoding, moved nothing either.

So the encoding, the byte and the frame are all ruled out. Two candidates remain: sleep needs a different frame class than the 0x65 combined command (the community reference ships canned sleep_1..4 frames rather than a field write), or this indoor unit does not implement sleep at all and the module advertises it because the capability table is the MODULE’s generic table, refined per model only by the ProductType tree (docs/11 5.1).

Discriminator RUN 2026-08-19: the unit DOES implement sleep. Pressing Sleep on the A/C’s own remote moved the byte:

A/C sleep_raw 0 -> 2      (profile 1 = General, i.e. profile * 2 as decoded)
A/C mute      0 -> 1      (fan_raw 0x00)
A/C sleep_raw 2 -> 0      (off again)

Three things follow. The status decode is confirmed live. The command path is what is wrong, not the A/C, so the entity is disabled for a fixable reason. And the unit read Mode: OFF throughout, so sleep does NOT require the A/C to be running, which removes one hypothesis.

Note the remote set mute at the same instant, with fan_raw 0x00 – a value that is not in the fan ladder and not the 0x02 our own mute command produces. So this remote’s Sleep looks like a COMPOSITE action, not a single field write, which fits the remaining candidate: sleep arrives as its own frame class rather than a field in the 0x65 combined command, exactly as the community reference implies by shipping canned sleep_1..4 frames.

What byte 17 actually does, measured from a non-zero start (2026-08-19). Every earlier probe began at sleep_raw = 0, where “selected a profile” and “ignored” are indistinguishable. Starting from sleep_raw = 2 (set on the remote) the behaviour is unambiguous:

tx_override: byte 17 = 0x05 (Old)   ->  A/C sleep_raw 2 -> 0     cleared, NOT set to 4
tx_override: byte 17 = 0x07 (Young) ->  no change (already 0)
tx_override: byte 17 = 0x09 (Kids)  ->  no change
tx_override: byte 17 = 0x03 (General) -> no change

So byte 17 in the 0x65 combined frame cancels sleep and cannot select a profile. Any non-zero value clears it; none sets one.

RESOLVED the same day. Setting a profile arrives on the MINIMAL frame, not the combined one, and the minimal frame was broken for an unrelated reason: hisense_build_single_field() omitted frame[31] = 0x01, the marker every combined command writes. With it added, byte 17 selects every profile:

byte 17 = 0x03 -> sleep_raw 0 -> 2   General
byte 17 = 0x05 -> sleep_raw 2 -> 4   Old
byte 17 = 0x07 -> sleep_raw 4 -> 6   Young
byte 17 = 0x09 -> sleep_raw 6 -> 8   Kids
byte 17 = 0x01 -> sleep_raw 8 -> 0   off

The encoding decoded from the capability table was right all along; only the carrier was wrong. Mute (byte 35) was broken by the same missing byte and is fixed by the same change, which also repairs ep4/ep6 on both Matter builds. See firmware/docs/07.

All four profiles confirmed on hardware (2026-08-19), by rotating them on the A/C’s remote:

sleep_raw 0 -> 4    Old       + mute 0 -> 1 (fan_raw 0x02)
sleep_raw 4 -> 6    Young
sleep_raw 6 -> 8    Kids
sleep_raw 8 -> 0    off
sleep_raw 0 -> 2    General

sleep_raw = profile * 2 therefore holds for every profile, not just the General case seen first, which closes the status side of t_sleep completely.

Note where the mute edge falls: ONCE, when sleep first engages, and never again as the profile changes. Engaging sleep also engages mute with a quiet fan; switching profile within sleep is a pure byte-17 change on the A/C’s side. So the carrier we are missing has to set a profile while sleep is already on, without disturbing mute, which is a narrower target than “sleep is a composite action”.

Note byte 37 already carries the horizontal-swing companion (0x14, bits 2 and 4) while t_8heat occupies bits 0 and 1, so an implementation must OR into that byte rather than assign, exactly as a bit-packed frame implies.

These are testable from the debug console with no firmware change: tx 37 0x03 should engage 8 C heat, tx 37 0x01 should clear it.

7.4d Full capability table decoded from a stock dump (2026-08-19)

All 60 t_/f_ records in the stock module’s table, decoded with the 7.4a/7.4b rules. The nine command bytes 7.4b confirmed independently all reproduce here, which is the cross-check that the walk is right: t_eco/t_super 33, t_up_down/t_left_right 32, t_temp 19, t_fan_speed 16, t_dimmer 36, t_purify 34, t_8heat 37.

There is no second sleep attribute. t_sleep is the only sleep record, so the missing profile-set path is not another field we failed to write.

attribute desc status command
t_8heat 083e0116 byte 77 bit 0, 1-bit byte 37, shift 1
t_anion 0c070106 byte 22 bit 4, 1-bit byte 21, shift 1
t_dal 0e44052c byte 83 bit 6, 1-bit byte 59, shift 5
t_demand_response 0f44072c byte 83 bit 7, 1-bit byte 59, shift 7
t_dimmer 0f160715 byte 37 bit 7, 1-bit byte 36, shift 7
t_eco 0a140512 byte 35 bit 2, 1-bit byte 33, shift 5
t_fan_mute 0a150514 byte 36 bit 2, 1-bit byte 35, shift 5
t_fan_speed 39010101 byte 16 bit 1, 7-bit byte 16, shift 1
t_fan_speed_s 38000101 byte 15 bit 0, 7-bit byte 16, shift 1
t_fanspeedCV 383b0119 byte 74 bit 0, 7-bit byte 40, shift 1
t_fresh_air 0e661f03 byte 117 bit 6, 1-bit byte 18, shift 7
t_humidity 38040104 byte 19 bit 0, 7-bit byte 19, shift 1
t_left_right 0e140511 byte 35 bit 6, 1-bit byte 32, shift 5
t_pump 0d070306 byte 22 bit 5, 1-bit byte 21, shift 3
t_purify 0f150713 byte 36 bit 7, 1-bit byte 34, shift 7
t_sleep 39020102 byte 17 bit 1, 7-bit byte 17, shift 1
t_super 09140312 byte 35 bit 1, 1-bit byte 33, shift 3
t_swing_angle 19130110 byte 34 bit 1, 3-bit byte 31, shift 1
t_swing_direction 18450120 byte 84 bit 0, 3-bit byte 47, shift 1
t_swing_follow 1441021a byte 80 bit 4, 2-bit byte 41, shift 2
t_talr 0d44032c byte 83 bit 5, 1-bit byte 59, shift 3
t_temp 38040104 byte 19 bit 0, 7-bit byte 19, shift 1
t_temp_compensate 240b0408 byte 26 bit 4, 4-bit byte 23, shift 4
t_temp_type 090b0108 byte 26 bit 1, 1-bit byte 23, shift 1
t_tms 0e450520 byte 84 bit 6, 1-bit byte 47, shift 5
t_up_down 0f140711 byte 35 bit 7, 1-bit byte 32, shift 7
t_work_mode 1c030503 byte 18 bit 4, 3-bit byte 18, shift 5

Controls this repo does not implement yet, with their command bytes now known: t_anion (21), t_pump (21), t_swing_direction (47), t_swing_follow (41), t_fanspeedCV (40), t_temp_compensate (23), and t_dal / t_talr / t_demand_response (all 59). Status fields beyond the 160-byte window we parse also appear: f_cool_qvalue at 153, f_heat_qvalue at 155, t_fresh_air at 117.

7.5 The extractor, and what static RE has now settled

A status-bit extractor lives at 0x9b6f8ac8 (file 0x28ac8). It loads a RAM struct pointer from 0x1000b8a8, reads struct bytes +0xa (group) and +0xb (bit index), and indexes the width table at 0x137e0c to build its mask. Its only call site, 0x9b6f9b26 (file 0x29b26), first checks frame byte 0 == 0x66 and byte 1 == 0.

Open questions 1 and 4 below are now RESOLVED by disassembly (2026-07-22, two independent passes, adversarially cross-checked). 2 and 3 remain open but do not touch the four fault bytes.

  1. Which frame the groups index. [RESOLVED: the 66/00 periodic status frame] The call site at 0x9b6f9b26 gates on byte0 == 0x66 and byte1 == 0, then passes that same buffer (the [sp,#68] slot, provably unwritten between the gate and the call) straight into the extractor. byte1 == 0 selects subtype 00 = status-poll, the exact 160-byte frame hisense_parse_faults() reads, and rejects 40 = ProductType. So 0x18/0x19/0x31/0x33 are confirmed wire-byte groups on the periodic status frame, not merely group ids.
  2. Record-count discrepancy. The extractor’s loop bound is base + 0x33c (69 records) while TEMPLATE_A is 67 records (0x324). Unchanged, and shown non-blocking: all four fault groups live among the real records, and the two tail slots (indices 67-68) reuse the t_work_mode/t_power strings with different group/bit, nowhere near the fault records. Settle fully by reading the allocation that fills 0x1000b8a8, or by dumping that RAM live.
  3. desc semantics for ranged/settable records (t_temp, t_fan_speed, the t_heat_control_logic cluster). Undecoded. Note the setpoint range (16/32 C) does not appear in t_temp’s desc in any byte position, so the naive (min, max, step, type) reading is already refuted.
  4. Whether 0x9b6f8ac8 and the §5a getter 0x9b6f0ee6 number the same wire bytes. [RESOLVED: yes] Both anchor index 0 at the class byte (wire 13). The §5a getter reads wire = 13 + payload_offset (e.g. ac_8heat payload 0x0D gives byte 26). The extractor reads payload[group + 2], i.e. wire = 13 + (group + 2) = 15 + group; its “group” field is simply payload_offset - 2. So group 0x18 gives wire 15 + 24 = 39, exactly HISENSE_FAULT_BYTE_INDOOR. Same numbering, verified by disassembling both getters.

Independent corroboration (the strongest single line of evidence). Walking the TEMPLATE_A array at 0x137e20 and dereferencing each record’s embedded name pointer prints the firmware’s own compiled-in fault labels against their group/bit: f_e_intemp (0x18 bit 7), f_e_incom (0x18 bit 0), f_e_outtemp (0x31 bit 0x0b), f_e_over_cold/f_e_over_hot (both 0x33 bit 0x0c). This maps 1:1 onto HISENSE_FAULT_BYTE_INDOOR/MODULE/OUTDOOR/PROTECT (39/40/64/66) in hisense_rs485.h:661-664, and confirms byte 39 is a multi-bit byte (intemp, incom, incoiltemp, inhumidity, … together), matching raw_indoor. The over_cold/over_hot alias sharing one bit is baked into the vendor template, not an RE transcription error.

Net for the live ep10 fault sensor: the decode (which frame, which byte, which bit) is now CONFIRMED by two independent lines of disassembly (control-flow + offset math, and the compiled-in fault-name strings). What remains unproven is purely semantic: whether a genuinely faulted unit asserts these bits on the wire. That is the one gate left, and it needs hardware. See §7.6.

7.6 Hardware validation [CONFIRMED 2026-07-22] and the fault-injection runbook

Validated on real hardware, 2026-07-22 (office AmebaZ2, v1.3.19). Two faults were induced; each set exactly its predicted bit, named it correctly, stayed isolated to the affected unit, and cleared on undo:

fault induced by observed
f_e_incom cut the indoor-to-outdoor comms line byte 39 -> 0x01 (bit 0), com=1; back to 0x00 on reconnect
f_e_intemp unplug the indoor temp thermistor byte 39 -> 0x80 (bit 7), temp=1; back to 0x00 on reconnect

The kitchen unit read all-clear the whole time (no false positive). Bit 0 and bit 7 are the two ends of byte 39, so both the byte offset and the within-byte bit decode are confirmed against live faults. That closes #38. Bytes 40/64/66 rest on the same extractor + compiled-name-string evidence (§7.5) but have not each been seen firing; the runbook below drives that if wanted.

Confirm a bit end-to-end on a debug-flavour unit (so the :2323 console is reachable), cheapest step first:

  1. Baseline (captured live 2026-07-22). nc <unit> 2323, then faults (expect no faults) and raw (hexdump). On a normal healthy unit bytes 39/40/64/66 read 00 00 00 00 (confirmed on the two debug-flavour AmebaZ2 nodes at v1.3.18). Byte 66 reads 0x80 ONLY while 8C frost-guard is engaged: that is the HISENSE_FAULT_NONFAULT_PROTECT mode-flag bit, masked out of the any aggregate, not a fault.
  2. Induce two faults, ideally on two DIFFERENT fault bytes (validates two byte offsets, not just two bits of one byte). Always disturb the indoor/outdoor side, never the module’s own RS-485 bus (that kills our poll). Any predicted bit firing where and only where its fault is induced is the proof; two independent hits make it conclusive. Reversible, most accessible first:
    • f_e_incom (byte 39, mask 0x01): interrupt the indoor-to-outdoor comms link. Cleanest, and the ConnectLife app should log a Communications fault at the same moment our decode flips.
    • f_e_waterfull (byte 39, mask 0x10): trip the condensate-tray float switch.
    • f_e_intemp (byte 39, mask 0x80) / f_e_incoiltemp etc.: unplug the corresponding indoor thermistor (reversible).
    • For a SECOND byte: f_e_outtemp (byte 64, mask 0x08) via the outdoor temp thermistor, or any byte-40 electronics fault, if reachable.
  3. Observe (read-back is turnkey). With each fault active, faults must name the predicted bit and the matching raw_* byte must show it set; clearing the fault must clear it. Capture the raw hexdump healthy vs faulted for each – the diff is the proof. The bench tooling can read both nodes’ :2323 consoles automatically, so once a fault is active just say so and the before/after can be pulled and recorded here.
  4. Cross-check, no bench fault needed. The still-cloud “Master Bedroom AC” can run “Self diagnostics” in the app; compare its reported categories against our decode of that unit’s status frame.
  5. On success, promote the fault-map language in hisense_rs485.h and the :2323 faults help text from “decode confirmed, bits hardware-unvalidated” to fully validated, and close #38.

Back to top