Device type and model capabilities
How the stock module decides which features the attached indoor unit has. You need this mechanism to support an A/C model other than the ones measured here.
Status of this page
Current reference for the mechanism. History for the tooling it was measured with.
- Sections 1 to 4 are static analysis of
dumps/w41h1_dump1.bin(2026-07-16, the dump is not in the repository), building on the call graph in10-stock-fw-init-and-comms.md. The address mappingruntime = file_offset + 0x9b6d0000was checked again before decoding (0x9b805506holds0a 04 00,0x9b807823holds07 01 00, matching doc 10 §3.3). - Each claim is tagged [PROVEN] (read off the disassembly, address cited) or [INFERRED] (reasoning). Treat [INFERRED] as untested. One [INFERRED] claim of doc 10 §4.5 was acted on, shipped in a Matter firmware build, and killed the A/C link (the account is there).
- Section 5 holds measurements on two units, on 2026-07-16 and 2026-07-18. They are called unit
A (driven by an ESP32 replacement board) and unit B (with its stock module) below. Both ran the
project’s Matter firmwares, and the flags were read through the diagnostic console on TCP port
2323 that those firmwares had. The Matter firmwares are retired (2026-10-12, tag
matter-final,docs/guide/Matter-Firmware-Retired.md). With the ESPHome firmware the same flags are thecapability_*binary sensors (docs/guide/Entities-and-Diagnostics.md). hisense_get_features()andhisense_parse_features()are the first driver’s names (firmware/test/reference-driver/). In the ESPHome component the parser isparse_features()inhisense_protocol.cpp.- The inference of §3.2 about the
descword was confirmed later (doc 10 §7.4a and §7.4b). - What is still open is in 14-open-questions.md.
1. The one-paragraph version
The A/C reports its device-type in the DevType (0x0A) reply. The module picks one of two
capability templates in flash, copies it to a heap struct, and lets the ProductType (0x66/40)
reply refine it by switching individual capabilities on. Consumers skip any capability whose
supported byte is 0. Static baseline plus runtime refinement is one mechanism in two stages,
so the two never compete.
DevType 0x0A reply ──▶ devtype ──▶ S4: malloc + copy TEMPLATE_{A,B} ──▶ [0x1000b8a8]/[0x1000b8b4]
│
ProductType 0x66/40 reply ──▶ flag decision tree (0x9b6f0d84) ──▶ writes `supported` bytes ──┘
└──▶ strcpy 3-digit profile → 0x100096dc
2. Device-type table [PROVEN]
This sweep covered all 27 callsites of the devtype getter 0x9b6f307c and all 10
literal-pool xrefs of [0x10009687]. The image compares only 1, 0x15, 0x36, 0x37,
plus 0xFF, a sentinel written on a NULL DevType payload @ 0x9b6f2194.
No lookup table exists. Dispatch is an if/else-if chain (bl 0x9b6f307c; cmp; beq).
| devtype | struct | malloc | records | profile codes | product |
|---|---|---|---|---|---|
0x00, 0x01 |
TEMPLATE_A 0x9b807e20 → [0x1000b8a8] |
0x33c |
69 | 100–129, 199 |
air conditioner (ours) |
0x15 |
TEMPLATE_B 0x9b808144 → [0x1000b8b4] |
0xe4 |
19 | 400, 499 |
dehumidifier (t_pump, t_anion, f_e_wetsensor) |
0x36 |
TEMPLATE_A | 0x33c |
69 | 200–203, 299 |
A/C variant |
0x37 |
TEMPLATE_A | 0x33c |
69 | 300–311, 399 |
A/C variant (also sets [0x1002942c]=1 @ 0x9b6f2194) |
| anything else | none allocated | falls to 0x9b6f9638 |
- The getter
0x9b6f307cisldrb r0,[0x10009687]; cmp r0,#1; it lo; movlo r0,#1. It maps 0 → 1, so devtype 0 and 1 name the same model. 0x36and0x37share TEMPLATE_A with the plain A/C. Their profile code differs, the struct does not.- Do not add driver branches for codes outside
{0,1,0x15,0x36,0x37}. Nothing else exists. - Our unit reports
01 01(measured on the wire, doc 10 §4.5) → TEMPLATE_A, profiles1xx.
Corrects three errors in an earlier doc 10 §4.1 (fixed there):
0x9b6f9126is theblto the getter, not the dispatch head;0x9b6f9130is the TEMPLATE_A branch (shared by all four A/C codes,beq.w@0x9b6f9728/0x9b6f9732), not an0x36/0x37-only path; and TEMPLATE_A is selected by an explicit==1test. The realelseallocates nothing.
3. The capability struct [PROVEN]
The templates are not opaque blobs. They are arrays of 12-byte records, and the capability names are cleartext strings in rodata:
struct cap_entry { // 12 bytes
uint8_t b0, b1;
uint8_t supported; // +2 <- THE GATE
uint8_t b3;
const char *name; // +4 -> e.g. "t_work_mode", "f_humidity", "t_pump"
uint32_t desc; // +8 {b0,b1,b2,b3} wire-position descriptor
};
Template word0 = 0x00010000 (byte2 = 1) → supported; 0x00000000 → not.
+2 is a hard skip-gate. From 0x9b6f9b50:
ldr.w r3,[fp] ; fp = &[0x1000b8a8]
muls r7, r6, #0xc ; record index * 12
ldrb r2, [r2, #2] ; +2 = supported
cmp r2, #0
beq.w #0x9b6f9d76 ; skip this capability entirely
3.1 Runtime struct = template + synthesized inserts
The malloc sizes exceed the templates because S4 inserts records during the copy. Both reconstruct to exactly the malloc size:
- STRUCT_A =
TA[0..14]+{"f_electricity"}@0x9b6f9252+TA[15..66]+{"f_ecm"}@0x9b6f9624→ 69 × 12 = 828 =0x33c✓ - STRUCT_B =
TB[0..7]+{"f_power_display"}@0x9b6f97e2+{"f_power_consumption"}@0x9b6f97f6TB[8]+TA[45](borrowed cross-template viasub.w r2,r5,#0x108@0x9b6f9820)TB[9..14]+{"f_ecm"}@0x9b6f98a0→ 19 × 12 = 228 =0xe4✓
The templates are contiguous in flash (0x9b807e20 + 0x324 == 0x9b808144); TEMPLATE_A’s real
extent is 67 records (0x324) and TEMPLATE_B’s is 15 (0xb4).
Corroborated three independent ways [PROVEN]: the record-count loop bounds 0x45(69) /
0x13(19) selected by devtype @ 0x9b6f79a4; the literal add.w ip, r3, #0x33c @ 0x9b6f8ae8;
and every one of ~40 ProductType strb offsets landing exactly on record*12 + 2.
3.2 The desc word (+8)
Two consumers read different halves of it:
| consumer | reads | meaning |
|---|---|---|
0x9b6f79a4 |
idx = b0+2, group = b1>>3 (vs buf[1]), bit = (b1&7)-1 |
[PROVEN] |
0x9b6f8adc |
idx = b2+2 (ldrb r5,[r3,#0xa], cbz → skip if 0); b3 < 0x48 → bitfield via mask table; b3 >= 0x48 → 16-bit BE read |
[PROVEN] |
[INFERRED] that (b0,b1) = position in the command (0x65) frame and (b2,b3) = position
in the status (0x66) frame. Consistent with spot checks (f_votage b3=0x80 → 16-bit read;
t_temp b2=4 → status byte 6) but not confirmed when this was written.
Confirmed since: doc 10 §7.4a decodes six status positions and §7.4b sixteen command values with
this reading, all of which had been established on the bus independently. The frame byte is
15 + the desc byte, so t_temp with b2=4 is frame byte 19, the setpoint.
word0’s b0/b1/b3 have no known consumer. Only +2 is proven meaningful.
4. ProductType refines the struct (they are one mechanism) [PROVEN]
handle_producttype_cmd_result (0x9b6f0c4c) does more than log. Gated on devtype <= 1
(ldrb r3,[0x10009687]; cmp r3,#1; bhi.w #0x9b6f16c4 @ 0x9b6f0d84) it writes supported bytes
straight into the S4 struct:
ldr r3,=0x1000b8a8; ldr r8,[r3] ; the S4-allocated struct
strb sl(=1), [r8,#0x266] ... ; ~17 unconditional enables, then a flag-driven tree
It also strcpys the 3-digit profile code to 0x100096dc (getter 0x9b6f308c).
ac_trans_102_64 set → 199 (generic/transparent profile; beq.w @ 0x9b6f0ddc →
'199' @ 0x9b6f0de4).
ProductType is the authoritative refinement layer. Within the devtype<=1 path it only
enables. Disable writes exist elsewhere, such as the = 0 stores in the 0x37 branch at
+0x31a, so scope “only enables” to this path.
This corrects doc 10 §5a’s “Whole commit gated on
[0x10009687]==0x15“. That==0x15test picks the printf format string and nothing more. Fixed in §5a.
5. What this means for our unit (devtype 0x01)
Static baseline: 9 of 69 capabilities carry supported=1 before any ProductType refinement.
They are t_work_mode, t_power, t_temp, t_temp_type, t_sleep, f_temp_in, f_e_push,
f_e_intemp, f_e_incoiltemp. [PROVEN]
The rest, including fan speed, every swing field, humidity, purify, eco, 8heat and electricity, start off. The ProductType decision tree switches them on.
5.1 MEASURED: the A/C does answer ProductType [PROVEN on hardware, 2026-07-16]
Read live from unit A (ESP32 board, Matter firmware 1.0.7, features command on its diagnostic
console):
0x66/40 ProductType: REPLY PARSED — the A/C DOES answer.
cool_heat=1 ai=0 infinite_fan=0 power_save/eco=1 fan_mute/quiet=1
swing_dir_8=0 swing_follow=0 humidity=0 power_display=1 demand_resp=0
purify=1 <- MISLABELED: [0x0D]&0x80 is really ac_8heat -> 8heat = 1
q_display=0 <- MISLABELED: [0x0A]&0x08 is really ac_purify -> purify = 0
Re-measured 2026-07-18 on fw 1.0.10, after the mislabel fix + the extended [0x19]/[0x1A]
tier landed (issue #83). Same board, same A/C:
cool_heat=1 ai=0 infinite_fan=0 power_save/eco=1 fan_mute/quiet=1
swing_dir_8=0 swing_follow=0 humidity=0 power_display=1 demand_resp=0
purify=0 ([0x0A]&0x08) 8heat=1 ([0x0D]&0x80, 8C frost-guard)
q_display=1 ([0x1A]&0x40) enable_8heat=0 ([0x1A]&0x04) trans_102_64=0 ([0x19]&0x08)
raw 0x66/40 reply length = 45B
Three things this settles, all [PROVEN on hardware]:
- The reply is 45 bytes, so the extended tier is reachable on this unit (it needs > 39). This was previously unknown and was the main risk that the three new flags would be undecidable.
ac_q_display= 1: a capability this project could not see before. The old code’sq_displayfield actually heldac_purify(0), so the true Q-display capability read as absent for as long as it has been parsed. It is present on this unit.ac_trans_102_64= 0, so this unit does NOT resolve to the generic profile199(docs/10 §5a: that bit set →'199'). Narrows §6 Q2 to the100–129range.
Every base-tier flag is byte-identical to the 2026-07-16 capture above, which is the regression signal that the extended tier was added without disturbing the existing decode.
This resolves the §5 paradox. The static baseline leaves eco, quiet and fan at supported=0; the
ProductType reply enables them (power_save=1, fan_mute=1), and we drive them. The two-stage
mechanism behaves as the disassembly describes, so trust the supported gate.
⚠️ A
0means “not on this unit”, never “not on this model range”.ProductType answers per unit. These flags come from unit A. Unit B may differ (§5.2 measured it), and someone else’s Hisense will differ more. A capability reading
0here can read1on another unit:ac_ai,ac_swing_direction_8,ac_humidityandac_purifyall exist as stock verbs, so some Hisense unit ships them.Design rule: keep the code path, gate it at runtime. Never delete a capability because this bench unit lacks it. Read
hisense_get_features()and expose per unit. Deleting the path breaks compatibility for every owner whose unit has the feature, and this repo is meant to serve them.
What the flags settle (unit A only):
| flag | value | what to do |
|---|---|---|
ac_power_display |
1 | Display/LED supported. The highest-value #52 item is real. Worth the capture. |
ac_8heat |
1 | 8 °C frost-guard heat supported (#52 / I19). Worth the capture. |
ac_cool_heat |
1 | Heat-pump capable, consistent with the heat mode we drive. |
ac_ai |
0 | Absent here. Keep support on the roadmap, gate on the flag. Other units ship AI/smart mode. |
ac_swing_direction_8 |
0 | 8-position louvre absent here. Keep it gated, don’t delete it. Deprioritise the capture until someone has a unit reporting 1. (Basic v/h swing is a different field and works: poll shows hswing=1.) |
ac_humidity |
0 | No humidity capability here, so the unfed Humidity endpoint of the Matter firmware (described in a document that is in the tree at matter-final) was not a firmware gap on this unit. Gate the endpoint on the flag rather than delete it: a unit reporting 1 should get it. |
ac_purify |
0 | Absent here. Gate, don’t remove. |
5.2 MEASURED: unit B returns the SAME flags as unit A [PROVEN on hardware, 2026-07-18]
Read from unit B (stock module, AmebaZ2 Matter firmware 1.2.12, debug flavour) through the
diagnostic console added for issue #23. Until then the stock module had no remote diagnostic
surface, so this unit had never been measured.
cool_heat=1 ai=0 infinite_fan=0 power_save/eco=1 fan_mute/quiet=1
swing_dir_8=0 swing_follow=0 humidity=0 power_display=1 demand_resp=0
purify=0 8heat=1 q_display=1 enable_8heat=0 trans_102_64=0
raw 0x66/40 reply length = 45B
Byte-identical to unit A (§5.1), including the 45 B reply length and the extended
[0x19]/[0x1A] tier. This answers §6 Q1b.
Read it as two units of the same model agreeing, not as a general rule. The design rule in §5.1 is unchanged: ProductType answers per unit, so keep gating at runtime rather than assuming this result holds for someone else’s A/C. What it does buy is confidence that the decode is stable across units and that a flag reading 0 here is a property of the model range as fielded, not a per-board quirk.
Recommendations
- Gate at runtime on the ProductType flags, never on the static
supportedbaseline. §5.1 shows the baseline exists to be refined; read it alone and you would conclude eco, quiet and fan are unavailable while we drive all three.hisense_get_features()already exposes the refined answer, so gate there. It stays [INFERRED] that an unsupported capability is un-drivable on the wire rather than unadvertised, though no counter-example has turned up. - Runtime gating beats compile-time assumptions, because our bench units are not the fleet.
Every capability the stock firmware names is one some Hisense unit ships. Build the path, gate
the exposure, let the A/C decide. A feature this unit reports
0for is a deprioritised capture target, not a dead one. hisense_parse_featuresmislabeled two flags (doc 10 §5a). Itsq_displayreadac_purify, itspurifyreadac_8heat, and trueac_q_displaywent unparsed (added 2026-07-18 withac_enable_8heat+ac_trans_102_64, gated onext_valid). Renamed 2026-07-16: same byte reads, correct names.f_humidity(record 44),t_temp_typeandf_temp_ingave the telemetry gaps of the Matter firmware a firmware-grounded gate.- Supporting a dehumidifier (
0x15) is bounded work: TEMPLATE_B, 19 records, its own verbs (t_pump,t_anion) and profiles400/499.
6. Unproven, and how to settle each
The open rows are also listed in 14-open-questions.md with the rest of the project’s open points.
| # | Open question | How to settle |
|---|---|---|
0x66/40 poll? |
✅ features command on the Matter firmware’s diagnostic console (ESP32 fw ≥ 1.0.7). With the ESPHome firmware, read the capability_* sensors per unit. |
|
✅ The Matter firmware’s diagnostic console (features), AmebaZ2 fw 1.2.12. See §5.2. |
||
| 2 | Which profile (100–129/199) our unit resolves to. Narrowed 2026-07-18: NOT 199 (unit A’s ac_trans_102_64 reads 0 (§5.1), and that bit set is what selects '199'). So it is in 100–129. |
Log the 0x66/40 payload, or read 0x100096dc (getter 0x9b6f308c) on a stock dongle. |
desc (b0,b1)=command vs (b2,b3)=status frame. |
✅ Six status positions and sixteen command values reproduce what was confirmed on the bus. | |
| 4 | word0 b0/b1/b3 semantics. |
Unknown; no consumer found, but dataflow tracing was not exhaustive. |
| 5 | Whether the 17 unconditional enables are ever written back to 0. |
Looked straight-line in asm; not exhaustively traced. Disable writes exist in the 0x37 branch (+0x31a). |
| 6 | Full behaviour of STRUCT_A readers 0x9b6f1e3a, 0x9b6f8150. |
Not decompiled in full. |