I turned a dumb IR-only Dyson AM07 tower fan into a fully stateful Home Assistant fan entity with speed detection, swing detection, and reliable control using a Broadlink RM4 Mini for IR transmission and a Kasa EP25 smart plug for power monitoring and state detection.
The Problem
The Dyson AM07 is controlled via IR with some nasty quirks:
- Rolling power codes: The remote cycles through multiple codes for the power button. Learning and replaying them from a Broadlink doesn’t work reliably because the fan expects a specific code in sequence.
- Toggle-only power: There’s no discrete on/off, just a toggle. If your state tracking is wrong, “off” turns it on.
- No state feedback: It’s IR. You send a command and hope for the best.
- Incremental speed only: No “set to speed 5” command. Just speed up and speed down, 10 levels.
The community approach has been “send commands and pray.” I wanted actual state.
The Solution
A Kasa EP25 smart plug with energy monitoring sits between the wall outlet and the Dyson. The plug reports real-time wattage to HA every ~5 seconds. Every speed level draws a distinct, measurable amount of power. Swing (oscillation) adds a consistent ~2.2W from the swing motor.
By mapping wattage ranges to speed levels and swing state, HA always knows exactly what the fan is doing, even when controlled by the physical remote.
The Wattage Map
Calibrated by running through all 10 speeds with and without swing:
| Watts Range | Speed | Swing |
|---|---|---|
| < 1 | Off | - |
| 1 - 5.2 | 1 | Off |
| 5.2 - 6.5 | 2 | Off |
| 6.5 - 7.5 | 1 | On |
| 7.5 - 8.8 | 2 | On |
| 8.8 - 10.4 | 3 | Off |
| 10.4 - 12.0 | 3 | On |
| 12.0 - 13.5 | 4 | Off |
| 13.5 - 15.7 | 4 | On |
| 15.7 - 17.9 | 5 | Off |
| 17.9 - 20.2 | 5 | On |
| 20.2 - 22.5 | 6 | Off |
| 22.5 - 25.3 | 6 | On |
| 25.3 - 28.0 | 7 | Off |
| 28.0 - 31.5 | 7 | On |
| 31.5 - 35.0 | 8 | Off |
| 35.0 - 38.5 | 8 | On |
| 38.5 - 42.5 | 9 | Off |
| 42.5 - 47.0 | 9 | On |
| 47.0 - 52.0 | 10 | Off |
| > 52.0 | 10 | On |
Every state has its own wattage band with clear separation and no overlap.
Calibration Script
To build your own wattage map, I created a calibration script that steps through all speeds with 20-second delays, then repeats with swing on. Run it and pull the wattage readings from the recorder database:
# scripts.yaml
dyson_calibrate:
alias: Dyson Calibrate
sequence:
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: power
- delay: 5
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: speed_down
num_repeats: 10
- delay: 20
# Then speed_up with 20s delay between each level
# ... repeat for all 10 speeds
# Turn on swing, go back down 10-1
# Power off at the end
After the calibration run, query the database for the wattage readings over the test window and note the settled value at each level.
IR Codes
Working Broadlink codes for the Dyson AM07 (community-sourced):
Hex format (for reference and conversion):
Power: 26002400451d1736171d161d1736161d171c171c171e161e161e161d161d171d1636161d16000d05
Swing: 260048004a1b1934191b191b19331a1a191a191a1935191b1934191b19331a1a1933191a19000cc94a1b1934191b191a1a33191b191a191a1935191b1934191a1a33191b1933191a19000d05
Speed Down: 26002400481d1736181b171c1734171c181b171b183617351735173517341734181b181b18000d05
Speed Up: 26004800481d1835181b181c1834181c181b181b191c1934181b1835181c18341834181b19000cbc481c1835181c181c1834181b181c171b181d1835181c1835181c18341834181b19000d05
Base64 format (for the Broadlink .storage file and for use with b64: prefix in remote.send_command):
Power: JgAkAEUdFzYXHRYdFzYWHRccFxwXHhYeFh4WHRYdFx0WNhYdFgANBQ==
Swing: JgBIAEobGTQZGxkbGTMaGhkaGRoZNRkbGTQZGxkzGhoZMxkaGQAMyUobGTQZGxkaGjMZGxkaGRoZNRkbGTQZGhozGRsZMxkaGQANBQ==
Speed Down: JgAkAEgdFzYYGxccFzQXHBgbFxsYNhc1FzUXNRc0FzQYGxgbGAANBQ==
Speed Up: JgBIAEgdGDUYGxgcGDQYHBgbGBsZHBk0GBsYNRgcGDQYNBgbGQAMvEgcGDUYHBgcGDQYGxgcFxsYHRg1GBwYNRgcGDQYNBgbGQANBQ==
Important: The power code is a toggle that turns the fan on AND off. The Dyson uses rolling codes for power, so codes learned by your Broadlink may not work reliably. The community-sourced codes above use a single-shot transmission that the fan accepts consistently.
Store these in the Broadlink codes file as a device called dyson_am07 with commands power, swing, speed_up, speed_down.
A note on learned vs community codes: We initially tried learning codes from our Dyson remote using remote.learn_command, but the learned codes were unreliable. The Broadlink captures include a repeat transmission that the Dyson sometimes interprets as two separate commands. The community-sourced codes above are clean single-shot transmissions that work consistently.
We ended up stopping Home Assistant, editing the Broadlink codes file at /config/.storage/broadlink_remote_<MAC>_codes directly, and replacing the learned codes with the community codes. The HA documentation recommends against editing .storage files, as HA holds them in memory and overwrites on shutdown. We only did this with HA stopped to avoid conflicts. If you take this approach, make sure HA is fully stopped first.
Template Sensors
# configuration.yaml
template:
- sensor:
- name: Dyson AM07 Speed
unique_id: dyson_am07_speed
state: >
{% set w = states('sensor.your_plug_current_consumption') | float(0) %}
{% if w < 1 %}0
{% elif w < 5.2 %}1
{% elif w < 6.5 %}2
{% elif w < 7.5 %}1
{% elif w < 8.8 %}2
{% elif w < 10.4 %}3
{% elif w < 12.0 %}3
{% elif w < 13.5 %}4
{% elif w < 15.7 %}4
{% elif w < 17.9 %}5
{% elif w < 20.2 %}5
{% elif w < 22.5 %}6
{% elif w < 25.3 %}6
{% elif w < 28.0 %}7
{% elif w < 31.5 %}7
{% elif w < 35.0 %}8
{% elif w < 38.5 %}8
{% elif w < 42.5 %}9
{% elif w < 47.0 %}9
{% elif w < 52.0 %}10
{% else %}10
{% endif %}
- binary_sensor:
- name: Dyson AM07 Swing
unique_id: dyson_am07_swing
state: >
{% set w = states('sensor.your_plug_current_consumption') | float(0) %}
{% if w < 1 %}off
{% elif w < 5.2 %}off
{% elif w < 6.5 %}off
{% elif w < 7.5 %}on
{% elif w < 8.8 %}on
{% elif w < 10.4 %}off
{% elif w < 12.0 %}on
{% elif w < 13.5 %}off
{% elif w < 15.7 %}on
{% elif w < 17.9 %}off
{% elif w < 20.2 %}on
{% elif w < 22.5 %}off
{% elif w < 25.3 %}on
{% elif w < 28.0 %}off
{% elif w < 31.5 %}on
{% elif w < 35.0 %}off
{% elif w < 38.5 %}on
{% elif w < 42.5 %}off
{% elif w < 47.0 %}on
{% elif w < 52.0 %}off
{% else %}on
{% endif %}
Fan Entity (Modern Template Syntax)
# Add to the template: section in configuration.yaml
- fan:
- unique_id: dyson_am07_fan
name: Dyson AM07
speed_count: 10
state: >
{% if states('sensor.your_plug_current_consumption') | float(0) > 1 %}on{% else %}off{% endif %}
percentage: >
{{ states('sensor.dyson_am07_speed') | int(0) * 10 }}
oscillating: >
{{ is_state('binary_sensor.dyson_am07_swing', 'on') }}
turn_on:
- action: script.dyson_power_toggle
turn_off:
- action: script.dyson_power_toggle
set_percentage:
- action: script.dyson_set_speed
data:
speed: "{{ (percentage / 10) | int }}"
set_oscillating:
- action: script.dyson_toggle_swing
Control Scripts
The set_speed script ensures the Kasa plug is on, handles power on/off, and sends the right number of IR commands:
# scripts.yaml
dyson_set_speed:
alias: Dyson Set Speed
mode: queued
fields:
speed:
description: Target speed 0-10 (0 = off)
sequence:
# Ensure the smart plug is on
- service: switch.turn_on
target:
entity_id: switch.your_kasa_plug
- delay: 1
- variables:
current: '{{ states(''sensor.dyson_am07_speed'') | int(0) }}'
target: '{{ speed | int(0) }}'
is_on: '{{ states(''sensor.your_plug_current_consumption'') | float(0) > 1 }}'
- choose:
# Target off, fan is on
- conditions: '{{ target == 0 and is_on }}'
sequence:
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: power
# Target off, already off
- conditions: '{{ target == 0 and not is_on }}'
sequence: []
# Target on, fan is off - power on then adjust
- conditions: '{{ target > 0 and not is_on }}'
sequence:
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: power
- delay: 2
- variables:
current: '{{ states(''sensor.dyson_am07_speed'') | int(0) }}'
diff: '{{ target - current }}'
- choose:
- conditions: '{{ diff > 0 }}'
sequence:
- repeat:
count: '{{ diff }}'
sequence:
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: speed_up
- delay: 0.6
- conditions: '{{ diff < 0 }}'
sequence:
- repeat:
count: '{{ diff | abs }}'
sequence:
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: speed_down
- delay: 0.6
# Target on, fan is on - just adjust speed
- conditions: '{{ target > 0 and is_on }}'
sequence:
- variables:
diff: '{{ target - current }}'
- choose:
- conditions: '{{ diff > 0 }}'
sequence:
- repeat:
count: '{{ diff }}'
sequence:
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: speed_up
- delay: 0.6
- conditions: '{{ diff < 0 }}'
sequence:
- repeat:
count: '{{ diff | abs }}'
sequence:
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: speed_down
- delay: 0.6
dyson_power_toggle:
alias: Dyson Power Toggle
mode: queued
sequence:
- service: switch.turn_on
target:
entity_id: switch.your_kasa_plug
- delay: 1
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: power
dyson_toggle_swing:
alias: Dyson Toggle Swing
mode: queued
sequence:
- service: remote.send_command
target:
entity_id: remote.your_broadlink
data:
device: dyson_am07
command: swing
HomeKit Slider Debounce
If you expose the fan entity to HomeKit via the HomeKit Bridge, you’ll hit a problem: dragging the speed slider sends rapid-fire set_percentage calls (5+ per second). The first call starts the IR command sequence, and subsequent calls either get rejected (“Already running”) or queue up and cause overshoot.
The fix is an input_number debounce buffer. Instead of the fan template calling the speed script directly, it writes to an input_number. A separate automation with mode: restart watches the input_number, waits 2 seconds for the slider to settle, then calls the script once with the final value.
# configuration.yaml (top-level entry, not nested inside anything)
input_number:
dyson_target_speed:
name: Dyson Target Speed
min: 0
max: 10
step: 1
mode: slider
Important: The input_number: must be a top-level key in configuration.yaml, not nested inside the template: or fan: sections. Missing this will result in “entity not currently available” errors.
Update the fan template’s set_percentage:
set_percentage:
- action: input_number.set_value
target:
entity_id: input_number.dyson_target_speed
data:
value: "{{ (percentage / 10) | int }}"
Add the debounce automation:
- id: dyson_speed_debounce
alias: Dyson Speed Debounce
trigger:
- platform: state
entity_id: input_number.dyson_target_speed
action:
- delay: 2
- service: script.dyson_set_speed
data:
speed: '{{ states(''input_number.dyson_target_speed'') | int(0) }}'
mode: restart
The mode: restart on this automation is safe because it only contains a delay and a single script call. Each new slider value cancels the previous delay and starts a fresh 2-second wait. The IR command script itself stays mode: queued so it never gets cancelled mid-transmission.
State Lag
The Kasa EP25 reports wattage every ~5 seconds. When the fan changes speed (via IR, physical remote, or automation), the UI will lag a few seconds before reflecting the new state. This is normal. The state always settles to the true value once the wattage stabilizes. If someone uses the physical Dyson remote, HA catches up within a few seconds without any intervention.
The Result
- Full fan entity in HA with on/off, 10-speed slider, and oscillate toggle
- Real-time state from wattage monitoring, always knows the current speed and swing state
- Physical remote still works. Wattage updates the entity automatically
- Works in HomeKit. Expose via HomeKit Bridge and it shows as a proper fan
- Pico remote control. Pair with a Lutron Pico for physical button control (see my pico_click post for double-press support)
- Reliable power toggle. The Kasa plug state tells HA definitively if the fan is on or off, preventing the toggle inversion problem
Hardware Required
- Broadlink RM4 Mini (~$25) for IR transmission
- Kasa EP25 smart plug (~$15) for energy monitoring
- Dyson AM07 tower fan
- Total: $40 to make a dumb fan smart
This Approach Works for Any IR Device
The wattage-to-state mapping pattern isn’t Dyson-specific. Any device with distinct power draw at different settings can be turned into a stateful entity this way: other fans, space heaters, humidifiers, etc. Calibrate the wattage map, build the template sensors, and you have state feedback for any dumb device.