From 7bc9e1ae9fc144d596df2572c28b232beab8ec75 Mon Sep 17 00:00:00 2001 From: Jafar Atili Date: Wed, 24 May 2023 10:44:36 +0300 Subject: [PATCH] Bump PySwitchbee to 1.8.0 (#92348) * fix * fixes * bump pyswitchbee * bump pyswitchbee * bump pyswitchbee * fix * bump pyswitchbee * Apply suggestions from code review --------- Co-authored-by: Jafar Atili Co-authored-by: Erik Montnemery --- .../components/switchbee/__init__.py | 108 ++++++++++++++++-- homeassistant/components/switchbee/climate.py | 2 +- .../components/switchbee/config_flow.py | 9 +- .../components/switchbee/coordinator.py | 9 +- homeassistant/components/switchbee/entity.py | 29 +---- .../components/switchbee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/switchbee/test_config_flow.py | 9 +- 9 files changed, 126 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 352f191588a..dee1fe5cd8f 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -2,18 +2,27 @@ from __future__ import annotations +import logging +import re + +from aiohttp import ClientSession from switchbee.api import CentralUnitPolling, CentralUnitWsRPC, is_wsrpc_api from switchbee.api.central_unit import SwitchBeeError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.device_registry as dr +import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import SwitchBeeCoordinator +_LOGGER = logging.getLogger(__name__) + + PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.CLIMATE, @@ -23,18 +32,14 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up SwitchBee Smart Home from a config entry.""" +async def get_api_object( + central_unit: str, user: str, password: str, websession: ClientSession +) -> CentralUnitPolling | CentralUnitWsRPC: + """Return SwitchBee API object.""" - hass.data.setdefault(DOMAIN, {}) - central_unit = entry.data[CONF_HOST] - user = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - websession = async_get_clientsession(hass, verify_ssl=False) api: CentralUnitPolling | CentralUnitWsRPC = CentralUnitPolling( central_unit, user, password, websession ) - # First try to connect and fetch the version try: await api.connect() @@ -46,6 +51,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = CentralUnitWsRPC(central_unit, user, password, websession) await api.connect() + return api + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up SwitchBee Smart Home from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + central_unit = entry.data[CONF_HOST] + user = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + websession = async_get_clientsession(hass, verify_ssl=False) + api = await get_api_object(central_unit, user, password, websession) + coordinator = SwitchBeeCoordinator( hass, api, @@ -71,3 +89,75 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + dev_reg = dr.async_get(hass) + websession = async_get_clientsession(hass, verify_ssl=False) + old_unique_id = config_entry.unique_id + assert isinstance(old_unique_id, str) + api = await get_api_object( + config_entry.data[CONF_HOST], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + websession, + ) + new_unique_id = api.unique_id + + @callback + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if match := re.match( + rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id + ): + entity_new_unique_id = f'{new_unique_id}-{match.group("id")}' + _LOGGER.info( + "Migrating entity %s from %s to new id %s", + entity_entry.entity_id, + entity_entry.unique_id, + entity_new_unique_id, + ) + return {"new_unique_id": entity_new_unique_id} + + return None + + if new_unique_id: + # Migrate devices + for device_entry in dr.async_entries_for_config_entry( + dev_reg, config_entry.entry_id + ): + assert isinstance(device_entry, dr.DeviceEntry) + for identifier in device_entry.identifiers: + if match := re.match( + rf"(?P.+)-{old_unique_id}$", identifier[1] + ): + new_identifiers = { + ( + DOMAIN, + f"{match.group('id')}-{new_unique_id}", + ) + } + _LOGGER.info( + "Migrating device %s identifiers from %s to %s", + device_entry.name, + device_entry.identifiers, + new_identifiers, + ) + dev_reg.async_update_device( + device_entry.id, new_identifiers=new_identifiers + ) + + # Migrate entities + await er.async_migrate_entries( + hass, config_entry.entry_id, update_unique_id + ) + + config_entry.version = 2 + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index bf780e76eb0..8dd740262f9 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -103,7 +103,7 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate # set HVAC capabilities self._attr_max_temp = device.max_temperature self._attr_min_temp = device.min_temperature - self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.unit] + self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.temperature_unit] self._attr_hvac_modes = [HVAC_MODE_SB_TO_HASS[mode] for mode in device.modes] self._attr_hvac_modes.append(HVACMode.OFF) self._update_attrs_from_coordinator() diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py index cb9cc27c1d0..8f109c7bf26 100644 --- a/homeassistant/components/switchbee/config_flow.py +++ b/homeassistant/components/switchbee/config_flow.py @@ -46,6 +46,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str: raise CannotConnect from exp + if api.unique_id: + return api.unique_id + assert api.mac is not None return format_mac(api.mac) @@ -53,7 +56,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str: class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for SwitchBee Smart Home.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -67,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - mac_formatted = await validate_input(self.hass, user_input) + unique_id = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -77,7 +80,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: - await self.async_set_unique_id(mac_formatted) + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index b1b606615dd..49400e3c28d 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -30,10 +30,12 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic """Initialize.""" self.api: CentralUnitPolling | CentralUnitWsRPC = swb_api self._reconnect_counts: int = 0 - self.mac_formatted: str | None = ( - None if self.api.mac is None else format_mac(self.api.mac) + assert self.api.mac is not None + self.unique_id = ( + self.api.unique_id + if self.api.unique_id is not None + else format_mac(self.api.mac) ) - super().__init__( hass, _LOGGER, @@ -77,6 +79,7 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic DeviceType.Shutter, DeviceType.Somfy, DeviceType.Thermostat, + DeviceType.VRFAC, ] ) except SwitchBeeError as exp: diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index 7e5df69fb6d..4f6a056202c 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -3,7 +3,6 @@ import logging from typing import Generic, TypeVar, cast from switchbee import SWITCHBEE_BRAND -from switchbee.api.central_unit import SwitchBeeDeviceOfflineError, SwitchBeeError from switchbee.device import DeviceType, SwitchBeeBaseDevice from homeassistant.helpers.entity import DeviceInfo @@ -32,7 +31,7 @@ class SwitchBeeEntity(CoordinatorEntity[SwitchBeeCoordinator], Generic[_DeviceTy super().__init__(coordinator) self._device = device self._attr_name = device.name - self._attr_unique_id = f"{coordinator.mac_formatted}-{device.id}" + self._attr_unique_id = f"{coordinator.unique_id}-{device.id}" class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): @@ -54,7 +53,7 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): identifiers={ ( DOMAIN, - f"{identifier}-{coordinator.mac_formatted}", + f"{identifier}-{coordinator.unique_id}", ) }, manufacturer=SWITCHBEE_BRAND, @@ -62,7 +61,7 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): suggested_area=device.zone, via_device=( DOMAIN, - f"{coordinator.api.name} ({coordinator.api.mac})", + f"{coordinator.api.name} ({coordinator.api.unique_id})", ), ) @@ -71,30 +70,8 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): """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 - def _check_if_became_offline(self) -> None: """Check if the device was online (now offline), log message and mark it as Unavailable.""" - # This specific call will refresh the state of the device in the CU - self.hass.async_create_task(self.async_refresh_state()) if self._is_online: _LOGGER.warning( diff --git a/homeassistant/components/switchbee/manifest.json b/homeassistant/components/switchbee/manifest.json index 3862539cb89..2175f28eede 100644 --- a/homeassistant/components/switchbee/manifest.json +++ b/homeassistant/components/switchbee/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switchbee", "iot_class": "local_push", - "requirements": ["pyswitchbee==1.7.19"] + "requirements": ["pyswitchbee==1.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80d0e22ca06..8b86c8d3779 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2014,7 +2014,7 @@ pystiebeleltron==0.0.1.dev2 pysuez==0.1.19 # homeassistant.components.switchbee -pyswitchbee==1.7.19 +pyswitchbee==1.8.0 # homeassistant.components.syncthru pysyncthru==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7118f312496..85e09fdf773 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1488,7 +1488,7 @@ pyspcwebgw==0.4.0 pysqueezebox==0.6.1 # homeassistant.components.switchbee -pyswitchbee==1.7.19 +pyswitchbee==1.8.0 # homeassistant.components.syncthru pysyncthru==0.7.10 diff --git a/tests/components/switchbee/test_config_flow.py b/tests/components/switchbee/test_config_flow.py index baa34280755..239777a4da3 100644 --- a/tests/components/switchbee/test_config_flow.py +++ b/tests/components/switchbee/test_config_flow.py @@ -2,6 +2,8 @@ import json from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.switchbee.config_flow import SwitchBeeError from homeassistant.components.switchbee.const import DOMAIN @@ -14,10 +16,15 @@ from . import MOCK_FAILED_TO_LOGIN_MSG, MOCK_INVALID_TOKEN_MGS from tests.common import MockConfigEntry, load_fixture -async def test_form(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("test_cucode_in_coordinator_data", [False, True]) +async def test_form(hass: HomeAssistant, test_cucode_in_coordinator_data) -> None: """Test we get the form.""" coordinator_data = json.loads(load_fixture("switchbee.json", "switchbee")) + + if test_cucode_in_coordinator_data: + coordinator_data["data"]["cuCode"] = "300F123456" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} )