A write-up of getting a cheap Wi-Fi IR remote to reliably control a mini-central air-conditioning damper — including the IR-encoding bug that cost us the most time, and how we worked around it.
Scope & honesty note. This documents a build we actually completed and verified on real hardware. Where we didn't prove a root cause, it says so. Every network-specific detail — IPs, hostnames, SSIDs, credentials, room and entity names — is a
<PLACEHOLDER>; substitute your own. Nothing here is tied to any particular network, router, or home.
- AC side: a Tadiran mini-central "damper" — a per-room airflow unit controlled by an IR handset. The handset speaks the ECOCLIM protocol (the name IRremoteESP8266 gives this Tadiran encoding). On this unit the fan-speed setting is the damper level (Min/Medium/Max/Auto = how far the damper opens).
- Bridge: an Athom "AR01" Tasmota IR Remote Controller (ESP8266, pre-flashed Tasmota) sitting in line-of-sight of the unit, talking MQTT to a Home Assistant instance.
- Goal: a normal HA
climatecard that turns the damper on/off, sets a temperature, and sets the fan/damper level — and that doesn't lie about its state when an IR command doesn't actually go out.
Athom AR01 Tasmota IR Remote Controller — ESP8266, 2 MB flash, on-board CH340C USB-serial, Tasmota pre-installed. ~US$15.
- Official product page: https://www.athom.tech/blank-1/tasmota-ir-controller
- Athom store home: https://www.athom.tech/
- Tasmota device template (Blakadder, model
AR01): https://templates.blakadder.com/athom_AR01.html - Where I actually bought it: https://www.aliexpress.com/item/1005009492798860.html
Any ESP8266/ESP32 IR blaster that runs Tasmota's IR build will work; the AR01 is just what we used and it ships pre-flashed.
You need a tasmota-ir build — the IR-specialised image that bundles the
full IRremoteESP8266 protocol set (regular Tasmota builds omit most of it to save
flash). We ran 15.4.0(release-ir).
- Tasmota IR docs: https://tasmota.github.io/docs/Tasmota-IR/ and https://tasmota.github.io/docs/IR-Remote/
- Firmware builds overview: https://tasmota.github.io/docs/Firmware-Builds/
- Web installer (pick "Tasmota IR"): https://tasmota.github.io/install/
- OTA binaries: https://ota.tasmota.com/tasmota/release/
If the device already runs a tasmota-ir build, leave it. Otherwise flash via
Firmware Upgrade → OTA URL:
http://ota.tasmota.com/tasmota/release/tasmota-ir.bin.gz
The Athom IR Remote has a built-in Tasmota template — apply it in the Console:
Template {"NAME":"Athom_IR_Remote","GPIO":[32,0,0,0,1056,1088,0,0,0,576,0,0,0,0],"FLAG":0,"BASE":18}
Module 0
GPIO mapping: GPIO0 = Button (32), GPIO4 = IRsend (1056), GPIO5 = IRrecv
(1088), GPIO13 = LedLink_inverted (576). The device reboots after Module 0.
Pitfall we hit: do not set
Module 3or hand-type GPIO codes. A wrong template (wrong IRrecv/LED codes) hung the device. Use the exactTemplatestring above. (We also briefly believed15.4.0"didn't haveIRHVAC" — that was wrong;IRHVACreturnedUnknownonly because the GPIOs weren't mapped yet. Apply the template first.)
Confirm receive works: point the room's real AC remote at the device and press a
button — Tasmota should log an IrReceived ECOCLIM frame. (That's also how you
capture your own frames; see §4.)
This ESP8266 IR blaster was fussy about Wi-Fi. The things that actually fixed it for us:
On our unit, DHCP did not complete reliably: it would associate to the AP but never obtain a lease, so it never got an IP and never reached the broker. We did not confirm the root cause (candidates we considered but did not prove: weak signal dropping the broadcast DHCP exchange, an ESP8266 SDK quirk, or a power issue — we measured no voltage drop, so we're not asserting any of them).
What reliably worked: a device-side static IP, which skips the DHCP exchange entirely. Set it from a host that can reach the device (substitute your subnet):
IPAddress1 <DEVICE_IP> # a free, fixed address for the device
IPAddress2 <GATEWAY_IP> # your gateway
IPAddress3 <NETMASK> # e.g. 255.255.255.0
IPAddress4 <DNS_IP> # your DNS server
Send these as short, individual commands — long Backlog strings got
clipped on this device.
A tucked-away IR blaster often sits at a weak signal level (commonly in the −75 to −80 dBm range), and many consumer routers actively evict weak clients at that level — which kept dropping it. Two router-side changes fixed the flapping for us:
- Disable minimum-RSSI / weak-client eviction. Some routers kick clients below ~−70 dBm — exactly where a tucked-away IR blaster lives. Vendor names vary ("Roaming Assistant", "band steering", "airtime fairness", "minimum RSSI", etc.).
- Disable 802.11ax (Wi-Fi 6) on the 2.4 GHz radio. Older ESP8266 clients tend to associate more reliably without it.
Look for the equivalent settings on your own router/AP's 2.4 GHz band.
Tasmota lets you configure a fallback SSId2. Don't, if you're using a
static IP and the two SSIDs are on different subnets. We learned this the hard
way: when the primary join failed, the device fell back to the second SSID
carrying the static IP from the first subnet — wrong network, dead. Leave
SSId2 empty (SSId2 0 clears it).
The AR01's on-board CH340 means you can configure/recover it over USB serial regardless of Wi-Fi state — invaluable when a bad network config leaves it with no usable IP (which is exactly how we migrated ours). On Linux:
# device shows up as /dev/ttyUSB0; you may need: sudo chmod a+rw /dev/ttyUSB0
stty -F /dev/ttyUSB0 115200 raw -echo
# read responses in one terminal:
cat /dev/ttyUSB0
# send commands (short, one at a time) in another:
printf 'IPAddress1 <DEVICE_IP>\r\n' > /dev/ttyUSB0SerialLog 2 gives verbose Wi-Fi logs. Command responses come back over serial
even when Wi-Fi is down.
Give the device a unique topic and point it at your broker:
curl -s -G "http://<DEVICE_IP>/cm" --data-urlencode \
"cmnd=Backlog MqttHost <BROKER_IP>; MqttPort 1883; MqttUser <MQTT_USER>; MqttPassword <MQTT_PASSWORD>; Topic tasmota_IR"Verify it connected (from the broker host):
mosquitto_sub -h localhost -u <MQTT_USER> -P <MQTT_PASSWORD> -t "tele/tasmota_IR/LWT" -C 1 -v
# → tele/tasmota_IR/LWT OnlineWith SetOption19 OFF (Tasmota-native discovery), HA's Tasmota integration
auto-discovers the device.
This is the part worth reading even if you don't have a Tadiran unit.
Sending ECOCLIM commands via Tasmota's high-level IRHVAC command worked only
about half the time — the AC would silently ignore the other half. It looked
like the Clock field mattered (changing the clock value sometimes "fixed" a
stuck command), which sent us down a long wrong path of clock/timing workarounds.
We reverse-engineered the 56-bit ECOCLIM frame in two passes. First we mapped the
overall field structure with the AR01 in IR-receive mode — point the real handset
at it and read the decoded frames it logs. Later, while setting up a Broadlink RM4
Pro and replaying captures through it, we tracked down the parity bit: we'd
been generating it wrong, and worked backwards from the failing frames to it.
Throughout we cross-checked against IRremoteESP8266's ir_Ecoclim.h
(https://github.com/crankyoldgit/IRremoteESP8266). The frame layout on our unit
(LSB-first bit offsets):
bits 0-2 fixed 0b010
bit 3 EVEN-PARITY over the whole 56-bit frame <-- the catch
bits 4-7 DipConfig (our remote: 0b0100 = 4, constant)
bits 8-23 timers (0xFFFF = disabled)
bits 24-34 Clock (minutes since midnight, 11 bits)
bit 35 fixed 0
bits 36-37 Fan (Min=0, Medium=1, Max=2, Auto=3)
bit 38 Power (1=on)
bit 39 Clear (0)
bits 40-44 Temp (Celsius - 5)
bits 45-47 Mode (Auto=0)
bits 48-52 SensorTemp (Celsius - 5)
bits 53-55 fixed 0
Bit 3 is an even-parity bit over the entire frame, and IRremoteESP8266's
ECOCLIM decoder marks bit 3 'Unknown' — so the library never computes it. Our Tadiran
unit rejects frames whose overall parity is wrong. That's why ~half the commands
were dropped, and why Clock looked causal: changing the clock value flips the
frame's bit-count parity, so it accidentally made some otherwise-wrong frames
correct.
What we proved: with parity-correct frames, every command actuates on the first send. Verified physically on our unit. We did not confirm anything about other ECOCLIM units or DIP settings — the
DipConfignibble was constant on our remote; yours may differ, so capture your own frames.
Stop using IRHVAC for ECOCLIM. Instead, precompute the parity-correct 56-bit
frame ourselves and send it raw with Tasmota's low-level IRsend:
IRsend {"Protocol":"ECOCLIM","Bits":56,"Data":"0x16117320FFFF4A","Repeat":3}
One extra quirk we confirmed and bake into every frame: SensorTemp must be
Temp + 5 (capped at 36) so the unit treats the room as "not yet satisfied"
and opens the damper in Auto mode.
ecoclim.py — builds a 56-bit frame as an integer and sets the parity bit. This
is the whole trick: assemble the fields, then if the popcount is odd, set bit 3.
"""EcoClim (Tadiran damper) 56-bit IR frame encoder/decoder.
Bit layout reverse-engineered from real captures and cross-checked against
IRremoteESP8266 ir_Ecoclim.h. LSB-first field offsets — see the table in §3.
"""
# ECOCLIM IR timing (microseconds) -- IRremoteESP8266 ir_Ecoclim.cpp
HDR_MARK = 5730
HDR_SPACE = 1935
BIT_MARK = 440
ONE_SPACE = 1739
ZERO_SPACE = 637
FOOTER_MARK = 7820
GAP = 100000 # inter-message gap (us); approximate, replay-only
SECTIONS = 3
NBITS = 56
FAN_CODE = {"Min": 0, "Medium": 1, "Max": 2, "Auto": 3}
MODE_CODE = {"Auto": 0, "Cool": 1, "Dry": 2, "Recycle": 3, "Fan": 4, "Heat": 5}
TEMP_MIN, TEMP_MAX = 16, 30
DIP_DEFAULT = 0b0100 # observed constant on our remote
TIMERS_DISABLED = 0xFFFF
def _popcount(x):
return bin(x).count("1")
def encode_frame(power, temp, fan, mode="Auto", clock=800, sensor_temp=None,
dip=DIP_DEFAULT, timers=TIMERS_DISABLED):
"""Build a 56-bit EcoClim frame as an int, with the even-parity bit set.
``sensor_temp`` defaults to min(temp+5, 36) so the unit always opens the
damper in Auto (the "SensorTemp > Temp => not satisfied => open" quirk).
"""
pwr = 1 if power in (True, 1, "On", "on") else 0
fan_v = FAN_CODE[fan] if isinstance(fan, str) else fan & 0x3
mode_v = MODE_CODE[mode] if isinstance(mode, str) else mode & 0x7
if sensor_temp is None:
sensor_temp = min(temp + 5, 36)
v = 0
v |= 0b010 << 0
v |= (dip & 0xF) << 4
v |= (timers & 0xFFFF) << 8
v |= (clock & 0x7FF) << 24
# bit 35 = 0
v |= (fan_v & 0x3) << 36
v |= (pwr & 0x1) << 38
# bit 39 (Clear) = 0
v |= ((temp - 5) & 0x1F) << 40
v |= (mode_v & 0x7) << 45
v |= ((sensor_temp - 5) & 0x1F) << 48
# bits 53-55 = 0
if _popcount(v) % 2 == 1: # set parity bit so total popcount is even
v |= 1 << 3
return v
def frame_to_hex(v):
"""56-bit frame int -> Tasmota IRsend Data string ('0x' + 14 hex digits)."""
return f"0x{v:014X}"(The full module also has decode_fields, frame_to_durations, and a
decode_durations_to_frame for parsing captured pulse lists — handy for
reverse-engineering your own unit, but not needed at runtime.)
Rather than encode at runtime, we pre-generate every (fan, temp) on-frame plus
the off-frame into a single HA Custom Template macro. A small script
(gen_ecoclim_jinja.py) calls the encoder and writes the macro:
"""Generate the HA Custom Jinja macro for the Tasmota ECOCLIM damper.
Bakes every (fan, temp) on-frame + the off-frame as parity-correct IRsend hex
into a single macro, so HA command templates never touch the buggy IRHVAC
ECOCLIM encoder. Run: python -m broadlink.gen_ecoclim_jinja
"""
import pathlib
from .ecoclim import encode_frame, frame_to_hex, TEMP_MIN, TEMP_MAX
FANS = ["Auto", "Min", "Medium", "Max"]
def build_hex_map():
m = {"Off": frame_to_hex(encode_frame(power="Off", temp=22, fan="Auto"))}
for fan in FANS:
for t in range(TEMP_MIN, TEMP_MAX + 1):
m[f"{fan}|{t}"] = frame_to_hex(encode_frame(power="On", temp=t, fan=fan))
return mThe generated macro lives at config/custom_templates/ecoclim.jinja. It's a
lookup table plus a total function — junk/unknown attributes (a freshly
reloaded entity has fan_mode: unknown) or out-of-range temps still yield a
valid frame instead of an empty string:
{#- ECOCLIM 56-bit frames, even-parity bit precomputed. GENERATED -- do not hand-edit. -#}
{% macro ecoclim_hex(power, temp, fan) %}
{%- set m = {
"Auto|16": "0x100B7320FFFF42",
"Auto|22": "0x16117320FFFF4A",
"Auto|30": "0x1E197320FFFF4A",
"Max|22": "0x16116320FFFF42",
"Medium|22": "0x16115320FFFF42",
"Min|22": "0x16114320FFFF4A",
"Off": "0x16113320FFFF42"
{#- ...full 16-30 °C table for each of Auto/Min/Medium/Max generated by the script... -#}
} -%}
{%- set t = [16, [30, temp | int(22)] | min] | max -%}
{%- set f = fan if fan in ['Auto', 'Min', 'Medium', 'Max'] else 'Auto' -%}
{%- if power == 'Off' -%}{{ m['Off'] }}{%- else -%}{{ m[f ~ '|' ~ t] }}{%- endif -%}
{% endmacro -%}The snippet above is trimmed to a few representative entries — the real file has the full 16–30 °C row for each fan level (61 frames). Regenerate it with the script rather than hand-editing.
Enable Custom Templates in configuration.yaml:
homeassistant:
packages: !include_dir_named packages
mqtt: !include mqtt.yaml(Custom Jinja macros in config/custom_templates/ are loaded automatically; use
{% from 'ecoclim.jinja' import ecoclim_hex %}.)
After editing
ecoclim.jinjaormqtt.yaml, reload without a full restart: in Developer Tools → Actions, runhomeassistant.reload_custom_templates(for the macro) andmqtt.reload(for the climate entity). Edit the macro and skip this, and you'll see no change.
IR is open-loop: the damper never acknowledges anything. But Tasmota does tell
us whether it actually transmitted — it publishes {"IRSend":"Done"} on
stat/tasmota_IR/RESULT after every IRsend, plus a retained LWT on
tele/tasmota_IR/LWT. We gate the card on both, so it doesn't advance to a state
the hardware never received.
Non-optimistic: commands carry the plain requested value to intermediate
ac/set/* topics; state only advances via ac/state/*, which an
automation publishes after the transmit is confirmed.
mqtt:
climate:
- name: "AC Damper"
unique_id: tasmota_ir_ecoclim_ac
modes: ["off", "auto"]
fan_modes: ["Auto", "Min", "Medium", "Max"]
min_temp: 16
max_temp: 30
temp_step: 1
# Card goes unavailable when Tasmota drops off the broker (retained LWT).
availability:
- topic: "tele/tasmota_IR/LWT"
payload_available: "Online"
payload_not_available: "Offline"
# Commands carry the PLAIN value; an automation does the ECOCLIM encoding.
mode_command_topic: "ac/set/mode"
mode_state_topic: "ac/state/mode"
temperature_command_topic: "ac/set/temperature"
temperature_state_topic: "ac/state/temperature"
fan_mode_command_topic: "ac/set/fan_mode"
fan_mode_state_topic: "ac/state/fan_mode"On any ac/set/* change it: (1) builds one parity-correct frame from
last-confirmed state + the changed field, (2) sends it as an IRsend, (3) waits
up to 5 s for the exact {"IRSend":"Done"}, (4) on confirm echoes the plain value
to ac/state/* (retained) so the card advances; on timeout publishes
nothing (card holds) and logs a warning. mode: queued serialises sends so each
RESULT correlates to its own IRsend.
automation:
- id: ac_send_and_confirm
alias: "AC Damper — send ECOCLIM frame and confirm IRsend"
mode: queued
max: 10
triggers:
- trigger: mqtt
topic: ac/set/mode
id: mode
- trigger: mqtt
topic: ac/set/temperature
id: temperature
- trigger: mqtt
topic: ac/set/fan_mode
id: fan_mode
variables:
field: "{{ trigger.id }}"
newval: "{{ trigger.payload }}"
actions:
# 1-2. Encode the holistic frame and blast it.
- action: mqtt.publish
data:
topic: "cmnd/tasmota_IR/IRsend"
payload: >-
{% from 'ecoclim.jinja' import ecoclim_hex %}
{% set cur_mode = states('climate.ac_damper') %}
{% set pw = ('Off' if newval == 'off' else 'On') if field == 'mode'
else ('Off' if cur_mode == 'off' else 'On') %}
{% set t = (newval | int) if field == 'temperature'
else state_attr('climate.ac_damper','temperature') | int(22) %}
{% set fan = newval if field == 'fan_mode'
else (state_attr('climate.ac_damper','fan_mode') or 'Auto') %}
{"Protocol":"ECOCLIM","Bits":56,"Data":"{{ ecoclim_hex(pw, t, fan) }}","Repeat":3}
# 3. Wait for Tasmota's transmit confirmation (exact-match the success payload).
- wait_for_trigger:
- trigger: mqtt
topic: stat/tasmota_IR/RESULT
payload: '{"IRSend":"Done"}'
timeout: "00:00:05"
continue_on_timeout: true
# 4. Confirmed -> advance the card; timeout -> hold last-confirmed state.
- choose:
- conditions: "{{ wait.completed }}"
sequence:
- action: mqtt.publish
data:
topic: "ac/state/{{ field }}"
payload: "{{ newval }}"
retain: true
default:
- action: system_log.write
data:
level: warning
logger: ac_damper
message: >-
ECOCLIM IRsend not confirmed (no {"IRSend":"Done"} within 5s)
for {{ field }}={{ newval }} — card state held at last confirmed.What this does and doesn't prove.
{"IRSend":"Done"}confirms Tasmota's IR LED fired — not that the damper received or physically moved. Closing that last gap needs sensor hardware (e.g. a vibration/reed sensor on the damper). We verified the happy path live (set → correct frame →Done→ state echo); we did not exercise the timeout-hold branch in production.
Watch the command and result topics while you poke the card:
mosquitto_sub -h localhost -u <MQTT_USER> -P <MQTT_PASSWORD> \
-t "cmnd/tasmota_IR/IRsend" -t "stat/tasmota_IR/RESULT" -vYou should see your {"Protocol":"ECOCLIM",...} go out and {"IRSend":"Done"}
come back. If the AC ignores a command but you see Done, re-check the parity
bit — that was always our culprit.
To capture your own unit's frames, put Tasmota in receive mode and press the real
remote; the logged ECOCLIM data is what you decode against the §3 layout.
- Use a
tasmota-irbuild and apply the built-inAthom_IR_Remotetemplate before anything else. - Static IP, single SSID, and disable weak-client eviction on the router — the ESP8266 IR blaster is fragile on Wi-Fi.
- The ECOCLIM frame has an even-parity bit (bit 3) that IRremoteESP8266
doesn't compute — compute it yourself and send raw
IRsendframes. This was the entire "half my commands get dropped" mystery. SensorTemp = Temp + 5so Auto opens the damper.- Gate the HA card on Tasmota's
{"IRSend":"Done"}so it reflects what was actually transmitted, not what you wished for.
Built and verified on real hardware.