From 58b9394a5f8acf2f06beef05065fd76d24c27a69 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 5 Feb 2021 02:48:47 -0700 Subject: [PATCH 01/18] Fix zwave_js cover control for Up/Down and Open/Close (#45965) * Fix issue with control of cover when the target value is Up/Down instead of Open/Close * Adjust open/close/stop cover control to account for no Open/Up or Close/Down targets * Revert back to using values of 0/99 to close/open a cover since it is supported by all covers * Replace RELEASE_BUTTON with False and remove unused PRESS_BUTTON in zwave_js cover --- homeassistant/components/zwave_js/cover.py | 20 ++++++++--------- tests/components/zwave_js/test_cover.py | 26 +++++++++++++--------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 5f473f80957..b86cbeba944 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -21,8 +21,6 @@ from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) SUPPORT_GARAGE = SUPPORT_OPEN | SUPPORT_CLOSE -PRESS_BUTTON = True -RELEASE_BUTTON = False async def async_setup_entry( @@ -79,17 +77,19 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - target_value = self.get_zwave_value("Open") - await self.info.node.async_set_value(target_value, PRESS_BUTTON) + target_value = self.get_zwave_value("targetValue") + await self.info.node.async_set_value(target_value, 99) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - target_value = self.get_zwave_value("Close") - await self.info.node.async_set_value(target_value, PRESS_BUTTON) + target_value = self.get_zwave_value("targetValue") + await self.info.node.async_set_value(target_value, 0) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" - target_value = self.get_zwave_value("Open") - await self.info.node.async_set_value(target_value, RELEASE_BUTTON) - target_value = self.get_zwave_value("Close") - await self.info.node.async_set_value(target_value, RELEASE_BUTTON) + target_value = self.get_zwave_value("Open") or self.get_zwave_value("Up") + if target_value: + await self.info.node.async_set_value(target_value, False) + target_value = self.get_zwave_value("Close") or self.get_zwave_value("Down") + if target_value: + await self.info.node.async_set_value(target_value, False) diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index f014245a5f8..52e0a444ec9 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -95,14 +95,16 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): "commandClassName": "Multilevel Switch", "commandClass": 38, "endpoint": 0, - "property": "Open", - "propertyName": "Open", + "property": "targetValue", + "propertyName": "targetValue", "metadata": { - "type": "boolean", + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", "readable": True, "writeable": True, - "label": "Perform a level change (Open)", - "ccSpecific": {"switchType": 3}, + "label": "Target value", }, } assert args["value"] @@ -194,17 +196,19 @@ async def test_cover(hass, client, chain_actuator_zws12, integration): "commandClassName": "Multilevel Switch", "commandClass": 38, "endpoint": 0, - "property": "Close", - "propertyName": "Close", + "property": "targetValue", + "propertyName": "targetValue", "metadata": { - "type": "boolean", + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", "readable": True, "writeable": True, - "label": "Perform a level change (Close)", - "ccSpecific": {"switchType": 3}, + "label": "Target value", }, } - assert args["value"] + assert args["value"] == 0 client.async_send_command.reset_mock() From 88485d384c2147ec7ed210f47c1b985f4b39314c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Conde=20G=C3=B3mez?= Date: Fri, 5 Feb 2021 22:39:31 +0100 Subject: [PATCH 02/18] Fix foscam to work again with non-admin accounts and make RTSP port configurable again (#45975) * Do not require admin account for foscam cameras. Foscam cameras require admin account for getting the MAC address, requiring an admin account in the integration is not desirable as an operator one is good enough (and a good practice). Old entries using the MAC address as unique_id are migrated to the new unique_id format so everything is consistent. Also fixed unhandled invalid responses from the camera in the config flow process. * Make RTSP port configurable again as some cameras reports wrong port * Remove periods from new log lines * Set new Config Flow version to 2 and adjust the entity migration * Create a proper error message for the InvalidResponse exception * Change crafted unique_id to use entry_id in the entity * Abort if same host and port is already configured * Fix entry tracking to use entry_id instead of unique_id * Remove unique_id from mocked config entry in tests --- homeassistant/components/foscam/__init__.py | 55 +++++++- homeassistant/components/foscam/camera.py | 45 ++++--- .../components/foscam/config_flow.py | 54 +++++++- homeassistant/components/foscam/const.py | 1 + homeassistant/components/foscam/strings.json | 2 + .../components/foscam/translations/en.json | 2 + tests/components/foscam/test_config_flow.py | 124 +++++++++++++++--- 7 files changed, 235 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index e5b82817d4b..6a2c961544f 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -1,10 +1,15 @@ """The foscam component.""" import asyncio -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from libpyfoscam import FoscamCamera -from .const import DOMAIN, SERVICE_PTZ, SERVICE_PTZ_PRESET +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_registry import async_migrate_entries + +from .config_flow import DEFAULT_RTSP_PORT +from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET PLATFORMS = ["camera"] @@ -22,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.config_entries.async_forward_entry_setup(entry, component) ) - hass.data[DOMAIN][entry.unique_id] = entry.data + hass.data[DOMAIN][entry.entry_id] = entry.data return True @@ -39,10 +44,50 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ) if unload_ok: - hass.data[DOMAIN].pop(entry.unique_id) + hass.data[DOMAIN].pop(entry.entry_id) if not hass.data[DOMAIN]: hass.services.async_remove(domain=DOMAIN, service=SERVICE_PTZ) hass.services.async_remove(domain=DOMAIN, service=SERVICE_PTZ_PRESET) return unload_ok + + +async def async_migrate_entry(hass, config_entry: ConfigEntry): + """Migrate old entry.""" + LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + # Change unique id + @callback + def update_unique_id(entry): + return {"new_unique_id": config_entry.entry_id} + + await async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + config_entry.unique_id = None + + # Get RTSP port from the camera or use the fallback one and store it in data + camera = FoscamCamera( + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + verbose=False, + ) + + ret, response = await hass.async_add_executor_job(camera.get_port_info) + + rtsp_port = DEFAULT_RTSP_PORT + + if ret != 0: + rtsp_port = response.get("rtspPort") or response.get("mediaPort") + + config_entry.data = {**config_entry.data, CONF_RTSP_PORT: rtsp_port} + + # Change entry version + config_entry.version = 2 + + LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index f66ad31c2a8..d600546c3b0 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -15,7 +15,14 @@ from homeassistant.const import ( ) from homeassistant.helpers import config_validation as cv, entity_platform -from .const import CONF_STREAM, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET +from .const import ( + CONF_RTSP_PORT, + CONF_STREAM, + DOMAIN, + LOGGER, + SERVICE_PTZ, + SERVICE_PTZ_PRESET, +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -24,7 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_NAME, default="Foscam Camera"): cv.string, vol.Optional(CONF_PORT, default=88): cv.port, - vol.Optional("rtsp_port"): cv.port, + vol.Optional(CONF_RTSP_PORT): cv.port, } ) @@ -71,6 +78,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_USERNAME: config[CONF_USERNAME], CONF_PASSWORD: config[CONF_PASSWORD], CONF_STREAM: "Main", + CONF_RTSP_PORT: config.get(CONF_RTSP_PORT, 554), } hass.async_create_task( @@ -134,8 +142,8 @@ class HassFoscamCamera(Camera): self._username = config_entry.data[CONF_USERNAME] self._password = config_entry.data[CONF_PASSWORD] self._stream = config_entry.data[CONF_STREAM] - self._unique_id = config_entry.unique_id - self._rtsp_port = None + self._unique_id = config_entry.entry_id + self._rtsp_port = config_entry.data[CONF_RTSP_PORT] self._motion_status = False async def async_added_to_hass(self): @@ -145,7 +153,13 @@ class HassFoscamCamera(Camera): self._foscam_session.get_motion_detect_config ) - if ret != 0: + if ret == -3: + LOGGER.info( + "Can't get motion detection status, camera %s configured with non-admin user", + self._name, + ) + + elif ret != 0: LOGGER.error( "Error getting motion detection status of %s: %s", self._name, ret ) @@ -153,17 +167,6 @@ class HassFoscamCamera(Camera): else: self._motion_status = response == 1 - # Get RTSP port - ret, response = await self.hass.async_add_executor_job( - self._foscam_session.get_port_info - ) - - if ret != 0: - LOGGER.error("Error getting RTSP port of %s: %s", self._name, ret) - - else: - self._rtsp_port = response.get("rtspPort") or response.get("mediaPort") - @property def unique_id(self): """Return the entity unique ID.""" @@ -205,6 +208,11 @@ class HassFoscamCamera(Camera): ret = self._foscam_session.enable_motion_detection() if ret != 0: + if ret == -3: + LOGGER.info( + "Can't set motion detection status, camera %s configured with non-admin user", + self._name, + ) return self._motion_status = True @@ -220,6 +228,11 @@ class HassFoscamCamera(Camera): ret = self._foscam_session.disable_motion_detection() if ret != 0: + if ret == -3: + LOGGER.info( + "Can't set motion detection status, camera %s configured with non-admin user", + self._name, + ) return self._motion_status = False diff --git a/homeassistant/components/foscam/config_flow.py b/homeassistant/components/foscam/config_flow.py index 7bb8cb50a51..bfeefb9e406 100644 --- a/homeassistant/components/foscam/config_flow.py +++ b/homeassistant/components/foscam/config_flow.py @@ -1,6 +1,10 @@ """Config flow for foscam integration.""" from libpyfoscam import FoscamCamera -from libpyfoscam.foscam import ERROR_FOSCAM_AUTH, ERROR_FOSCAM_UNAVAILABLE +from libpyfoscam.foscam import ( + ERROR_FOSCAM_AUTH, + ERROR_FOSCAM_UNAVAILABLE, + FOSCAM_SUCCESS, +) import voluptuous as vol from homeassistant import config_entries, exceptions @@ -13,12 +17,13 @@ from homeassistant.const import ( ) from homeassistant.data_entry_flow import AbortFlow -from .const import CONF_STREAM, LOGGER +from .const import CONF_RTSP_PORT, CONF_STREAM, LOGGER from .const import DOMAIN # pylint:disable=unused-import STREAMS = ["Main", "Sub"] DEFAULT_PORT = 88 +DEFAULT_RTSP_PORT = 554 DATA_SCHEMA = vol.Schema( @@ -28,6 +33,7 @@ DATA_SCHEMA = vol.Schema( vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Required(CONF_STREAM, default=STREAMS[0]): vol.In(STREAMS), + vol.Required(CONF_RTSP_PORT, default=DEFAULT_RTSP_PORT): int, } ) @@ -35,7 +41,7 @@ DATA_SCHEMA = vol.Schema( class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for foscam.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL async def _validate_and_create(self, data): @@ -43,6 +49,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): Data has the keys from DATA_SCHEMA with values provided by the user. """ + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if ( + entry.data[CONF_HOST] == data[CONF_HOST] + and entry.data[CONF_PORT] == data[CONF_PORT] + ): + raise AbortFlow("already_configured") + camera = FoscamCamera( data[CONF_HOST], data[CONF_PORT], @@ -52,7 +66,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) # Validate data by sending a request to the camera - ret, response = await self.hass.async_add_executor_job(camera.get_dev_info) + ret, _ = await self.hass.async_add_executor_job(camera.get_product_all_info) if ret == ERROR_FOSCAM_UNAVAILABLE: raise CannotConnect @@ -60,10 +74,23 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if ret == ERROR_FOSCAM_AUTH: raise InvalidAuth - await self.async_set_unique_id(response["mac"]) - self._abort_if_unique_id_configured() + if ret != FOSCAM_SUCCESS: + LOGGER.error( + "Unexpected error code from camera %s:%s: %s", + data[CONF_HOST], + data[CONF_PORT], + ret, + ) + raise InvalidResponse - name = data.pop(CONF_NAME, response["devName"]) + # Try to get camera name (only possible with admin account) + ret, response = await self.hass.async_add_executor_job(camera.get_dev_info) + + dev_name = response.get( + "devName", f"Foscam {data[CONF_HOST]}:{data[CONF_PORT]}" + ) + + name = data.pop(CONF_NAME, dev_name) return self.async_create_entry(title=name, data=data) @@ -81,6 +108,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except InvalidAuth: errors["base"] = "invalid_auth" + except InvalidResponse: + errors["base"] = "invalid_response" + except AbortFlow: raise @@ -105,6 +135,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.error("Error importing foscam platform config: invalid auth.") return self.async_abort(reason="invalid_auth") + except InvalidResponse: + LOGGER.exception( + "Error importing foscam platform config: invalid response from camera." + ) + return self.async_abort(reason="invalid_response") + except AbortFlow: raise @@ -121,3 +157,7 @@ class CannotConnect(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class InvalidResponse(exceptions.HomeAssistantError): + """Error to indicate there is invalid response.""" diff --git a/homeassistant/components/foscam/const.py b/homeassistant/components/foscam/const.py index a42b430993e..d5ac0f5c567 100644 --- a/homeassistant/components/foscam/const.py +++ b/homeassistant/components/foscam/const.py @@ -5,6 +5,7 @@ LOGGER = logging.getLogger(__package__) DOMAIN = "foscam" +CONF_RTSP_PORT = "rtsp_port" CONF_STREAM = "stream" SERVICE_PTZ = "ptz" diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 6033fa099cd..5c0622af9d1 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -8,6 +8,7 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", + "rtsp_port": "RTSP port", "stream": "Stream" } } @@ -15,6 +16,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_response": "Invalid response from the device", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/foscam/translations/en.json b/homeassistant/components/foscam/translations/en.json index 3d1454a4ebd..16a7d0b7800 100644 --- a/homeassistant/components/foscam/translations/en.json +++ b/homeassistant/components/foscam/translations/en.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", + "invalid_response": "Invalid response from the device", "unknown": "Unexpected error" }, "step": { @@ -14,6 +15,7 @@ "host": "Host", "password": "Password", "port": "Port", + "rtsp_port": "RTSP port", "stream": "Stream", "username": "Username" } diff --git a/tests/components/foscam/test_config_flow.py b/tests/components/foscam/test_config_flow.py index 8087ac1894f..3b8910c4dbc 100644 --- a/tests/components/foscam/test_config_flow.py +++ b/tests/components/foscam/test_config_flow.py @@ -1,7 +1,12 @@ """Test the Foscam config flow.""" from unittest.mock import patch -from libpyfoscam.foscam import ERROR_FOSCAM_AUTH, ERROR_FOSCAM_UNAVAILABLE +from libpyfoscam.foscam import ( + ERROR_FOSCAM_AUTH, + ERROR_FOSCAM_CMD, + ERROR_FOSCAM_UNAVAILABLE, + ERROR_FOSCAM_UNKNOWN, +) from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.foscam import config_flow @@ -14,6 +19,13 @@ VALID_CONFIG = { config_flow.CONF_USERNAME: "admin", config_flow.CONF_PASSWORD: "1234", config_flow.CONF_STREAM: "Main", + config_flow.CONF_RTSP_PORT: 554, +} +OPERATOR_CONFIG = { + config_flow.CONF_USERNAME: "operator", +} +INVALID_RESPONSE_CONFIG = { + config_flow.CONF_USERNAME: "interr", } CAMERA_NAME = "Mocked Foscam Camera" CAMERA_MAC = "C0:C1:D0:F4:B4:D4" @@ -23,26 +35,39 @@ def setup_mock_foscam_camera(mock_foscam_camera): """Mock FoscamCamera simulating behaviour using a base valid config.""" def configure_mock_on_init(host, port, user, passwd, verbose=False): - return_code = 0 - data = {} + product_all_info_rc = 0 + dev_info_rc = 0 + dev_info_data = {} if ( host != VALID_CONFIG[config_flow.CONF_HOST] or port != VALID_CONFIG[config_flow.CONF_PORT] ): - return_code = ERROR_FOSCAM_UNAVAILABLE + product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNAVAILABLE elif ( - user != VALID_CONFIG[config_flow.CONF_USERNAME] + user + not in [ + VALID_CONFIG[config_flow.CONF_USERNAME], + OPERATOR_CONFIG[config_flow.CONF_USERNAME], + INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME], + ] or passwd != VALID_CONFIG[config_flow.CONF_PASSWORD] ): - return_code = ERROR_FOSCAM_AUTH + product_all_info_rc = dev_info_rc = ERROR_FOSCAM_AUTH + + elif user == INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME]: + product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNKNOWN + + elif user == OPERATOR_CONFIG[config_flow.CONF_USERNAME]: + dev_info_rc = ERROR_FOSCAM_CMD else: - data["devName"] = CAMERA_NAME - data["mac"] = CAMERA_MAC + dev_info_data["devName"] = CAMERA_NAME + dev_info_data["mac"] = CAMERA_MAC - mock_foscam_camera.get_dev_info.return_value = (return_code, data) + mock_foscam_camera.get_product_all_info.return_value = (product_all_info_rc, {}) + mock_foscam_camera.get_dev_info.return_value = (dev_info_rc, dev_info_data) return mock_foscam_camera @@ -142,12 +167,44 @@ async def test_user_cannot_connect(hass): assert result["errors"] == {"base": "cannot_connect"} +async def test_user_invalid_response(hass): + """Test we handle invalid response error from user input.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + + invalid_response = VALID_CONFIG.copy() + invalid_response[config_flow.CONF_USERNAME] = INVALID_RESPONSE_CONFIG[ + config_flow.CONF_USERNAME + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + invalid_response, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_response"} + + async def test_user_already_configured(hass): """Test we handle already configured from user input.""" await setup.async_setup_component(hass, "persistent_notification", {}) entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, unique_id=CAMERA_MAC + domain=config_flow.DOMAIN, + data=VALID_CONFIG, ) entry.add_to_hass(hass) @@ -201,6 +258,8 @@ async def test_user_unknown_exception(hass): async def test_import_user_valid(hass): """Test valid config from import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( "homeassistant.components.foscam.config_flow.FoscamCamera", ) as mock_foscam_camera, patch( @@ -229,6 +288,8 @@ async def test_import_user_valid(hass): async def test_import_user_valid_with_name(hass): """Test valid config with extra name from import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( "homeassistant.components.foscam.config_flow.FoscamCamera", ) as mock_foscam_camera, patch( @@ -261,10 +322,7 @@ async def test_import_user_valid_with_name(hass): async def test_import_invalid_auth(hass): """Test we handle invalid auth from import.""" - entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, unique_id=CAMERA_MAC - ) - entry.add_to_hass(hass) + await setup.async_setup_component(hass, "persistent_notification", {}) with patch( "homeassistant.components.foscam.config_flow.FoscamCamera", @@ -287,11 +345,8 @@ async def test_import_invalid_auth(hass): async def test_import_cannot_connect(hass): - """Test we handle invalid auth from import.""" - entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, unique_id=CAMERA_MAC - ) - entry.add_to_hass(hass) + """Test we handle cannot connect error from import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) with patch( "homeassistant.components.foscam.config_flow.FoscamCamera", @@ -313,10 +368,39 @@ async def test_import_cannot_connect(hass): assert result["reason"] == "cannot_connect" +async def test_import_invalid_response(hass): + """Test we handle invalid response error from import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + + invalid_response = VALID_CONFIG.copy() + invalid_response[config_flow.CONF_USERNAME] = INVALID_RESPONSE_CONFIG[ + config_flow.CONF_USERNAME + ] + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=invalid_response, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "invalid_response" + + async def test_import_already_configured(hass): """Test we handle already configured from import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, unique_id=CAMERA_MAC + domain=config_flow.DOMAIN, + data=VALID_CONFIG, ) entry.add_to_hass(hass) From 285fd3d43ccdebf0fec21901c798d1ff41f8f813 Mon Sep 17 00:00:00 2001 From: Steven Rollason <2099542+gadgetchnnel@users.noreply.github.com> Date: Sat, 6 Feb 2021 13:05:50 +0000 Subject: [PATCH 03/18] Fix downloader path validation on subdir (#46061) --- homeassistant/components/downloader/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 94617ce43aa..3856df696ad 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -70,8 +70,9 @@ def setup(hass, config): overwrite = service.data.get(ATTR_OVERWRITE) - # Check the path - raise_if_invalid_path(subdir) + if subdir: + # Check the path + raise_if_invalid_path(subdir) final_path = None From 1e59fa2cb2ed8a8663402391210b260e1e250abc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 5 Feb 2021 19:12:23 +0100 Subject: [PATCH 04/18] Fix deprecated method isAlive() (#46062) --- homeassistant/components/zwave/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave/light.py b/homeassistant/components/zwave/light.py index 244b4a557e1..52014e37eea 100644 --- a/homeassistant/components/zwave/light.py +++ b/homeassistant/components/zwave/light.py @@ -175,7 +175,7 @@ class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): self._refreshing = True self.values.primary.refresh() - if self._timer is not None and self._timer.isAlive(): + if self._timer is not None and self._timer.is_alive(): self._timer.cancel() self._timer = Timer(self._delay, _refresh_value) From 96d8d432d7a078350a9490b93d5a133081615393 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 5 Feb 2021 23:07:12 +0100 Subject: [PATCH 05/18] Improve deCONZ logbook to be more robust in different situations (#46063) --- homeassistant/components/deconz/logbook.py | 38 ++++++++++--- tests/components/deconz/test_logbook.py | 62 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index 73c157ac8f6..85982244364 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -6,7 +6,7 @@ from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import Event -from .const import DOMAIN as DECONZ_DOMAIN +from .const import CONF_GESTURE, DOMAIN as DECONZ_DOMAIN from .deconz_event import CONF_DECONZ_EVENT, DeconzEvent from .device_trigger import ( CONF_BOTH_BUTTONS, @@ -107,8 +107,12 @@ def _get_device_event_description(modelid: str, event: str) -> tuple: device_event_descriptions: dict = REMOTES[modelid] for event_type_tuple, event_dict in device_event_descriptions.items(): - if event == event_dict[CONF_EVENT]: + if event == event_dict.get(CONF_EVENT): return event_type_tuple + if event == event_dict.get(CONF_GESTURE): + return event_type_tuple + + return (None, None) @callback @@ -125,15 +129,35 @@ def async_describe_events( hass, event.data[ATTR_DEVICE_ID] ) - if deconz_event.device.modelid not in REMOTES: + action = None + interface = None + data = event.data.get(CONF_EVENT) or event.data.get(CONF_GESTURE, "") + + if data and deconz_event.device.modelid in REMOTES: + action, interface = _get_device_event_description( + deconz_event.device.modelid, data + ) + + # Unknown event + if not data: return { "name": f"{deconz_event.device.name}", - "message": f"fired event '{event.data[CONF_EVENT]}'.", + "message": "fired an unknown event.", } - action, interface = _get_device_event_description( - deconz_event.device.modelid, event.data[CONF_EVENT] - ) + # No device event match + if not action: + return { + "name": f"{deconz_event.device.name}", + "message": f"fired event '{data}'.", + } + + # Gesture event + if not interface: + return { + "name": f"{deconz_event.device.name}", + "message": f"fired event '{ACTIONS[action]}'.", + } return { "name": f"{deconz_event.device.name}", diff --git a/tests/components/deconz/test_logbook.py b/tests/components/deconz/test_logbook.py index 7315a766d5c..500ca03b7ed 100644 --- a/tests/components/deconz/test_logbook.py +++ b/tests/components/deconz/test_logbook.py @@ -3,6 +3,7 @@ from copy import deepcopy from homeassistant.components import logbook +from homeassistant.components.deconz.const import CONF_GESTURE from homeassistant.components.deconz.deconz_event import CONF_DECONZ_EVENT from homeassistant.components.deconz.gateway import get_gateway_from_config_entry from homeassistant.const import CONF_DEVICE_ID, CONF_EVENT, CONF_ID, CONF_UNIQUE_ID @@ -34,6 +35,23 @@ async def test_humanifying_deconz_event(hass): "config": {}, "uniqueid": "00:00:00:00:00:00:00:02-00", }, + "2": { + "id": "Xiaomi cube id", + "name": "Xiaomi cube", + "type": "ZHASwitch", + "modelid": "lumi.sensor_cube", + "state": {"buttonevent": 1000, "gesture": 1}, + "config": {}, + "uniqueid": "00:00:00:00:00:00:00:03-00", + }, + "3": { + "id": "faulty", + "name": "Faulty event", + "type": "ZHASwitch", + "state": {}, + "config": {}, + "uniqueid": "00:00:00:00:00:00:00:04-00", + }, } config_entry = await setup_deconz_integration(hass, get_state_response=data) gateway = get_gateway_from_config_entry(hass, config_entry) @@ -46,6 +64,7 @@ async def test_humanifying_deconz_event(hass): logbook.humanify( hass, [ + # Event without matching device trigger MockLazyEventPartialState( CONF_DECONZ_EVENT, { @@ -55,6 +74,7 @@ async def test_humanifying_deconz_event(hass): CONF_UNIQUE_ID: gateway.events[0].serial, }, ), + # Event with matching device trigger MockLazyEventPartialState( CONF_DECONZ_EVENT, { @@ -64,6 +84,36 @@ async def test_humanifying_deconz_event(hass): CONF_UNIQUE_ID: gateway.events[1].serial, }, ), + # Gesture with matching device trigger + MockLazyEventPartialState( + CONF_DECONZ_EVENT, + { + CONF_DEVICE_ID: gateway.events[2].device_id, + CONF_GESTURE: 1, + CONF_ID: gateway.events[2].event_id, + CONF_UNIQUE_ID: gateway.events[2].serial, + }, + ), + # Unsupported device trigger + MockLazyEventPartialState( + CONF_DECONZ_EVENT, + { + CONF_DEVICE_ID: gateway.events[2].device_id, + CONF_GESTURE: "unsupported_gesture", + CONF_ID: gateway.events[2].event_id, + CONF_UNIQUE_ID: gateway.events[2].serial, + }, + ), + # Unknown event + MockLazyEventPartialState( + CONF_DECONZ_EVENT, + { + CONF_DEVICE_ID: gateway.events[3].device_id, + "unknown_event": None, + CONF_ID: gateway.events[3].event_id, + CONF_UNIQUE_ID: gateway.events[3].serial, + }, + ), ], entity_attr_cache, {}, @@ -77,3 +127,15 @@ async def test_humanifying_deconz_event(hass): assert events[1]["name"] == "Hue remote" assert events[1]["domain"] == "deconz" assert events[1]["message"] == "'Long press' event for 'Dim up' was fired." + + assert events[2]["name"] == "Xiaomi cube" + assert events[2]["domain"] == "deconz" + assert events[2]["message"] == "fired event 'Shake'." + + assert events[3]["name"] == "Xiaomi cube" + assert events[3]["domain"] == "deconz" + assert events[3]["message"] == "fired event 'unsupported_gesture'." + + assert events[4]["name"] == "Faulty event" + assert events[4]["domain"] == "deconz" + assert events[4]["message"] == "fired an unknown event." From a9eb9d613663becc20b90b595d56a076d4bad6d4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 5 Feb 2021 16:36:42 -0600 Subject: [PATCH 06/18] Fix zwave_js Notification CC sensors and binary sensors (#46072) * only include property key name in sensor name if it exists * add endpoint to binary_sensor and sensor notification CC entities if > 0 * refactor to have helper method generate name * change default behavior of generate_name * return value for notification sensor when we can't find the state * store generated name --- .../components/zwave_js/binary_sensor.py | 12 ++---- homeassistant/components/zwave_js/entity.py | 31 ++++++++++--- homeassistant/components/zwave_js/sensor.py | 43 +++++++++++-------- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index f17d893e371..bb2e4355f16 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -293,6 +293,10 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Initialize a ZWaveNotificationBinarySensor entity.""" super().__init__(config_entry, client, info) self.state_key = state_key + self._name = self.generate_name( + self.info.primary_value.property_name, + [self.info.primary_value.metadata.states[self.state_key]], + ) # check if we have a custom mapping for this value self._mapping_info = self._get_sensor_mapping() @@ -301,14 +305,6 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Return if the sensor is on or off.""" return int(self.info.primary_value.value) == int(self.state_key) - @property - def name(self) -> str: - """Return default name from device name and value name combination.""" - node_name = self.info.node.name or self.info.node.device_config.description - value_name = self.info.primary_value.property_name - state_label = self.info.primary_value.metadata.states[self.state_key] - return f"{node_name}: {value_name} - {state_label}" - @property def device_class(self) -> Optional[str]: """Return device class.""" diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 334a2cccd4f..08571ad5d8c 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -1,7 +1,7 @@ """Generic Z-Wave Entity Class.""" import logging -from typing import Optional, Tuple, Union +from typing import List, Optional, Tuple, Union from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.node import Node as ZwaveNode @@ -35,6 +35,7 @@ class ZWaveBaseEntity(Entity): self.config_entry = config_entry self.client = client self.info = info + self._name = self.generate_name() # entities requiring additional values, can add extra ids to this list self.watched_value_ids = {self.info.primary_value.value_id} @@ -61,19 +62,35 @@ class ZWaveBaseEntity(Entity): "identifiers": {get_device_id(self.client, self.info.node)}, } - @property - def name(self) -> str: - """Return default name from device name and value name combination.""" + def generate_name( + self, + alternate_value_name: Optional[str] = None, + additional_info: Optional[List[str]] = None, + ) -> str: + """Generate entity name.""" + if additional_info is None: + additional_info = [] node_name = self.info.node.name or self.info.node.device_config.description value_name = ( - self.info.primary_value.metadata.label + alternate_value_name + or self.info.primary_value.metadata.label or self.info.primary_value.property_key_name or self.info.primary_value.property_name ) + name = f"{node_name}: {value_name}" + for item in additional_info: + if item: + name += f" - {item}" # append endpoint if > 1 if self.info.primary_value.endpoint > 1: - value_name += f" ({self.info.primary_value.endpoint})" - return f"{node_name}: {value_name}" + name += f" ({self.info.primary_value.endpoint})" + + return name + + @property + def name(self) -> str: + """Return default name from device name and value name combination.""" + return self._name @property def unique_id(self) -> str: diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3d3f782bc1b..78b536b81f7 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -123,6 +123,17 @@ class ZWaveStringSensor(ZwaveSensorBase): class ZWaveNumericSensor(ZwaveSensorBase): """Representation of a Z-Wave Numeric sensor.""" + def __init__( + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + ) -> None: + """Initialize a ZWaveNumericSensor entity.""" + super().__init__(config_entry, client, info) + if self.info.primary_value.command_class == CommandClass.BASIC: + self._name = self.generate_name(self.info.primary_value.command_class_name) + @property def state(self) -> float: """Return state of the sensor.""" @@ -142,19 +153,23 @@ class ZWaveNumericSensor(ZwaveSensorBase): return str(self.info.primary_value.metadata.unit) - @property - def name(self) -> str: - """Return default name from device name and value name combination.""" - if self.info.primary_value.command_class == CommandClass.BASIC: - node_name = self.info.node.name or self.info.node.device_config.description - label = self.info.primary_value.command_class_name - return f"{node_name}: {label}" - return super().name - class ZWaveListSensor(ZwaveSensorBase): """Representation of a Z-Wave Numeric sensor with multiple states.""" + def __init__( + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + ) -> None: + """Initialize a ZWaveListSensor entity.""" + super().__init__(config_entry, client, info) + self._name = self.generate_name( + self.info.primary_value.property_name, + [self.info.primary_value.property_key_name], + ) + @property def state(self) -> Optional[str]: """Return state of the sensor.""" @@ -164,7 +179,7 @@ class ZWaveListSensor(ZwaveSensorBase): not str(self.info.primary_value.value) in self.info.primary_value.metadata.states ): - return None + return str(self.info.primary_value.value) return str( self.info.primary_value.metadata.states[str(self.info.primary_value.value)] ) @@ -174,11 +189,3 @@ class ZWaveListSensor(ZwaveSensorBase): """Return the device specific state attributes.""" # add the value's int value as property for multi-value (list) items return {"value": self.info.primary_value.value} - - @property - def name(self) -> str: - """Return default name from device name and value name combination.""" - node_name = self.info.node.name or self.info.node.device_config.description - prop_name = self.info.primary_value.property_name - prop_key_name = self.info.primary_value.property_key_name - return f"{node_name}: {prop_name} - {prop_key_name}" From c57fa16a1ffc74bacf730dd5147b4626f945c693 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Feb 2021 12:23:02 -1000 Subject: [PATCH 07/18] Fix incorrect current temperature for homekit water heaters (#46076) --- .../components/homekit/type_thermostats.py | 13 +++++++++---- tests/components/homekit/test_type_thermostats.py | 8 ++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 54e2e9f92a8..a1c13432614 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -595,10 +595,15 @@ class WaterHeater(HomeAccessory): def async_update_state(self, new_state): """Update water_heater state after state change.""" # Update current and target temperature - temperature = _get_target_temperature(new_state, self._unit) - if temperature is not None: - if temperature != self.char_current_temp.value: - self.char_target_temp.set_value(temperature) + target_temperature = _get_target_temperature(new_state, self._unit) + if target_temperature is not None: + if target_temperature != self.char_target_temp.value: + self.char_target_temp.set_value(target_temperature) + + current_temperature = _get_current_temperature(new_state, self._unit) + if current_temperature is not None: + if current_temperature != self.char_current_temp.value: + self.char_current_temp.set_value(current_temperature) # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index ce17cf7ea07..79b5ca21097 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1599,11 +1599,15 @@ async def test_water_heater(hass, hk_driver, events): hass.states.async_set( entity_id, HVAC_MODE_HEAT, - {ATTR_HVAC_MODE: HVAC_MODE_HEAT, ATTR_TEMPERATURE: 56.0}, + { + ATTR_HVAC_MODE: HVAC_MODE_HEAT, + ATTR_TEMPERATURE: 56.0, + ATTR_CURRENT_TEMPERATURE: 35.0, + }, ) await hass.async_block_till_done() assert acc.char_target_temp.value == 56.0 - assert acc.char_current_temp.value == 50.0 + assert acc.char_current_temp.value == 35.0 assert acc.char_target_heat_cool.value == 1 assert acc.char_current_heat_cool.value == 1 assert acc.char_display_units.value == 0 From 483b1dfa61587fd4281cd295d4d2b928a7356c55 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 6 Feb 2021 13:17:52 +0100 Subject: [PATCH 08/18] Use async_update_entry rather than updating config_entry.data directly in Axis (#46078) Don't step version in migrate_entry to support rollbacking --- homeassistant/components/axis/__init__.py | 15 ++++++++++----- tests/components/axis/test_init.py | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index c467359c17e..8722c41c3e0 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -48,8 +48,11 @@ async def async_migrate_entry(hass, config_entry): # Flatten configuration but keep old data if user rollbacks HASS prior to 0.106 if config_entry.version == 1: - config_entry.data = {**config_entry.data, **config_entry.data[CONF_DEVICE]} - config_entry.unique_id = config_entry.data[CONF_MAC] + unique_id = config_entry.data[CONF_MAC] + data = {**config_entry.data, **config_entry.data[CONF_DEVICE]} + hass.config_entries.async_update_entry( + config_entry, unique_id=unique_id, data=data + ) config_entry.version = 2 # Normalise MAC address of device which also affects entity unique IDs @@ -66,10 +69,12 @@ async def async_migrate_entry(hass, config_entry): ) } - await async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + if old_unique_id != new_unique_id: + await async_migrate_entries(hass, config_entry.entry_id, update_unique_id) - config_entry.unique_id = new_unique_id - config_entry.version = 3 + hass.config_entries.async_update_entry( + config_entry, unique_id=new_unique_id + ) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py index b7faceaf10d..36a603ea7b3 100644 --- a/tests/components/axis/test_init.py +++ b/tests/components/axis/test_init.py @@ -109,7 +109,7 @@ async def test_migrate_entry(hass): CONF_MODEL: "model", CONF_NAME: "name", } - assert entry.version == 3 + assert entry.version == 2 # Keep version to support rollbacking assert entry.unique_id == "00:40:8c:12:34:56" vmd4_entity = registry.async_get("binary_sensor.vmd4") From d16d6f3f3f5324cfccb04c6018d8ad48855de8af Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 6 Feb 2021 14:02:03 +0100 Subject: [PATCH 09/18] Handle missing value in all platforms of zwave_js (#46081) --- homeassistant/components/zwave_js/climate.py | 15 +++++++++++++++ homeassistant/components/zwave_js/cover.py | 12 +++++++++--- homeassistant/components/zwave_js/entity.py | 8 +------- homeassistant/components/zwave_js/fan.py | 9 ++++++++- homeassistant/components/zwave_js/lock.py | 3 +++ homeassistant/components/zwave_js/switch.py | 7 +++++-- 6 files changed, 41 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index b125c8bcd6a..a0b0648932c 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -207,6 +207,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): if self._current_mode is None: # Thermostat(valve) with no support for setting a mode is considered heating-only return HVAC_MODE_HEAT + if self._current_mode.value is None: + # guard missing value + return HVAC_MODE_HEAT return ZW_HVAC_MODE_MAP.get(int(self._current_mode.value), HVAC_MODE_HEAT_COOL) @property @@ -219,6 +222,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): """Return the current running hvac operation if supported.""" if not self._operating_state: return None + if self._operating_state.value is None: + # guard missing value + return None return HVAC_CURRENT_MAP.get(int(self._operating_state.value)) @property @@ -234,12 +240,18 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): @property def target_temperature(self) -> Optional[float]: """Return the temperature we try to reach.""" + if self._current_mode and self._current_mode.value is None: + # guard missing value + return None temp = self._setpoint_value(self._current_mode_setpoint_enums[0]) return temp.value if temp else None @property def target_temperature_high(self) -> Optional[float]: """Return the highbound target temperature we try to reach.""" + if self._current_mode and self._current_mode.value is None: + # guard missing value + return None temp = self._setpoint_value(self._current_mode_setpoint_enums[1]) return temp.value if temp else None @@ -251,6 +263,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): @property def preset_mode(self) -> Optional[str]: """Return the current preset mode, e.g., home, away, temp.""" + if self._current_mode and self._current_mode.value is None: + # guard missing value + return None if self._current_mode and int(self._current_mode.value) not in THERMOSTAT_MODES: return_val: str = self._current_mode.metadata.states.get( self._current_mode.value diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index b86cbeba944..38c891f7376 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -1,6 +1,6 @@ """Support for Z-Wave cover devices.""" import logging -from typing import Any, Callable, List +from typing import Any, Callable, List, Optional from zwave_js_server.client import Client as ZwaveClient @@ -59,13 +59,19 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave Cover device.""" @property - def is_closed(self) -> bool: + def is_closed(self) -> Optional[bool]: """Return true if cover is closed.""" + if self.info.primary_value.value is None: + # guard missing value + return None return bool(self.info.primary_value.value == 0) @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> Optional[int]: """Return the current position of cover where 0 means closed and 100 is fully open.""" + if self.info.primary_value.value is None: + # guard missing value + return None return round((self.info.primary_value.value / 99) * 100) async def async_set_cover_position(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 08571ad5d8c..a17e43e2f23 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -100,13 +100,7 @@ class ZWaveBaseEntity(Entity): @property def available(self) -> bool: """Return entity availability.""" - return ( - self.client.connected - and bool(self.info.node.ready) - # a None value indicates something wrong with the device, - # or the value is simply not yet there (it will arrive later). - and self.info.primary_value.value is not None - ) + return self.client.connected and bool(self.info.node.ready) @callback def _value_changed(self, event_data: dict) -> None: diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 7113272d2ea..360f907e74a 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -87,8 +87,11 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): await self.info.node.async_set_value(target_value, 0) @property - def is_on(self) -> bool: + def is_on(self) -> Optional[bool]: # type: ignore """Return true if device is on (speed above 0).""" + if self.info.primary_value.value is None: + # guard missing value + return None return bool(self.info.primary_value.value > 0) @property @@ -98,6 +101,10 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): The Z-Wave speed value is a byte 0-255. 255 means previous value. The normal range of the speed is 0-99. 0 means off. """ + if self.info.primary_value.value is None: + # guard missing value + return None + value = math.ceil(self.info.primary_value.value * 3 / 100) return VALUE_TO_SPEED.get(value, self._previous_speed) diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index dedaf9a5e45..6f2a1a72c7d 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -90,6 +90,9 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): @property def is_locked(self) -> Optional[bool]: """Return true if the lock is locked.""" + if self.info.primary_value.value is None: + # guard missing value + return None return int( LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP[ CommandClass(self.info.primary_value.command_class) diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 2060894684c..8feba5911f8 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -1,7 +1,7 @@ """Representation of Z-Wave switches.""" import logging -from typing import Any, Callable, List +from typing import Any, Callable, List, Optional from zwave_js_server.client import Client as ZwaveClient @@ -44,8 +44,11 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): """Representation of a Z-Wave switch.""" @property - def is_on(self) -> bool: + def is_on(self) -> Optional[bool]: # type: ignore """Return a boolean for the state of the switch.""" + if self.info.primary_value.value is None: + # guard missing value + return None return bool(self.info.primary_value.value) async def async_turn_on(self, **kwargs: Any) -> None: From f9f681ac3698b8cb69c6286a582a88b01b5f2fe9 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 8 Feb 2021 17:56:19 +0100 Subject: [PATCH 10/18] update discovery scheme for zwave_js light platform (#46082) --- homeassistant/components/zwave_js/discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index d741946a1c9..03593ab79ad 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -130,6 +130,7 @@ DISCOVERY_SCHEMAS = [ "Multilevel Remote Switch", "Multilevel Power Switch", "Multilevel Scene Switch", + "Unused", }, command_class={CommandClass.SWITCH_MULTILEVEL}, property={"currentValue"}, From fd19f3ebdbbbdbf335edde16ca744a36ce6dd249 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 8 Feb 2021 17:57:22 +0100 Subject: [PATCH 11/18] Update zwave_js discovery scheme for boolean sensors in the Alarm CC (#46085) --- homeassistant/components/zwave_js/discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 03593ab79ad..be6d9b698d4 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -143,6 +143,7 @@ DISCOVERY_SCHEMAS = [ command_class={ CommandClass.SENSOR_BINARY, CommandClass.BATTERY, + CommandClass.SENSOR_ALARM, }, type={"boolean"}, ), From 39a7d975f65f30f7cddf1286b2871f772bb2a960 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Mon, 8 Feb 2021 11:43:30 +0100 Subject: [PATCH 12/18] Fix Google translate TTS by bumping gTTS from 2.2.1 to 2.2.2 (#46110) --- homeassistant/components/google_translate/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index c5b3edc8798..64d19bed277 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -2,6 +2,6 @@ "domain": "google_translate", "name": "Google Translate Text-to-Speech", "documentation": "https://www.home-assistant.io/integrations/google_translate", - "requirements": ["gTTS==2.2.1"], + "requirements": ["gTTS==2.2.2"], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index 56b9f7a331c..258474f97f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -622,7 +622,7 @@ freesms==0.1.2 fritzconnection==1.4.0 # homeassistant.components.google_translate -gTTS==2.2.1 +gTTS==2.2.2 # homeassistant.components.garmin_connect garminconnect==0.1.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0a44a96f0a..9583d901193 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -316,7 +316,7 @@ foobot_async==1.0.0 fritzconnection==1.4.0 # homeassistant.components.google_translate -gTTS==2.2.1 +gTTS==2.2.2 # homeassistant.components.garmin_connect garminconnect==0.1.16 From 983e98055403290b03e3f9b184becea468959b1c Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Sun, 7 Feb 2021 22:05:10 -0800 Subject: [PATCH 13/18] Revert "Convert ozw climate values to correct units (#45369)" (#46163) This reverts commit 1b6ee8301a5c076f93d0799b9f7fcb82cc6eb902. --- homeassistant/components/ozw/climate.py | 51 ++++--------------------- tests/components/ozw/test_climate.py | 20 ++++------ 2 files changed, 15 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/ozw/climate.py b/homeassistant/components/ozw/climate.py index 67bbe5cdc4d..a74fd869f0f 100644 --- a/homeassistant/components/ozw/climate.py +++ b/homeassistant/components/ozw/climate.py @@ -28,7 +28,6 @@ from homeassistant.components.climate.const import ( from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util.temperature import convert as convert_temperature from .const import DATA_UNSUBSCRIBE, DOMAIN from .entity import ZWaveDeviceEntity @@ -155,13 +154,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -def convert_units(units): - """Return units as a farenheit or celsius constant.""" - if units == "F": - return TEMP_FAHRENHEIT - return TEMP_CELSIUS - - class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): """Representation of a Z-Wave Climate device.""" @@ -207,18 +199,16 @@ class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): @property def temperature_unit(self): """Return the unit of measurement.""" - return convert_units(self._current_mode_setpoint_values[0].units) + if self.values.temperature is not None and self.values.temperature.units == "F": + return TEMP_FAHRENHEIT + return TEMP_CELSIUS @property def current_temperature(self): """Return the current temperature.""" if not self.values.temperature: return None - return convert_temperature( - self.values.temperature.value, - convert_units(self._current_mode_setpoint_values[0].units), - self.temperature_unit, - ) + return self.values.temperature.value @property def hvac_action(self): @@ -246,29 +236,17 @@ class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): @property def target_temperature(self): """Return the temperature we try to reach.""" - return convert_temperature( - self._current_mode_setpoint_values[0].value, - convert_units(self._current_mode_setpoint_values[0].units), - self.temperature_unit, - ) + return self._current_mode_setpoint_values[0].value @property def target_temperature_low(self) -> Optional[float]: """Return the lowbound target temperature we try to reach.""" - return convert_temperature( - self._current_mode_setpoint_values[0].value, - convert_units(self._current_mode_setpoint_values[0].units), - self.temperature_unit, - ) + return self._current_mode_setpoint_values[0].value @property def target_temperature_high(self) -> Optional[float]: """Return the highbound target temperature we try to reach.""" - return convert_temperature( - self._current_mode_setpoint_values[1].value, - convert_units(self._current_mode_setpoint_values[1].units), - self.temperature_unit, - ) + return self._current_mode_setpoint_values[1].value async def async_set_temperature(self, **kwargs): """Set new target temperature. @@ -284,29 +262,14 @@ class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): setpoint = self._current_mode_setpoint_values[0] target_temp = kwargs.get(ATTR_TEMPERATURE) if setpoint is not None and target_temp is not None: - target_temp = convert_temperature( - target_temp, - self.temperature_unit, - convert_units(setpoint.units), - ) setpoint.send_value(target_temp) elif len(self._current_mode_setpoint_values) == 2: (setpoint_low, setpoint_high) = self._current_mode_setpoint_values target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) if setpoint_low is not None and target_temp_low is not None: - target_temp_low = convert_temperature( - target_temp_low, - self.temperature_unit, - convert_units(setpoint_low.units), - ) setpoint_low.send_value(target_temp_low) if setpoint_high is not None and target_temp_high is not None: - target_temp_high = convert_temperature( - target_temp_high, - self.temperature_unit, - convert_units(setpoint_high.units), - ) setpoint_high.send_value(target_temp_high) async def async_set_fan_mode(self, fan_mode): diff --git a/tests/components/ozw/test_climate.py b/tests/components/ozw/test_climate.py index e251a93c115..3414e6c4832 100644 --- a/tests/components/ozw/test_climate.py +++ b/tests/components/ozw/test_climate.py @@ -16,8 +16,6 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, ) -from homeassistant.components.ozw.climate import convert_units -from homeassistant.const import TEMP_FAHRENHEIT from .common import setup_ozw @@ -38,8 +36,8 @@ async def test_climate(hass, climate_data, sent_messages, climate_msg, caplog): HVAC_MODE_HEAT_COOL, ] assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 73.5 - assert state.attributes[ATTR_TEMPERATURE] == 70.0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 23.1 + assert state.attributes[ATTR_TEMPERATURE] == 21.1 assert state.attributes.get(ATTR_TARGET_TEMP_LOW) is None assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) is None assert state.attributes[ATTR_FAN_MODE] == "Auto Low" @@ -56,7 +54,7 @@ async def test_climate(hass, climate_data, sent_messages, climate_msg, caplog): msg = sent_messages[-1] assert msg["topic"] == "OpenZWave/1/command/setvalue/" # Celsius is converted to Fahrenheit here! - assert round(msg["payload"]["Value"], 2) == 26.1 + assert round(msg["payload"]["Value"], 2) == 78.98 assert msg["payload"]["ValueIDKey"] == 281475099443218 # Test hvac_mode with set_temperature @@ -74,7 +72,7 @@ async def test_climate(hass, climate_data, sent_messages, climate_msg, caplog): msg = sent_messages[-1] assert msg["topic"] == "OpenZWave/1/command/setvalue/" # Celsius is converted to Fahrenheit here! - assert round(msg["payload"]["Value"], 2) == 24.1 + assert round(msg["payload"]["Value"], 2) == 75.38 assert msg["payload"]["ValueIDKey"] == 281475099443218 # Test set mode @@ -129,8 +127,8 @@ async def test_climate(hass, climate_data, sent_messages, climate_msg, caplog): assert state is not None assert state.state == HVAC_MODE_HEAT_COOL assert state.attributes.get(ATTR_TEMPERATURE) is None - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 70.0 - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 78.0 + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 21.1 + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.6 # Test setting high/low temp on multiple setpoints await hass.services.async_call( @@ -146,11 +144,11 @@ async def test_climate(hass, climate_data, sent_messages, climate_msg, caplog): assert len(sent_messages) == 7 # 2 messages ! msg = sent_messages[-2] # low setpoint assert msg["topic"] == "OpenZWave/1/command/setvalue/" - assert round(msg["payload"]["Value"], 2) == 20.0 + assert round(msg["payload"]["Value"], 2) == 68.0 assert msg["payload"]["ValueIDKey"] == 281475099443218 msg = sent_messages[-1] # high setpoint assert msg["topic"] == "OpenZWave/1/command/setvalue/" - assert round(msg["payload"]["Value"], 2) == 25.0 + assert round(msg["payload"]["Value"], 2) == 77.0 assert msg["payload"]["ValueIDKey"] == 562950076153874 # Test basic/single-setpoint thermostat (node 16 in dump) @@ -327,5 +325,3 @@ async def test_climate(hass, climate_data, sent_messages, climate_msg, caplog): ) assert len(sent_messages) == 12 assert "does not support setting a mode" in caplog.text - - assert convert_units("F") == TEMP_FAHRENHEIT From 47464b89af199ca412978ead28923aadacbddcdd Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 8 Feb 2021 11:23:50 +0100 Subject: [PATCH 14/18] Enable KNX auto_reconnect for auto-discovered connections (#46178) --- homeassistant/components/knx/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 7dbeb513e09..1492e5df7b7 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -290,7 +290,7 @@ class KNXModule: if CONF_KNX_ROUTING in self.config[DOMAIN]: return self.connection_config_routing() # config from xknx.yaml always has priority later on - return ConnectionConfig() + return ConnectionConfig(auto_reconnect=True) def connection_config_routing(self): """Return the connection_config if routing is configured.""" From 384a5ae053260a4ab89539b53c99cbb1ec711374 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Feb 2021 12:22:38 -1000 Subject: [PATCH 15/18] Ensure creating an index that already exists is forgiving for postgresql (#46185) Unlikely sqlite and mysql, postgresql throws ProgrammingError instead of InternalError or OperationalError when trying to create an index that already exists. --- .../components/recorder/migration.py | 16 +++++----- tests/components/recorder/test_migrate.py | 30 ++++++++++++++++++- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4501b25385e..aeb62cc111d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -3,7 +3,12 @@ import logging from sqlalchemy import ForeignKeyConstraint, MetaData, Table, text from sqlalchemy.engine import reflection -from sqlalchemy.exc import InternalError, OperationalError, SQLAlchemyError +from sqlalchemy.exc import ( + InternalError, + OperationalError, + ProgrammingError, + SQLAlchemyError, +) from sqlalchemy.schema import AddConstraint, DropConstraint from .const import DOMAIN @@ -69,7 +74,7 @@ def _create_index(engine, table_name, index_name): ) try: index.create(engine) - except OperationalError as err: + except (InternalError, ProgrammingError, OperationalError) as err: lower_err_str = str(err).lower() if "already exists" not in lower_err_str and "duplicate" not in lower_err_str: @@ -78,13 +83,6 @@ def _create_index(engine, table_name, index_name): _LOGGER.warning( "Index %s already exists on %s, continuing", index_name, table_name ) - except InternalError as err: - if "duplicate" not in str(err).lower(): - raise - - _LOGGER.warning( - "Index %s already exists on %s, continuing", index_name, table_name - ) _LOGGER.debug("Finished creating %s", index_name) diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index d10dad43d75..c29dad2d495 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -1,9 +1,10 @@ """The tests for the Recorder component.""" # pylint: disable=protected-access -from unittest.mock import call, patch +from unittest.mock import Mock, PropertyMock, call, patch import pytest from sqlalchemy import create_engine +from sqlalchemy.exc import InternalError, OperationalError, ProgrammingError from sqlalchemy.pool import StaticPool from homeassistant.bootstrap import async_setup_component @@ -79,3 +80,30 @@ def test_forgiving_add_index(): engine = create_engine("sqlite://", poolclass=StaticPool) models.Base.metadata.create_all(engine) migration._create_index(engine, "states", "ix_states_context_id") + + +@pytest.mark.parametrize( + "exception_type", [OperationalError, ProgrammingError, InternalError] +) +def test_forgiving_add_index_with_other_db_types(caplog, exception_type): + """Test that add index will continue if index exists on mysql and postgres.""" + mocked_index = Mock() + type(mocked_index).name = "ix_states_context_id" + mocked_index.create = Mock( + side_effect=exception_type( + "CREATE INDEX ix_states_old_state_id ON states (old_state_id);", + [], + 'relation "ix_states_old_state_id" already exists', + ) + ) + + mocked_table = Mock() + type(mocked_table).indexes = PropertyMock(return_value=[mocked_index]) + + with patch( + "homeassistant.components.recorder.migration.Table", return_value=mocked_table + ): + migration._create_index(Mock(), "states", "ix_states_context_id") + + assert "already exists on states" in caplog.text + assert "continuing" in caplog.text From 387301f024481b51cc3ef827ee2029091765d49a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 8 Feb 2021 22:49:46 +0100 Subject: [PATCH 16/18] Fix Tado Power and Link binary sensors (#46235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Power and Link aren't converted from strings to booleans by python-tado, so we need to properly parse before assigning the string value to binary sensors. Fixes: 067f2d0098d1 ("Add tado zone binary sensors (#44576)") Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/tado/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 1acefdb4c16..71b52931013 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -244,10 +244,10 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): return if self.zone_variable == "power": - self._state = self._tado_zone_data.power + self._state = self._tado_zone_data.power == "ON" elif self.zone_variable == "link": - self._state = self._tado_zone_data.link + self._state = self._tado_zone_data.link == "ONLINE" elif self.zone_variable == "overlay": self._state = self._tado_zone_data.overlay_active From c02870686a78a4c2de26e5be9dd2ae3b9f439cb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Feb 2021 11:51:46 -1000 Subject: [PATCH 17/18] Handle empty mylink response at startup (#46241) --- homeassistant/components/somfy_mylink/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index d15ea029530..d371fd96310 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -108,6 +108,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): ) return False + if "result" not in mylink_status: + raise ConfigEntryNotReady("The Somfy MyLink device returned an empty result") + _async_migrate_entity_config(hass, entry, mylink_status) undo_listener = entry.add_update_listener(_async_update_listener) From 6ec49c696c0a25e56a853771077acbd1602e7806 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 8 Feb 2021 22:37:01 +0000 Subject: [PATCH 18/18] Bumped version to 2021.2.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 08d9dd751db..5dba56f0247 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 2021 MINOR_VERSION = 2 -PATCH_VERSION = "1" +PATCH_VERSION = "2" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 8, 0)