ESPHome firmware for ESP32 that emulates Carrier/Bryant/ICP devices on the "ABCD" RS485 bus. System Access Module(SAM) emulation gives Home Assistant native control HVAC systems and Damper Control Module emulation gives the thermostat access to any physical hardware that esphome can actuate.
No cloud or Carrier API required, just a serial bus and a microcontroller.
Disclaimer: This firmware was developed by reverse-engineering a proprietary protocol. Everything described below has been confirmed working in the author's system. Your mileage may vary with different equipment, firmware versions, or bus configurations.
InfinitESP speaks the Carrier ABCD bus protocol and registers as a SAM (address 0x92). The thermostat sees a valid SAM on the bus and InfinitESP creates a climate device in Home Assistant to control each active zone:
| Entity Type | What You Get |
|---|---|
| Climate | Per-zone thermostat with heat/cool/auto/off modes, dual heat+cool setpoints, fan control, and preset support (per schedule, home, away, sleep, wake, hold timer, hold indefinitely) |
| Covers | Per-zone damper position (0–100%) from the zone controller(either an emulated or a real physical zone controller) |
| Sensors | Zone temperature, zone humidity, outdoor air temp, blower RPM, airflow CFM, compressor RPM, ODU demand/stage/modulation, expansion valve position, superheat/subcooling targets & actuals, ODU temperatures (outdoor/coil/suction/discharge) plus suction superheat, vacation min/max temps |
| Binary Sensors | Bus online/offline status, compressor running, electric heat active, active fault, per-zone occupancy |
| Selects | System mode (heat/cool/auto/off/emergency heat), per-zone fan speed (auto/low/med/high) |
| Text Sensors | Zone names, hold state, thermostat WiFi SSID/hostname/MAC, proxy server, dealer info, comfort profile dump |
Any ESP32 with an RS485 transceiver works. ESP8266 is untested. The author's setup uses the Waveshare ESP32-S3-Relay-6CH board.
The Waveshare board was chosen as a reference design for its case, onboard RS485 interface, and 6 relay outputs. The relays open the door to emulating other devices beyond the SAM, notably the NIM (Network Interface Module, SYSTXCCNIM01) the Damper Control Module (SYSTXCC4ZC01) is already part of InfinitESP. Damper actuation works through a trigger callback: each zone's cover fires on_change whenever the thermostat commands a new damper position, handing the new position to whatever relay or motor driver your hardware uses (see the Covers examples). When emulating the zone controller, each zone also takes a temperature sensor so the thermostat sees a room reading, falling back to the zone 1 temperature when no sensor is configured (see Zone Controller). The relay GPIOs are exposed in the example YAML config but are not part of the core SAM emulation. The firmware is hardware-agnostic. It just needs a uart::UARTComponent. The RS485 transceiver, ESP32 variant, and relay hardware are all irrelevant to the protocol engine.
Generic ESP32 dev boards with a separate RS485 transceiver module should also work.
Note: The Waveshare board's RS485 auto-direction circuit has a time constant too short for reliable operation at 38400 baud. The circuit assumes the UART idle state (HIGH) means "stop transmitting," so runs of consecutive
1bits cause it to stop driving the bus mid-byte. On an insufficiently-biased bus, the line voltage collapses and the receiver reads garbage.InfinitESP ships with an experimental
uart_rmtxUART component that drives the RS485 interface through the ESP32's Remote Control Transceiver (RMT) peripheral instead of the hardware UART. It emits a sub-bit line code that keeps the transceiver's auto-direction DE line primed during long constant-level runs, avoiding the dropout with no hardware modification. Bench-validated against a single physical SAM (100% reply rate on 160 short-frame queries, zero CRC errors). See its README for the mechanism, configuration, and current validation status.A live Carrier ABCD bus may tolerate the flaw without any fix, depending on bus topology. If
uart_rmtxdoes not resolve it, any other fix requires adding or modifying hardware (for example, bypassing the onboard transceiver with a separate RS485 module).
If you have hardware such as a Carrier NIM (SYSTXCCNIM01), Damper Control Module (SYSTXCC4ZC01)(implemented, but new logs are always good validation), or any other interesting communicating hardware (remote room sensors, zone controllers, etc.) on your ABCD bus and would be willing to capture raw bus traffic, please open an issue. Understanding and emulating these devices requires protocol traces that can only come from real hardware. Even a few minutes of logs would be valuable. See Reporting Issues for how to collect them. A REPORT? snapshot helps too, but full protocol logs are best for emulation work since they show the timing and framing that static register dumps miss. Share captures via a GitHub discussion on the infinitude project or by contacting the author directly.
Carrier ABCD Bus ESP32 Board
───────────────── ───────────────
A (data+) ──────────── RS485 A
B (data-) ──────────── RS485 B
GND ──────────── GND
The Waveshare board has an auto-direction RS485 transceiver. No DE/RE pin to manage. If using a generic transceiver (e.g. MAX485), tie DE and RE high for transmit or manage them with a GPIO.
Carrier ABCD bus (RS485, 38400 baud, 8N1)
│
├── 0x20 Thermostat (Infinity Touch / Evolution Connex)
├── 0x40 Indoor Unit / Furnace / Air Handler
├── 0x50 Outdoor Unit / Heat Pump
├── 0x60 Zone Controller (Damper Control Module, SYSTXCC4ZC01)
├── 0x61 Zone Controller (second board, zones 5-8 on large systems)
└── 0x92 InfinitESP (SAM emulator)
InfinitESP registers as address 0x92, the same address used by physical SAM modules and other SAM emulators. Only one device can be at this address on the bus. If you have a physical SAM installed, disconnect or remove it. Likewise, do not run InfinitESP alongside infinitude (if its SAM emulation is enabled) or infinitive, which also occupy 0x92. The same single-occupant rule applies to the zone-controller address 0x60: if you already have a physical Damper Control Module on the bus, do not also enable zone_controller_address: 0x60. Either let InfinitESP passively monitor the real one, or emulate it after removing the hardware.
- ESPHome 2026.1 or newer
- An ESP32 with an RS485 transceiver connected to your Carrier ABCD bus
git clone https://github.com/nebulous/infinitesp.git
cd infinitespCreate secrets.yaml with your WiFi credentials:
ssid: "YourWiFiNetwork"
password: "YourWiFiPassword"Review infinitesp.yaml and remove any zones you don't have. The default config defines all 8 zones; unused ones stay dormant in Home Assistant.
# Hold BOOT button while plugging in USB, then release
esphome compile infinitesp.yaml
esphome upload infinitesp.yaml --device /dev/ttyACM0esphome upload infinitesp.yaml --device infinitesp.local- Power off the ESP32
- Connect RS485 A and B to the Carrier ABCD bus
- Power on the ESP32
- Check logs for
Parsed framemessages to confirm bus traffic is being decoded:
esphome logs infinitesp.yaml --device infinitesp.localThe device appears automatically via ESPHome's native API integration. All entities populate within seconds of the bus coming online.
InfinitESP supports three UART transport methods. Switch between them by changing uart_id on the infinitesp: block in your YAML:
uart:
id: bus_uart
tx_pin: GPIO17
rx_pin: GPIO18
baud_rate: 38400
data_bits: 8
parity: NONE
stop_bits: 1
rx_buffer_size: 4096
infinitesp:
uart_id: bus_uarttinyusb:
usb_cdc_acm:
rx_buffer_size: 4096
tx_buffer_size: 1024
interfaces:
- id: usb_bus_0
infinitesp:
uart_id: usb_bus_0Connect a USB cable from the ESP32 to a serial-to-TCP bridge (e.g. socat) on a host machine that's wired to the RS485 bus. Useful when the ESP32 isn't physically near the HVAC equipment.
uart_tcp_client:
id: tcp_bus_0
host: "192.168.1.23"
port: 23
rx_buffer_size: 4096
infinitesp:
uart_id: tcp_bus_0The TCP and USB CDC transports are provided by esphome-uart-link, a companion ESPHome component that implements UART-over-TCP and UART-over-USB transports. InfinitESP neither knows nor cares which transport backs the UART. They're all interchangeable.
Expose the raw ABCD serial bus over TCP so external tools (Infinitude, Wireshark dissector, custom scripts) can observe the bus in real time. This is useful for protocol analysis and for running Infinitude alongside InfinitESP.
# Add these alongside the normal infinitesp config.
# IMPORTANT: infinitesp must read from the bridge, not the hardware UART.
# Change uart_id on the infinitesp: block from bus_uart to bus_bridge.
uart_tcp_server:
- id: raw_bus_tap
port: 4242
max_clients: 2
client_mode: fanout
idle_timeout: 30s
uart_bridge:
id: bus_bridge
uarts:
- bus_uart # hardware RS485 (bidirectional)
- uart: raw_bus_tap
flow: from_bridge # monitor-only; use 'both' to allow TCP client writesConnect with nc infinitesp.local 4242 to see raw hex traffic. The from_bridge flow direction means TCP clients can only observe, not inject bytes onto the bus. This is the safer default. Change to both for bidirectional access (e.g., running Infinitude through the tap).
Warning: When using
flow: both, TCP clients share the bus with InfinitESP. The ABCD bus has no arbitration. Simultaneous transmits will collide. Only use bidirectional mode if your tool understands the protocol timing.
infinitesp:
id: infinitesp_hub
uart_id: bus_uart
sam_address: 0x92 # SAM address. 0x93 = FakeSAM test mode, 0 = disabled (passive monitor)
zone_controller_address: 0x60 # Zone Controller address. 0 = no emulation (passive monitor of a real ZC if present)
# Optional: temperature unit detection (default: auto)
# temperature_unit: auto # read from bus, or force F / C
# Optional status LED (mutually exclusive):
status_light_id: rgb_led # Reference an existing ESPHome light (RGB supported)
# status_led_pin: GPIO2 # Or just a GPIO pin for a simple LEDCarrier zoned systems put a Damper Control Module (SYSTXCC4ZC01) on the bus at address 0x60. InfinitESP can either emulate one or passively monitor a real one:
zone_controller_address: 0x60: InfinitESP emulates the zone controller. The thermostat talks to InfinitESP as if it were the hardware. This lets you inject temperatures from external sensors (below). Do not enable this if a physical zone controller is already on the bus; the two would collide at0x60.
Warning — emulation requires real dampers, or it halts conditioning. When the thermostat commissions a zone it runs an automated duct checkout: it commands different damper positions and blower speeds, then reads the indoor unit's fan telemetry to build a damper-position-to-airflow map. That map only converges if closing a damper actually raises duct static pressure, which needs real physical dampers and ductwork. An emulated zone controller reports damper position on the bus but produces no airflow change unless you have wired real dampers to the cover's
on_changetrigger to actually open and close them. With no dampers on the trigger, the checkout never validates. It runs both at install and as a recurring daily checkout, and it stops all heating and cooling while it runs, potentially indefinitely until resolved. This is a property of the emulation, not a bug InfinitESP can fix. Emulate a zone controller only on a system with real dampers and ductwork installed (and wire them toon_changeso they actually move). To add a temperature sensor to an existing zoned system, leavezone_controller_address: 0and let InfinitESP passively monitor the real hardware.
zone_controller_address: 0(default): no emulation. InfinitESP passively snoops the thermostat↔zone-controller traffic and still reports damper positions and per-zone conditioning state from the real hardware. This is the mode to use if you already have a physical Damper Control Module installed.
In either case the damper cover entities (see Covers) and per-zone climate heating/cooling action reflect the real damper state.
Injecting external zone temperature sensors (emulation only).
When emulating the zone controller, the thermostat expects each zone to report a temperature. InfinitESP can source these from any ESPHome sensor (a local Dallas 1-Wire or DHT/BME wired to the ESP32, or a Home Assistant entity via the homeassistant platform) instead of letting the thermostat read its own remote sensors. Configure one block per zone (zones 2–8; zone 1 is reported by the thermostat itself):
Systems with more than four zones use a second controller at 0x61. InfinitESP emulates both the primary (0x60, zones 1–4) and secondary (0x61, zones 5–8) when zone_controller_address is set. Zone 5 maps to local zone 1 on the secondary controller, zone 6 to local 2, and so on. A thermostat commissioned for only four zones never polls 0x61, so the secondary stays inert unless you wire sensors into zc_zone_5 through zc_zone_8.
# Option A: local Dallas 1-Wire sensor wired to the ESP32
dallas:
- pin: GPIO4
sensor:
- platform: dallas
address: 0x1c000003ebee # unique ROM address; run without this to log discovered addresses
id: upstairs_temp
internal: true
# Option B: a temperature entity already in Home Assistant
# - platform: homeassistant
# id: upstairs_temp
# entity_id: sensor.upstairs_temperature
# internal: true
infinitesp:
zone_controller_address: 0x60
zc_zone_2:
temperature_sensor: upstairs_temp
staleness_timeout: 120 # seconds; fall back to bus/thermostat value if no update
sensor_unit: F # REQUIRED. "C" or "F": the unit your sensor publishes in
zc_zone_3:
temperature_sensor: ...
zc_zone_4:
temperature_sensor: ...
# Zones 5-8 live on a second emulated controller at 0x61 (only if the
# thermostat is commissioned for more than four zones).
# zc_zone_5:
# temperature_sensor: office_temp
# sensor_unit: FOnly one sensor: block is needed per zone. InfinitESP reads whichever id you wire in. internal: true keeps the raw sensor out of Home Assistant since its value surfaces through the zone controller emulation. If temperature_sensor is omitted, InfinitESP reports whatever the bus last reported for that zone. staleness_timeout (default 120s) controls how long to keep using the external value before falling back.
Set sensor_unit explicitly for every zone. It declares the unit your sensor publishes, not the thermostat's display setting. For example, F for a sensor that emits °F, C for one that emits °C. The two are unrelated: flipping the thermostat between °F/°C display does not change what your sensor publishes. ESPHome emits a config warning for any zone where sensor_unit is missing. The default is the system unit; a wrong guess causes silent mis-conversion (see below).
Why it matters / sanity check. InfinitESP rejects any injected reading that converts to values outside of the 40-99 °F band (the indoor range the thermostat itself uses for setpoints) and falls back to the primary zone-1 ambient value until a plausible reading returns. A wrong sensor_unit always lands outside this band. For example, a °F sensor treated as °C reports a 70 °F room as ~160 °F, so the zone reads zone-1 ambient instead of garbage. The range check is a safety net, not a correctness test: after configuring, check the zone temperatures on your thermostat and confirm they match the room. A reading that silently fell back to zone-1 because of a mis-set unit looks "fine" (a real temperature, just not that zone's), so visual confirmation is the only reliable validation.
LAT/HPT thermistor ports (emulation only). The zone board also has leaving-air-temperature (LAT) and HPT thermistor ports, reported as TLV entries in the same register (ids 0x14 and 0x1C). zc_lat and zc_hpt feed external sensors into those ports. Unlike zone temperatures, supply-air temp has no sane ambient fallback: when the fed sensor goes stale (past staleness_timeout), the entry reverts to not-installed so the thermostat stops seeing it rather than reading a bogus value. These sensors are disabled by default in Home Assistant; enable them if your board reports them.
This sensor-injection feature requires emulation. With a passive (physical) zone controller, the real hardware owns temperature reporting and these blocks have no effect.
The Carrier ABCD bus encodes temperatures differently depending on the thermostat's display unit setting (°F or °C). InfinitESP reads the active unit from the bus and applies it automatically.
The unit flag lives at data offset 1 of every table-0x3B register the thermostat serves (state, zones, accessories, dealer): 0x00 = English/°F, 0x01 = Metric/°C. In auto mode InfinitESP reads that flag:
- When emulating the SAM, the thermostat pushes its dealer register (3B06) to InfinitESP on every poll cycle. InfinitESP reads the flag from it.
- When not emulating the SAM, InfinitESP polls the thermostat's accessories register (3B05) and reads the flag from the reply.
Until the first authoritative read lands (a few seconds after boot), InfinitESP falls back to a heuristic: any active zone temperature byte ≤ 50 means °C. No plausible HVAC zone exceeds 50°C (122°F).
The temperature_unit option forces a unit instead of reading it:
| Value | Behavior |
|---|---|
auto (default) |
Read the unit from the bus (3B06 when emulating the SAM, else a polled 3B05), with the zone-temperature heuristic as a boot-time fallback. |
F |
Force Fahrenheit. |
C |
Force Celsius. |
Most users never change this from auto. The explicit options exist for edge cases or debugging.
All temperature sensors publish in °C with device_class: temperature, so Home Assistant converts to the user's preferred display unit.
The status LED indicates system health:
| Status | RGB Color | Simple LED | Meaning |
|---|---|---|---|
| Bus not online | Yellow blink (1s) | Slow blink (1s) | Bus not yet established |
| Bus online, no WiFi | Blue blink (500ms) | Fast blink (250ms) | Bus good, WiFi connecting |
| Both online | Solid green | Solid on | Everything working |
climate:
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Living Room"
zone: 1 # 1–8, matches Carrier zone numberSupports heat, cool, heat_cool (auto), and off modes. Temperature range: 40-99°F in whole-degree steps. Fan modes: auto, low, medium, high. Presets: per schedule, home, away, sleep, wake, hold timer, hold indefinitely.
Each climate entity's action (heating/cooling/idle) is gated on its zone's damper state when a zone controller is present (emulated or physical), so on a zoned system only the zones actually receiving conditioned air report heating/cooling.
cover:
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Living Room Damper"
zone: 1 # 1–8, matches Carrier zone number
device_class: damperReports each zone's commanded damper position (0–100%) from register 0308 (mirrored to 0319). The thermostat uses 16 steps (0x00–0x0F); cover position = step / 15.0. Works whether you emulate the zone controller or passively monitor a physical one. Without any zone controller on the bus, these covers never publish (they report unknown).
The cover is a position reporter, not a bus actuator: it never writes the ABCD bus. Its job is to expose damper position to Home Assistant and to fire a trigger when that position is commanded to change.
Add an on_change trigger to run automations whenever the cover's reported position doesn't match the commanded position (from the thermostat bus or from Home Assistant). It fires once per real step change (periodic bus re-asserts and the 0308/0319 mirror are de-duped, comparisons are in the protocol's 16-step space so re-commanding the current step is a no-op) and passes the new position as pos (a float 0.0–1.0):
cover:
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Living Room Damper"
zone: 1
device_class: damper
on_change:
then:
- logger.log:
format: "Zone 1 damper -> %.0f%%"
args: [ 'pos * 100.0' ]Because the cover never writes the bus, what the trigger's actions do with pos is entirely up to you. on_change is a standard ESPHome trigger, so it takes any standard ESPHome action (logger.log, switch.turn_on, homeassistant.event, etc.). For example, to drive a binary damper relay (ON when the damper is commanded open at any step, OFF when closed), use switch.control with a templated boolean:
cover:
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Living Room Damper"
zone: 1
device_class: damper
on_change:
then:
- switch.control:
id: living_room_damper_relay
state: !lambda 'return pos > 0.0f;'pos * 15.0 gives the protocol's 16 damper steps if you need proportional control; you handle any relay timing in the lambda. Home Assistant-set positions are transient: the cover acknowledges the command immediately, but the next bus-commanded change overwrites the reported position with what the thermostat actually commands. InfinitESP does not ship a built-in damper/relay driver. Any real-world actuation lives in your on_change actions.
sensor:
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Living Room Temperature"
type: temperature # temperature, humidity, outdoor_temperature
zone: 1 # required for temperature and humidity
# Diagnostic sensors (no zone required)
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Blower RPM"
type: blower_rpm
# All diagnostic sensor types:
# blower_rpm, airflow_cfm, compressor_rpm (actual), target_compressor_rpm
# (`compressor_rpm` = measured RPM [2..3]; `target_compressor_rpm` = commanded [0..1].)
# compressor_frequency, odu_expansion_valve, odu_commanded_stage, odu_stage, odu_mode, odu_line_voltage
# odu_outdoor_temp, odu_coil_temp, odu_suction_temp,
# odu_suction_superheat, odu_indoor_ambient, odu_discharge_temp
# odu_float_1 (superheat target), odu_float_2 (superheat actual),
# odu_float_3 (subcooling target), odu_float_4 (subcooling actual),
# odu_float_5, odu_float_6
# vacation_min_temp, vacation_max_temp
#
# Zone controller sensors (need zone_controller on the bus; zc_lat/zc_hpt are
# the board's LAT/HPT thermistor ports, disabled by default):
# zc_zone_temperature (requires zone:), zc_lat, zc_hpt
#
# Cycle counters and runtime hours (may not be available on all systems):
# idu_low_heat_cycles, idu_high_heat_cycles, idu_med_heat_cycles,
# idu_blower_cycles, idu_poweron_cycles
# idu_low_heat_hours, idu_high_heat_hours, idu_med_heat_hours,
# idu_blower_hours, idu_poweron_hours
# odu_heat_cycles, odu_cool_cycles, odu_defrost_cycles, odu_poweron_cycles
# odu_heat_hours, odu_cool_hours, odu_defrost_hours, odu_poweron_hoursbinary_sensor:
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Bus Status"
type: bus_status # bus_status, compressor_running, electric_heat, active_fault
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Active Fault"
type: active_fault # ON while any thermostat fault (0x4202) is currently active
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Zone 1 Occupancy"
type: occupancy # per-zone: occupied vs the thermostat's away state (not motion)
zone: 1select:
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "System Mode"
type: system_mode # system_mode (global)
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Zone 1 Fan Mode"
type: fan_mode # fan_mode (per-zone, requires zone:)
zone: 1text_sensor:
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Zone 1 Name"
type: zone_name # zone_name (per-zone)
zone: 1
# Per-zone hold state: "Schedule", "Hold until HH:MM PM", or "Hold - Permanent"
- platform: infinitesp
infinitesp_id: infinitesp_hub
name: "Zone 1 Hold State"
type: hold_state # hold_state (per-zone, requires zone:)
zone: 1
# Diagnostic text sensors (no zone required)
# tstat_ssid, tstat_hostname, tstat_wifi_mac,
# tstat_cloud_host, tstat_proxy_server,
# tstat_dealer_name, tstat_dealer_brand, tstat_dealer_url,
# comfort_profile, fault_historyInfinitESP implements the Carrier SAM ASCII serial protocol — the same text command/response interface a real SYSTXCCSAM01 exposes on its DB-9 RS-232 port (9600 8N1, CRLF). The sam_ascii component is a plain ESPHome UARTDevice: it speaks the protocol over whatever uart_id you bind it to and has no transport of its own. The command set works the same way regardless of transport.
Over the network (the default in the example config). The bundled config binds sam_ascii to a uart_tcp_server on port 23, so you can drive it with any telnet client:
nc infinitesp.local 23Over a real RS-232 port (a true SAM replacement). Point sam_ascii at a second hardware UART wired to an RS-232 transceiver and it behaves like the physical SAM's serial port — useful for replacing a faulty module or feeding a legacy automation controller that expects a SAM:
uart:
- id: bus_uart # ABCD RS485 bus (38400)
# ... RS485 pins ...
baud_rate: 38400
- id: ascii_uart # SAM RS-232 port (9600 8N1)
tx_pin: GPIO4
rx_pin: GPIO5
baud_rate: 9600
data_bits: 8
parity: NONE
stop_bits: 1
sam_ascii:
infinitesp_id: infinitesp_hub
uart_id: ascii_uartThe rest of this section shows the commands; they work identically over either transport.
| Command | Description |
|---|---|
MODE? |
System mode (HEAT/COOL/AUTO/OFF/EMERGENCY HEAT) |
OAT? |
Outdoor air temperature (°F) |
TIME? |
Current time from bus clock |
DAY? |
Current day of week |
ZONE? |
Active zone bitmask |
BLIGHT? |
Backlight (ON/OFF) |
CFGEM? |
Display units (F/C) |
CFGDEAD? |
Heat/cool deadband (0-6) |
CFGCPH? |
Cycles per hour (2-6) |
CFGPER? |
Schedule periods per day (2 or 4) |
CFGPGM? |
Programming enabled (ON/OFF) |
DEALER? |
Dealer name |
DEALERPH? |
Dealer phone |
FILTRLVL? / UVLVL? / HUMLVL? / VENTLVL? |
Accessory life used % |
FILTRRMD? / UVRMD? / HUMRMD? / VENTRMD? |
Accessory reminder (ON/OFF) |
VACAT? |
Vacation state (ON/OFF) |
VACMINT? / VACMAXT? |
Vacation min/max temperature |
VACMINH? / VACMAXH? |
Vacation min/max humidity |
VACFAN? |
Vacation fan mode |
Z1RT? |
Zone 1 room temperature (°F) |
Z1RH? |
Zone 1 humidity (%) |
Z1RHTG? |
Zone 1 humidification target (%) |
Z1HTSP? |
Zone 1 heat setpoint (°F) |
Z1CLSP? |
Zone 1 cool setpoint (°F) |
Z1FAN? |
Zone 1 fan mode (AUTO/LOW/MED/HIGH) |
Z1HOLD? |
Hold state (OFF / ON until HH:MM PM / PERMANENT) |
Z1OVR? |
Timed override active (ON/OFF) |
Z1OTMR? |
Override timer (HH:MM) |
Z1UNOCC? |
Zone unoccupied (ON/OFF) |
Z1NAME? |
Zone 1 name |
HELP |
List all commands |
Prefix with Z# for other zones (e.g., Z2HTSP?).
Append ! and a value to set parameters:
MODE!COOL # Set system mode
Z1HTSP!72 # Set zone 1 heat setpoint
Z1CLSP!68 # Set zone 1 cool setpoint
Z1FAN!AUTO # Set zone 1 fan mode
Z1HOLD!120 # Hold zone 1 for 120 minutes
Z1HOLD!on # Permanent hold
Z1HOLD!off # Cancel hold, resume schedule
The Carrier Infinity system uses an RS485 serial bus with a framed protocol:
[DST] [DST_BUS] [SRC] [SRC_BUS] [LEN] [PID] [EXT] [FUNC] [PAYLOAD...] [CRC16_LO] [CRC16_HI]
1B 1B 1B 1B 1B 1B 1B 1B LEN bytes 2B
- CRC-16 Modbus (init
0x0000, polynomial0xA001) - Function codes:
0x0B(read),0x06(reply),0x0C(write),0x15(exception)
InfinitESP does four things:
- Listens for read requests addressed to the SAM and replies with emulated register data
- Accepts write requests from the thermostat and stores register updates
- Polls the thermostat every 3 seconds for live state (temperatures, mode, setpoints)
- Writes setpoint/mode/fan changes from Home Assistant to the thermostat as bus writes
Mode and setpoint changes use a two-pronged approach: a 3B03 notification write (with change flags) primes the thermostat, then a direct data write delivers the actual values. Both are required. Neither alone works.
In addition to SAM-register traffic, InfinitESP passively observes inter-device traffic on the bus to extract diagnostic data:
- Indoor unit (0x40): blower RPM, airflow CFM, electric heat status
- Outdoor unit (0x50): compressor RPM, demand %, operating stage, modulation, expansion valve position, superheat/subcooling, outdoor/coil/suction/discharge temperatures plus suction superheat
- Zone controller (0x60 / 0x61): zone temperatures and damper positions. When you are not emulating the zone controller, InfinitESP passively snoops the thermostat↔zone-controller traffic to report damper positions and drive per-zone climate conditioning state. (When you are emulating it, the same data is captured directly through the emulation path.)
No extra polling needed. The thermostat already queries these devices, and InfinitESP just listens in.
When using hardware UART transport, InfinitESP can automatically discover the thermostat's WiFi credentials from register 4608 and cache them to NVS flash. If the ESP32 subsequently boots without WiFi connectivity (e.g., changed router), it injects the cached credentials after a 15-second grace period. This lets the device self-provision from the bus. No manual WiFi config needed after initial setup.
This feature is not available with the TCP serial bridge transport (circular dependency: need WiFi to reach the TCP bridge, need the bus to get WiFi credentials).
InfinitESP targets Carrier Infinity / Bryant Evolution / ICP systems that communicate over the ABCD RS485 bus. The protocol is shared across this family; only the author's own system has been confirmed working. The model numbers below identify the kinds of devices found on that bus. They are not a verified compatibility list, and behavior can vary across firmware revisions:
- SAM modules (the device InfinitESP emulates): SYSTXCCSAM01, SYSTXCCSAMC01
- Thermostats: Infinity Touch (SYSTXCCITC01), Evolution Connex (SYSTXBBECC01), legacy UID/UIZ controls with firmware 14+
- HVAC equipment: Infinity/Evolution furnace, air handler, or heat pump on the ABCD bus
What matters is the bus protocol, not the badge. See the Disclaimer and NOTICE.
- Verify RS485 A/B wiring. Try swapping if unsure (wrong polarity won't damage anything)
- Check that UART baud rate is exactly 38400
- Watch ESPHome logs for any
crc_failin the STATS line
- Check that the Bus Status binary sensor shows
on - Zones populate after the ESP sees register data from the thermostat. Allow 10-15 seconds after first bus connection
- Verify your zone number (1–8) matches a zone that's active on your thermostat
- There's a 1–3 second propagation delay (poll cycle)
- If changes never take effect, check logs for CRC errors indicating RS485 signal quality issues
- Mode OFF may be rejected by some thermostat firmwares. This is a thermostat limitation, not a bug.
- Hold the BOOT button while plugging in USB to enter download mode
- On ESP32-S3, GPIO45 and GPIO46 are strapping pins. The YAML includes
ignore_strapping_warning: true.
- Check the STATS line in logs every 5 seconds
- Healthy:
crc_fail=0,stale=0,overflow_evts=0,reply_got ≈ reply_exp crc_fail > 0→ bus noise or bad wiringoverflow_evts > 0→ main loop not keeping up (reduce logging verbosity)stale > 0→ bytes arriving with gaps > 100ms (transport issue)
To help diagnose the problem, it's useful to include any of the following that you can gather:
ESPHome logs are the most useful for protocol issues:
esphome logs infinitesp.yaml --device infinitesp.local30 seconds of output is usually plenty. The STATS line printed every 5 seconds contains bus health diagnostics (crc_fail, reply_exp, reply_got, etc.).
The REPORT? command provides a quick bus snapshot and often eliminates the need for full logs. It produces a JSON dump of all observed bus traffic, device info, cached registers, and diagnostic counters — a self-contained snapshot of the bus state.
To save a clean snapshot to report.json, run this one-liner from any machine that can reach the device (Python ships with the ESPHome toolchain; use python instead of python3 on Windows):
python3 -c "import socket;s=socket.create_connection(('infinitesp.local',23),5);s.sendall(b'REPORT?\r\n');open('report.json','wb').write(next(l for l in s.makefile('rb') if l.startswith(b'{')))"Attach report.json to your issue. To explore interactively instead (querying live values like MODE?, Z1RT?), connect with netcat and type commands:
nc infinitesp.local 23Privacy note: the REPORT output includes device serial numbers (in the
devarray) and dealer information (in the register dump). WiFi credentials are excluded automatically. If posting the file publicly, open it and remove theserialfields first, or share it privately with the maintainer.
Hardware details help narrow down firmware-specific issues:
- Thermostat model and firmware version (shown in REPORT? under the
deviceline for address20) - Indoor unit model (furnace/air handler)
- Outdoor unit model (condenser/heat pump)
- Zone controller (installed or not)
- RS485 transport (direct UART or TCP serial bridge)
infinitesp.yaml # Main device config
secrets.yaml # WiFi credentials (not tracked)
components/
├── infinitesp/ # ABCD bus protocol engine
│ ├── infinitesp.h/cpp # Core: frame parsing, register mgmt, polling, CRC
│ ├── climate/ # HA climate entities (setpoint, mode, fan, preset)
│ ├── cover/ # HA cover entities (per-zone damper position)
│ ├── sensor/ # Temperature, humidity, RPM, ODU diagnostic sensors
│ ├── binary_sensor/ # Bus status, compressor, electric heat
│ ├── select/ # System mode, fan mode selects
│ └── text_sensor/ # Zone names, WiFi info, dealer info, profiles
├── uart_rmtx/ # RMT-line-code UART driver (RS485 auto-direction error workaround)
└── sam_ascii/ # SAM ASCII serial CLI (REPORT?, setpoints, etc.)
#
# Transport components (uart_tcp_client, usb_cdc_acm, uart_tcp_server,
# uart_bridge) come from esphome-uart-link, pulled in via external_components.
# See the UART Transports section above.
You are connecting a multi-thousand-dollar HVAC system to some random person's code on the internet. This project was built by reverse-engineering a proprietary serial protocol. There is no official documentation, no vendor endorsement, and no guarantee that anything here is correct. A bug, a misinterpreted register, or a corrupted write could send commands to your equipment that it wasn't designed to handle.
If that happens, that's on you. You chose to wire an ESP32 into your furnace. Nobody involved in this project is coming to fix your HVAC, replace your compressor, or explain to your spouse why the house is 90°F in July.
If that bothers you, don't use this.
- infinitude: Perl-based Infinity system controller and the origin of this project. Provides a full web interface for Infinity/Evolution systems, includes a SAM emulator, and was the primary protocol reference for InfinitESP.
- infinitive: Go-based SAM emulator, reference for the write protocol
- Carrier/ICP protocol details from the open-source HVAC community
MIT