diff --git a/.coveragerc b/.coveragerc index b5111024120..67ac5c8e811 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1232,6 +1232,7 @@ omit = homeassistant/components/switchbee/button.py homeassistant/components/switchbee/coordinator.py homeassistant/components/switchbee/entity.py + homeassistant/components/switchbee/light.py homeassistant/components/switchbee/switch.py homeassistant/components/switchbot/__init__.py homeassistant/components/switchbot/binary_sensor.py diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 12cce234bf1..d841121889b 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import SwitchBeeCoordinator -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.LIGHT, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index bf94c98d21b..1eddba27fd3 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -61,6 +61,7 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic DeviceType.GroupSwitch, DeviceType.TimedPowerSwitch, DeviceType.Scenario, + DeviceType.Dimmer, ] ) except SwitchBeeError as exp: diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index 5925eba2488..f4f5163a3c9 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -2,6 +2,7 @@ from typing import Generic, TypeVar from switchbee import SWITCHBEE_BRAND +from switchbee.api import SwitchBeeDeviceOfflineError, SwitchBeeError from switchbee.device import SwitchBeeBaseDevice from homeassistant.helpers.entity import DeviceInfo @@ -40,6 +41,7 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): ) -> None: """Initialize the Switchbee device.""" super().__init__(device, coordinator) + self._is_online: bool = True self._attr_device_info = DeviceInfo( name=f"SwitchBee {device.unit_id}", identifiers={ @@ -56,3 +58,28 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): f"{coordinator.api.name} ({coordinator.api.mac})", ), ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._is_online and super().available + + async def async_refresh_state(self) -> None: + """Refresh the device state in the Central Unit. + + This function addresses issue of a device that came online back but still report + unavailable state (-1). + Such device (offline device) will keep reporting unavailable state (-1) + until it has been actuated by the user (state changed to on/off). + + With this code we keep trying setting dummy state for the device + in order for it to start reporting its real state back (assuming it came back online) + + """ + + try: + await self.coordinator.api.set_state(self._device.id, "dummy") + except SwitchBeeDeviceOfflineError: + return + except SwitchBeeError: + return diff --git a/homeassistant/components/switchbee/light.py b/homeassistant/components/switchbee/light.py new file mode 100644 index 00000000000..d42d5dc4c5f --- /dev/null +++ b/homeassistant/components/switchbee/light.py @@ -0,0 +1,148 @@ +"""Support for SwitchBee light.""" + +from __future__ import annotations + +import logging +from typing import Any, cast + +from switchbee.api import SwitchBeeDeviceOfflineError, SwitchBeeError +from switchbee.device import ApiStateCommand, DeviceType, SwitchBeeDimmer + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SwitchBeeCoordinator +from .entity import SwitchBeeDeviceEntity + +MAX_BRIGHTNESS = 255 + +_LOGGER = logging.getLogger(__name__) + + +def _hass_brightness_to_switchbee(value: int) -> int: + """Convert hass brightness to SwitchBee.""" + sb_brightness = int(100 * value / MAX_BRIGHTNESS) + # SwitchBee maximum brightness is 99 + return sb_brightness if sb_brightness != 100 else 99 + + +def _switchbee_brightness_to_hass(value: int) -> int: + """Convert SwitchBee brightness to hass.""" + if value == 99: + value = 100 + return round(value * MAX_BRIGHTNESS / 100) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up SwitchBee light.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + SwitchBeeLightEntity(switchbee_device, coordinator) + for switchbee_device in coordinator.data.values() + if switchbee_device.type == DeviceType.Dimmer + ) + + +class SwitchBeeLightEntity(SwitchBeeDeviceEntity[SwitchBeeDimmer], LightEntity): + """Representation of a SwitchBee light.""" + + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + def __init__( + self, + device: SwitchBeeDimmer, + coordinator: SwitchBeeCoordinator, + ) -> None: + """Initialize the SwitchBee light.""" + super().__init__(device, coordinator) + self._attr_is_on = False + self._attr_brightness = 0 + + self._update_attrs_from_coordinator() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs_from_coordinator() + super()._handle_coordinator_update() + + def _update_attrs_from_coordinator(self) -> None: + + coordinator_device = cast( + SwitchBeeDimmer, self.coordinator.data[self._device.id] + ) + brightness = coordinator_device.brightness + + # module is offline + if brightness == -1: + # This specific call will refresh the state of the device in the CU + self.hass.async_create_task(self.async_refresh_state()) + + # if the device was online (now offline), log message and mark it as Unavailable + if self._is_online: + _LOGGER.warning( + "%s light is not responding, check the status in the SwitchBee mobile app", + self.name, + ) + self._is_online = False + + return + + # check if the device was offline (now online) and bring it back + if not self._is_online: + _LOGGER.info( + "%s light is now responding", + self.name, + ) + self._is_online = True + + self._attr_is_on = bool(brightness != 0) + + # 1-99 is the only valid SwitchBee brightness range + if 0 < brightness < 100: + self._attr_brightness = _switchbee_brightness_to_hass(brightness) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Async function to set on to light.""" + if ATTR_BRIGHTNESS in kwargs: + state: int | str = _hass_brightness_to_switchbee(kwargs[ATTR_BRIGHTNESS]) + else: + state = ApiStateCommand.ON + if self.brightness: + state = _hass_brightness_to_switchbee(self.brightness) + + try: + await self.coordinator.api.set_state(self._device.id, state) + except (SwitchBeeError, SwitchBeeDeviceOfflineError) as exp: + raise HomeAssistantError( + f"Failed to set {self.name} state {state}, {str(exp)}" + ) from exp + + if not isinstance(state, int): + # We just turned the light on, still don't know the last brightness known the Central Unit (yet) + # the brightness will be learned and updated in the next coordinator refresh + return + + # update the coordinator data manually we already know the Central Unit brightness data for this light + cast(SwitchBeeDimmer, self.coordinator.data[self._device.id]).brightness = state + self.coordinator.async_set_updated_data(self.coordinator.data) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off SwitchBee light.""" + try: + await self.coordinator.api.set_state(self._device.id, ApiStateCommand.OFF) + except (SwitchBeeError, SwitchBeeDeviceOfflineError) as exp: + raise HomeAssistantError( + f"Failed to turn off {self._attr_name}, {str(exp)}" + ) from exp + + # update the coordinator manually + cast(SwitchBeeDimmer, self.coordinator.data[self._device.id]).brightness = 0 + self.coordinator.async_set_updated_data(self.coordinator.data) diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py index a8fccd632cb..2b408513495 100644 --- a/homeassistant/components/switchbee/switch.py +++ b/homeassistant/components/switchbee/switch.py @@ -69,12 +69,6 @@ class SwitchBeeSwitchEntity(SwitchBeeDeviceEntity[_DeviceTypeT], SwitchEntity): """Initialize the Switchbee switch.""" super().__init__(device, coordinator) self._attr_is_on = False - self._is_online = True - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._is_online and super().available @callback def _handle_coordinator_update(self) -> None: @@ -85,32 +79,12 @@ class SwitchBeeSwitchEntity(SwitchBeeDeviceEntity[_DeviceTypeT], SwitchEntity): def _update_from_coordinator(self) -> None: """Update the entity attributes from the coordinator data.""" - async def async_refresh_state() -> None: - """Refresh the device state in the Central Unit. - - This function addresses issue of a device that came online back but still report - unavailable state (-1). - Such device (offline device) will keep reporting unavailable state (-1) - until it has been actuated by the user (state changed to on/off). - - With this code we keep trying setting dummy state for the device - in order for it to start reporting its real state back (assuming it came back online) - - """ - - try: - await self.coordinator.api.set_state(self._device.id, "dummy") - except SwitchBeeDeviceOfflineError: - return - except SwitchBeeError: - return - coordinator_device = cast(_DeviceTypeT, self.coordinator.data[self._device.id]) if coordinator_device.state == -1: # This specific call will refresh the state of the device in the CU - self.hass.async_create_task(async_refresh_state()) + self.hass.async_create_task(self.async_refresh_state()) # if the device was online (now offline), log message and mark it as Unavailable if self._is_online: