Skip to content

Commit ed5365b

Browse files
lasswelltTom Lasswell
andauthored
feat(fan): add ceiling-fan control for light+fan combos like H1310 (fixes #74) (#90)
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. Co-authored-by: Tom Lasswell <tom@calcyon.com>
1 parent 42ca8c1 commit ed5365b

3 files changed

Lines changed: 392 additions & 2 deletions

File tree

custom_components/govee/fan.py

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,36 @@
1212
import logging
1313
from typing import Any
1414

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+
)
1621
from homeassistant.config_entries import ConfigEntry
1722
from homeassistant.core import HomeAssistant
1823
from homeassistant.helpers.entity_platform import AddEntitiesCallback
24+
from homeassistant.helpers.restore_state import RestoreEntity
1925
from homeassistant.util.percentage import (
2026
ordered_list_item_to_percentage,
2127
percentage_to_ordered_list_item,
2228
)
2329

2430
from .coordinator import GoveeCoordinator
2531
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+
)
2745

2846
_LOGGER = logging.getLogger(__name__)
2947

@@ -60,6 +78,19 @@ async def async_setup_entry(
6078
)
6179
entities.append(GoveeFanEntity(coordinator, device))
6280

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+
6394
async_add_entities(entities)
6495
_LOGGER.debug("Set up %d Govee fan entities", len(entities))
6596

@@ -241,3 +272,153 @@ async def async_oscillate(self, oscillating: bool) -> None:
241272
self._device_id,
242273
OscillationCommand(oscillating=oscillating),
243274
)
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()

custom_components/govee/models/device.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@
6060
INSTANCE_TEMPERATURE = "temperature"
6161
INSTANCE_TARGET_TEMPERATURE = "targetTemperature"
6262
INSTANCE_FAN_SPEED = "fanSpeed"
63+
# Ceiling-fan-with-light combo instances (e.g. H1310, reported as
64+
# devices.types.light with an integrated fan). Distinct from the standalone
65+
# fan shape (workMode / fanSpeed / oscillationToggle) — issue #74.
66+
INSTANCE_FAN_TOGGLE = "fanToggle"
67+
INSTANCE_FAN_SPEED_MODE = "fanSpeedMode"
68+
INSTANCE_REVERSE_AIRFLOW = "reverseAirflowToggle"
6369
INSTANCE_PURIFIER_MODE = "purifierMode"
6470
INSTANCE_THERMOSTAT_TOGGLE = "thermostatToggle"
6571
INSTANCE_HUMIDITY = "humidity"
@@ -484,6 +490,40 @@ def supports_work_mode(self) -> bool:
484490
"""Check if device supports work mode (fans)."""
485491
return any(cap.is_work_mode for cap in self.capabilities)
486492

493+
@property
494+
def supports_ceiling_fan(self) -> bool:
495+
"""Check if device has an integrated ceiling fan (e.g. H1310).
496+
497+
These report as devices.types.light but carry a ``fanToggle`` toggle
498+
plus a ``fanSpeedMode`` mode capability. Distinct from standalone fans
499+
(workMode / fanSpeed / oscillation) — issue #74.
500+
"""
501+
has_toggle = any(
502+
cap.type == CAPABILITY_TOGGLE and cap.instance == INSTANCE_FAN_TOGGLE
503+
for cap in self.capabilities
504+
)
505+
has_speed = any(
506+
cap.type == CAPABILITY_MODE and cap.instance == INSTANCE_FAN_SPEED_MODE
507+
for cap in self.capabilities
508+
)
509+
return has_toggle and has_speed
510+
511+
@property
512+
def supports_reverse_airflow(self) -> bool:
513+
"""Check if the integrated ceiling fan supports reverse airflow."""
514+
return any(
515+
cap.type == CAPABILITY_TOGGLE and cap.instance == INSTANCE_REVERSE_AIRFLOW
516+
for cap in self.capabilities
517+
)
518+
519+
def get_ceiling_fan_speed_options(self) -> list[dict[str, Any]]:
520+
"""Get ``fanSpeedMode`` speed options as {"name", "value"} dicts."""
521+
for cap in self.capabilities:
522+
if cap.type == CAPABILITY_MODE and cap.instance == INSTANCE_FAN_SPEED_MODE:
523+
options: list[dict[str, Any]] = cap.parameters.get("options", [])
524+
return options
525+
return []
526+
487527
@property
488528
def supports_hdmi_source(self) -> bool:
489529
"""Check if device supports HDMI source selection."""

0 commit comments

Comments
 (0)