HAIR 1.0: Admin UI for your Infrared Network - Capture, Store and Trigger IR Devices

Here you go


 ============================================================================
# HAIR ESPHome Config
# ----------------------------------------------------------------------------
# Device:            Athom RF IR Remote (ESP32, RF433 + IR)
# Hardware revision: v3.0.1
# Variant:           full
# Contributor:       DAB-LABS
# Source:            https://github.com/athom-tech/esp32-configs
# License:           Apache 2.0
# Tested against:
#   HAIR:            0.2.0
#   HA Core:         2026.5.2
#   ESPHome:         2026.5.1
# Last verified:     2026-05-29
# Purpose:           RX+TX
# Hardware purchase: https://www.athom.tech/
# GPIO map:
#   GPIO25           IR TX (remote_transmitter)
#   GPIO33           IR RX (remote_receiver, inverted)
#   GPIO18           RF TX (remote_transmitter, 433 MHz)
#   GPIO19           RF RX (remote_receiver, 433 MHz, inverted)
#   GPIO27           Status LED
#   GPIO0            Button
# Notes:
#   - Standalone config -- no Athom package import. Everything is laid out
#     so you can see and customize every component.
#   - Based on Athom's athom-rf-ir-remote.yaml v3.0.1, set up for the
#     native HA infrared platform path on HA 2026.6+.
#   - RF hardware components are included but only IR is used by HAIR.
#   - The legacy on_pronto bridge is preserved further down as
#     commented-out historical reference for users on HA 2026.4-2026.5.
#     On HA 2026.6+ it is unused.
#   - BLE proxy, climate IR, and improv are included. Remove any sections
#     you do not need.
# ============================================================================

# --- Substitutions ---
# Change these to match your device.
substitutions:
  name: athom-rf-ir-remote
  friendly_name: Athom RF IR Remote
  # Athom project metadata (used by dashboard_import)
  project_name: "China Athom Technology.Athom RF IR Remote"
  project_version: "v3.0.1"
  # Climate IR platform -- see https://esphome.io/components/climate/climate_ir/
  # Valid values: coolix, daikin, fujitsu_general, gree, hitachi_ac344,
  # climate_ir_lg, midea_ir, mitsubishi, tcl112, toshiba, whirlpool, etc.
  #ac_platform: coolix

# --- Core ---

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  name_add_mac_suffix: false
  min_version: 2026.5.1
  project:
    name: "${project_name}"
    version: "${project_version}"
  platformio_options:
    board_build.flash_mode: dio

esp32:
  board: esp32dev
  variant: esp32
  flash_size: 8MB
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_BT_BLE_42_FEATURES_SUPPORTED: y
      CONFIG_ESP_TASK_WDT_TIMEOUT_S: "10"

preferences:
  flash_write_interval: 1min

# --- Network and connectivity ---

api:
  encryption:
    key: !secret api_encryption_key
  reboot_timeout: 0s
  # Start BLE scanning when API client connects, stop when it disconnects.
  on_client_connected:
    - esp32_ble_tracker.start_scan:
        continuous: true
  on_client_disconnected:
    if:
      condition:
        not:
          api.connected:
      then:
        - esp32_ble_tracker.stop_scan:

ota:
  - platform: esphome
    password: !secret ota_password

logger:
  baud_rate: 0
  level: DEBUG

mdns:
  disabled: false

web_server:
  port: 80
  version: 3

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap: {}
  fast_connect: false
  domain: ".local"

captive_portal:

# --- BLE proxy ---
# Enables Bluetooth proxy for HA. Remove this section if you do not
# need BLE forwarding and want to save memory.

esp32_ble_tracker:
  scan_parameters:
    continuous: false
    active: true
    interval: 320ms
    window: 300ms

bluetooth_proxy:
  active: true

esp32_improv:
  authorizer: none

dashboard_import:
  package_import_url: github://athom-tech/esp32-configs/athom-rf-ir-remote.yaml

# =====================================================================
# IR Hardware
# =====================================================================

# --- IR Transmitter (TX) ---
remote_transmitter:
  # IR LED on GPIO25, 50% duty cycle for standard 38 kHz modulation.
  - pin:
      number: GPIO25
      inverted: false
    carrier_duty_percent: 50%
    non_blocking: true
    id: ir_transmitter

  # RF 433 MHz transmitter on GPIO18, 100% duty (OOK modulation).
  - pin:
      number: GPIO18
    carrier_duty_percent: 100%
    non_blocking: true
    id: rf_transmitter

