|
12 | 12 | import logging |
13 | 13 | from typing import Any |
14 | 14 |
|
15 | | -from homeassistant.components.fan import FanEntity, FanEntityFeature |
| 15 | +from homeassistant.components.fan import ( |
| 16 | + DIRECTION_FORWARD, |
| 17 | + DIRECTION_REVERSE, |
| 18 | + FanEntity, |
| 19 | + FanEntityFeature, |
| 20 | +) |
16 | 21 | from homeassistant.config_entries import ConfigEntry |
17 | 22 | from homeassistant.core import HomeAssistant |
18 | 23 | from homeassistant.helpers.entity_platform import AddEntitiesCallback |
| 24 | +from homeassistant.helpers.restore_state import RestoreEntity |
19 | 25 | from homeassistant.util.percentage import ( |
20 | 26 | ordered_list_item_to_percentage, |
21 | 27 | percentage_to_ordered_list_item, |
22 | 28 | ) |
23 | 29 |
|
24 | 30 | from .coordinator import GoveeCoordinator |
25 | 31 | from .entity import GoveeEntity |
26 | | -from .models import GoveeDevice, OscillationCommand, PowerCommand, WorkModeCommand |
| 32 | +from .models import ( |
| 33 | + GoveeDevice, |
| 34 | + ModeCommand, |
| 35 | + OscillationCommand, |
| 36 | + PowerCommand, |
| 37 | + ToggleCommand, |
| 38 | + WorkModeCommand, |
| 39 | +) |
| 40 | +from .models.device import ( |
| 41 | + INSTANCE_FAN_SPEED_MODE, |
| 42 | + INSTANCE_FAN_TOGGLE, |
| 43 | + INSTANCE_REVERSE_AIRFLOW, |
| 44 | +) |
27 | 45 |
|
28 | 46 | _LOGGER = logging.getLogger(__name__) |
29 | 47 |
|
@@ -60,6 +78,19 @@ async def async_setup_entry( |
60 | 78 | ) |
61 | 79 | entities.append(GoveeFanEntity(coordinator, device)) |
62 | 80 |
|
| 81 | + # Ceiling-fan-with-light combos (e.g. H1310) report as |
| 82 | + # devices.types.light, so they get a light entity from the light |
| 83 | + # platform AND a fan entity here for the integrated fan (issue #74). |
| 84 | + elif device.supports_ceiling_fan: |
| 85 | + _LOGGER.debug( |
| 86 | + "Creating ceiling fan entity for %s (%s): reverse=%s, speeds=%d", |
| 87 | + device.name, |
| 88 | + device.sku, |
| 89 | + device.supports_reverse_airflow, |
| 90 | + len(device.get_ceiling_fan_speed_options()), |
| 91 | + ) |
| 92 | + entities.append(GoveeCeilingFanEntity(coordinator, device)) |
| 93 | + |
63 | 94 | async_add_entities(entities) |
64 | 95 | _LOGGER.debug("Set up %d Govee fan entities", len(entities)) |
65 | 96 |
|
@@ -241,3 +272,153 @@ async def async_oscillate(self, oscillating: bool) -> None: |
241 | 272 | self._device_id, |
242 | 273 | OscillationCommand(oscillating=oscillating), |
243 | 274 | ) |
| 275 | + |
| 276 | + |
| 277 | +class GoveeCeilingFanEntity(GoveeEntity, FanEntity, RestoreEntity): |
| 278 | + """Fan entity for ceiling-fan-with-light combos (e.g. H1310). |
| 279 | +
|
| 280 | + Controls the integrated fan via the ``fanToggle`` / ``fanSpeedMode`` / |
| 281 | + ``reverseAirflowToggle`` capabilities — separate from the device's light |
| 282 | + entity (the H1310 reports as devices.types.light). Govee's state poll |
| 283 | + does not return these fan values, so state is optimistic and restored |
| 284 | + across restarts via RestoreEntity (issue #74). |
| 285 | + """ |
| 286 | + |
| 287 | + _attr_icon = "mdi:ceiling-fan-light" |
| 288 | + |
| 289 | + def __init__( |
| 290 | + self, |
| 291 | + coordinator: GoveeCoordinator, |
| 292 | + device: GoveeDevice, |
| 293 | + ) -> None: |
| 294 | + """Initialize the ceiling fan entity.""" |
| 295 | + super().__init__(coordinator, device) |
| 296 | + |
| 297 | + # Distinct unique_id — the device_id alone backs the light entity. |
| 298 | + self._attr_unique_id = f"{device.device_id}_fan" |
| 299 | + self._attr_name = "Fan" |
| 300 | + |
| 301 | + # Speed values from fanSpeedMode options (e.g. [1, 2, 3, 4, 5, 6]). |
| 302 | + options = device.get_ceiling_fan_speed_options() |
| 303 | + self._speed_values: list[int] = ( |
| 304 | + [int(o["value"]) for o in options if "value" in o] if options else [1, 2, 3] |
| 305 | + ) |
| 306 | + self._attr_speed_count = len(self._speed_values) |
| 307 | + |
| 308 | + features = ( |
| 309 | + FanEntityFeature.TURN_ON |
| 310 | + | FanEntityFeature.TURN_OFF |
| 311 | + | FanEntityFeature.SET_SPEED |
| 312 | + ) |
| 313 | + if device.supports_reverse_airflow: |
| 314 | + features |= FanEntityFeature.DIRECTION |
| 315 | + self._attr_supported_features = features |
| 316 | + |
| 317 | + # Optimistic state — Govee does not report fan state on poll. |
| 318 | + self._is_on = False |
| 319 | + self._speed_value: int | None = None |
| 320 | + self._direction = DIRECTION_FORWARD |
| 321 | + |
| 322 | + async def async_added_to_hass(self) -> None: |
| 323 | + """Restore optimistic state on startup.""" |
| 324 | + await super().async_added_to_hass() |
| 325 | + last_state = await self.async_get_last_state() |
| 326 | + if last_state is None: |
| 327 | + return |
| 328 | + self._is_on = last_state.state == "on" |
| 329 | + pct = last_state.attributes.get("percentage") |
| 330 | + if pct is not None: |
| 331 | + try: |
| 332 | + self._speed_value = percentage_to_ordered_list_item( |
| 333 | + self._speed_values, int(pct) |
| 334 | + ) |
| 335 | + except (ValueError, TypeError): |
| 336 | + self._speed_value = None |
| 337 | + direction = last_state.attributes.get("direction") |
| 338 | + if direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): |
| 339 | + self._direction = direction |
| 340 | + |
| 341 | + @property |
| 342 | + def is_on(self) -> bool: |
| 343 | + """Return True if the fan is on (optimistic).""" |
| 344 | + return self._is_on |
| 345 | + |
| 346 | + @property |
| 347 | + def percentage(self) -> int | None: |
| 348 | + """Return current speed as a percentage (optimistic).""" |
| 349 | + if not self._is_on or self._speed_value is None: |
| 350 | + return 0 if not self._is_on else None |
| 351 | + try: |
| 352 | + return ordered_list_item_to_percentage( |
| 353 | + self._speed_values, self._speed_value |
| 354 | + ) |
| 355 | + except ValueError: |
| 356 | + return None |
| 357 | + |
| 358 | + @property |
| 359 | + def current_direction(self) -> str | None: |
| 360 | + """Return the current airflow direction (optimistic).""" |
| 361 | + if not self._device.supports_reverse_airflow: |
| 362 | + return None |
| 363 | + return self._direction |
| 364 | + |
| 365 | + async def async_turn_on( |
| 366 | + self, |
| 367 | + percentage: int | None = None, |
| 368 | + preset_mode: str | None = None, |
| 369 | + **kwargs: Any, |
| 370 | + ) -> None: |
| 371 | + """Turn the fan on, optionally at a given speed.""" |
| 372 | + success = await self.coordinator.async_control_device( |
| 373 | + self._device_id, |
| 374 | + ToggleCommand(toggle_instance=INSTANCE_FAN_TOGGLE, enabled=True), |
| 375 | + ) |
| 376 | + if success: |
| 377 | + self._is_on = True |
| 378 | + self.async_write_ha_state() |
| 379 | + if percentage is not None: |
| 380 | + await self.async_set_percentage(percentage) |
| 381 | + |
| 382 | + async def async_turn_off(self, **kwargs: Any) -> None: |
| 383 | + """Turn the fan off.""" |
| 384 | + success = await self.coordinator.async_control_device( |
| 385 | + self._device_id, |
| 386 | + ToggleCommand(toggle_instance=INSTANCE_FAN_TOGGLE, enabled=False), |
| 387 | + ) |
| 388 | + if success: |
| 389 | + self._is_on = False |
| 390 | + self.async_write_ha_state() |
| 391 | + |
| 392 | + async def async_set_percentage(self, percentage: int) -> None: |
| 393 | + """Set the fan speed from a percentage. 0% turns off.""" |
| 394 | + if percentage == 0: |
| 395 | + await self.async_turn_off() |
| 396 | + return |
| 397 | + |
| 398 | + speed_value = percentage_to_ordered_list_item(self._speed_values, percentage) |
| 399 | + _LOGGER.debug( |
| 400 | + "Setting ceiling fan speed: percentage=%d, fanSpeedMode=%d", |
| 401 | + percentage, |
| 402 | + speed_value, |
| 403 | + ) |
| 404 | + success = await self.coordinator.async_control_device( |
| 405 | + self._device_id, |
| 406 | + ModeCommand(mode_instance=INSTANCE_FAN_SPEED_MODE, value=speed_value), |
| 407 | + ) |
| 408 | + if success: |
| 409 | + self._speed_value = speed_value |
| 410 | + # Setting a speed implies the fan is running. |
| 411 | + self._is_on = True |
| 412 | + self.async_write_ha_state() |
| 413 | + |
| 414 | + async def async_set_direction(self, direction: str) -> None: |
| 415 | + """Set the airflow direction (reverse airflow toggle).""" |
| 416 | + reverse = direction == DIRECTION_REVERSE |
| 417 | + _LOGGER.debug("Setting ceiling fan direction: %s", direction) |
| 418 | + success = await self.coordinator.async_control_device( |
| 419 | + self._device_id, |
| 420 | + ToggleCommand(toggle_instance=INSTANCE_REVERSE_AIRFLOW, enabled=reverse), |
| 421 | + ) |
| 422 | + if success: |
| 423 | + self._direction = DIRECTION_REVERSE if reverse else DIRECTION_FORWARD |
| 424 | + self.async_write_ha_state() |
0 commit comments