01: How the components are built
For someone who is about to change the code under firmware/esphome/components/. It says which
file does what, which files may know about ESPHome, where a new decision has to go and which test
covers each part. Configuration options are in
firmware/esphome/README.md, and the frame format is in
reverse-engineering/docs/03-rs485-ac-protocol.md.
The three components
| Component | What it is | Runs on |
|---|---|---|
hisense_ac |
the A/C bus: codec, bus master, command tracking, the hub and the entities | every platform ESPHome has a uart: for |
w41h1_module |
what the stock AEH-W41H1 module needs on top: its flash layout and the two checks on it, the reboot workaround, the serial port restart | rtl87xx (RTL8720C family) only |
wifi_setup_mode |
the node’s hotspot and captive portal on demand | every platform with wifi: |
hisense_ac depends on nothing but ESPHome’s uart. The other two depend on it, and it builds
and works without them.
The layers of hisense_ac
needs nothing but <cstdint>: compiled and tested on a host
----------------------------------------------------------------------------------------
hisense_protocol.{h,cpp} frame constants, builders, parsers, the frame assembler,
| the debounce of the remote's setup request
|
+-- hisense_bus.{h,cpp} BusScheduler: one transaction at a time, DE timing,
| the reply window, re-sends of an unanswered frame,
| link up and link lost
|
+-- hisense_mapping.h climate values <-> bus values, the fan ladder,
| the setpoint, the rules that keep the command shadow
| in step with the unit
+-- hisense_special.h eco, turbo, quiet, sleep: the preset table,
| the ordered writes between two states, the queue
+-- hisense_confirm.h what a command asks, what the unit can be
expected to show, the verdict on a status
hisense_telemetry.h power estimate and its limit, when a sensor is published,
the outdoor temperature placeholder (includes nothing)
hisense_map.h includes the six above and declares nothing
hisense_commands.h CommandTracker: the command shadow, the pending command,
the special-mode queue, hold-off, counters.
Frames leave through the CommandSink interface
hisense_entities.h the climate request as a plan, a status frame as what the
climate entity, the switches and the select show
----------------------------------------------------------------------------------------
needs ESPHome: runs on a board or in the host end-to-end test
hisense_ac.{h,cpp} the hub. Implements BusIO (the UART and the DE pin),
BusListener (results of the bus) and CommandSink (frames out)
hisense_climate.* hisense_switch.* hisense_select.* the entity classes
__init__.py climate.py sensor.py binary_sensor.py switch.py select.py
text_sensor.py schemas and code generation
The arrows are #include lines and nothing else: a file sees only what is above it on its
branch. hisense_map.h used to hold the mapping, the presets, the confirm rules and the
telemetry in one file. It is now an include of the four parts, kept so that code and tests that
include it still build.
What each layer is responsible for
hisense_protocol knows bytes. A builder returns a finished frame with its checksum, a parser
returns a struct, and the FrameAssembler turns a byte stream into frames (start tag, the LEN
byte, doubled 0xF4, end tag). It keeps no time and sends nothing. Every byte value traces to a
bus capture, to the vendor module’s firmware or to a frame published by an earlier community
project. The three frames that were taken from pslawinski/esphome_airconintl (power on, power
off and the status poll) are named in the comment beside each and in
NOTICE.md. An unconfirmed value
is marked VERIFY.
hisense_bus knows time and order. Every exchange is a transaction, as on the vendor module:
flush the receiver, stamp the frame, raise DE, wait 5 ms, send, hold DE 25 ms past the last byte,
drop DE, listen up to 500 ms for a reply. At boot it sends the device type request (0x0A) up to
10 times, 500 ms apart, then the 0x07 frame. Each cycle of about 1 s carries the 0x1E
heartbeat, the queued commands that fit, and the 0x66 status poll. Five status polls in a row
without a reply mean the link is lost. The ProductType poll (0x66/40) goes out on the first
cycle that is heard and on every 60th after. A command frame with no reply is sent again in the
next cycle, ahead of the queue, three sends at most and only while the unit answers its status
poll. The scheduler has no task, no blocking wait, no logger and no global state. It reaches the
wire through BusIO and reports through BusListener, so a host test drives it against a
simulated unit.
hisense_mapping, hisense_special, hisense_confirm and hisense_telemetry are functions
from values to values. They hold the answers to questions such as: which bus mode is this climate
mode, which five writes take the unit from eco with a sleep profile to turbo, is a setpoint that
differs from the request the unit’s own behaviour in dry mode or a lost command, is -20 C a
reading or the placeholder.
hisense_commands is the one stateful part of the pure layer. CommandTracker holds what the
hub knows about commands it has sent or still has to send, and asks the functions above what to
do next. It has no clock (the caller passes the time in) and no bus (frames leave through
CommandSink). The hub implements that sink over the scheduler. A host test implements it over a
model of a unit that loses frames, so firmware/test/test_esphome_confirm.cpp runs the same
class the firmware runs.
hisense_entities is the entity side of the same idea. climate_plan() takes a climate request
as plain values and returns what to send and what to refuse. climate_view_from_status(),
switch_state_from_status() and sleep_profile_from_status() return what an entity shows. The
entity classes translate an ESPHome call into those structs and carry the result out.
The hub (hisense_ac.cpp) owns the UART, the DE pin, the log and the entity pointers. Its
loop() polls the scheduler first, asks ESPHome for a tight loop while DE timing is running, and
publishes a status that the scheduler parked during the poll. It makes no decisions of its own.
The rule: a decision is a pure function with a test
Anything that chooses between two outcomes belongs in the pure layer, with a host test: is this status my own echo, should the shadow follow the unit, did this command take, which frame goes next, may this request be sent before the first status. The hub and the entity classes only move values between ESPHome and those functions.
The rule comes from a defect that escaped review. On an early build a status readback was written back as a command, which produced another readback, three to six times per user action. It lived in a layer that no host test could reach and was found on a unit.
The reason it still holds, now that the hub can be run on a host: a case in the host end-to-end test costs seconds, because the hub’s timers are real (a 1 s bus cycle, a 4 s hold-off, 10 s between special-mode writes). The same decision as a pure function costs microseconds and can be tested over its whole input domain.
Two mechanical checks keep the boundary. run_tests.sh compiles every pure file with g++ and
no ESPHome on the include path, so an ESPHome include breaks the build of a test.
hisense_climate.cpp holds a static_assert for each climate constant that hisense_mapping.h
mirrors as a plain integer, so a renumbered enum upstream is a compile error.
Which test covers which file
| File | Host tests | Also |
|---|---|---|
hisense_protocol.* |
test_esphome_codec_parity.cpp, test_esphome_edge_cases.cpp, test_esphome_codec_gaps.cpp |
every fuzz harness |
hisense_bus.* |
test_esphome_bus.cpp, test_esphome_bus_stress.cpp, test_esphome_setup_request.cpp, test_esphome_setup_edges.cpp |
fuzz_bus.cpp |
hisense_mapping.h, hisense_special.h, hisense_confirm.h, hisense_telemetry.h |
test_esphome_edge_cases.cpp, test_esphome_confirm.cpp, test_esphome_map_gaps.cpp, test_esphome_codec_parity.cpp |
|
hisense_commands.h |
test_esphome_confirm.cpp (second half) |
|
hisense_entities.h |
test_esphome_entities.cpp |
|
the Python tables (binary_sensor.py, climate.py) |
test_esphome_tables.py, test_esphome_config_matrix.py |
|
hisense_ac.*, the entity classes |
none on the pure side | firmware/test/host_e2e/ |
w41h1_module/w41h1_uart_policy.h |
test_esphome_uart_policy.cpp |
|
wifi_setup_mode/setup_mode_logic.h |
test_esphome_setup_mode.cpp |
What each test layer proves and what it cannot is in 04-qa-strategy.md.
The codec and the frozen reference driver
firmware/test/reference-driver/ is the project’s first bus driver. It ran on real units inside
the retired Matter firmwares and is frozen. The component carries its own codec because ESPHome
cannot accept a component that depends on files outside its directory, and because the old
driver’s contract (a FreeRTOS task, the Realtek HAL) does not fit ESPHome’s loop.
test_esphome_codec_parity.cpp runs every builder and parser of both over the same inputs (the
full command cartesian product, exhaustive small domains, seeded random frames) and compares the
results byte for byte and field by field. That test is in run_tests.sh, so CI fails on a
difference. A protocol fact that both carry is changed on both sides in one commit.
The reference is the side of that comparison that ran on hardware for months. It is not a
specification and not a second firmware: it has no confirm and retry, it sends a command once,
and it does not know the facts that were learned after it was frozen. What it is and is not is
in firmware/test/reference-driver/README.md.
Commands: from a tap in Home Assistant to the wire and back
- The climate entity turns the call into a
ClimateRequestand asksclimate_plan(). The plan says which fields change, whether the request is a power-off, a preset, or refused (for example a fan speed while turbo holds the fan, or anything but “off” before the first status). - The hub hands it to the tracker (
send_user_command,send_user_power_off,request_preset,enqueue_special). The tracker updates the command shadow, builds the frame through the sink and remembers the request as aPendingCommand. - Power, mode, setpoint, fan and swing ride one combined
0x65frame built from the whole shadow. Eco and turbo share byte 33 of that frame. Quiet and the sleep profile are frames of their own. Special-mode writes go through a queue that sends one every 10 s (SPECIAL_SETTLE_MS), because the unit ignores one that arrives within about 8 s of the previous. - For 4 s after a user command (
CONFIRM_SETTLE_MS, 3.9 s) a status that still shows the old state does not move the entity. That is the hold-off. - The first status after the hold-off is judged by
confirm_decision(): taken, sent again (twice at most for the main fields, once for special modes), changed from elsewhere (someone used the remote, the command is dropped), or given up with a warning and a count infailed_commands. - A newer command replaces the pending one. A lost link drops everything that is open.
intent_expected_fields() is where the unit’s own behaviour is written down, so it is not taken
for a lost command: the unit keeps its own setpoint in auto, dry and fan-only, turbo forces cool
at 16 C on high fan, quiet and the sleep profiles hold the fan, and a unit that is off ignores
everything except power.
A mode request to a unit that is off is one frame with the power-on bits in byte 18 (0x5C cool,
0x3C heat), as the stock module sends it. If the unit is still off at the check, the re-send is
the older pair of frames, power on and then the mode (resend_frames()).
Confirm and retry exists because of a measurement. Before it, hardware runs on two units failed
about one check in 52, each time with unanswered_commands at exactly 1: the unit missed one
frame and nothing sent it again. The stock module verifies the reply to every command and packs
power and mode into one frame (reverse-engineering/docs/10-stock-fw-init-and-comms.md,
sections 4.6 and 5b-2).
Special modes and presets
The interlocks between eco, turbo, quiet and sleep were measured on a live A/C (by the Home
Assistant wrapper the retired Matter builds used) and are written down in hisense_special.h:
- which combinations exist: eco pairs with quiet or with any sleep profile, quiet never with
sleep, turbo with nothing (the
PRESETStable); - how a state in between is named: the exact row, else turbo, eco, quiet, sleep
(
preset_detect()); - the order of the writes: a sleep clear first, then the byte 33 and mute clears, then the sets,
and a sleep profile last, sent again after any byte 33 write because eco after sleep drops the
profile (
special_plan(),preset_plan()); - a fan speed is refused while turbo, quiet or sleep holds the fan (
fan_request_allowed()).
The climate entity keeps showing the requested preset until the queue has drained and the hold-off has passed, so a climate group that mirrors it never copies a state in between. Every preset reading back as itself from each starting preset has not been checked on a unit.
The reply to a command
The reply to a command is read and classified (command_reply_verdict() in hisense_bus.h:
echo of the first two payload bytes, then 1). The layout comes from the vendor firmware and is
marked VERIFY. One stock module logged Command answered (class 0x65, echo, ack) on
2026-10-10, which agrees with it. The verdict is logged at DEBUG and nothing acts on it: the
status check is what decides.
What an operator sees of all this, and the three counters, is in
docs/guide/Entities-and-Diagnostics.md.
Telemetry rules
Sensor values are published when they change and at least once a minute
(telemetry_publish_due(), TELEMETRY_REFRESH_MS).
Power is 4.15 W * raw^2 of status byte 55, with a power factor of 0.95 folded in. The
calibration is in 09-energy-monitoring.md. An estimate above the
hub’s max_power (5 kW unless configured) is published as unknown, and the current derived from
it too (power_reading()): the glue passes the limit in and publishes what comes back.
The outdoor and coil placeholder. Right after mains power returns, the unit fills status
bytes 44 and 45 with 0xEC, which decodes to -20 C, until it has real readings. Seen on one unit
on 2026-10-10: both sensors at exactly -20, then 35 and 26 C the same afternoon. The component
publishes that pair as unknown (outdoor_temps_measured() in hisense_telemetry.h, marked
VERIFY because it rests on one observation). The rule is narrow on purpose, since -20 C is also
a temperature: both bytes must be 0xEC in the same frame, and only until a real pair has been
seen since the bus link came up. After that, -20 is published as a reading. What it still hides
is a unit at rest in exactly -20 C weather, from the moment the node starts until either reading
moves. How long the placeholder lasts, and whether it can return while the unit stays powered,
was not measured.
Portability
The component uses ESPHome’s own API and nothing below it: uart::UARTDevice for the bytes, a
GPIOPin for DE, the loop time for the clock, ESPHome’s log macros, and ESPHome’s preferences
for the beeper switch. It calls neither ESP-IDF nor FreeRTOS, and its format strings do not
assume an integer width. It compiles without a warning of its own for ESP32, ESP32-C3, ESP8266,
RP2040 and the RTL8710C.
Two things differ between platforms. Both are covered in test_esphome_bus.cpp.
- A UART write that blocks. ESP-IDF queues the bytes and returns. LibreTiny waits on the transmit FIFO, so a 50 byte command frame holds the loop for up to about 52 ms, the frame’s time on the wire. The DE release is counted from before the write, so it falls 25 ms after the last byte either way.
- A coarse loop. The scheduler stays correct when it is polled only every 16 ms. DE is then released up to one loop period late, which is why the hub asks for a tight loop while DE is high. How short the delay is on a platform other than ESP32 and the RTL8710C has to be measured there.
Every deadline is an elapsed-time or signed-difference comparison on a 32 bit millisecond clock, tested through the 49.7 day wrap.
One platform fix lives in the hub. On LibreTiny, HisenseAC::bus_read() reads each byte under
ESPHome’s InterruptLock, because LibreTiny’s receive buffer loses count of a byte when its
interrupt lands inside a read. The measurements are in
15-esphome-path.md.
w41h1_module
| File | Does |
|---|---|
__init__.py |
copies the named layout-*.json into the build directory and hands it to PlatformIO as custom_board, defines W41H1_LAYOUT_<name>, adds the two linker flags of the reboot workaround |
layout-factory.json, layout-sdk.json, layout-native.json |
the slot and settings addresses of each flash layout |
w41h1_slots.h |
fails the build when the image’s addresses are not those of the named layout, and holds the run-time comparison with the bootloader’s partition table |
w41h1_uart_policy.h |
when the bus port may be restarted. Pure, host-tested |
w41h1_module.{h,cpp} |
runs the layout check at boot, switches the update server off on a mismatch, looks at the hub’s link state once a second and restarts the port when the policy says so, publishes the two diagnostics every update_interval |
Why each exists, with what was measured on a module, is in
15-esphome-path.md.
wifi_setup_mode
The component is split the way the hub is. Every decision is a pure function in
setup_mode_logic.h: a state machine step(state, event, inputs, config), the rule for which
credentials are “the previous ones”, the rule for a journal found at boot, and what a status LED
shows. wifi_setup_mode.cpp does the calls that the returned actions name.
firmware/test/test_esphome_setup_mode.cpp runs every state against every event and every input,
and every deadline across the wrap of the millisecond clock. packages/node.yaml holds the
wiring only. How the mode behaves for a user is in
firmware/esphome/README.md.
Setup mode has run once on a unit, in part. On 2026-10-13 the gesture opened the hotspot on an ESP32-C3 and the timeout put the node back on its network. The portal, a second gesture, the LED and the stock module have run in host tests and compiles only.
The design decisions:
- The request on the bus. The gesture is a bit in byte 17 of the unit’s reply to the
0x1Eheartbeat.0x20alone is taken from one reply (the bench unit sends one per gesture). Anything with0x08needs three replies in a row.0x20is ignored for 10 s after a request and after the indication goes off (#69). The component is stricter than the reference driver in four places, because here the request is a toggle that takes the node off the network. The reply’s checksum must be good.0x08is ignored while the node’s own provisioning flag (the same bit, outbound) is on the wire, and afterwards until one reply comes without it.0x08already set at boot is ignored, or a unit that keeps asking would reopen setup after every restart. The lockout also starts at boot and is kept as elapsed time, so it cannot come back to life when the clock wraps. Tests:test_esphome_setup_request.cppandtest_esphome_setup_edges.cpp. - The hub parks a request during the bus poll and runs the automations from
loop().set_setup_indicator()only changes what the next heartbeat carries. - Opening the hotspot on demand. ESPHome 2026.7 has no call for it:
setup_ap_config_()is protected, andloop()opens the hotspot only when no network has been connected forap_timeout. Setup mode gets there with public calls only:disable(),set_ap_timeout(1),enable(), thenset_sta()with a network that does not exist, all in memory. That is the state ESPHome’s fallback is written for, so the portal, its scan and the retry pacing are stock behaviour.disable(),set_sta(),enable()is also the sequence ESPHome’s ownwifi.configureaction uses. It only holds when Wi-Fi scans before it connects. Withfast_connect, or a network marked hidden,start()connects at once, before the placeholder is in place. The code generation reads thewifi:configuration, warns, and the node then refuses the request with a log line and stays on its network. - Two designs that were turned down. Persisting a flag and clearing the station list before
Wi-Fi starts makes the portal save under a preference key the next normal boot does not read
(
start()derives the key fromhas_sta()). Reaching into the Wi-Fi component for the protected call would break on an update of ESPHome. - Leaving is a restart, by timeout, by a second gesture, or once a network entered in the portal has connected. The Wi-Fi component never clears its “hotspot was set up” flag, so within one boot the hotspot opens once. A restart also restores the station list and the timeout from the build and from flash, with nothing to undo by hand. The exception is a request made while the fallback hotspot is already open: nothing was parked, so there is nothing a restart would restore. That case only lights “77” and ends without one.
- What cannot strand a node. The portal writes what the user enters to ESPHome’s own saved
Wi-Fi settings. On the way in, the component writes the SSID and password to go back to into a
journal (a preference of its own, with the build’s configuration hash). On every way out it
clears the journal, after writing those credentials back if the portal’s entry has not
connected: at the timeout, on a second gesture, and from
on_shutdown(), which ESPHome runs forApp.reboot()andApp.safe_reboot()alike, so also for the API component’s restart after 15 minutes without a client. A power cut runs no hook. Then the journal is still there at the next boot, andsetup(), which runs after Wi-Fi has started and loaded its saved network, writes the previous one back when the two differ. If the hotspot does not open within 30 s the node restarts. - Which credentials are the previous ones. The station’s current entry cannot say:
get_sta()is empty while Wi-Fi scans. The component remembers the network at each connection and the one loaded from flash at boot, and falls back to the first network of the build, which the code generation hands it. - A network entered late. A connection attempt takes up to 46 s and the pause before the next one, with the hotspot up, is 30 s. So an entry moves the deadline to at least 90 s away.
- The clocks. Deadlines are compared as a signed difference, the timeout is limited to 24 h,
and the look at Wi-Fi runs four times a second from
loop(). - The status LED.
status_display()decides what a LED shows, for both LED packages, so the two cannot disagree.
Still to be seen on a unit: which bit each unit raises, that “77” lights and clears, that the
hotspot opens on ESP-IDF and on LibreTiny after disable() and enable(), that the journal
survives a power cut on each chip, and that a restart on the stock module out of setup mode comes
back (LibreTiny issue 396).
Code style
C++17 in the component, with ESPHome’s .clang-format and .clang-tidy. Every protocol byte value has a comment that traces it to a capture, to the vendor firmware or to
its published source. firmware/scripts/cpp-lint.sh check is the gate
(firmware/scripts/README.md). hisense_ac also passes ESPHome’s own
gates on the pinned release (esphome-upstream-check.sh), because it is meant to be submitted
upstream one day. w41h1_module and wifi_setup_mode are held to the C++ rules only.