# --- IR Receiver (RX) ---
remote_receiver:
  # RF 433 MHz receiver on GPIO19.
  - pin:
      number: GPIO19
      inverted: true
    dump: all
    tolerance: 25%
    id: rf_receiver

  # IR receiver on GPIO33. The infrared: platform below references this
  # block's id to register the receiver as a native HA
  # InfraredReceiverEntity, which HAIR subscribes to via
  # infrared.async_subscribe_receiver() on HA 2026.6+.
  #
  # The legacy on_pronto event-bus bridge that HAIR used on HA
  # 2026.4-2026.5 is preserved below as commented-out historical
  # reference. It is no longer active and no longer needed on 2026.6+.
  # Uncomment it only if you are running an older HA version that
  # predates native InfraredReceiverEntity support.
  - pin:
      number: GPIO33
      inverted: true
    dump: pronto
    tolerance: 25%
    idle: 10ms
    id: ir_receiver

    # Legacy HAIR bridge (pre-2026.6). No longer needed on HA 2026.6+.
    # on_pronto:
    #   then:
    #     - homeassistant.event:
    #         event: esphome.remote_received
    #         data:
    #           protocol: PRONTO
    #           code: !lambda 'return x.data;'

# =====================================================================
# HA Native Infrared Platform
# =====================================================================
# These entries register the device as infrared entities in HA. HAIR
# discovers them automatically.
#
# On HA 2026.6+:
#   - TX entity (ir_proxy_transmitter) handles outbound commands.
#   - RX entity (ir_proxy_receiver) is a native InfraredReceiverEntity
#     and HAIR subscribes to it directly. This is the active path.
#
# On HA 2026.4-2026.5:
#   - TX entity works the same way.
#   - The native RX subscription is not available. To enable Sniffer
#     capture on those HA versions, uncomment the on_pronto bridge in
#     the remote_receiver block above.

infrared:
  - platform: ir_rf_proxy
    name: IR Proxy Transmitter
    id: ir_proxy_transmitter
    remote_transmitter_id: ir_transmitter

  - platform: ir_rf_proxy
    name: IR Proxy Receiver
    id: ir_proxy_receiver
    receiver_frequency: 38kHz
    remote_receiver_id: ir_receiver

# --- RF platform (not used by HAIR) ---
radio_frequency:
  - platform: ir_rf_proxy
    name: 433MHz RF Transmitter
    id: rf_proxy_transmitter
    frequency: 433.92MHz
    remote_transmitter_id: rf_transmitter

  - platform: ir_rf_proxy
    name: 433MHz RF Receiver
    id: rf_proxy_receiver
    frequency: 433.92MHz
    remote_receiver_id: rf_receiver

# =====================================================================
# Climate IR
# =====================================================================
# Built-in climate entity for AC control. Uses the IR transmitter and
# receiver directly. Change the platform in substitutions to match
# your AC brand.
#climate:
#  - platform: ${ac_platform}
#    transmitter_id: ir_transmitter
#    name: "AC"
#    receiver_id: ir_receiver

# =====================================================================
# Diagnostics and UI
# =====================================================================

binary_sensor:
  - platform: status
    name: "Status"
    entity_category: diagnostic

  - platform: gpio
    pin:
      number: GPIO0
      mode:
        input: true
      inverted: true
    name: "Button"
    disabled_by_default: true
    on_multi_click:
      - timing:
          - ON for at least 4s
        then:
          - button.press: Reset

  - platform: template
    name: "Wall Switch"
    id: wall_switch

  - platform: remote_receiver
    receiver_id: rf_receiver
    id: rf_on_trigger
    internal: false # Hides it from Home Assistant
    pronto:
      data : '0000006D00480000000A000C000A000C000A000C000A000C000A000C000A0035000B000B000B0035000A000C000A000C000A000C000A000D00090036000A000C000A003700090035000B000B000B000B000B000B000B000B000B0035000B000C000A000C000B000C000A000C000A000C000900370009000E0008003700090036000A000D000800370008000E0007000E0008000F0007000F0008000E0008000E000800370009000D0009000D0009000E000800380008000E0008000E0008000E000800370008000D000900370009000E000900370008000E0008000E0008000D0009000E000800380008000E000800380007000F000700380008000D0009000D0009000D0009000E0009000E0008000D0009000D0009000D00090036000A000D00090036000A0180' # Put your ON code here
    on_press:
      then:
        - binary_sensor.template.publish:
            id: wall_switch
            state: ON

  - platform: remote_receiver
    receiver_id: rf_receiver
    id: rf_off_trigger
    internal: false # Hides it from Home Assistant
    pronto:
      data : '0000006D00480000000B000C000B000B000B000C000A0035000B000B000B000B000B000B000B0036000B000B000B000B000B000B000B000B000B0035000B000B000B0036000B0035000B000B000B000B000B000B000B000B000B0035000B000C000B000B000B000B000B000B000B000B000B0035000B000B000B0036000B0035000B000B000B0035000B000B000B000B000B000B000B000C000B000B000B000B000B0035000B000B000B000B000B000B000B0035000B000B000B000B000B000B000B0035000B000B000A0035000B000C000' # Put your ON code here
    on_press:
      then:
        - binary_sensor.template.publish:
            id: wall_switch
            state: OFF
    
