Dyson AM07 as a Full Fan Entity via Power Monitoring (IR + Kasa EP25)

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.

Update: Moved to a Custom Python Component

After running the YAML automation approach for a while, I hit fundamental limitations with HA’s automation system for this use case:

  • repeat: until loops can’t reliably mutate variables between iterations
  • input_number.set_value doesn’t commit state synchronously, causing race conditions in loops
  • mode: single rejects triggers that arrive while running, breaking self-sustaining chains
  • HomeKit slider spam creates cascading issues with any YAML-based speed control

The wattage mapping and IR codes are solid. The problem was the control loop. So I rewrote it as a custom Python component that handles everything internally.

What Changed

The YAML template fan, template sensors, input_numbers, timers, and speed control automations are all gone. Replaced by a single custom component: dyson_fan.

The Custom Component

Two files in /config/custom_components/dyson_fan/:

Key features:

  • Pure Python asyncio control loop with real variables and real timing
  • asyncio.Lock prevents concurrent speed changes
  • asyncio.CancelledError handles HomeKit slider debounce naturally (each new value cancels the previous pending task, last value wins)
  • Wattage updates from the Kasa plug are ignored while IR commands are in flight and during a settle period after
  • self._speed is updated immediately as each IR command fires, so the UI shows real-time progress
  • Mid-sequence target changes are detected each iteration and the loop adjusts direction
  • Settle period (configurable, default 10s) blocks wattage from overwriting tracked speed until the EP25 reports stable readings

Configuration

# configuration.yaml
dyson_fan:
  fans:
    am07:
      name: Dyson AM07
      remote_entity: remote.your_broadlink
      power_sensor: sensor.your_plug_current_consumption
      plug_switch: switch.your_plug
      device_name: dyson_am07
      speed_count: 10
      ir_delay: 0.6
      settle_time: 10.0
      debounce_time: 2.0

The Control Flow

  1. set_percentage called (from UI, HomeKit, automation, whatever)
  2. Target speed calculated from percentage
  3. If a previous debounce task is pending, cancel it (HomeKit slider spam handling)
  4. Wait for debounce period (adaptive: 0.3s checks, up to 2s if values keep changing)
  5. Acquire control lock (blocks concurrent changes)
  6. Read self._speed as current position (NOT wattage, which may be transitional)
  7. While loop: send speed_up or speed_down, increment/decrement tracked, update UI
  8. 0.6s delay between IR commands (matching the physical remote’s cadence)
  9. Each iteration re-reads self._target_speed in case it changed
  10. When done, set settle period to block wattage updates for 10s
  11. Release lock

Why Python Over YAML

The same logic that took hundreds of lines of YAML across multiple automations, timers, input_numbers, and choose blocks is ~50 lines of Python in _move_to_speed(). It runs in a single async context with proper variable scoping, real cancellation, and a mutex lock. No fighting the platform.

Wattage Still Provides Ground Truth

The wattage mapping is unchanged. Outside of active speed changes, the Kasa EP25 power readings update the fan entity every ~5 seconds. If someone uses the physical Dyson remote, the speed change shows up in HA within seconds. The custom component just gates those updates during IR operations to prevent interference.

Installation

Create /config/custom_components/dyson_fan/ and add these three files:

manifest.json

{
  "domain": "dyson_fan",
  "name": "Dyson IR Fan",
  "version": "1.0.0",
  "documentation": "",
  "dependencies": [],
  "codeowners": [],
  "iot_class": "local_polling"
}

__init__.py

"""Dyson IR Fan - Stateful fan entity via Broadlink IR + power monitoring.

Creates a proper fan entity for IR-controlled Dyson fans using:
- Broadlink RM for IR transmission
- Kasa EP25 (or similar) power monitoring plug for state detection
- Wattage-to-speed mapping for real-time state feedback
"""

import asyncio
import logging

from homeassistant.core import HomeAssistant
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType

_LOGGER = logging.getLogger(__name__)

DOMAIN = "dyson_fan"
PLATFORM = "fan"


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Set up the Dyson IR Fan component."""
    conf = config.get(DOMAIN, {})
    fans_conf = conf.get("fans", {})

    if not fans_conf:
        _LOGGER.error("No fans configured in dyson_fan")
        return False

    hass.data[DOMAIN] = fans_conf

    await async_load_platform(hass, PLATFORM, DOMAIN, {}, config)

    _LOGGER.info("Dyson IR Fan loaded with %d fan(s)", len(fans_conf))
    return True

fan.py

"""Fan platform for Dyson IR Fan."""

import asyncio
import logging
import time

from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

_LOGGER = logging.getLogger(__name__)

DOMAIN = "dyson_fan"

# Default wattage thresholds (Dyson AM07)
# Calibrate these for your specific fan model
DEFAULT_THRESHOLDS = [
    (1.0, 0, False),
    (5.2, 1, False),
    (6.5, 2, False),
    (7.5, 1, True),
    (8.8, 2, True),
    (10.4, 3, False),
    (12.0, 3, True),
    (13.5, 4, False),
    (15.7, 4, True),
    (17.9, 5, False),
    (20.2, 5, True),
    (22.5, 6, False),
    (25.3, 6, True),
    (28.0, 7, False),
    (31.5, 7, True),
    (35.0, 8, False),
    (38.5, 8, True),
    (42.5, 9, False),
    (47.0, 9, True),
    (52.0, 10, False),
]
DEFAULT_MAX_SPEED = 10
DEFAULT_MAX_SWING = True


async def async_setup_platform(
    hass: HomeAssistant,
    config: ConfigType,
    async_add_entities: AddEntitiesCallback,
    discovery_info: DiscoveryInfoType | None = None,
) -> None:
    """Set up the Dyson IR Fan platform."""
    fans_conf = hass.data.get(DOMAIN, {})
    entities = []
    for fan_id, fan_conf in fans_conf.items():
        entities.append(DysonIRFan(hass, fan_id, fan_conf))
    async_add_entities(entities)


class DysonIRFan(FanEntity):
    """A Dyson fan controlled via IR with power monitoring for state."""

    def __init__(self, hass: HomeAssistant, fan_id: str, conf: dict):
        self.hass = hass
        self._fan_id = fan_id
        self._attr_unique_id = f"dyson_fan_{fan_id}"
        self._attr_name = conf.get("name", f"Dyson {fan_id}")
        self._attr_supported_features = (
            FanEntityFeature.SET_SPEED
            | FanEntityFeature.OSCILLATE
            | FanEntityFeature.TURN_ON
            | FanEntityFeature.TURN_OFF
        )
        self._attr_speed_count = conf.get("speed_count", 10)

        self._remote_entity = conf.get("remote_entity")
        self._power_sensor = conf.get("power_sensor")
        self._plug_switch = conf.get("plug_switch")
        self._device_name = conf.get("device_name", "dyson_am07")

        self._thresholds = DEFAULT_THRESHOLDS
        self._ir_delay = conf.get("ir_delay", 0.6)
        self._settle_time = conf.get("settle_time", 6.0)
        self._debounce_time = conf.get("debounce_time", 2.0)

        # Internal state
        self._speed = 0
        self._last_speed = 1  # speed fan resumes at after power on
        self._oscillating = False
        self._is_on = False

        # Control state
        self._target_speed = None
        self._control_lock = asyncio.Lock()
        self._ignore_wattage_until = 0.0
        self._debounce_task = None

    async def async_added_to_hass(self):
        """Start listening for power sensor changes."""
        if self._power_sensor:
            async_track_state_change_event(
                self.hass, [self._power_sensor], self._power_changed
            )
            self._update_from_wattage()

        _LOGGER.info(
            "Dyson IR Fan '%s' ready (remote=%s, power=%s)",
            self._attr_name, self._remote_entity, self._power_sensor
        )

    @callback
    def _power_changed(self, event):
        """Handle power sensor state changes.

        Ignores updates during active IR operations and settle periods.
        Filters out 1-level transitional spikes.
        """
        now = time.monotonic()
        if now < self._ignore_wattage_until:
            return
        if self._control_lock.locked():
            return

        state = self.hass.states.get(self._power_sensor)
        if state is None or state.state in ("unknown", "unavailable"):
            return
        try:
            watts = float(state.state)
        except (ValueError, TypeError):
            return

        new_speed, new_swing = self._watts_to_state(watts)

        if abs(new_speed - self._speed) <= 1 and new_speed > 0 and self._speed > 0:
            self._oscillating = new_swing
            if new_speed == self._speed:
                self.async_write_ha_state()
                return
            self._speed = new_speed
            self._is_on = new_speed > 0
            self.async_write_ha_state()
        else:
            self._speed = new_speed
            self._oscillating = new_swing
            self._is_on = new_speed > 0
            self.async_write_ha_state()

    def _update_from_wattage(self):
        """Read wattage and update speed/swing/on state."""
        state = self.hass.states.get(self._power_sensor)
        if state is None or state.state in ("unknown", "unavailable"):
            return
        try:
            watts = float(state.state)
        except (ValueError, TypeError):
            return

        speed, swing = self._watts_to_state(watts)
        self._speed = speed
        self._oscillating = swing
        self._is_on = speed > 0

    def _watts_to_state(self, watts: float) -> tuple:
        """Convert wattage to speed and swing state."""
        for max_w, speed, swing in self._thresholds:
            if watts < max_w:
                return speed, swing
        return DEFAULT_MAX_SPEED, DEFAULT_MAX_SWING

    @property
    def is_on(self) -> bool:
        return self._is_on

    @property
    def percentage(self) -> int | None:
        if not self._is_on:
            return 0
        return int((self._speed / self._attr_speed_count) * 100)

    @property
    def oscillating(self) -> bool | None:
        return self._oscillating

    async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs):
        if percentage is not None:
            await self.async_set_percentage(percentage)
        elif not self._is_on:
            await self._send_ir("power")
            self._is_on = True
            self._speed = 1
            self._ignore_wattage_until = time.monotonic() + self._settle_time
            self.async_write_ha_state()

    async def async_turn_off(self, **kwargs):
        if self._is_on:
            self._last_speed = self._speed
            await self._send_ir("power")
            self._is_on = False
            self._speed = 0
            self._ignore_wattage_until = time.monotonic() + self._settle_time
            self.async_write_ha_state()

    async def async_set_percentage(self, percentage: int) -> None:
        target = round(percentage / (100 / self._attr_speed_count))
        target = max(0, min(self._attr_speed_count, target))

        if target == 0:
            await self.async_turn_off()
            return

        self._target_speed = target

        if self._debounce_task is not None:
            self._debounce_task.cancel()

        self._debounce_task = self.hass.async_create_task(
            self._debounced_set_speed(target)
        )

    async def _debounced_set_speed(self, target: int):
        """Wait for rapid changes to settle, then execute.

        Uses adaptive debounce: waits 0.3s, checks if target is still
        changing. If stable, executes immediately. If still changing,
        waits up to debounce_time total.
        """
        try:
            elapsed = 0.0
            check_interval = 0.3
            while elapsed < self._debounce_time:
                await asyncio.sleep(check_interval)
                elapsed += check_interval
                if self._target_speed == target:
                    break
                target = self._target_speed
        except asyncio.CancelledError:
            return

        target = self._target_speed
        if target is None or target == 0:
            return

        async with self._control_lock:
            await self._move_to_speed(target)

    async def _move_to_speed(self, target: int):
        """Move fan to target speed. Runs under control_lock."""
        self._ignore_wattage_until = time.monotonic() + 30

        current = self._speed

        # Handle fan off -> on
        if not self._is_on:
            if self._plug_switch:
                await self.hass.services.async_call(
                    "switch", "turn_on",
                    {"entity_id": self._plug_switch},
                    blocking=True
                )
                await asyncio.sleep(0.5)

            await self._send_ir("power")
            # Show last known speed in UI immediately
            self._is_on = True
            self._speed = self._last_speed
            self.async_write_ha_state()
            # Wait for wattage to stabilize and read actual speed
            await asyncio.sleep(self._settle_time)
            self._ignore_wattage_until = 0
            self._update_from_wattage()
            self._ignore_wattage_until = time.monotonic() + 30
            current = self._speed
            _LOGGER.info(
                "Dyson %s: powered on at speed %d (expected %d)",
                self._attr_name, current, self._last_speed
            )

        tracked = current

        _LOGGER.info("Dyson %s: speed %d -> %d", self._attr_name, current, target)

        while tracked != target:
            latest_target = self._target_speed
            if latest_target is not None and latest_target != target:
                target = latest_target
                _LOGGER.info("Dyson %s: target changed to %d", self._attr_name, target)
                if tracked == target:
                    break

            if tracked < target:
                await self._send_ir("speed_up")
                tracked += 1
            else:
                await self._send_ir("speed_down")
                tracked -= 1

            self._speed = tracked
            self._is_on = tracked > 0
            self.async_write_ha_state()

            if tracked != target:
                await asyncio.sleep(self._ir_delay)

        _LOGGER.info("Dyson %s: reached speed %d", self._attr_name, tracked)
        self._ignore_wattage_until = time.monotonic() + self._settle_time

    async def async_oscillate(self, oscillating: bool) -> None:
        await self._send_ir("swing")

    async def _send_ir(self, command: str):
        """Send an IR command via the Broadlink remote."""
        await self.hass.services.async_call(
            "remote", "send_command",
            {
                "entity_id": self._remote_entity,
                "device": self._device_name,
                "command": command,
            },
            blocking=True
        )