diff --git a/.coveragerc b/.coveragerc index af27bb86d662fdca0a54b195f56374e76557ef03..365f64076b313a54d6a6f27e54014723d7a411a4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1205,6 +1205,7 @@ omit = homeassistant/components/switchbot/const.py homeassistant/components/switchbot/entity.py homeassistant/components/switchbot/cover.py + homeassistant/components/switchbot/light.py homeassistant/components/switchbot/sensor.py homeassistant/components/switchbot/coordinator.py homeassistant/components/switchmate/switch.py diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 3f63a507e52a5f7e7e096623f4f1fa118f814ab2..59ed071f325699c34d786a493efa0bf0a6879212 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -23,12 +23,14 @@ from .const import ( CONNECTABLE_SUPPORTED_MODEL_TYPES, DEFAULT_RETRY_COUNT, DOMAIN, + HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL, SupportedModels, ) from .coordinator import SwitchbotDataUpdateCoordinator PLATFORMS_BY_TYPE = { - SupportedModels.BULB.value: [Platform.SENSOR], + SupportedModels.BULB.value: [Platform.SENSOR, Platform.LIGHT], + SupportedModels.LIGHT_STRIP.value: [Platform.SENSOR, Platform.LIGHT], SupportedModels.BOT.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.PLUG.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.CURTAIN.value: [ @@ -44,6 +46,8 @@ CLASS_BY_DEVICE = { SupportedModels.CURTAIN.value: switchbot.SwitchbotCurtain, SupportedModels.BOT.value: switchbot.Switchbot, SupportedModels.PLUG.value: switchbot.SwitchbotPlugMini, + SupportedModels.BULB.value: switchbot.SwitchbotBulb, + SupportedModels.LIGHT_STRIP.value: switchbot.SwitchbotLightStrip, } @@ -72,8 +76,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) sensor_type: str = entry.data[CONF_SENSOR_TYPE] + switchbot_model = HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL[sensor_type] # connectable means we can make connections to the device - connectable = sensor_type in CONNECTABLE_SUPPORTED_MODEL_TYPES.values() + connectable = switchbot_model in CONNECTABLE_SUPPORTED_MODEL_TYPES address: str = entry.data[CONF_ADDRESS] ble_device = bluetooth.async_ble_device_from_address( hass, address.upper(), connectable @@ -97,6 +102,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.unique_id, entry.data.get(CONF_NAME, entry.title), connectable, + switchbot_model, ) entry.async_on_unload(coordinator.async_start()) if not await coordinator.async_wait_ready(): diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index ad06dc7efcf5c4b4c90d9599cb83f3ce9214a70c..aa334120b85156d2a5e6044429cae74878e9a11c 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -18,6 +18,7 @@ class SupportedModels(StrEnum): BULB = "bulb" CURTAIN = "curtain" HYGROMETER = "hygrometer" + LIGHT_STRIP = "light_strip" CONTACT = "contact" PLUG = "plug" MOTION = "motion" @@ -28,6 +29,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.CURTAIN: SupportedModels.CURTAIN, SwitchbotModel.PLUG_MINI: SupportedModels.PLUG, SwitchbotModel.COLOR_BULB: SupportedModels.BULB, + SwitchbotModel.LIGHT_STRIP: SupportedModels.LIGHT_STRIP, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -36,9 +38,13 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, } -SUPPORTED_MODEL_TYPES = { - **CONNECTABLE_SUPPORTED_MODEL_TYPES, - **NON_CONNECTABLE_SUPPORTED_MODEL_TYPES, +SUPPORTED_MODEL_TYPES = ( + CONNECTABLE_SUPPORTED_MODEL_TYPES | NON_CONNECTABLE_SUPPORTED_MODEL_TYPES +) + + +HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { + str(v): k for k, v in SUPPORTED_MODEL_TYPES.items() } # Config Defaults diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index 8b56b2f282fe3a857514d29172eef4a2fcb2210d..103e9d67c5812fca57d6136f0d6cefaebcc9c782 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -21,6 +21,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +DEVICE_STARTUP_TIMEOUT = 30 + def flatten_sensors_data(sensor): """Deconstruct SwitchBot library temp object C/FÂș readings from dictionary.""" @@ -42,6 +44,7 @@ class SwitchbotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator): base_unique_id: str, device_name: str, connectable: bool, + model: str, ) -> None: """Initialize global switchbot data updater.""" super().__init__( @@ -56,6 +59,7 @@ class SwitchbotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator): self.data: dict[str, Any] = {} self.device_name = device_name self.base_unique_id = base_unique_id + self.model = model self._ready_event = asyncio.Event() @callback @@ -65,7 +69,6 @@ class SwitchbotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator): change: bluetooth.BluetoothChange, ) -> None: """Handle a Bluetooth event.""" - super()._async_handle_bluetooth_event(service_info, change) if adv := switchbot.parse_advertisement_data( service_info.device, service_info.advertisement ): @@ -74,12 +77,12 @@ class SwitchbotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator): self._ready_event.set() _LOGGER.debug("%s: Switchbot data: %s", self.ble_device.address, self.data) self.device.update_from_advertisement(adv) - self.async_update_listeners() + super()._async_handle_bluetooth_event(service_info, change) async def async_wait_ready(self) -> bool: """Wait for the device to be ready.""" with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(55): + async with async_timeout.timeout(DEVICE_STARTUP_TIMEOUT): await self._ready_event.wait() return True return False diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index 2e5ba78dcc820f9307802ed238ad94a05bdf5df3..b8d08e74f5f260d7c0fcd95f820ff3baf9e3d53d 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -1,13 +1,17 @@ """An abstract class common to all Switchbot entities.""" from __future__ import annotations +from abc import abstractmethod from collections.abc import Mapping from typing import Any +from switchbot import SwitchbotDevice + from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, ) from homeassistant.const import ATTR_CONNECTIONS +from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo @@ -19,6 +23,7 @@ class SwitchbotEntity(PassiveBluetoothCoordinatorEntity): """Generic entity encapsulating common features of Switchbot device.""" coordinator: SwitchbotDataUpdateCoordinator + _device: SwitchbotDevice def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: """Initialize the entity.""" @@ -31,7 +36,7 @@ class SwitchbotEntity(PassiveBluetoothCoordinatorEntity): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_BLUETOOTH, self._address)}, manufacturer=MANUFACTURER, - model=self.data["modelName"], + model=coordinator.model, # Sometimes the modelName is missing from the advertisement data name=coordinator.device_name, ) if ":" not in self._address: @@ -54,3 +59,29 @@ class SwitchbotEntity(PassiveBluetoothCoordinatorEntity): def extra_state_attributes(self) -> Mapping[Any, Any]: """Return the state attributes.""" return {"last_run_success": self._last_run_success} + + +class SwitchbotSubscribeEntity(SwitchbotEntity): + """Base class for Switchbot entities that use subscribe.""" + + @abstractmethod + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + self._async_update_attrs() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.async_on_remove(self._device.subscribe(self._handle_coordinator_update)) + return await super().async_added_to_hass() + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await self._device.update() diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py new file mode 100644 index 0000000000000000000000000000000000000000..e55f5fff9b11a98d40108dcd9e370e468b51c1e7 --- /dev/null +++ b/homeassistant/components/switchbot/light.py @@ -0,0 +1,98 @@ +"""Switchbot integration light platform.""" +from __future__ import annotations + +from typing import Any + +from switchbot import ColorMode as SwitchBotColorMode, SwitchbotBaseLight + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) + +from .const import DOMAIN +from .coordinator import SwitchbotDataUpdateCoordinator +from .entity import SwitchbotSubscribeEntity + +SWITCHBOT_COLOR_MODE_TO_HASS = { + SwitchBotColorMode.RGB: ColorMode.RGB, + SwitchBotColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP, +} + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the switchbot light.""" + coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SwitchbotLightEntity(coordinator)]) + + +class SwitchbotLightEntity(SwitchbotSubscribeEntity, LightEntity): + """Representation of switchbot light bulb.""" + + _device: SwitchbotBaseLight + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the Switchbot light.""" + super().__init__(coordinator) + device = self._device + self._attr_min_mireds = color_temperature_kelvin_to_mired(device.max_temp) + self._attr_max_mireds = color_temperature_kelvin_to_mired(device.min_temp) + self._attr_supported_color_modes = { + SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in device.color_modes + } + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + device = self._device + self._attr_is_on = self._device.on + self._attr_brightness = max(0, min(255, round(device.brightness * 2.55))) + if device.color_mode == SwitchBotColorMode.COLOR_TEMP: + self._attr_color_temp = color_temperature_kelvin_to_mired(device.color_temp) + self._attr_color_mode = ColorMode.COLOR_TEMP + return + self._attr_rgb_color = device.rgb + self._attr_color_mode = ColorMode.RGB + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on.""" + brightness = round(kwargs.get(ATTR_BRIGHTNESS, self.brightness) / 255 * 100) + + if ( + self.supported_color_modes + and ColorMode.COLOR_TEMP in self.supported_color_modes + and ATTR_COLOR_TEMP in kwargs + ): + color_temp = kwargs[ATTR_COLOR_TEMP] + kelvin = max(2700, min(6500, color_temperature_mired_to_kelvin(color_temp))) + await self._device.set_color_temp(brightness, kelvin) + return + if ATTR_RGB_COLOR in kwargs: + rgb = kwargs[ATTR_RGB_COLOR] + await self._device.set_rgb(brightness, rgb[0], rgb[1], rgb[2]) + return + if ATTR_BRIGHTNESS in kwargs: + await self._device.set_brightness(brightness) + return + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + await self._device.turn_off() diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 5011ed7e30602ab8b6c02c5694d8948d9eb0a18f..cda3f958f5c209910d93f3fc0e1082b376632d82 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -2,7 +2,7 @@ "domain": "switchbot", "name": "SwitchBot", "documentation": "https://www.home-assistant.io/integrations/switchbot", - "requirements": ["PySwitchbot==0.18.14"], + "requirements": ["PySwitchbot==0.18.21"], "config_flow": true, "dependencies": ["bluetooth"], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 37c9595143a3db1aefcb3b09edfd4c8250a47076..3a52b69416758221040a496b69f2082ea7f0273a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -37,7 +37,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.18.14 +PySwitchbot==0.18.21 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60caeaf7c6a665601ef38c45d416d40461d734e5..244adebff5398ca020e8e76dcc1aed8ade8a70ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -33,7 +33,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.18.14 +PySwitchbot==0.18.21 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1