sensor:
  - platform: uptime
    name: "Uptime Sensor"
    id: uptime_sensor
    type: timestamp
    entity_category: diagnostic

  - platform: wifi_signal
    name: "WiFi Signal dB"
    id: wifi_signal_db
    update_interval: 60s
    entity_category: diagnostic

  - platform: copy
    source_id: wifi_signal_db
    name: "WiFi Signal Percent"
    filters:
      - lambda: return min(max(2 * (x + 100.0), 0.0), 100.0);
    unit_of_measurement: "Signal %"
    entity_category: diagnostic
    device_class: ""

button:
  - platform: restart
    name: "Restart"
    entity_category: config

  - platform: factory_reset
    name: "Factory Reset"
    id: Reset
    entity_category: config

  - platform: safe_mode
    name: "Safe Mode"
    internal: false
    entity_category: config

  - platform: template
    name: "Wall Switch 1"
    on_press:
      - remote_transmitter.transmit_pronto:
          transmitter_id: rf_transmitter
          data: >-
            0000 006D 0048 0000 000A 000C 000A 000C 000A 000C 000A 000C 000A 000C 000A 0035 000B 000B 000B 0035 000A 000C 000A 000C 000A 000C 000A 000D 0009 0036 000A 000C 000A 0037 0009 0035 000B 000B 000B 000B 000B 000B 000B 000B 000B 0035
            000B 000C 000A 000C 000B 000C 000A 000C 000A 000C 0009 0037 0009 000E 0008 0037 0009 0036 000A 000D 0008 0037 0008 000E 0007 000E 0008 000F 0007 000F 0008 000E 0008 000E 0008 0037 0009 000D 0009 000D 0009 000E 0008 0038 0008 000E
            0008 000E 0008 000E 0008 0037 0008 000D 0009 0037 0009 000E 0009 0037 0008 000E 0008 000E 0008 000D 0009 000E 0008 0038 0008 000E 0008 0038 0007 000F 0007 0038 0008 000D 0009 000D 0009 000D 0009 000E 0009 000E 0008 000D 0009 000D
            0009 000D 0009 0036 000A 000D 0009 0036 000A 0180

light:
  - platform: status_led
    name: "Status LED"
    disabled_by_default: true
    pin: GPIO27

text_sensor:
  - platform: wifi_info
    ip_address:
      name: "IP Address"
      entity_category: diagnostic
    ssid:
      name: "Connected SSID"
      entity_category: diagnostic
    mac_address:
      name: "Mac Address"
      entity_category: diagnostic

  - platform: template
    name: "Last Restart"
    id: device_last_restart
    icon: mdi:clock
    entity_category: diagnostic

time:
  - platform: sntp
    id: sntp_time
    timezone: ""
    update_interval: 6h
    servers:
      - 0.pool.ntp.org
      - 1.pool.ntp.org
      - 2.pool.ntp.org
    on_time_sync:
      then:
        - if:
            condition:
              lambda: 'return id(device_last_restart).state == "";'
            then:
              - text_sensor.template.publish:
                  id: device_last_restart
                  state: !lambda 'return id(sntp_time).now().strftime("%a %d %b %Y - %I:%M:%S %p");'

Thanks for the screenshot, that helped.

Your Athom is actually working. The badges are throwing you off. RX-NATIVE means HAIR is hooked into the receiver through HA's modern infrared platform. RX-BRIDGE is the older path and only shows when the legacy on_pronto bridge in the YAML is still firing. We removed that from the example for HA 2026.6+, so your Athom doesn't have it. Your "Sitting IR" device still has the old bridge in its YAML, which is why it shows both badges. The Athom is on the cleaner setup, not missing anything.

On the Sniffer issue, a few things to check:

  • Is the Sniffer tab empty when you press a button, or does it show signals from other devices but nothing from the Athom?
  • Have you tried Show Dismissed? If the source remote got dismissed, signals are hidden.
  • Could the signal be landing on a HAIR device instead? In 0.4.0 we match known commands and surface them on the device card, not the Sniffer.
  • Have you restarted HA since adding the Athom? HAIR's receiver subscription happens at startup.

Let me know what you see and we can dig in.

Also unrelated... your rf_off_trigger Pronto looks truncated, ends with '0009000 instead of a proper closing word. Worth a look.

~ DAB

2 Likes

Thanks that explains a lot about the RX-Bridge.

I can’t see the remote that I already assigned to a device in the sniffer I think it’s because I cleared it in the sniffer instead of dismiss.

If I want to see if the remote is working in the sniffer I need to keep the previous assigned in dismiss and label it. Rather than clear it.

A couple updates since the launch post. :clinking_beer_mugs:

v0.4.0 added protocol-decoded transmit (currently NEC):

  • Clean re-encoded Protocols (currently NEC) timings on Test and automation, which fixes replay against destinations that reject jittered captures. A per- command toggle keeps the captured timings for the rare device that needs them.
  • Code-library picker on the Clipper tab for pre-filling a remote from supported manufacturer/model combos in your installed infrared library.

v0.4.20 added a unified Pronto editor and a handful of related polish:

  • Edit a Pronto code in place on any signal or command; HAIR re-evaluates it as a fresh capture and bound triggers re-point automatically when the fingerprint shifts.
  • One-click snap of off-standard carriers to the nearest IR standard (30/33/36/38/40/56 kHz).
  • Rename a device command with action mappings cascading to the new name.
  • Per-command send-N-times count for devices that need a repeated frame to register.

Full changelog: HAIR/CHANGELOG.md at 5cb7be80b3867d9f3ea5cd1521858e23120bdd94 Ā· DAB-LABS/HAIR Ā· GitHub

I've got HAIR v0.5.6 installed and am trying to get it set up with a couple of remotes and a generic ESPHome setup. I'm building a system to trigger actions in Home Assistant with IR remotes, so I can control lights and fans (etc) in a room without having to use an app. I'd previously manually mapped remote buttons in ESPHome to trigger events, which works well, so I'm confident my hardware setup is viable. I've also got the new infrared hub set up in my ESPHome.

When I first installed HAIR, nothing showed in the sniffer, with multiple remotes. It wasn't until I did Settings → Devices & services → Integrations → HAIR → Hubs / HAIR → Reload. Then the sniffer started working.

I've played with 2 remotes so far. One is a generic thing that I think is supposed to be a replacement Roku remote. The other is a Sony RMT-B119A. The Roku is registering individual button presses fine, grouped under a single remote. The Sony, on the other hand, hardly ever shows the same signal twice, and is registering as multiple devices. Maybe the batteries are dying? I'll try that, but something is definitely fishy there.

I'm confused by the trigger mechanism with in HAIR. I don't really care about emulating the entire remote: I'm not trying to control an existing device via IR, I'm trying to use the remote as a control surface for HA, and my goal is to have identical receivers and IR remote controls in multiple rooms. The trigger mechanism appears to only fire when a button is pressed, without any kind of differentiation as to where it was received. If I hit the mute button in the garage, I want to mute the audio in the garage, but the trigger just says "the mute button on A remote was pressed" without telling the listener what received the signal. Is there a way to do that?

Thanks,
Brian

1 Like

The workaround I'm using is to use get the entity ID of the last active infrared entity:

triggers:
  - trigger: event.received
    target:
      entity_id: event.hair_trigger_test_button
    options:
      event_type:
        - ir_command_received
variables:
  last_ir_entity: |
    {{ states.infrared | sort(attribute = 'last_changed', reverse=true)
    | map(attribute = 'entity_id') | first }}
actions:
  - ...

... you can use that directly or use it to get the Area with area_name().

But I agree, the receiver's device ID should really be part of the event data.

1 Like

That's a heck of a workaround, but well done!

1 Like

Great idea! Thanks for the work around @Didgeridrew. I’ll scope a solution when I’m back from the flu. :face_with_thermometer:

~ DAB

1 Like

Finally over the flu (and so is the rest of the house, almost), so I'm back. :clinking_beer_mugs:

v0.5.7 just shipped: Release v0.5.7 -- Location Awareness + Dots Ā· DAB-LABS/HAIR Ā· GitHub

Big one this release is location-aware triggers, straight out of the conversation @blalor and @Didgeridrew started upthread.

Every trigger event now carries receiver_entity_id, receiver_area_id, and receiver_area_name, resolved live at fire time.

The trigger dialog also gets a multi-select Receiver scope, so the same physical remote button pressed in the garage vs the living room can drive two separate triggers with two separate names. No Jinja gymnastics needed. Thanks to both of you for the report and the design pressure.

Also folded in a Spanish translation for the config flow from @Waterbrain, plus a corner-dot polish pass on the signal-row Assign and Trigger buttons so counts and state are visible at a glance.

HACS should pick it up within a few minutes. Full notes on the release page above. :barber_pole:

~ DAB


2 Likes

Any way to use Esphome RF proxy with HAIR?

Hi all, newbie here. I purchased a smart IR mate, connected it to my home wifi, it was discovered in HA. I installed HAIR via hacs but it does not see my smart IR mate as a device. ESPhome is installed.

So, I think I am missing some Yaml stuff but I am not certain if I need to flash the device or copy the yaml files to somewhere etc.

I have never used ESPhome before so I am getting lost and the instructions appear to be for those who have done this before. I do have an RM4-pro that is showing up in HAIR but of course as a TX device. I have been trying to sort it out but my tiny brain is not getting it.

I have read the docs on GitHub and HA but I am confused. Can anyone help get me past the flash/yaml confusion I have with getting the smart IR mate identified?

Update:
I finally found the ir mate readme and the full configuration along with the instructions. I think I can work with that!

HA is core 2026.7.1, on latest HAOS.

Does the device appear in the ESPHome Device Builder's Web UI?

No. HAIR is for IR. I have scoped out ā€œHARF,ā€ but haven’t had a chance to build it out yet. I just got some hardware in for testing. :clap:t2:

~ DAB

4 Likes

Thank you so much for ā€œHAIRā€!
This allowed me to integrate my very good but very dumb old Klarstein fan into HA, and I can finally control the fan with my voice.
I set this up using the Athom RF IR Remote (full.yaml), which also gives me the Bluetooth proxy I was missing in my office.
In case anyone is interested: The Athom device consumes 0.9 watts. With Bluetooth turned off, it consumes 0.8 watts.

2 Likes

Hello
I’ve got a problem with HAIR, it sniff the same codes for different buttons on my sony tv remote :

I have managed to get my IR Mate set up and I flashed it with the full.yaml and it shows up under Hair as I would expect it should. The only thing I can’t figure out is that when in the ESPhome Web UI, it shows up, however, it is always offline. I was able to flash it using USB to My Computer. Is it supposed to be that way or should it show as online via its wifi connection to my network.

I haven’t tried to learn any commands yet. Just getting my feet wet with the setup. I am surprised that I have gotten this far!

Thanks for any info on wether it is normal for the device to show as offline in the Web UI for ESPhome and if flashing with USB with computer is the proper way to do that.

I am looking forward to using HAIR and potentially HARF when the time comes (and with support for the RM4 Pro).

Thanks,
Scott.

It should be marked ā€œOnlineā€.

Do the entities show up as available on the device’s page in HA? You can test that the devices is communicating with HA by tapping the top of the IR Mate and seeing if the event entity updates it’s state.

It appears to be an issue in how my Asus router handles mDNS. The device itself is working in HA and when I press the top of the IR Mate, I do get an event notification instantly so that is all working. It is just accessing it via the web UI in esphome.

I have forced the hostname that the Asus was created for itself into the ip reservation and then updated the yaml to that hostname and it seems to have fixed it and it is now showing online in esphome. It is definitely an Asus issue. It is a fairly old router but it still gets firmware updates so I keep using it.

Update:
Trying to play around with host names on the Asus did not work. To get around this, I had to edit the config. Select core config, wifi, advance settings, Use Address and enter the reserved IP address that I created for the IR Mate in my Asus router. ESPhome will then use the IP address to find the IR Mate instead of the hostname. You cannot enter an IP address directly in as hostname without it complaining about the ā€œ.ā€ character not being allowed in a hostname.

This may help someone who is having name resolution issues with an older router.

1 Like

Interesting… can you open an issue on GitHub? What happens when you test the captured signal?

~ DAB

The goal is to use an old tv remote (the tv is broken) to trigger script in HA. so I can’t test recevied code.

on the original post I send logs of esphome

the sniffed codes: