From a89af35dfbe483176e5f97699ab35213d2f28b4e Mon Sep 17 00:00:00 2001 From: Tom Lasswell Date: Sun, 31 May 2026 09:45:02 -0400 Subject: [PATCH] feat(fan): add ceiling-fan control for light+fan combos like H1310 (fixes #74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The H1310 reports as devices.types.light, so it only got a light entity — its integrated fan (fanToggle / fanSpeedMode / reverseAirflowToggle) had no control. Add GoveeCeilingFanEntity for devices exposing those capabilities, giving on/off, 6-speed control, and reverse-airflow direction. State is optimistic + RestoreEntity since Govee's poll doesn't return fan state. --- custom_components/govee/fan.py | 185 ++++++++++++++++++++++- custom_components/govee/models/device.py | 40 +++++ tests/test_fan.py | 169 +++++++++++++++++++++ 3 files changed, 392 insertions(+), 2 deletions(-) diff --git a/custom_components/govee/fan.py b/custom_components/govee/fan.py index f7ca25e9..d16d2a04 100644 --- a/custom_components/govee/fan.py +++ b/custom_components/govee/fan.py @@ -12,10 +12,16 @@ import logging from typing import Any -from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + FanEntity, + FanEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -23,7 +29,19 @@ from .coordinator import GoveeCoordinator from .entity import GoveeEntity -from .models import GoveeDevice, OscillationCommand, PowerCommand, WorkModeCommand +from .models import ( + GoveeDevice, + ModeCommand, + OscillationCommand, + PowerCommand, + ToggleCommand, + WorkModeCommand, +) +from .models.device import ( + INSTANCE_FAN_SPEED_MODE, + INSTANCE_FAN_TOGGLE, + INSTANCE_REVERSE_AIRFLOW, +) _LOGGER = logging.getLogger(__name__) @@ -60,6 +78,19 @@ async def async_setup_entry( ) entities.append(GoveeFanEntity(coordinator, device)) + # Ceiling-fan-with-light combos (e.g. H1310) report as + # devices.types.light, so they get a light entity from the light + # platform AND a fan entity here for the integrated fan (issue #74). + elif device.supports_ceiling_fan: + _LOGGER.debug( + "Creating ceiling fan entity for %s (%s): reverse=%s, speeds=%d", + device.name, + device.sku, + device.supports_reverse_airflow, + len(device.get_ceiling_fan_speed_options()), + ) + entities.append(GoveeCeilingFanEntity(coordinator, device)) + async_add_entities(entities) _LOGGER.debug("Set up %d Govee fan entities", len(entities)) @@ -241,3 +272,153 @@ async def async_oscillate(self, oscillating: bool) -> None: self._device_id, OscillationCommand(oscillating=oscillating), ) + + +class GoveeCeilingFanEntity(GoveeEntity, FanEntity, RestoreEntity): + """Fan entity for ceiling-fan-with-light combos (e.g. H1310). + + Controls the integrated fan via the ``fanToggle`` / ``fanSpeedMode`` / + ``reverseAirflowToggle`` capabilities — separate from the device's light + entity (the H1310 reports as devices.types.light). Govee's state poll + does not return these fan values, so state is optimistic and restored + across restarts via RestoreEntity (issue #74). + """ + + _attr_icon = "mdi:ceiling-fan-light" + + def __init__( + self, + coordinator: GoveeCoordinator, + device: GoveeDevice, + ) -> None: + """Initialize the ceiling fan entity.""" + super().__init__(coordinator, device) + + # Distinct unique_id — the device_id alone backs the light entity. + self._attr_unique_id = f"{device.device_id}_fan" + self._attr_name = "Fan" + + # Speed values from fanSpeedMode options (e.g. [1, 2, 3, 4, 5, 6]). + options = device.get_ceiling_fan_speed_options() + self._speed_values: list[int] = ( + [int(o["value"]) for o in options if "value" in o] if options else [1, 2, 3] + ) + self._attr_speed_count = len(self._speed_values) + + features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + if device.supports_reverse_airflow: + features |= FanEntityFeature.DIRECTION + self._attr_supported_features = features + + # Optimistic state — Govee does not report fan state on poll. + self._is_on = False + self._speed_value: int | None = None + self._direction = DIRECTION_FORWARD + + async def async_added_to_hass(self) -> None: + """Restore optimistic state on startup.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state is None: + return + self._is_on = last_state.state == "on" + pct = last_state.attributes.get("percentage") + if pct is not None: + try: + self._speed_value = percentage_to_ordered_list_item( + self._speed_values, int(pct) + ) + except (ValueError, TypeError): + self._speed_value = None + direction = last_state.attributes.get("direction") + if direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): + self._direction = direction + + @property + def is_on(self) -> bool: + """Return True if the fan is on (optimistic).""" + return self._is_on + + @property + def percentage(self) -> int | None: + """Return current speed as a percentage (optimistic).""" + if not self._is_on or self._speed_value is None: + return 0 if not self._is_on else None + try: + return ordered_list_item_to_percentage( + self._speed_values, self._speed_value + ) + except ValueError: + return None + + @property + def current_direction(self) -> str | None: + """Return the current airflow direction (optimistic).""" + if not self._device.supports_reverse_airflow: + return None + return self._direction + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on, optionally at a given speed.""" + success = await self.coordinator.async_control_device( + self._device_id, + ToggleCommand(toggle_instance=INSTANCE_FAN_TOGGLE, enabled=True), + ) + if success: + self._is_on = True + self.async_write_ha_state() + if percentage is not None: + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + success = await self.coordinator.async_control_device( + self._device_id, + ToggleCommand(toggle_instance=INSTANCE_FAN_TOGGLE, enabled=False), + ) + if success: + self._is_on = False + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the fan speed from a percentage. 0% turns off.""" + if percentage == 0: + await self.async_turn_off() + return + + speed_value = percentage_to_ordered_list_item(self._speed_values, percentage) + _LOGGER.debug( + "Setting ceiling fan speed: percentage=%d, fanSpeedMode=%d", + percentage, + speed_value, + ) + success = await self.coordinator.async_control_device( + self._device_id, + ModeCommand(mode_instance=INSTANCE_FAN_SPEED_MODE, value=speed_value), + ) + if success: + self._speed_value = speed_value + # Setting a speed implies the fan is running. + self._is_on = True + self.async_write_ha_state() + + async def async_set_direction(self, direction: str) -> None: + """Set the airflow direction (reverse airflow toggle).""" + reverse = direction == DIRECTION_REVERSE + _LOGGER.debug("Setting ceiling fan direction: %s", direction) + success = await self.coordinator.async_control_device( + self._device_id, + ToggleCommand(toggle_instance=INSTANCE_REVERSE_AIRFLOW, enabled=reverse), + ) + if success: + self._direction = DIRECTION_REVERSE if reverse else DIRECTION_FORWARD + self.async_write_ha_state() diff --git a/custom_components/govee/models/device.py b/custom_components/govee/models/device.py index 23f0b596..5577398a 100644 --- a/custom_components/govee/models/device.py +++ b/custom_components/govee/models/device.py @@ -60,6 +60,12 @@ INSTANCE_TEMPERATURE = "temperature" INSTANCE_TARGET_TEMPERATURE = "targetTemperature" INSTANCE_FAN_SPEED = "fanSpeed" +# Ceiling-fan-with-light combo instances (e.g. H1310, reported as +# devices.types.light with an integrated fan). Distinct from the standalone +# fan shape (workMode / fanSpeed / oscillationToggle) — issue #74. +INSTANCE_FAN_TOGGLE = "fanToggle" +INSTANCE_FAN_SPEED_MODE = "fanSpeedMode" +INSTANCE_REVERSE_AIRFLOW = "reverseAirflowToggle" INSTANCE_PURIFIER_MODE = "purifierMode" INSTANCE_THERMOSTAT_TOGGLE = "thermostatToggle" INSTANCE_HUMIDITY = "humidity" @@ -484,6 +490,40 @@ def supports_work_mode(self) -> bool: """Check if device supports work mode (fans).""" return any(cap.is_work_mode for cap in self.capabilities) + @property + def supports_ceiling_fan(self) -> bool: + """Check if device has an integrated ceiling fan (e.g. H1310). + + These report as devices.types.light but carry a ``fanToggle`` toggle + plus a ``fanSpeedMode`` mode capability. Distinct from standalone fans + (workMode / fanSpeed / oscillation) — issue #74. + """ + has_toggle = any( + cap.type == CAPABILITY_TOGGLE and cap.instance == INSTANCE_FAN_TOGGLE + for cap in self.capabilities + ) + has_speed = any( + cap.type == CAPABILITY_MODE and cap.instance == INSTANCE_FAN_SPEED_MODE + for cap in self.capabilities + ) + return has_toggle and has_speed + + @property + def supports_reverse_airflow(self) -> bool: + """Check if the integrated ceiling fan supports reverse airflow.""" + return any( + cap.type == CAPABILITY_TOGGLE and cap.instance == INSTANCE_REVERSE_AIRFLOW + for cap in self.capabilities + ) + + def get_ceiling_fan_speed_options(self) -> list[dict[str, Any]]: + """Get ``fanSpeedMode`` speed options as {"name", "value"} dicts.""" + for cap in self.capabilities: + if cap.type == CAPABILITY_MODE and cap.instance == INSTANCE_FAN_SPEED_MODE: + options: list[dict[str, Any]] = cap.parameters.get("options", []) + return options + return [] + @property def supports_hdmi_source(self) -> bool: """Check if device supports HDMI source selection.""" diff --git a/tests/test_fan.py b/tests/test_fan.py index eff58362..5e469375 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -351,3 +351,172 @@ async def test_set_percentage_sends_correct_mode_value( assert isinstance(call_args[0][1], WorkModeCommand) assert call_args[0][1].work_mode == WORK_MODE_GEAR assert call_args[0][1].mode_value == 4 + + +# ============================================================================== +# Ceiling Fan (H1310) Tests — issue #74 +# ============================================================================== + + +def _h1310_device(): + """Build an H1310-shaped ceiling-fan-with-light device.""" + from custom_components.govee.models import GoveeDevice, GoveeCapability + from custom_components.govee.models.device import ( + CAPABILITY_ON_OFF, + CAPABILITY_TOGGLE, + CAPABILITY_MODE, + INSTANCE_POWER, + INSTANCE_FAN_TOGGLE, + INSTANCE_FAN_SPEED_MODE, + INSTANCE_REVERSE_AIRFLOW, + ) + + on_off = {"name": "on", "value": 1} + off = {"name": "off", "value": 0} + return GoveeDevice( + device_id="AA:BB:CC:DD:EE:FF:13:10", + sku="H1310", + name="Room1 Ceiling Fan", + device_type="devices.types.light", + capabilities=( + GoveeCapability(type=CAPABILITY_ON_OFF, instance=INSTANCE_POWER, parameters={}), + GoveeCapability( + type=CAPABILITY_TOGGLE, + instance=INSTANCE_FAN_TOGGLE, + parameters={"dataType": "ENUM", "options": [on_off, off]}, + ), + GoveeCapability( + type=CAPABILITY_MODE, + instance=INSTANCE_FAN_SPEED_MODE, + parameters={ + "dataType": "ENUM", + "options": [{"name": f"Speed {i}", "value": i} for i in range(1, 7)], + }, + ), + GoveeCapability( + type=CAPABILITY_TOGGLE, + instance=INSTANCE_REVERSE_AIRFLOW, + parameters={"dataType": "ENUM", "options": [on_off, off]}, + ), + ), + is_group=False, + ) + + +class TestGoveeCeilingFanEntity: + """Test GoveeCeilingFanEntity (H1310) — issue #74.""" + + @pytest.fixture + def device(self): + return _h1310_device() + + @pytest.fixture + def mock_coordinator(self, device): + coordinator = MagicMock() + coordinator.devices = {device.device_id: device} + coordinator.get_state = MagicMock(return_value=MagicMock(online=True)) + coordinator.async_control_device = AsyncMock(return_value=True) + return coordinator + + @pytest.fixture + def fan_entity(self, mock_coordinator, device): + from custom_components.govee.fan import GoveeCeilingFanEntity + + return GoveeCeilingFanEntity(mock_coordinator, device) + + def test_detection(self, device): + """H1310 is a ceiling fan and NOT a standalone fan, but IS a light.""" + assert device.supports_ceiling_fan is True + assert device.supports_reverse_airflow is True + assert device.is_fan is False + assert device.is_light_device is True + + def test_speed_options(self, device): + """fanSpeedMode exposes 6 speed values.""" + opts = device.get_ceiling_fan_speed_options() + assert [o["value"] for o in opts] == [1, 2, 3, 4, 5, 6] + + def test_unique_id_suffixed(self, fan_entity, device): + """Fan unique_id must differ from the light entity (bare device_id).""" + assert fan_entity.unique_id == f"{device.device_id}_fan" + + def test_supported_features(self, fan_entity): + from homeassistant.components.fan import FanEntityFeature + + features = fan_entity.supported_features + assert features & FanEntityFeature.TURN_ON + assert features & FanEntityFeature.TURN_OFF + assert features & FanEntityFeature.SET_SPEED + assert features & FanEntityFeature.DIRECTION + + def test_speed_count(self, fan_entity): + assert fan_entity.speed_count == 6 + + @pytest.mark.asyncio + async def test_turn_on_sends_fan_toggle(self, fan_entity, mock_coordinator): + from custom_components.govee.models import ToggleCommand + from custom_components.govee.models.device import INSTANCE_FAN_TOGGLE + + fan_entity.async_write_ha_state = MagicMock() + await fan_entity.async_turn_on() + + call_args = mock_coordinator.async_control_device.call_args + cmd = call_args[0][1] + assert isinstance(cmd, ToggleCommand) + assert cmd.toggle_instance == INSTANCE_FAN_TOGGLE + assert cmd.enabled is True + assert fan_entity.is_on is True + + @pytest.mark.asyncio + async def test_turn_off_sends_fan_toggle(self, fan_entity, mock_coordinator): + from custom_components.govee.models import ToggleCommand + + fan_entity.async_write_ha_state = MagicMock() + await fan_entity.async_turn_off() + + cmd = mock_coordinator.async_control_device.call_args[0][1] + assert isinstance(cmd, ToggleCommand) + assert cmd.enabled is False + assert fan_entity.is_on is False + + @pytest.mark.asyncio + async def test_set_percentage_sends_mode_command(self, fan_entity, mock_coordinator): + from custom_components.govee.models import ModeCommand + from custom_components.govee.models.device import INSTANCE_FAN_SPEED_MODE + + fan_entity.async_write_ha_state = MagicMock() + await fan_entity.async_set_percentage(100) + + cmd = mock_coordinator.async_control_device.call_args[0][1] + assert isinstance(cmd, ModeCommand) + assert cmd.mode_instance == INSTANCE_FAN_SPEED_MODE + assert cmd.value == 6 # 100% -> top speed of 6 + assert fan_entity.is_on is True + assert fan_entity.percentage == 100 + + @pytest.mark.asyncio + async def test_set_percentage_zero_turns_off(self, fan_entity, mock_coordinator): + from custom_components.govee.models import ToggleCommand + + fan_entity.async_write_ha_state = MagicMock() + await fan_entity.async_set_percentage(0) + + cmd = mock_coordinator.async_control_device.call_args[0][1] + assert isinstance(cmd, ToggleCommand) + assert cmd.enabled is False + assert fan_entity.is_on is False + + @pytest.mark.asyncio + async def test_set_direction_reverse(self, fan_entity, mock_coordinator): + from homeassistant.components.fan import DIRECTION_REVERSE + from custom_components.govee.models import ToggleCommand + from custom_components.govee.models.device import INSTANCE_REVERSE_AIRFLOW + + fan_entity.async_write_ha_state = MagicMock() + await fan_entity.async_set_direction(DIRECTION_REVERSE) + + cmd = mock_coordinator.async_control_device.call_args[0][1] + assert isinstance(cmd, ToggleCommand) + assert cmd.toggle_instance == INSTANCE_REVERSE_AIRFLOW + assert cmd.enabled is True + assert fan_entity.current_direction == DIRECTION_REVERSE