Skip to content

Instantly share code, notes, and snippets.

@MadaraUchiha
Created June 16, 2026 18:17
Show Gist options
  • Select an option

  • Save MadaraUchiha/f1d405da81559cb94f56cb9b91b269a6 to your computer and use it in GitHub Desktop.

Select an option

Save MadaraUchiha/f1d405da81559cb94f56cb9b91b269a6 to your computer and use it in GitHub Desktop.
Setting Up Damper Support for TADIRAN Mini-Central AC using IR Blaster

Driving a Tadiran "ECOCLIM" AC damper from Home Assistant with a Tasmota IR blaster

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.


The setup

  • 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 climate card 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.

1. The hardware

Athom AR01 Tasmota IR Remote Controller — ESP8266, 2 MB flash, on-board CH340C USB-serial, Tasmota pre-installed. ~US$15.

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.

Firmware

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).

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

Device template (maps the IR send/receive GPIOs)

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 3 or hand-type GPIO codes. A wrong template (wrong IRrecv/LED codes) hung the device. Use the exact Template string above. (We also briefly believed 15.4.0 "didn't have IRHVAC" — that was wrong; IRHVAC returned Unknown only 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.)


2. Getting it onto the network reliably

This ESP8266 IR blaster was fussy about Wi-Fi. The things that actually fixed it for us:

Use a static IP — don't rely on DHCP

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.

Stop the AP from evicting it for weak signal

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.

One SSID only — don't set a second SSID on another subnet

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).

Serial is your reliable recovery channel

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/ttyUSB0

SerialLog 2 gives verbose Wi-Fi logs. Command responses come back over serial even when Wi-Fi is down.

MQTT config

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 Online

With SetOption19 OFF (Tasmota-native discovery), HA's Tasmota integration auto-discovers the device.


3. The IR-encoding bug that cost us the most time

This is the part worth reading even if you don't have a Tadiran unit.

Symptom

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.

Root cause

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 DipConfig nibble was constant on our remote; yours may differ, so capture your own frames.

Solution

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.


4. The encoder

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.)


5. Baking the frames into a Home Assistant Jinja macro

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 m

The 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.jinja or mqtt.yaml, reload without a full restart: in Developer Tools → Actions, run homeassistant.reload_custom_templates (for the macro) and mqtt.reload (for the climate entity). Edit the macro and skip this, and you'll see no change.


6. The Home Assistant card — non-optimistic "send and confirm"

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.

The climate entity (mqtt.yaml)

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"

The send-and-confirm automation (packages/ac_damper.yaml)

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 firednot 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.


7. Verifying / debugging

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" -v

You 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.


Summary of the non-obvious bits

  1. Use a tasmota-ir build and apply the built-in Athom_IR_Remote template before anything else.
  2. Static IP, single SSID, and disable weak-client eviction on the router — the ESP8266 IR blaster is fragile on Wi-Fi.
  3. The ECOCLIM frame has an even-parity bit (bit 3) that IRremoteESP8266 doesn't compute — compute it yourself and send raw IRsend frames. This was the entire "half my commands get dropped" mystery.
  4. SensorTemp = Temp + 5 so Auto opens the damper.
  5. 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.

"""EcoClim (Tadiran damper) 56-bit IR frame encoder/decoder.
Bit layout reverse-engineered from real RM4 Pro captures and cross-checked
against IRremoteESP8266 ``ir_Ecoclim.h`` (the ``EcoclimProtocol`` union).
LSB-first field offsets inside the 56-bit value:
bits 0-2 fixed 0b010
bit 3 EVEN-PARITY over the whole 56-bit frame <-- NOT in IRremoteESP8266
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
The frame is transmitted MSB-first, 56 bits, three identical sections,
38 kHz. Timing constants are the IRremoteESP8266 values.
"""
# 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.
``fan``/``mode`` accept either the string name or the raw int code.
``power`` accepts "On"/"Off"/bool/int. ``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 has_even_parity(v):
return _popcount(v) % 2 == 0
def frame_to_hex(v):
"""56-bit frame int -> Tasmota IRsend Data string ('0x' + 14 hex digits)."""
return f"0x{v:014X}"
def decode_fields(v):
"""Decode a 56-bit frame int into its named fields."""
g = lambda off, w: (v >> off) & ((1 << w) - 1)
return {
"parity_bit": g(3, 1),
"dip": g(4, 4),
"timers": g(8, 16),
"clock": g(24, 11),
"fan": g(36, 2),
"power": g(38, 1),
"temp": g(40, 5) + 5,
"mode": g(45, 3),
"sensor_temp": g(48, 5) + 5,
"even_parity": has_even_parity(v),
}
def frame_to_durations(data56):
"""Expand a 56-bit frame into the IR pulse-duration list (3 sections)."""
bits = [(data56 >> (NBITS - 1 - i)) & 1 for i in range(NBITS)] # MSB-first
out = []
for _ in range(SECTIONS):
out += [HDR_MARK, HDR_SPACE]
for b in bits:
out += [BIT_MARK, ONE_SPACE if b else ZERO_SPACE]
out += [FOOTER_MARK, GAP]
return out
def decode_durations_to_frame(durations):
"""Inverse of frame_to_durations: return the first section's 56-bit int."""
i = 0
n = len(durations)
while i < n - 1:
mark, space = durations[i], durations[i + 1]
if abs(mark - HDR_MARK) < HDR_MARK * 0.35 and abs(space - HDR_SPACE) < HDR_SPACE * 0.4:
i += 2
val = 0
for _ in range(NBITS):
if i + 1 >= n:
raise ValueError("truncated frame")
sp = durations[i + 1]
i += 2
val = (val << 1) | (1 if sp > (ONE_SPACE + ZERO_SPACE) / 2 else 0)
return val
i += 1
raise ValueError("no ECOCLIM header found")
"""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 (missing parity bit -> silently dropped frames). Run::
python -m broadlink.gen_ecoclim_jinja
# writes homeassistant/config/custom_templates/ecoclim.jinja
"""
import pathlib
from .ecoclim import encode_frame, frame_to_hex, TEMP_MIN, TEMP_MAX
FANS = ["Auto", "Min", "Medium", "Max"]
OUT = (pathlib.Path(__file__).resolve().parent.parent
/ "homeassistant" / "config" / "custom_templates" / "ecoclim.jinja")
HEADER = """\
{#- ECOCLIM 56-bit frames, even-parity bit precomputed.
GENERATED by `python -m broadlink.gen_ecoclim_jinja` -- do not hand-edit.
Source encoder: broadlink/ecoclim.py (capture-verified). -#}
"""
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 m
def render_jinja():
m = build_hex_map()
entries = ",\n".join(f' "{k}": "{v}"' for k, v in sorted(m.items()))
return (
HEADER
+ "{% macro ecoclim_hex(power, temp, fan) %}\n"
+ "{%- set m = {\n" + entries + "\n } -%}\n"
# Total function: junk attrs (fresh entity after reload -> fan_mode
# 'unknown'/None) or out-of-range temps still yield a valid frame.
+ "{%- set t = [16, [30, temp | int(22)] | min] | max -%}\n"
+ "{%- set f = fan if fan in ['Auto', 'Min', 'Medium', 'Max'] else 'Auto' -%}\n"
+ "{%- if power == 'Off' -%}{{ m['Off'] }}"
+ "{%- else -%}{{ m[f ~ '|' ~ t] }}{%- endif -%}\n"
+ "{% endmacro -%}\n"
)
def main():
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(render_jinja())
print(f"wrote {OUT}")
if __name__ == "__main__":
main()
{#- ECOCLIM 56-bit frames, even-parity bit precomputed.
GENERATED by `python -m broadlink.gen_ecoclim_jinja` -- do not hand-edit.
Source encoder: broadlink/ecoclim.py (capture-verified). -#}
{% macro ecoclim_hex(power, temp, fan) %}
{%- set m = {
"Auto|16": "0x100B7320FFFF42",
"Auto|17": "0x110C7320FFFF42",
"Auto|18": "0x120D7320FFFF4A",
"Auto|19": "0x130E7320FFFF42",
"Auto|20": "0x140F7320FFFF42",
"Auto|21": "0x15107320FFFF42",
"Auto|22": "0x16117320FFFF4A",
"Auto|23": "0x17127320FFFF42",
"Auto|24": "0x18137320FFFF4A",
"Auto|25": "0x19147320FFFF4A",
"Auto|26": "0x1A157320FFFF42",
"Auto|27": "0x1B167320FFFF4A",
"Auto|28": "0x1C177320FFFF4A",
"Auto|29": "0x1D187320FFFF42",
"Auto|30": "0x1E197320FFFF4A",
"Max|16": "0x100B6320FFFF4A",
"Max|17": "0x110C6320FFFF4A",
"Max|18": "0x120D6320FFFF42",
"Max|19": "0x130E6320FFFF4A",
"Max|20": "0x140F6320FFFF4A",
"Max|21": "0x15106320FFFF4A",
"Max|22": "0x16116320FFFF42",
"Max|23": "0x17126320FFFF4A",
"Max|24": "0x18136320FFFF42",
"Max|25": "0x19146320FFFF42",
"Max|26": "0x1A156320FFFF4A",
"Max|27": "0x1B166320FFFF42",
"Max|28": "0x1C176320FFFF42",
"Max|29": "0x1D186320FFFF4A",
"Max|30": "0x1E196320FFFF42",
"Medium|16": "0x100B5320FFFF4A",
"Medium|17": "0x110C5320FFFF4A",
"Medium|18": "0x120D5320FFFF42",
"Medium|19": "0x130E5320FFFF4A",
"Medium|20": "0x140F5320FFFF4A",
"Medium|21": "0x15105320FFFF4A",
"Medium|22": "0x16115320FFFF42",
"Medium|23": "0x17125320FFFF4A",
"Medium|24": "0x18135320FFFF42",
"Medium|25": "0x19145320FFFF42",
"Medium|26": "0x1A155320FFFF4A",
"Medium|27": "0x1B165320FFFF42",
"Medium|28": "0x1C175320FFFF42",
"Medium|29": "0x1D185320FFFF4A",
"Medium|30": "0x1E195320FFFF42",
"Min|16": "0x100B4320FFFF42",
"Min|17": "0x110C4320FFFF42",
"Min|18": "0x120D4320FFFF4A",
"Min|19": "0x130E4320FFFF42",
"Min|20": "0x140F4320FFFF42",
"Min|21": "0x15104320FFFF42",
"Min|22": "0x16114320FFFF4A",
"Min|23": "0x17124320FFFF42",
"Min|24": "0x18134320FFFF4A",
"Min|25": "0x19144320FFFF4A",
"Min|26": "0x1A154320FFFF42",
"Min|27": "0x1B164320FFFF4A",
"Min|28": "0x1C174320FFFF4A",
"Min|29": "0x1D184320FFFF42",
"Min|30": "0x1E194320FFFF4A",
"Off": "0x16113320FFFF42"
} -%}
{%- 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 -%}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment