From f78d57515a2ebb0103bfbf922199fd254c88308c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Aug 2021 22:11:21 +0200 Subject: [PATCH 001/168] Bumped version to 2021.9.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7f5dba5b17d..b4751f86b41 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 1865a280839bcfaa1a0ed3d71fd84596eaed226e Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 26 Aug 2021 11:35:35 -0500 Subject: [PATCH 002/168] Set up polling task with subscriptions in Sonos (#54355) --- homeassistant/components/sonos/speaker.py | 32 +++++++++-------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 6f37739a17a..30d107bdd8d 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -323,6 +323,18 @@ class SonosSpeaker: async def async_subscribe(self) -> bool: """Initiate event subscriptions.""" _LOGGER.debug("Creating subscriptions for %s", self.zone_name) + + # Create a polling task in case subscriptions fail or callback events do not arrive + if not self._poll_timer: + self._poll_timer = self.hass.helpers.event.async_track_time_interval( + partial( + async_dispatcher_send, + self.hass, + f"{SONOS_POLL_UPDATE}-{self.soco.uid}", + ), + SCAN_INTERVAL, + ) + try: await self.hass.async_add_executor_job(self.set_basic_info) @@ -337,10 +349,10 @@ class SonosSpeaker: for service in SUBSCRIPTION_SERVICES ] await asyncio.gather(*subscriptions) - return True except SoCoException as ex: _LOGGER.warning("Could not connect %s: %s", self.zone_name, ex) return False + return True async def _subscribe( self, target: SubscriptionBase, sub_callback: Callable @@ -497,15 +509,6 @@ class SonosSpeaker: self.soco.ip_address, ) - self._poll_timer = self.hass.helpers.event.async_track_time_interval( - partial( - async_dispatcher_send, - self.hass, - f"{SONOS_POLL_UPDATE}-{self.soco.uid}", - ), - SCAN_INTERVAL, - ) - if self._is_ready and not self.subscriptions_failed: done = await self.async_subscribe() if not done: @@ -567,15 +570,6 @@ class SonosSpeaker: self._seen_timer = self.hass.helpers.event.async_call_later( SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen ) - if not self._poll_timer: - self._poll_timer = self.hass.helpers.event.async_track_time_interval( - partial( - async_dispatcher_send, - self.hass, - f"{SONOS_POLL_UPDATE}-{self.soco.uid}", - ), - SCAN_INTERVAL, - ) self.async_write_entity_states() # From 05df9b4b8b5aa5795ba4b96ea9603096efaee1a4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 26 Aug 2021 00:37:18 +0200 Subject: [PATCH 003/168] Remove temperature conversion - tado (#55231) --- homeassistant/components/tado/sensor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 537e094bfd2..044241f2be0 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -168,10 +168,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): return if self.home_variable == "outdoor temperature": - self._state = self.hass.config.units.temperature( - self._tado_weather_data["outsideTemperature"]["celsius"], - TEMP_CELSIUS, - ) + self._state = self._tado_weather_data["outsideTemperature"]["celsius"] self._state_attributes = { "time": self._tado_weather_data["outsideTemperature"]["timestamp"], } @@ -245,7 +242,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.zone_variable == "temperature": - return self.hass.config.units.temperature_unit + return TEMP_CELSIUS if self.zone_variable == "humidity": return PERCENTAGE if self.zone_variable == "heating": @@ -277,9 +274,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): return if self.zone_variable == "temperature": - self._state = self.hass.config.units.temperature( - self._tado_zone_data.current_temp, TEMP_CELSIUS - ) + self._state = self._tado_zone_data.current_temp self._state_attributes = { "time": self._tado_zone_data.current_temp_timestamp, "setting": 0, # setting is used in climate device From 3d09478aea849c1db496f600e23baaa02abbf597 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 08:59:02 -0500 Subject: [PATCH 004/168] Limit USB discovery to specific manufacturer/description/serial_number matches (#55236) * Limit USB discovery to specific manufacturer/description/serial_number matches * test for None case --- homeassistant/components/usb/__init__.py | 20 ++ homeassistant/components/zha/manifest.json | 7 +- homeassistant/generated/usb.py | 14 +- script/hassfest/manifest.py | 3 + tests/components/usb/test_init.py | 293 ++++++++++++++++++++- 5 files changed, 324 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 3aaccc15a64..d02c01ad03d 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import dataclasses +import fnmatch import logging import os import sys @@ -72,6 +73,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _fnmatch_lower(name: str | None, pattern: str) -> bool: + """Match a lowercase version of the name.""" + if name is None: + return False + return fnmatch.fnmatch(name.lower(), pattern) + + class USBDiscovery: """Manage USB Discovery.""" @@ -152,6 +160,18 @@ class USBDiscovery: continue if "pid" in matcher and device.pid != matcher["pid"]: continue + if "serial_number" in matcher and not _fnmatch_lower( + device.serial_number, matcher["serial_number"] + ): + continue + if "manufacturer" in matcher and not _fnmatch_lower( + device.manufacturer, matcher["manufacturer"] + ): + continue + if "description" in matcher and not _fnmatch_lower( + device.description, matcher["description"] + ): + continue flow: USBFlow = { "domain": matcher["domain"], "context": {"source": config_entries.SOURCE_USB}, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 93d9816d339..2c1d625b7fe 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -16,10 +16,9 @@ "zigpy-znp==0.5.3" ], "usb": [ - {"vid":"10C4","pid":"EA60","known_devices":["slae.sh cc2652rb stick"]}, - {"vid":"1CF1","pid":"0030","known_devices":["Conbee II"]}, - {"vid":"1A86","pid":"7523","known_devices":["Electrolama zig-a-zig-ah"]}, - {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]} + {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, + {"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]}, + {"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]} ], "codeowners": ["@dmulcahey", "@adminiuga"], "zeroconf": [ diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index cb672c736b2..477a762ae62 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -9,22 +9,20 @@ USB = [ { "domain": "zha", "vid": "10C4", - "pid": "EA60" + "pid": "EA60", + "description": "*2652*" }, { "domain": "zha", "vid": "1CF1", - "pid": "0030" - }, - { - "domain": "zha", - "vid": "1A86", - "pid": "7523" + "pid": "0030", + "description": "*conbee*" }, { "domain": "zha", "vid": "10C4", - "pid": "8A2A" + "pid": "8A2A", + "description": "*zigbee*" }, { "domain": "zwave_js", diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 8c9776ed7c9..abade24dbf9 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -210,6 +210,9 @@ MANIFEST_SCHEMA = vol.Schema( { vol.Optional("vid"): vol.All(str, verify_uppercase), vol.Optional("pid"): vol.All(str, verify_uppercase), + vol.Optional("serial_number"): vol.All(str, verify_lowercase), + vol.Optional("manufacturer"): vol.All(str, verify_lowercase), + vol.Optional("description"): vol.All(str, verify_lowercase), vol.Optional("known_devices"): [str], } ) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 9c480f11fc6..e22e514f230 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components import usb from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.setup import async_setup_component -from . import slae_sh_device +from . import conbee_device, slae_sh_device @pytest.fixture(name="operating_system") @@ -171,6 +171,297 @@ async def test_discovered_by_websocket_scan(hass, hass_ws_client): assert mock_config_flow.mock_calls[0][1][0] == "test1" +async def test_discovered_by_websocket_scan_limited_by_description_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the description matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*2652*"} + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_description_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan rejected by the description matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*not_it*"} + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the serial_number matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "serial_number": "00_12_4b_00*", + } + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is rejected by the serial_number matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} + ] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the manufacturer matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "manufacturer": "dresden elektronik ingenieurtechnik*", + } + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is rejected by the manufacturer matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "manufacturer": "other vendor*", + } + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( + hass, hass_ws_client +): + """Test a device is discovered from websocket is rejected with empty serial number.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=None, + manufacturer=None, + description=None, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + async def test_discovered_by_websocket_scan_match_vid_only(hass, hass_ws_client): """Test a device is discovered from websocket scan only matching vid.""" new_usb = [{"domain": "test1", "vid": "3039"}] From aa907f4d10eeee6e70c15141f2b4528608ddc8b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 11:36:25 -0500 Subject: [PATCH 005/168] Only warn once per entity when the async_camera_image signature needs to be updated (#55238) --- homeassistant/components/camera/__init__.py | 17 +++++++++++----- tests/components/camera/test_init.py | 22 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 14cd64df920..9724e8e1e70 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -165,10 +165,7 @@ async def _async_get_image( width=width, height=height ) else: - _LOGGER.warning( - "The camera entity %s does not support requesting width and height, please open an issue with the integration author", - camera.entity_id, - ) + camera.async_warn_old_async_camera_image_signature() image_bytes = await camera.async_camera_image() if image_bytes: @@ -381,6 +378,7 @@ class Camera(Entity): self.stream_options: dict[str, str] = {} self.content_type: str = DEFAULT_CONTENT_TYPE self.access_tokens: collections.deque = collections.deque([], 2) + self._warned_old_signature = False self.async_update_token() @property @@ -455,11 +453,20 @@ class Camera(Entity): return await self.hass.async_add_executor_job( partial(self.camera_image, width=width, height=height) ) + self.async_warn_old_async_camera_image_signature() + return await self.hass.async_add_executor_job(self.camera_image) + + # Remove in 2022.1 after all custom components have had a chance to change their signature + @callback + def async_warn_old_async_camera_image_signature(self) -> None: + """Warn once when calling async_camera_image with the function old signature.""" + if self._warned_old_signature: + return _LOGGER.warning( "The camera entity %s does not support requesting width and height, please open an issue with the integration author", self.entity_id, ) - return await self.hass.async_add_executor_job(self.camera_image) + self._warned_old_signature = True async def handle_async_still_stream( self, request: web.Request, interval: float diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index bb3f76e0d1b..df4b64e4310 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -77,6 +77,28 @@ async def test_get_image_from_camera(hass, image_mock_url): assert image.content == b"Test" +async def test_legacy_async_get_image_signature_warns_only_once( + hass, image_mock_url, caplog +): + """Test that we only warn once when we encounter a legacy async_get_image function signature.""" + + async def _legacy_async_camera_image(self): + return b"Image" + + with patch( + "homeassistant.components.demo.camera.DemoCamera.async_camera_image", + new=_legacy_async_camera_image, + ): + image = await camera.async_get_image(hass, "camera.demo_camera") + assert image.content == b"Image" + assert "does not support requesting width and height" in caplog.text + caplog.clear() + + image = await camera.async_get_image(hass, "camera.demo_camera") + assert image.content == b"Image" + assert "does not support requesting width and height" not in caplog.text + + async def test_get_image_from_camera_with_width_height(hass, image_mock_url): """Grab an image from camera entity with width and height.""" From 175febe63590fb6e075c951f41dbab6255278328 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 08:59:41 -0500 Subject: [PATCH 006/168] Defer zha auto configure probe until after clicking configure (#55239) --- homeassistant/components/zha/config_flow.py | 18 ++++++------- homeassistant/components/zha/strings.json | 3 ++- tests/components/zha/test_config_flow.py | 28 +++++++-------------- 3 files changed, 18 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 61898328d2e..772362b3850 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -36,7 +36,6 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize flow instance.""" self._device_path = None self._radio_type = None - self._auto_detected_data = None self._title = None async def async_step_user(self, user_input=None): @@ -124,15 +123,6 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if flow["handler"] == "deconz": return self.async_abort(reason="not_zha_device") - # The Nortek sticks are a special case since they - # have a Z-Wave and a Zigbee radio. We need to reject - # the Z-Wave radio. - if vid == "10C4" and pid == "8A2A" and "ZigBee" not in description: - return self.async_abort(reason="not_zha_device") - - self._auto_detected_data = await detect_radios(dev_path) - if self._auto_detected_data is None: - return self.async_abort(reason="not_zha_device") self._device_path = dev_path self._title = usb.human_readable_device_name( dev_path, @@ -149,9 +139,15 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_confirm(self, user_input=None): """Confirm a discovery.""" if user_input is not None: + auto_detected_data = await detect_radios(self._device_path) + if auto_detected_data is None: + # This probably will not happen how they have + # have very specific usb matching, but there could + # be a problem with the device + return self.async_abort(reason="usb_probe_failed") return self.async_create_entry( title=self._title, - data=self._auto_detected_data, + data=auto_detected_data, ) return self.async_show_form( diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 4b5b429522f..5953df52e92 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -30,7 +30,8 @@ }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "not_zha_device": "This device is not a zha device" + "not_zha_device": "This device is not a zha device", + "usb_probe_failed": "Failed to probe the usb device" } }, "config_panel": { diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 9f7e3baeaf1..281a0683eb8 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -164,27 +164,17 @@ async def test_discovery_via_usb_no_radio(detect_mock, hass): "zha", context={"source": SOURCE_USB}, data=discovery_info ) await hass.async_block_till_done() - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "not_zha_device" + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + with patch("homeassistant.components.zha.async_setup_entry"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() -@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) -async def test_discovery_via_usb_rejects_nortek_zwave(detect_mock, hass): - """Test usb flow -- reject the nortek zwave radio.""" - discovery_info = { - "device": "/dev/null", - "vid": "10C4", - "pid": "8A2A", - "serial_number": "612020FD", - "description": "HubZ Smart Home Controller - HubZ Z-Wave Com Port", - "manufacturer": "Silicon Labs", - } - result = await hass.config_entries.flow.async_init( - "zha", context={"source": SOURCE_USB}, data=discovery_info - ) - await hass.async_block_till_done() - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "not_zha_device" + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "usb_probe_failed" @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) From 6f24f4e302333be2e2a07fb0157fd7dee88b3b6e Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 26 Aug 2021 11:38:35 -0400 Subject: [PATCH 007/168] Bump up ZHA dependencies (#55242) * Bump up ZHA dependencies * Bump up zha-device-handlers --- homeassistant/components/zha/manifest.json | 12 ++++++------ requirements_all.txt | 12 ++++++------ requirements_test_all.txt | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 2c1d625b7fe..4b2b27e829c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,16 +4,16 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.26.0", + "bellows==0.27.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.59", + "zha-quirks==0.0.60", "zigpy-cc==0.5.2", - "zigpy-deconz==0.12.1", - "zigpy==0.36.1", - "zigpy-xbee==0.13.0", + "zigpy-deconz==0.13.0", + "zigpy==0.37.1", + "zigpy-xbee==0.14.0", "zigpy-zigate==0.7.3", - "zigpy-znp==0.5.3" + "zigpy-znp==0.5.4" ], "usb": [ {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, diff --git a/requirements_all.txt b/requirements_all.txt index b9611f50d76..83e3c0418cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ beautifulsoup4==4.9.3 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.26.0 +bellows==0.27.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.19 @@ -2459,7 +2459,7 @@ zengge==0.2 zeroconf==0.36.0 # homeassistant.components.zha -zha-quirks==0.0.59 +zha-quirks==0.0.60 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2471,19 +2471,19 @@ ziggo-mediabox-xl==1.1.0 zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.1 +zigpy-deconz==0.13.0 # homeassistant.components.zha -zigpy-xbee==0.13.0 +zigpy-xbee==0.14.0 # homeassistant.components.zha zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.3 +zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.36.1 +zigpy==0.37.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 23ea1648560..375948cacc4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ azure-eventhub==5.5.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.26.0 +bellows==0.27.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.19 @@ -1379,25 +1379,25 @@ zeep[async]==4.0.0 zeroconf==0.36.0 # homeassistant.components.zha -zha-quirks==0.0.59 +zha-quirks==0.0.60 # homeassistant.components.zha zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.1 +zigpy-deconz==0.13.0 # homeassistant.components.zha -zigpy-xbee==0.13.0 +zigpy-xbee==0.14.0 # homeassistant.components.zha zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.3 +zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.36.1 +zigpy==0.37.1 # homeassistant.components.zwave_js zwave-js-server-python==0.29.0 From dff6151ff42f857abee33e5e643bbe9bc0cdf51c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 09:00:32 -0500 Subject: [PATCH 008/168] Abort zha usb discovery if deconz is setup (#55245) * Abort zha usb discovery if deconz is setup * Update tests/components/zha/test_config_flow.py * add deconz domain const * Update homeassistant/components/zha/config_flow.py Co-authored-by: Robert Svensson Co-authored-by: Robert Svensson --- homeassistant/components/zha/config_flow.py | 6 ++- tests/components/zha/test_config_flow.py | 48 ++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 772362b3850..4bf255e95a0 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -25,6 +25,7 @@ SUPPORTED_PORT_SETTINGS = ( CONF_BAUDRATE, CONF_FLOWCONTROL, ) +DECONZ_DOMAIN = "deconz" class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -120,7 +121,10 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # we ignore the usb discovery as they probably # want to use it there instead for flow in self.hass.config_entries.flow.async_progress(): - if flow["handler"] == "deconz": + if flow["handler"] == DECONZ_DOMAIN: + return self.async_abort(reason="not_zha_device") + for entry in self.hass.config_entries.async_entries(DECONZ_DOMAIN): + if entry.source != config_entries.SOURCE_IGNORE: return self.async_abort(reason="not_zha_device") self._device_path = dev_path diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 281a0683eb8..732b7cf440d 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -7,7 +7,7 @@ import serial.tools.list_ports import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -from homeassistant import setup +from homeassistant import config_entries, setup from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, ATTR_UPNP_MANUFACTURER_URL, @@ -271,6 +271,52 @@ async def test_discovery_via_usb_deconz_already_discovered(detect_mock, hass): assert result["reason"] == "not_zha_device" +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_already_setup(detect_mock, hass): + """Test usb flow -- deconz setup.""" + MockConfigEntry(domain="deconz", data={}).add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "not_zha_device" + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_ignored(detect_mock, hass): + """Test usb flow -- deconz ignored.""" + MockConfigEntry( + domain="deconz", source=config_entries.SOURCE_IGNORE, data={} + ).add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_already_setup(detect_mock, hass): From 20796303da001eed64e93798a0b308c203c54f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 26 Aug 2021 10:54:42 +0200 Subject: [PATCH 009/168] Only postfix image name for container (#55248) --- homeassistant/components/version/sensor.py | 2 +- tests/components/version/test_sensor.py | 30 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index 1cd42cce9b3..925e9111c1a 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -87,7 +87,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= source = HaVersionSource.CONTAINER if ( - source in (HaVersionSource.SUPERVISOR, HaVersionSource.CONTAINER) + source == HaVersionSource.CONTAINER and image is not None and image != DEFAULT_IMAGE ): diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index c8883e72389..cd56223a1e6 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -98,3 +98,33 @@ async def test_update(hass): state = hass.states.get("sensor.current_version") assert state assert state.state == "1234" + + +async def test_image_name_container(hass): + """Test the Version sensor with image name for container.""" + config = { + "sensor": {"platform": "version", "source": "docker", "image": "qemux86-64"} + } + + with patch("homeassistant.components.version.sensor.HaVersion") as haversion: + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + constructor = haversion.call_args[1] + assert constructor["source"] == "container" + assert constructor["image"] == "qemux86-64-homeassistant" + + +async def test_image_name_supervisor(hass): + """Test the Version sensor with image name for supervisor.""" + config = { + "sensor": {"platform": "version", "source": "hassio", "image": "qemux86-64"} + } + + with patch("homeassistant.components.version.sensor.HaVersion") as haversion: + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + constructor = haversion.call_args[1] + assert constructor["source"] == "supervisor" + assert constructor["image"] == "qemux86-64" From 080cb6b6e9988690a03969208f4f73adf22efaf1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Aug 2021 10:06:53 +0200 Subject: [PATCH 010/168] Fix double precision float for postgresql (#55249) --- homeassistant/components/recorder/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 017c65cd75f..8b5aef88738 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -70,7 +70,7 @@ DOUBLE_TYPE = ( Float() .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") .with_variant(oracle.DOUBLE_PRECISION(), "oracle") - .with_variant(postgresql.DOUBLE_PRECISION, "postgresql") + .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) From 3658eeb8d1aa39e2e8ea73676827401aa3ba6587 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Aug 2021 11:14:42 +0200 Subject: [PATCH 011/168] Fix MQTT add-on discovery to be ignorable (#55250) --- homeassistant/components/mqtt/config_flow.py | 3 +-- homeassistant/components/mqtt/strings.json | 1 + .../components/mqtt/translations/en.json | 1 + tests/components/mqtt/test_config_flow.py | 21 +++++++++++++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index c6af0cc08b5..172657ded98 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -95,8 +95,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info): """Receive a Hass.io discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + await self._async_handle_discovery_without_unique_id() self._hassio_discovery = discovery_info diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9de9075f19d..155f9fcb4f2 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -20,6 +20,7 @@ } }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "error": { diff --git a/homeassistant/components/mqtt/translations/en.json b/homeassistant/components/mqtt/translations/en.json index 775b4d21c9b..23012946a71 100644 --- a/homeassistant/components/mqtt/translations/en.json +++ b/homeassistant/components/mqtt/translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Service is already configured", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 55bacb0ef91..e00e959e606 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.components import mqtt +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -100,7 +101,7 @@ async def test_user_single_instance(hass): assert result["reason"] == "single_instance_allowed" -async def test_hassio_single_instance(hass): +async def test_hassio_already_configured(hass): """Test we only allow a single config flow.""" MockConfigEntry(domain="mqtt").add_to_hass(hass) @@ -108,7 +109,23 @@ async def test_hassio_single_instance(hass): "mqtt", context={"source": config_entries.SOURCE_HASSIO} ) assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" + + +async def test_hassio_ignored(hass: HomeAssistant) -> None: + """Test we supervisor discovered instance can be ignored.""" + MockConfigEntry( + domain=mqtt.DOMAIN, source=config_entries.SOURCE_IGNORE + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + mqtt.DOMAIN, + data={"addon": "Mosquitto", "host": "mock-mosquitto", "port": "1883"}, + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" async def test_hassio_confirm(hass, mock_try_connection, mock_finish_setup): From aefd3df9145a136a0357d6ae6d794343bd647b1f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Aug 2021 14:27:14 +0200 Subject: [PATCH 012/168] Warn if a sensor with state_class_total has a decreasing value twice (#55251) --- homeassistant/components/sensor/recorder.py | 13 ++++++++++++- tests/components/sensor/test_recorder.py | 20 +++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 2115cca2892..bcb21136007 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -108,6 +108,7 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { } # Keep track of entities for which a warning about decreasing value has been logged +SEEN_DIP = "sensor_seen_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip" # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" @@ -233,7 +234,17 @@ def _normalize_states( def warn_dip(hass: HomeAssistant, entity_id: str) -> None: - """Log a warning once if a sensor with state_class_total has a decreasing value.""" + """Log a warning once if a sensor with state_class_total has a decreasing value. + + The log will be suppressed until two dips have been seen to prevent warning due to + rounding issues with databases storing the state as a single precision float, which + was fixed in recorder DB version 20. + """ + if SEEN_DIP not in hass.data: + hass.data[SEEN_DIP] = set() + if entity_id not in hass.data[SEEN_DIP]: + hass.data[SEEN_DIP].add(entity_id) + return if WARN_DIP not in hass.data: hass.data[WARN_DIP] = set() if entity_id not in hass.data[WARN_DIP]: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 0234a8c0613..c7f356e49ee 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -371,7 +371,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "state_class": "total_increasing", "unit_of_measurement": unit, } - seq = [10, 15, 20, 19, 30, 40, 50, 60, 70] + seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] four, eight, states = record_meter_states( hass, zero, "sensor.test1", attributes, seq @@ -385,8 +385,20 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( wait_recording_done(hass) recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) wait_recording_done(hass) + assert ( + "Entity sensor.test1 has state class total_increasing, but its state is not " + "strictly increasing. Please create a bug report at https://github.com/" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" + "+recorder%22" + ) not in caplog.text recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) wait_recording_done(hass) + assert ( + "Entity sensor.test1 has state class total_increasing, but its state is not " + "strictly increasing. Please create a bug report at https://github.com/" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" + "+recorder%22" + ) in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} @@ -427,12 +439,6 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( ] } assert "Error while processing event StatisticsTask" not in caplog.text - assert ( - "Entity sensor.test1 has state class total_increasing, but its state is not " - "strictly increasing. Please create a bug report at https://github.com/" - "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" - "+recorder%22" - ) in caplog.text def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): From f2765ba3201a1b8cec29cc1003bb3f7ddfb4b7e3 Mon Sep 17 00:00:00 2001 From: Florian Gareis Date: Thu, 26 Aug 2021 18:33:41 +0200 Subject: [PATCH 013/168] Don't create DSL sensor for devices that don't support DSL (#55269) --- homeassistant/components/fritz/sensor.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index e3d366e83fd..7b6a6528eab 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,7 +5,12 @@ import datetime import logging from typing import Callable, TypedDict -from fritzconnection.core.exceptions import FritzConnectionException +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzActionFailedError, + FritzConnectionException, + FritzServiceError, +) from fritzconnection.lib.fritzstatus import FritzStatus from homeassistant.components.sensor import ( @@ -260,12 +265,16 @@ async def async_setup_entry( return entities = [] - dslinterface = await hass.async_add_executor_job( - fritzbox_tools.connection.call_action, - "WANDSLInterfaceConfig:1", - "GetInfo", - ) - dsl: bool = dslinterface["NewEnable"] + dsl: bool = False + try: + dslinterface = await hass.async_add_executor_job( + fritzbox_tools.connection.call_action, + "WANDSLInterfaceConfig:1", + "GetInfo", + ) + dsl = dslinterface["NewEnable"] + except (FritzActionError, FritzActionFailedError, FritzServiceError): + pass for sensor_type, sensor_data in SENSOR_DATA.items(): if not dsl and sensor_data.get("connection_type") == DSL_CONNECTION: From 67dd861d8c17b127ae90868d44422662b2476083 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 26 Aug 2021 18:29:25 +0200 Subject: [PATCH 014/168] Fix AttributeError for non-MIOT Xiaomi Miio purifiers (#55271) --- homeassistant/components/xiaomi_miio/fan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 19d85ced2dc..42828943d93 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -333,7 +333,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): } ) self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = self.coordinator.data.fan_level + self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) self.async_write_ha_state() # @@ -440,7 +440,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): {attribute: None for attribute in self._available_attributes} ) self._mode = self._state_attrs.get(ATTR_MODE) - self._fan_level = self.coordinator.data.fan_level + self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) @property def preset_mode(self): From 219868b30847c72cbcf79f9cbb8024356db60fff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Aug 2021 09:37:25 -0700 Subject: [PATCH 015/168] Bumped version to 2021.9.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b4751f86b41..3f7a7c89dee 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From e6e72bfa821858f71030169bf92a088c53421480 Mon Sep 17 00:00:00 2001 From: prwood80 <22550665+prwood80@users.noreply.github.com> Date: Fri, 27 Aug 2021 11:22:49 -0500 Subject: [PATCH 016/168] Improve performance of ring camera still images (#53803) Co-authored-by: Pat Wood Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- homeassistant/components/ring/camera.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 509877ae5ff..6a4ef692c1e 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -52,6 +52,7 @@ class RingCam(RingEntityMixin, Camera): self._last_event = None self._last_video_id = None self._video_url = None + self._image = None self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL async def async_added_to_hass(self): @@ -80,6 +81,7 @@ class RingCam(RingEntityMixin, Camera): self._last_event = None self._last_video_id = None self._video_url = None + self._image = None self._expires_at = dt_util.utcnow() self.async_write_ha_state() @@ -106,12 +108,18 @@ class RingCam(RingEntityMixin, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - if self._video_url is None: - return + if self._image is None and self._video_url: + image = await ffmpeg.async_get_image( + self.hass, + self._video_url, + width=width, + height=height, + ) - return await ffmpeg.async_get_image( - self.hass, self._video_url, width=width, height=height - ) + if image: + self._image = image + + return self._image async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" @@ -144,6 +152,9 @@ class RingCam(RingEntityMixin, Camera): if self._last_video_id == self._last_event["id"] and utcnow <= self._expires_at: return + if self._last_video_id != self._last_event["id"]: + self._image = None + try: video_url = await self.hass.async_add_executor_job( self._device.recording_url, self._last_event["id"] From 2a1e943b1846b2801c60110a269ca47b7b31153b Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 26 Aug 2021 13:43:26 -0700 Subject: [PATCH 017/168] Fix unique_id conflict in smarttthings (#55235) --- homeassistant/components/smartthings/sensor.py | 2 +- tests/components/smartthings/test_sensor.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 7c682486f04..f5ab5562229 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -561,7 +561,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): @property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self._device.device_id}.{self.report_name}" + return f"{self._device.device_id}.{self.report_name}_meter" @property def native_value(self): diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 70103c3a837..f36f05616d6 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -168,7 +168,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "1412.002" entry = entity_registry.async_get("sensor.refrigerator_energy") assert entry - assert entry.unique_id == f"{device.device_id}.energy" + assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label @@ -180,7 +180,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "109" entry = entity_registry.async_get("sensor.refrigerator_power") assert entry - assert entry.unique_id == f"{device.device_id}.power" + assert entry.unique_id == f"{device.device_id}.power_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label @@ -202,7 +202,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "unknown" entry = entity_registry.async_get("sensor.vacuum_energy") assert entry - assert entry.unique_id == f"{device.device_id}.energy" + assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label From 7df84dadadfda652cacd4e71024a6941a8a3f669 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 13:25:26 -0500 Subject: [PATCH 018/168] Fix some yeelights showing wrong state after on/off (#55279) --- homeassistant/components/yeelight/__init__.py | 6 +- homeassistant/components/yeelight/light.py | 7 +++ tests/components/yeelight/test_light.py | 56 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 2bdde2113a4..d795abfeb32 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -684,10 +684,10 @@ class YeelightDevice: else: self._name = self._host # Default name is host - async def async_update(self): + async def async_update(self, force=False): """Update device properties and send data updated signal.""" - if self._initialized and self._available: - # No need to poll, already connected + if not force and self._initialized and self._available: + # No need to poll unless force, already connected return await self._async_update_properties() async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 4766d897909..bc4d027ce93 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -762,6 +762,10 @@ class YeelightGenericLight(YeelightEntity, LightEntity): _LOGGER.error("Unable to set the defaults: %s", ex) return + # Some devices (mainly nightlights) will not send back the on state so we need to force a refresh + if not self.is_on: + await self.device.async_update(True) + async def async_turn_off(self, **kwargs) -> None: """Turn off.""" if not self.is_on: @@ -772,6 +776,9 @@ class YeelightGenericLight(YeelightEntity, LightEntity): duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s await self.device.async_turn_off(duration=duration, light_type=self.light_type) + # Some devices will not send back the off state so we need to force a refresh + if self.is_on: + await self.device.async_update(True) async def async_set_mode(self, mode: str): """Set a power mode.""" diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 4b8717f4ba4..17924facfad 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -1122,3 +1122,59 @@ async def test_effects(hass: HomeAssistant): for name, target in effects.items(): await _async_test_effect(name, target) await _async_test_effect("not_existed", called=False) + + +async def test_state_fails_to_update_triggers_update(hass: HomeAssistant): + """Ensure we call async_get_properties if the turn on/off fails to update the state.""" + mocked_bulb = _mocked_bulb() + properties = {**PROPERTIES} + properties.pop("active_mode") + properties["color_mode"] = "3" # HSV + mocked_bulb.last_properties = properties + mocked_bulb.bulb_type = BulbType.Color + config_entry = MockConfigEntry( + domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} + ) + config_entry.add_to_hass(hass) + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # We use asyncio.create_task now to avoid + # blocking starting so we need to block again + await hass.async_block_till_done() + + mocked_bulb.last_properties["power"] = "off" + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + }, + blocking=True, + ) + assert len(mocked_bulb.async_turn_on.mock_calls) == 1 + assert len(mocked_bulb.async_get_properties.mock_calls) == 2 + + mocked_bulb.last_properties["power"] = "on" + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + }, + blocking=True, + ) + assert len(mocked_bulb.async_turn_off.mock_calls) == 1 + assert len(mocked_bulb.async_get_properties.mock_calls) == 3 + + # But if the state is correct no calls + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + }, + blocking=True, + ) + assert len(mocked_bulb.async_turn_on.mock_calls) == 1 + assert len(mocked_bulb.async_get_properties.mock_calls) == 3 From 9f7398e0df2d5af331ed6a7ed3341ce74004ddc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 15:18:36 -0500 Subject: [PATCH 019/168] Fix yeelight brightness when nightlight switch is disabled (#55278) --- homeassistant/components/yeelight/light.py | 14 +++++++-- tests/components/yeelight/test_light.py | 36 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index bc4d027ce93..be876690b06 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -859,7 +859,12 @@ class YeelightColorLightWithoutNightlightSwitch( @property def _brightness_property(self): - return "current_brightness" + # If the nightlight is not active, we do not + # want to "current_brightness" since it will check + # "bg_power" and main light could still be on + if self.device.is_nightlight_enabled: + return "current_brightness" + return super()._brightness_property class YeelightColorLightWithNightlightSwitch( @@ -883,7 +888,12 @@ class YeelightWhiteTempWithoutNightlightSwitch( @property def _brightness_property(self): - return "current_brightness" + # If the nightlight is not active, we do not + # want to "current_brightness" since it will check + # "bg_power" and main light could still be on + if self.device.is_nightlight_enabled: + return "current_brightness" + return super()._brightness_property class YeelightWithNightLight( diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 17924facfad..030f6a54cea 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -96,6 +96,7 @@ from homeassistant.util.color import ( ) from . import ( + CAPABILITIES, ENTITY_LIGHT, ENTITY_NIGHTLIGHT, IP_ADDRESS, @@ -1178,3 +1179,38 @@ async def test_state_fails_to_update_triggers_update(hass: HomeAssistant): ) assert len(mocked_bulb.async_turn_on.mock_calls) == 1 assert len(mocked_bulb.async_get_properties.mock_calls) == 3 + + +async def test_ambilight_with_nightlight_disabled(hass: HomeAssistant): + """Test that main light on ambilights with the nightlight disabled shows the correct brightness.""" + mocked_bulb = _mocked_bulb() + properties = {**PROPERTIES} + capabilities = {**CAPABILITIES} + capabilities["model"] = "ceiling10" + properties["color_mode"] = "3" # HSV + properties["bg_power"] = "off" + properties["current_brightness"] = 0 + properties["bg_lmode"] = "2" # CT + mocked_bulb.last_properties = properties + mocked_bulb.bulb_type = BulbType.WhiteTempMood + main_light_entity_id = "light.yeelight_ceiling10_0x15243f" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}, + options={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}, + ) + config_entry.add_to_hass(hass) + with _patch_discovery(capabilities=capabilities), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # We use asyncio.create_task now to avoid + # blocking starting so we need to block again + await hass.async_block_till_done() + + state = hass.states.get(main_light_entity_id) + assert state.state == "on" + # bg_power off should not set the brightness to 0 + assert state.attributes[ATTR_BRIGHTNESS] == 128 From 8a2c07ce199de26c30238308a1b376dac2721c31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 13:02:59 -0500 Subject: [PATCH 020/168] Ensure yeelight model is set in the config entry (#55281) * Ensure yeelight model is set in the config entry - If the model was not set in the config entry the light could be sent commands it could not handle * update tests * fix test --- homeassistant/components/yeelight/__init__.py | 22 +++++++---- .../components/yeelight/config_flow.py | 13 ++++++- tests/components/yeelight/test_config_flow.py | 37 +++++++++++++++---- tests/components/yeelight/test_init.py | 3 ++ 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index d795abfeb32..3d1a6cd03e1 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -196,7 +196,6 @@ async def _async_initialize( entry_data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = { DATA_PLATFORMS_LOADED: False } - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @callback def _async_load_platforms(): @@ -212,6 +211,15 @@ async def _async_initialize( await device.async_setup() entry_data[DATA_DEVICE] = device + if ( + device.capabilities + and entry.options.get(CONF_MODEL) != device.capabilities["model"] + ): + hass.config_entries.async_update_entry( + entry, options={**entry.options, CONF_MODEL: device.capabilities["model"]} + ) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( async_dispatcher_connect( hass, DEVICE_INITIALIZED.format(host), _async_load_platforms @@ -540,7 +548,7 @@ class YeelightDevice: self._config = config self._host = host self._bulb_device = bulb - self._capabilities = {} + self.capabilities = {} self._device_type = None self._available = False self._initialized = False @@ -574,12 +582,12 @@ class YeelightDevice: @property def model(self): """Return configured/autodetected device model.""" - return self._bulb_device.model or self._capabilities.get("model") + return self._bulb_device.model or self.capabilities.get("model") @property def fw_version(self): """Return the firmware version.""" - return self._capabilities.get("fw_ver") + return self.capabilities.get("fw_ver") @property def is_nightlight_supported(self) -> bool: @@ -674,13 +682,13 @@ class YeelightDevice: async def async_setup(self): """Fetch capabilities and setup name if available.""" scanner = YeelightScanner.async_get(self._hass) - self._capabilities = await scanner.async_get_capabilities(self._host) or {} + self.capabilities = await scanner.async_get_capabilities(self._host) or {} if name := self._config.get(CONF_NAME): # Override default name when name is set in config self._name = name - elif self._capabilities: + elif self.capabilities: # Generate name from model and id when capabilities is available - self._name = _async_unique_name(self._capabilities) + self._name = _async_unique_name(self.capabilities) else: self._name = self._host # Default name is host diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 268a0e9cea2..73bbcdcfe5f 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -96,7 +96,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_create_entry( title=async_format_model_id(self._discovered_model, self.unique_id), - data={CONF_ID: self.unique_id, CONF_HOST: self._discovered_ip}, + data={ + CONF_ID: self.unique_id, + CONF_HOST: self._discovered_ip, + CONF_MODEL: self._discovered_model, + }, ) self._set_confirm_only() @@ -129,6 +133,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data={ CONF_HOST: user_input[CONF_HOST], CONF_ID: self.unique_id, + CONF_MODEL: model, }, ) @@ -151,7 +156,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host = urlparse(capabilities["location"]).hostname return self.async_create_entry( title=_async_unique_name(capabilities), - data={CONF_ID: unique_id, CONF_HOST: host}, + data={ + CONF_ID: unique_id, + CONF_HOST: host, + CONF_MODEL: capabilities["model"], + }, ) configured_devices = { diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index bde8a18ae55..6bc3ba68275 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -19,7 +19,7 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, ) -from homeassistant.components.yeelight.config_flow import CannotConnect +from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM @@ -28,6 +28,7 @@ from . import ( CAPABILITIES, ID, IP_ADDRESS, + MODEL, MODULE, MODULE_CONFIG_FLOW, NAME, @@ -87,7 +88,7 @@ async def test_discovery(hass: HomeAssistant): ) assert result3["type"] == "create_entry" assert result3["title"] == UNIQUE_FRIENDLY_NAME - assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS} + assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS, CONF_MODEL: MODEL} await hass.async_block_till_done() mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -160,7 +161,11 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant): ) assert result3["type"] == "create_entry" assert result3["title"] == UNIQUE_FRIENDLY_NAME - assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS} + assert result3["data"] == { + CONF_ID: ID, + CONF_HOST: IP_ADDRESS, + CONF_MODEL: MODEL, + } await hass.async_block_till_done() await hass.async_block_till_done() @@ -300,7 +305,11 @@ async def test_manual(hass: HomeAssistant): await hass.async_block_till_done() assert result4["type"] == "create_entry" assert result4["title"] == "Color 0x15243f" - assert result4["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert result4["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } # Duplicate result = await hass.config_entries.flow.async_init( @@ -333,7 +342,7 @@ async def test_options(hass: HomeAssistant): config = { CONF_NAME: NAME, - CONF_MODEL: "", + CONF_MODEL: MODEL, CONF_TRANSITION: DEFAULT_TRANSITION, CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE, @@ -383,7 +392,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant): result["flow_id"], {CONF_HOST: IP_ADDRESS} ) assert result["type"] == "create_entry" - assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: None} + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: None, + CONF_MODEL: MODEL_UNKNOWN, + } async def test_discovered_by_homekit_and_dhcp(hass): @@ -480,7 +493,11 @@ async def test_discovered_by_dhcp_or_homekit(hass, source, data): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } assert mock_async_setup.called assert mock_async_setup_entry.called @@ -540,7 +557,11 @@ async def test_discovered_ssdp(hass): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_ID: "0x000000000015243f"} + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } assert mock_async_setup.called assert mock_async_setup_entry.called diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 84c87b7f1dc..4414909d8e0 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from yeelight import BulbException, BulbType from homeassistant.components.yeelight import ( + CONF_MODEL, CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH_TYPE, DATA_CONFIG_ENTRIES, @@ -35,6 +36,7 @@ from . import ( FAIL_TO_BIND_IP, ID, IP_ADDRESS, + MODEL, MODULE, SHORT_ID, _mocked_bulb, @@ -360,6 +362,7 @@ async def test_async_listen_error_late_discovery(hass, caplog): assert "Failed to connect to bulb at" not in caplog.text assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options[CONF_MODEL] == MODEL async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): From 97ff5e2085fc7345fe722faeb788f2508cab8905 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 19:04:12 -0500 Subject: [PATCH 021/168] Set yeelight capabilities from external discovery (#55280) --- homeassistant/components/yeelight/__init__.py | 2 ++ homeassistant/components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 3d1a6cd03e1..8684e331fad 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -683,6 +683,8 @@ class YeelightDevice: """Fetch capabilities and setup name if available.""" scanner = YeelightScanner.async_get(self._hass) self.capabilities = await scanner.async_get_capabilities(self._host) or {} + if self.capabilities: + self._bulb_device.set_capabilities(self.capabilities) if name := self._config.get(CONF_NAME): # Override default name when name is set in config self._name = name diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 5910341cfb4..0a4b5d4499f 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.3", "async-upnp-client==0.20.0"], + "requirements": ["yeelight==0.7.4", "async-upnp-client==0.20.0"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/requirements_all.txt b/requirements_all.txt index 83e3c0418cb..274a1241c74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2438,7 +2438,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.3 +yeelight==0.7.4 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 375948cacc4..ec37d01c003 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.7.3 +yeelight==0.7.4 # homeassistant.components.youless youless-api==0.12 From 06e40036406d3ff74faa00fad7af942411031ca4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Aug 2021 10:37:53 -0700 Subject: [PATCH 022/168] Bump ring to 0.7.1 (#55282) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index ecb64c99fd7..527fb143aff 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -2,7 +2,7 @@ "domain": "ring", "name": "Ring", "documentation": "https://www.home-assistant.io/integrations/ring", - "requirements": ["ring_doorbell==0.6.2"], + "requirements": ["ring_doorbell==0.7.1"], "dependencies": ["ffmpeg"], "codeowners": ["@balloob"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 274a1241c74..e612f922d24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2043,7 +2043,7 @@ rfk101py==0.0.1 rflink==0.0.58 # homeassistant.components.ring -ring_doorbell==0.6.2 +ring_doorbell==0.7.1 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec37d01c003..031cba6ccc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1148,7 +1148,7 @@ restrictedpython==5.1 rflink==0.0.58 # homeassistant.components.ring -ring_doorbell==0.6.2 +ring_doorbell==0.7.1 # homeassistant.components.roku rokuecp==0.8.1 From 93750d71ce6dc9f1fa3d27bb4978af4a04270b04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 15:47:10 -0500 Subject: [PATCH 023/168] Gracefully handle pyudev failing to filter on WSL (#55286) * Gracefully handle pyudev failing to filter on WSL * add debug message * add mocks so we reach the new check --- homeassistant/components/usb/__init__.py | 8 +++- tests/components/usb/test_init.py | 56 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index d02c01ad03d..679f2e1caa2 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -127,7 +127,13 @@ class USBDiscovery: return monitor = Monitor.from_netlink(context) - monitor.filter_by(subsystem="tty") + try: + monitor.filter_by(subsystem="tty") + except ValueError as ex: # this fails on WSL + _LOGGER.debug( + "Unable to setup pyudev filtering; This is expected on WSL: %s", ex + ) + return observer = MonitorObserver( monitor, callback=self._device_discovered, name="usb-observer" ) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index e22e514f230..6ba21222052 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -38,6 +38,20 @@ def mock_docker(): yield +@pytest.fixture(name="venv") +def mock_venv(): + """Mock running Home Assistant in a venv container.""" + with patch( + "homeassistant.components.usb.system_info.async_get_system_info", + return_value={ + "hassio": False, + "docker": False, + "virtualenv": True, + }, + ): + yield + + @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Only works on linux", @@ -606,6 +620,48 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.skipif( + not sys.platform.startswith("linux"), + reason="Only works on linux", +) +async def test_observer_on_wsl_fallback_without_throwing_exception( + hass, hass_ws_client, venv +): + """Test that observer on WSL failure results in fallback to scanning without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context"), patch( + "pyudev.Monitor.filter_by", side_effect=ValueError + ), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Only works on linux", From f9a0f44137f8f39d36f9f5b428796b8810f8a74c Mon Sep 17 00:00:00 2001 From: realPy Date: Fri, 27 Aug 2021 18:25:27 +0200 Subject: [PATCH 024/168] Correct flash light livarno when use hue (#55294) Co-authored-by: Paulus Schoutsen --- homeassistant/components/hue/light.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 345156de7d7..ea89d91113b 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -282,12 +282,14 @@ class HueLight(CoordinatorEntity, LightEntity): self.is_osram = False self.is_philips = False self.is_innr = False + self.is_livarno = False self.gamut_typ = GAMUT_TYPE_UNAVAILABLE self.gamut = None else: self.is_osram = light.manufacturername == "OSRAM" self.is_philips = light.manufacturername == "Philips" self.is_innr = light.manufacturername == "innr" + self.is_livarno = light.manufacturername.startswith("_TZ3000_") self.gamut_typ = self.light.colorgamuttype self.gamut = self.light.colorgamut _LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut)) @@ -383,6 +385,8 @@ class HueLight(CoordinatorEntity, LightEntity): """Return the warmest color_temp that this light supports.""" if self.is_group: return super().max_mireds + if self.is_livarno: + return 500 max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") @@ -493,7 +497,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr: + elif not self.is_innr and not self.is_livarno: command["alert"] = "none" if ATTR_EFFECT in kwargs: @@ -532,7 +536,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr: + elif not self.is_innr and not self.is_livarno: command["alert"] = "none" if self.is_group: From bfc98b444f3745d38643cce5cba80e323755a03d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 17:02:49 -0500 Subject: [PATCH 025/168] Fix creation of new nmap tracker entities (#55297) --- homeassistant/components/nmap_tracker/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 78465fbe91d..dfd8987484c 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -361,13 +361,6 @@ class NmapDeviceScanner: continue formatted_mac = format_mac(mac) - new = formatted_mac not in devices.tracked - if ( - new - and formatted_mac not in devices.tracked - and formatted_mac not in self._known_mac_addresses - ): - continue if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) @@ -382,6 +375,7 @@ class NmapDeviceScanner: formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 ) + new = formatted_mac not in devices.tracked devices.tracked[formatted_mac] = device devices.ipv4_last_mac[ipv4] = formatted_mac self._last_results.append(device) From ddb28db21a5b8e42b11d6e4438e89221ca017ae8 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Fri, 27 Aug 2021 12:03:25 +0200 Subject: [PATCH 026/168] Bump bimmer_connected to 0.7.20 (#55299) --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index a7c4c5c837b..8a1e7e2c826 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.19"], + "requirements": ["bimmer_connected==0.7.20"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index e612f922d24..f3dfd3fd871 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ beautifulsoup4==4.9.3 bellows==0.27.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.19 +bimmer_connected==0.7.20 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 031cba6ccc3..f87f7fc0397 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -230,7 +230,7 @@ base36==0.1.1 bellows==0.27.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.19 +bimmer_connected==0.7.20 # homeassistant.components.blebox blebox_uniapi==1.3.3 From c963cf874352e32184584372517140d1bea8bae6 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 26 Aug 2021 16:59:27 -0600 Subject: [PATCH 027/168] Bump aiorecollect to 1.0.8 (#55300) --- homeassistant/components/recollect_waste/manifest.json | 2 +- homeassistant/components/recollect_waste/sensor.py | 5 ++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json index 258d74915f7..85cb7100a65 100644 --- a/homeassistant/components/recollect_waste/manifest.json +++ b/homeassistant/components/recollect_waste/manifest.json @@ -3,7 +3,7 @@ "name": "ReCollect Waste", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/recollect_waste", - "requirements": ["aiorecollect==1.0.7"], + "requirements": ["aiorecollect==1.0.8"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 304eaafb85f..434d24be22a 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -20,7 +20,6 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from homeassistant.util.dt import as_utc from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER @@ -124,7 +123,7 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names( self._entry, next_pickup_event.pickup_types ), - ATTR_NEXT_PICKUP_DATE: as_utc(next_pickup_event.date).isoformat(), + ATTR_NEXT_PICKUP_DATE: next_pickup_event.date.isoformat(), } ) - self._attr_native_value = as_utc(pickup_event.date).isoformat() + self._attr_native_value = pickup_event.date.isoformat() diff --git a/requirements_all.txt b/requirements_all.txt index f3dfd3fd871..2a5651bad6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.7 +aiorecollect==1.0.8 # homeassistant.components.shelly aioshelly==0.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f87f7fc0397..5c2da6980fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.7 +aiorecollect==1.0.8 # homeassistant.components.shelly aioshelly==0.6.4 From fb25c6c115f1cc5920b6018b00f741e7fd6b87ba Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 27 Aug 2021 00:48:20 -0600 Subject: [PATCH 028/168] Bump simplisafe-python to 11.0.5 (#55306) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 6bf029ead6e..2d524d4c381 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==11.0.4"], + "requirements": ["simplisafe-python==11.0.5"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 2a5651bad6b..32eb110b1d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2131,7 +2131,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.4 +simplisafe-python==11.0.5 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c2da6980fd..bb6f5c4c2ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1191,7 +1191,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.4 +simplisafe-python==11.0.5 # homeassistant.components.slack slackclient==2.5.0 From 865656d4365d9278def9665ac8882a938467cb49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 01:49:50 -0500 Subject: [PATCH 029/168] Always send powerview move command in case shade is out of sync (#55308) --- homeassistant/components/hunterdouglas_powerview/cover.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 901a048fc7f..22636b7e3c4 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -177,8 +177,6 @@ class PowerViewShade(ShadeEntity, CoverEntity): """Move the shade to a position.""" current_hass_position = hd_position_to_hass(self._current_cover_position) steps_to_move = abs(current_hass_position - target_hass_position) - if not steps_to_move: - return self._async_schedule_update_for_transition(steps_to_move) self._async_update_from_command( await self._shade.move( From 5b993129d6cfcfd4a57f0746fe36ac7e0e63182f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Aug 2021 23:37:28 -0500 Subject: [PATCH 030/168] Fix lifx model to be a string (#55309) Fixes #55307 --- homeassistant/components/lifx/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 30c0ffbe850..a4412d042a8 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -470,7 +470,7 @@ class LIFXLight(LightEntity): model = product_map.get(self.bulb.product) or self.bulb.product if model is not None: - info["model"] = model + info["model"] = str(model) return info From f53a10d39a151bf58c9feba957d900433809d371 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Aug 2021 16:18:49 +0200 Subject: [PATCH 031/168] Handle statistics for sensor with changing state class (#55316) --- homeassistant/components/recorder/models.py | 1 + .../components/recorder/statistics.py | 89 +++++++++++++----- homeassistant/components/sensor/recorder.py | 2 +- tests/components/sensor/test_recorder.py | 90 +++++++++++++++++++ 4 files changed, 158 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 8b5aef88738..28eff4d9d95 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -267,6 +267,7 @@ class Statistics(Base): # type: ignore class StatisticMetaData(TypedDict, total=False): """Statistic meta data class.""" + statistic_id: str unit_of_measurement: str | None has_mean: bool has_sum: bool diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 06f7851b1a6..ddc542d23b7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -53,6 +53,13 @@ QUERY_STATISTIC_META = [ StatisticsMeta.id, StatisticsMeta.statistic_id, StatisticsMeta.unit_of_measurement, + StatisticsMeta.has_mean, + StatisticsMeta.has_sum, +] + +QUERY_STATISTIC_META_ID = [ + StatisticsMeta.id, + StatisticsMeta.statistic_id, ] STATISTICS_BAKERY = "recorder_statistics_bakery" @@ -124,33 +131,61 @@ def _get_metadata_ids( ) -> list[str]: """Resolve metadata_id for a list of statistic_ids.""" baked_query = hass.data[STATISTICS_META_BAKERY]( - lambda session: session.query(*QUERY_STATISTIC_META) + lambda session: session.query(*QUERY_STATISTIC_META_ID) ) baked_query += lambda q: q.filter( StatisticsMeta.statistic_id.in_(bindparam("statistic_ids")) ) result = execute(baked_query(session).params(statistic_ids=statistic_ids)) - return [id for id, _, _ in result] if result else [] + return [id for id, _ in result] if result else [] -def _get_or_add_metadata_id( +def _update_or_add_metadata( hass: HomeAssistant, session: scoped_session, statistic_id: str, - metadata: StatisticMetaData, + new_metadata: StatisticMetaData, ) -> str: """Get metadata_id for a statistic_id, add if it doesn't exist.""" - metadata_id = _get_metadata_ids(hass, session, [statistic_id]) - if not metadata_id: - unit = metadata["unit_of_measurement"] - has_mean = metadata["has_mean"] - has_sum = metadata["has_sum"] + old_metadata_dict = _get_metadata(hass, session, [statistic_id], None) + if not old_metadata_dict: + unit = new_metadata["unit_of_measurement"] + has_mean = new_metadata["has_mean"] + has_sum = new_metadata["has_sum"] session.add( StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum) ) - metadata_id = _get_metadata_ids(hass, session, [statistic_id]) - return metadata_id[0] + metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) + _LOGGER.debug( + "Added new statistics metadata for %s, new_metadata: %s", + statistic_id, + new_metadata, + ) + return metadata_ids[0] + + metadata_id, old_metadata = next(iter(old_metadata_dict.items())) + if ( + old_metadata["has_mean"] != new_metadata["has_mean"] + or old_metadata["has_sum"] != new_metadata["has_sum"] + or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"] + ): + session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( + { + StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.has_sum: new_metadata["has_sum"], + StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], + }, + synchronize_session=False, + ) + _LOGGER.debug( + "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", + statistic_id, + old_metadata, + new_metadata, + ) + + return metadata_id @retryable_database_job("statistics") @@ -177,7 +212,7 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: with session_scope(session=instance.get_session()) as session: # type: ignore for stats in platform_stats: for entity_id, stat in stats.items(): - metadata_id = _get_or_add_metadata_id( + metadata_id = _update_or_add_metadata( instance.hass, session, entity_id, stat["meta"] ) session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) @@ -191,14 +226,19 @@ def _get_metadata( session: scoped_session, statistic_ids: list[str] | None, statistic_type: str | None, -) -> dict[str, dict[str, str]]: +) -> dict[str, StatisticMetaData]: """Fetch meta data.""" - def _meta(metas: list, wanted_metadata_id: str) -> dict[str, str] | None: - meta = None - for metadata_id, statistic_id, unit in metas: + def _meta(metas: list, wanted_metadata_id: str) -> StatisticMetaData | None: + meta: StatisticMetaData | None = None + for metadata_id, statistic_id, unit, has_mean, has_sum in metas: if metadata_id == wanted_metadata_id: - meta = {"unit_of_measurement": unit, "statistic_id": statistic_id} + meta = { + "statistic_id": statistic_id, + "unit_of_measurement": unit, + "has_mean": has_mean, + "has_sum": has_sum, + } return meta baked_query = hass.data[STATISTICS_META_BAKERY]( @@ -219,7 +259,7 @@ def _get_metadata( return {} metadata_ids = [metadata[0] for metadata in result] - metadata = {} + metadata: dict[str, StatisticMetaData] = {} for _id in metadata_ids: meta = _meta(result, _id) if meta: @@ -230,7 +270,7 @@ def _get_metadata( def get_metadata( hass: HomeAssistant, statistic_id: str, -) -> dict[str, str] | None: +) -> StatisticMetaData | None: """Return metadata for a statistic_id.""" statistic_ids = [statistic_id] with session_scope(hass=hass) as session: @@ -255,7 +295,7 @@ def _configured_unit(unit: str, units: UnitSystem) -> str: def list_statistic_ids( hass: HomeAssistant, statistic_type: str | None = None -) -> list[dict[str, str] | None]: +) -> list[StatisticMetaData | None]: """Return statistic_ids and meta data.""" units = hass.config.units statistic_ids = {} @@ -263,7 +303,9 @@ def list_statistic_ids( metadata = _get_metadata(hass, session, None, statistic_type) for meta in metadata.values(): - unit = _configured_unit(meta["unit_of_measurement"], units) + unit = meta["unit_of_measurement"] + if unit is not None: + unit = _configured_unit(unit, units) meta["unit_of_measurement"] = unit statistic_ids = { @@ -277,7 +319,8 @@ def list_statistic_ids( platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type) for statistic_id, unit in platform_statistic_ids.items(): - unit = _configured_unit(unit, units) + if unit is not None: + unit = _configured_unit(unit, units) platform_statistic_ids[statistic_id] = unit statistic_ids = {**statistic_ids, **platform_statistic_ids} @@ -367,7 +410,7 @@ def _sorted_statistics_to_dict( hass: HomeAssistant, stats: list, statistic_ids: list[str] | None, - metadata: dict[str, dict[str, str]], + metadata: dict[str, StatisticMetaData], ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index bcb21136007..2b59592dd17 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -352,7 +352,7 @@ def compile_statistics( # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] new_state = old_state = last_stats[entity_id][0]["state"] - _sum = last_stats[entity_id][0]["sum"] + _sum = last_stats[entity_id][0]["sum"] or 0 for fstate, state in fstates: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index c7f356e49ee..2e300b9c748 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -10,6 +10,7 @@ from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat from homeassistant.components.recorder.statistics import ( + get_metadata, list_statistic_ids, statistics_during_period, ) @@ -1037,6 +1038,95 @@ def test_compile_hourly_statistics_changing_units_2( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_statistics( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change during an hour.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes_1 = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + attributes_2 = { + "device_class": device_class, + "state_class": "total_increasing", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes_1) + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": None} + ] + metadata = get_metadata(hass, "sensor.test1") + assert metadata == { + "has_mean": True, + "has_sum": False, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + } + + # Add more states, with changed state class + four, _states = record_states( + hass, zero + timedelta(hours=1), "sensor.test1", attributes_2 + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": None} + ] + metadata = get_metadata(hass, "sensor.test1") + assert metadata == { + "has_mean": False, + "has_sum": True, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + } + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": approx(30.0), + "sum": approx(30.0), + }, + ] + } + + assert "Error while processing event StatisticsTask" not in caplog.text + + def record_states(hass, zero, entity_id, attributes): """Record some test states. From 34f0fecef8db20d236fdb35ab711a98cd4854feb Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 27 Aug 2021 12:25:40 -0400 Subject: [PATCH 032/168] Fix sonos alarm schema (#55318) --- homeassistant/components/sonos/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 4fdc5c6f320..5cb6e225510 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -223,6 +223,7 @@ async def async_setup_entry( { vol.Required(ATTR_ALARM_ID): cv.positive_int, vol.Optional(ATTR_TIME): cv.time, + vol.Optional(ATTR_VOLUME): cv.small_float, vol.Optional(ATTR_ENABLED): cv.boolean, vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, }, From e097e4c1c255312cd6face8b1219da5bbd2601bb Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Fri, 27 Aug 2021 11:04:07 -0500 Subject: [PATCH 033/168] Fix reauth for sonarr (#55329) * fix reauth for sonarr * Update config_flow.py * Update config_flow.py * Update config_flow.py * Update test_config_flow.py * Update config_flow.py * Update test_config_flow.py * Update config_flow.py --- .../components/sonarr/config_flow.py | 30 +++++++------------ tests/components/sonarr/test_config_flow.py | 10 ++++--- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index db82e729483..cc35a8db4af 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -64,9 +64,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the flow.""" - self._reauth = False - self._entry_id = None - self._entry_data = {} + self.entry = None @staticmethod @callback @@ -76,10 +74,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle configuration by re-auth.""" - self._reauth = True - self._entry_data = dict(data) - entry = await self.async_set_unique_id(self.unique_id) - self._entry_id = entry.entry_id + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() @@ -90,7 +85,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="reauth_confirm", - description_placeholders={"host": self._entry_data[CONF_HOST]}, + description_placeholders={"host": self.entry.data[CONF_HOST]}, data_schema=vol.Schema({}), errors={}, ) @@ -104,8 +99,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - if self._reauth: - user_input = {**self._entry_data, **user_input} + if self.entry: + user_input = {**self.entry.data, **user_input} if CONF_VERIFY_SSL not in user_input: user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL @@ -120,10 +115,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: - if self._reauth: - return await self._async_reauth_update_entry( - self._entry_id, user_input - ) + if self.entry: + return await self._async_reauth_update_entry(user_input) return self.async_create_entry( title=user_input[CONF_HOST], data=user_input @@ -136,17 +129,16 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_reauth_update_entry(self, entry_id: str, data: dict) -> FlowResult: + async def _async_reauth_update_entry(self, data: dict) -> FlowResult: """Update existing config entry.""" - entry = self.hass.config_entries.async_get_entry(entry_id) - self.hass.config_entries.async_update_entry(entry, data=data) - await self.hass.config_entries.async_reload(entry.entry_id) + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") def _get_user_data_schema(self) -> dict[str, Any]: """Get the data schema to display user form.""" - if self._reauth: + if self.entry: return {vol.Required(CONF_API_KEY): str} data_schema = { diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py index c1896061f79..87b38e52742 100644 --- a/tests/components/sonarr/test_config_flow.py +++ b/tests/components/sonarr/test_config_flow.py @@ -100,14 +100,16 @@ async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test the manual reauth flow from start to finish.""" - entry = await setup_integration( - hass, aioclient_mock, skip_entry_setup=True, unique_id="any" - ) + entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True) assert entry result = await hass.config_entries.flow.async_init( DOMAIN, - context={CONF_SOURCE: SOURCE_REAUTH, "unique_id": entry.unique_id}, + context={ + CONF_SOURCE: SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, data=entry.data, ) From b3e0b7b86e976ba3190a2259d0ac74747622c477 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 27 Aug 2021 18:26:57 +0200 Subject: [PATCH 034/168] Add modbus name to log_error (#55336) --- homeassistant/components/modbus/modbus.py | 2 +- tests/components/modbus/test_init.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index c2cae9f4ec3..4889b27faf0 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -243,7 +243,7 @@ class ModbusHub: self._msg_wait = 0 def _log_error(self, text: str, error_state=True): - log_text = f"Pymodbus: {text}" + log_text = f"Pymodbus: {self.name}: {text}" if self._in_error: _LOGGER.debug(log_text) else: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index b24115ee964..1bb538a886a 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -593,6 +593,7 @@ async def test_pymodbus_constructor_fail(hass, caplog): config = { DOMAIN: [ { + CONF_NAME: TEST_MODBUS_NAME, CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, CONF_PORT: TEST_PORT_TCP, @@ -606,7 +607,8 @@ async def test_pymodbus_constructor_fail(hass, caplog): mock_pb.side_effect = ModbusException("test no class") assert await async_setup_component(hass, DOMAIN, config) is False await hass.async_block_till_done() - assert caplog.messages[0].startswith("Pymodbus: Modbus Error: test") + message = f"Pymodbus: {TEST_MODBUS_NAME}: Modbus Error: test" + assert caplog.messages[0].startswith(message) assert caplog.records[0].levelname == "ERROR" assert mock_pb.called From d8b64be41cf59ffd30fba9200bb0bb253257b140 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 11:53:29 -0500 Subject: [PATCH 035/168] Retrigger config flow when the ssdp location changes for a UDN (#55343) Fixes #55229 --- homeassistant/components/ssdp/__init__.py | 12 ++- tests/components/ssdp/test_init.py | 124 ++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 1fd2bba77cc..6e9441534ab 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -286,6 +286,11 @@ class Scanner: if header_st is not None: self.seen.add((header_st, header_location)) + def _async_unsee(self, header_st: str | None, header_location: str | None) -> None: + """If we see a device in a new location, unsee the original location.""" + if header_st is not None: + self.seen.remove((header_st, header_location)) + async def _async_process_entry(self, headers: Mapping[str, str]) -> None: """Process SSDP entries.""" _LOGGER.debug("_async_process_entry: %s", headers) @@ -293,7 +298,12 @@ class Scanner: h_location = headers.get("location") if h_st and (udn := _udn_from_usn(headers.get("usn"))): - self.cache[(udn, h_st)] = headers + cache_key = (udn, h_st) + if old_headers := self.cache.get(cache_key): + old_h_location = old_headers.get("location") + if h_location != old_h_location: + self._async_unsee(old_headers.get("st"), old_h_location) + self.cache[cache_key] = headers callbacks = self._async_get_matching_callbacks(headers) if self._async_seen(h_st, h_location) and not callbacks: diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 2c5dc74db44..43b7fd98cd0 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -926,3 +926,127 @@ async def test_ipv4_does_additional_search_for_sonos(hass, caplog): ), ), } + + +async def test_location_change_evicts_prior_location_from_cache(hass, aioclient_mock): + """Test that a location change for a UDN will evict the prior location from the cache.""" + mock_get_ssdp = { + "hue": [{"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}] + } + + hue_response = """ + + +1 +0 + +http://{ip_address}:80/ + +urn:schemas-upnp-org:device:Basic:1 +Philips hue ({ip_address}) +Signify +http://www.philips-hue.com +Philips hue Personal Wireless Lighting +Philips hue bridge 2015 +BSB002 +http://www.philips-hue.com +001788a36abf +uuid:2f402f80-da50-11e1-9b23-001788a36abf + + + """ + + aioclient_mock.get( + "http://192.168.212.23/description.xml", + text=hue_response.format(ip_address="192.168.212.23"), + ) + aioclient_mock.get( + "http://169.254.8.155/description.xml", + text=hue_response.format(ip_address="169.254.8.155"), + ) + ssdp_response_without_location = { + "ST": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", + "_udn": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", + "USN": "uuid:2f402f80-da50-11e1-9b23-001788a36abf", + "SERVER": "Hue/1.0 UPnP/1.0 IpBridge/1.44.0", + "hue-bridgeid": "001788FFFEA36ABF", + "EXT": "", + } + + mock_good_ip_ssdp_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://192.168.212.23/description.xml"}, + ) + mock_link_local_ip_ssdp_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://169.254.8.155/description.xml"}, + ) + mock_ssdp_response = mock_good_ip_ssdp_response + + def _generate_fake_ssdp_listener(*args, **kwargs): + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + pass + + @callback + def _callback(*_): + import pprint + + pprint.pprint(mock_ssdp_response) + hass.async_create_task(listener.async_callback(mock_ssdp_response)) + + listener.async_start = _async_callback + listener.async_search = _callback + return listener + + with patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value=mock_get_ssdp, + ), patch( + "homeassistant.components.ssdp.SSDPListener", + new=_generate_fake_ssdp_listener, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_init: + assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "hue" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == mock_good_ip_ssdp_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = mock_link_local_ip_ssdp_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "hue" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == mock_link_local_ip_ssdp_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = mock_good_ip_ssdp_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "hue" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == mock_good_ip_ssdp_response["location"] + ) From 76bb036968655a3696cdf5d9ed2e3505699f3af4 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 27 Aug 2021 18:53:42 +0200 Subject: [PATCH 036/168] Upgrade aiolifx to 0.6.10 (#55344) --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 9e1a4fc2689..847c75b4fa5 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.6.9", "aiolifx_effects==0.2.2"], + "requirements": ["aiolifx==0.6.10", "aiolifx_effects==0.2.2"], "homekit": { "models": ["LIFX"] }, diff --git a/requirements_all.txt b/requirements_all.txt index 32eb110b1d8..f17978a3c80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aiokafka==0.6.0 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.6.9 +aiolifx==0.6.10 # homeassistant.components.lifx aiolifx_effects==0.2.2 From d0ada6c6e23260798ef2715daeac5421cea15c81 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Aug 2021 10:00:20 -0700 Subject: [PATCH 037/168] Bumped version to 2021.9.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3f7a7c89dee..c523d88606a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 8641740ed8d656cd187f4ee368867b79bd4a841a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Aug 2021 12:43:53 -0500 Subject: [PATCH 038/168] Ensure yeelights resync state if they are busy on first connect (#55333) --- homeassistant/components/yeelight/__init__.py | 27 +++++++++++--- homeassistant/components/yeelight/light.py | 21 +++++------ tests/components/yeelight/__init__.py | 36 ++++++++++++++----- tests/components/yeelight/test_init.py | 27 ++++++++++++++ 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 8684e331fad..a0deb0fdf21 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -163,6 +163,8 @@ UPDATE_REQUEST_PROPERTIES = [ "active_mode", ] +BULB_EXCEPTIONS = (BulbException, asyncio.TimeoutError) + PLATFORMS = ["binary_sensor", "light"] @@ -272,7 +274,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.data.get(CONF_HOST): try: device = await _async_get_device(hass, entry.data[CONF_HOST], entry) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: # If CONF_ID is not valid we cannot fallback to discovery # so we must retry by raising ConfigEntryNotReady if not entry.data.get(CONF_ID): @@ -287,7 +289,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = urlparse(capabilities["location"]).hostname try: await _async_initialize(hass, entry, host) - except BulbException: + except BULB_EXCEPTIONS: _LOGGER.exception("Failed to connect to bulb at %s", host) # discovery @@ -552,6 +554,7 @@ class YeelightDevice: self._device_type = None self._available = False self._initialized = False + self._did_first_update = False self._name = None @property @@ -647,14 +650,14 @@ class YeelightDevice: await self.bulb.async_turn_on( duration=duration, light_type=light_type, power_mode=power_mode ) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) async def async_turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn off device.""" try: await self.bulb.async_turn_off(duration=duration, light_type=light_type) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error( "Unable to turn the bulb off: %s, %s: %s", self._host, self.name, ex ) @@ -670,7 +673,7 @@ class YeelightDevice: if not self._initialized: self._initialized = True async_dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: if self._available: # just inform once _LOGGER.error( "Unable to update device %s, %s: %s", self._host, self.name, ex @@ -696,6 +699,7 @@ class YeelightDevice: async def async_update(self, force=False): """Update device properties and send data updated signal.""" + self._did_first_update = True if not force and self._initialized and self._available: # No need to poll unless force, already connected return @@ -705,7 +709,20 @@ class YeelightDevice: @callback def async_update_callback(self, data): """Update push from device.""" + was_available = self._available self._available = data.get(KEY_CONNECTED, True) + if self._did_first_update and not was_available and self._available: + # On reconnect the properties may be out of sync + # + # We need to make sure the DEVICE_INITIALIZED dispatcher is setup + # before we can update on reconnect by checking self._did_first_update + # + # If the device drops the connection right away, we do not want to + # do a property resync via async_update since its about + # to be called when async_setup_entry reaches the end of the + # function + # + asyncio.create_task(self.async_update(True)) async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index be876690b06..e0c21f21fc7 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -6,7 +6,7 @@ import math import voluptuous as vol import yeelight -from yeelight import Bulb, BulbException, Flow, RGBTransition, SleepTransition, flows +from yeelight import Bulb, Flow, RGBTransition, SleepTransition, flows from yeelight.enums import BulbType, LightType, PowerMode, SceneClass from homeassistant.components.light import ( @@ -49,6 +49,7 @@ from . import ( ATTR_COUNT, ATTR_MODE_MUSIC, ATTR_TRANSITIONS, + BULB_EXCEPTIONS, CONF_FLOW_PARAMS, CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH, @@ -241,7 +242,7 @@ def _async_cmd(func): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) return await func(self, *args, **kwargs) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Error when calling %s: %s", func, ex) return _async_wrap @@ -678,7 +679,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): flow = Flow(count=count, transitions=transitions) try: await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set flash: %s", ex) @_async_cmd @@ -709,7 +710,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): try: await self._bulb.async_start_flow(flow, light_type=self.light_type) self._effect = effect - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set effect: %s", ex) async def async_turn_on(self, **kwargs) -> None: @@ -737,7 +738,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): await self.hass.async_add_executor_job( self.set_music_mode, self.config[CONF_MODE_MUSIC] ) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error( "Unable to turn on music mode, consider disabling it: %s", ex ) @@ -750,7 +751,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): await self.async_set_brightness(brightness, duration) await self.async_set_flash(flash) await self.async_set_effect(effect) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set bulb properties: %s", ex) return @@ -758,7 +759,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb): try: await self.async_set_default() - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set the defaults: %s", ex) return @@ -784,7 +785,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """Set a power mode.""" try: await self._bulb.async_set_power_mode(PowerMode[mode.upper()]) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set the power mode: %s", ex) async def async_start_flow(self, transitions, count=0, action=ACTION_RECOVER): @@ -795,7 +796,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): ) await self._bulb.async_start_flow(flow, light_type=self.light_type) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set effect: %s", ex) async def async_set_scene(self, scene_class, *args): @@ -806,7 +807,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """ try: await self._bulb.async_set_scene(scene_class, *args) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set scene: %s", ex) diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 06c0243e918..4a862fa13dd 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -90,11 +90,33 @@ YAML_CONFIGURATION = { CONFIG_ENTRY_DATA = {CONF_ID: ID} +class MockAsyncBulb: + """A mock for yeelight.aio.AsyncBulb.""" + + def __init__(self, model, bulb_type, cannot_connect): + """Init the mock.""" + self.model = model + self.bulb_type = bulb_type + self._async_callback = None + self._cannot_connect = cannot_connect + + async def async_listen(self, callback): + """Mock the listener.""" + if self._cannot_connect: + raise BulbException + self._async_callback = callback + + async def async_stop_listening(self): + """Drop the listener.""" + self._async_callback = None + + def set_capabilities(self, capabilities): + """Mock setting capabilities.""" + self.capabilities = capabilities + + def _mocked_bulb(cannot_connect=False): - bulb = MagicMock() - type(bulb).async_listen = AsyncMock( - side_effect=BulbException if cannot_connect else None - ) + bulb = MockAsyncBulb(MODEL, BulbType.Color, cannot_connect) type(bulb).async_get_properties = AsyncMock( side_effect=BulbException if cannot_connect else None ) @@ -102,14 +124,10 @@ def _mocked_bulb(cannot_connect=False): side_effect=BulbException if cannot_connect else None ) type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) - bulb.capabilities = CAPABILITIES.copy() - bulb.model = MODEL - bulb.bulb_type = BulbType.Color bulb.last_properties = PROPERTIES.copy() bulb.music_mode = False bulb.async_get_properties = AsyncMock() - bulb.async_stop_listening = AsyncMock() bulb.async_update = AsyncMock() bulb.async_turn_on = AsyncMock() bulb.async_turn_off = AsyncMock() @@ -122,7 +140,7 @@ def _mocked_bulb(cannot_connect=False): bulb.async_set_power_mode = AsyncMock() bulb.async_set_scene = AsyncMock() bulb.async_set_default = AsyncMock() - + bulb.start_music = MagicMock() return bulb diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 4414909d8e0..4b3ac8e0e83 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from yeelight import BulbException, BulbType +from yeelight.aio import KEY_CONNECTED from homeassistant.components.yeelight import ( CONF_MODEL, @@ -414,3 +415,29 @@ async def test_async_setup_with_missing_id(hass: HomeAssistant): assert config_entry.state is ConfigEntryState.LOADED assert config_entry.data[CONF_ID] == ID + + +async def test_connection_dropped_resyncs_properties(hass: HomeAssistant): + """Test handling a connection drop results in a property resync.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ID, + data={CONF_HOST: "127.0.0.1"}, + options={CONF_NAME: "Test name"}, + ) + config_entry.add_to_hass(hass) + mocked_bulb = _mocked_bulb() + + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mocked_bulb.async_get_properties.mock_calls) == 1 + mocked_bulb._async_callback({KEY_CONNECTED: False}) + await hass.async_block_till_done() + assert len(mocked_bulb.async_get_properties.mock_calls) == 1 + mocked_bulb._async_callback({KEY_CONNECTED: True}) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(mocked_bulb.async_get_properties.mock_calls) == 2 From 08ca43221f1d38903f1cfac0742a7bfff89ea3e3 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 27 Aug 2021 18:01:20 -0400 Subject: [PATCH 039/168] Listen to node events in the zwave_js node status sensor (#55341) --- homeassistant/components/zwave_js/sensor.py | 6 +-- tests/components/zwave_js/conftest.py | 10 +++++ tests/components/zwave_js/test_sensor.py | 41 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 5c8ed8633f1..40159b383a6 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -462,6 +462,7 @@ class ZWaveNodeStatusSensor(SensorEntity): """Poll a value.""" raise ValueError("There is no value to poll for this entity") + @callback def _status_changed(self, _: dict) -> None: """Call when status event is received.""" self._attr_native_value = self.node.status.name.lower() @@ -480,8 +481,3 @@ class ZWaveNodeStatusSensor(SensorEntity): ) ) self.async_write_ha_state() - - @property - def available(self) -> bool: - """Return entity availability.""" - return self.client.connected and bool(self.node.ready) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 900a7937539..6634fdf759d 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -769,6 +769,16 @@ def lock_id_lock_as_id150(client, lock_id_lock_as_id150_state): return node +@pytest.fixture(name="lock_id_lock_as_id150_not_ready") +def node_not_ready(client, lock_id_lock_as_id150_state): + """Mock an id lock id-150 lock node that's not ready.""" + state = copy.deepcopy(lock_id_lock_as_id150_state) + state["ready"] = False + node = Node(client, state) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="climate_radio_thermostat_ct101_multiple_temp_units") def climate_radio_thermostat_ct101_multiple_temp_units_fixture( client, climate_radio_thermostat_ct101_multiple_temp_units_state diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 6d64f6f92dd..b595b6462b3 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_WATT, + STATE_UNAVAILABLE, TEMP_CELSIUS, ) from homeassistant.helpers import entity_registry as er @@ -136,7 +137,7 @@ async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration) assert entity_entry.disabled -async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): +async def test_node_status_sensor(hass, client, lock_id_lock_as_id150, integration): """Test node status sensor is created and gets updated on node state changes.""" NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150 @@ -179,6 +180,44 @@ async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + # Disconnect the client and make sure the entity is still available + await client.disconnect() + assert hass.states.get(NODE_STATUS_ENTITY).state != STATE_UNAVAILABLE + + +async def test_node_status_sensor_not_ready( + hass, + client, + lock_id_lock_as_id150_not_ready, + lock_id_lock_as_id150_state, + integration, +): + """Test node status sensor is created and available if node is not ready.""" + NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" + node = lock_id_lock_as_id150_not_ready + assert not node.ready + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + assert not updated_entry.disabled + assert hass.states.get(NODE_STATUS_ENTITY) + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + + # Mark node as ready + event = Event("ready", {"nodeState": lock_id_lock_as_id150_state}) + node.receive_event(event) + assert node.ready + assert hass.states.get(NODE_STATUS_ENTITY) + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + async def test_reset_meter( hass, From 06b47ee2f515c25016fd9be78fc795c6ae2e4e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 28 Aug 2021 17:57:57 +0200 Subject: [PATCH 040/168] Tractive name (#55342) --- homeassistant/components/tractive/sensor.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index fdc38d8b83a..aa578bf046b 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -40,6 +40,7 @@ class TractiveSensor(TractiveEntity, SensorEntity): """Initialize sensor entity.""" super().__init__(user_id, trackable, tracker_details) + self._attr_name = f"{trackable['details']['name']} {description.name}" self._attr_unique_id = unique_id self.entity_description = description @@ -53,11 +54,6 @@ class TractiveSensor(TractiveEntity, SensorEntity): class TractiveHardwareSensor(TractiveSensor): """Tractive hardware sensor.""" - def __init__(self, user_id, trackable, tracker_details, unique_id, description): - """Initialize sensor entity.""" - super().__init__(user_id, trackable, tracker_details, unique_id, description) - self._attr_name = f"{self._tracker_id} {description.name}" - @callback def handle_hardware_status_update(self, event): """Handle hardware status update.""" @@ -88,11 +84,6 @@ class TractiveHardwareSensor(TractiveSensor): class TractiveActivitySensor(TractiveSensor): """Tractive active sensor.""" - def __init__(self, user_id, trackable, tracker_details, unique_id, description): - """Initialize sensor entity.""" - super().__init__(user_id, trackable, tracker_details, unique_id, description) - self._attr_name = f"{trackable['details']['name']} {description.name}" - @callback def handle_activity_status_update(self, event): """Handle activity status update.""" From efc3894303c5cd0681ef81e7b7c641d817d54b7c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Aug 2021 14:59:55 -0700 Subject: [PATCH 041/168] Convert solarlog to coordinator (#55345) --- homeassistant/components/solarlog/__init__.py | 86 +++++++++++ homeassistant/components/solarlog/const.py | 6 +- homeassistant/components/solarlog/sensor.py | 133 +++--------------- 3 files changed, 108 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index b3cfebe9abc..e32f1d85564 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -1,12 +1,28 @@ """Solar-Log integration.""" +from datetime import timedelta +import logging +from urllib.parse import ParseResult, urlparse + +from requests.exceptions import HTTPError, Timeout +from sunwatcher.solarlog.solarlog import SolarLog + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) PLATFORMS = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for solarlog.""" + coordinator = SolarlogData(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -14,3 +30,73 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass, entry): """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class SolarlogData(update_coordinator.DataUpdateCoordinator): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) + ) + + host_entry = entry.data[CONF_HOST] + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + self.unique_id = entry.entry_id + self.name = entry.title + self.host = url.geturl() + + async def _async_update_data(self): + """Update the data from the SolarLog device.""" + try: + api = await self.hass.async_add_executor_job(SolarLog, self.host) + except (OSError, Timeout, HTTPError) as err: + raise update_coordinator.UpdateFailed(err) + + if api.time.year == 1999: + raise update_coordinator.UpdateFailed( + "Invalid data returned (can happen after Solarlog restart)." + ) + + self.logger.debug( + "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", + api.time, + ) + + data = {} + + try: + data["TIME"] = api.time + data["powerAC"] = api.power_ac + data["powerDC"] = api.power_dc + data["voltageAC"] = api.voltage_ac + data["voltageDC"] = api.voltage_dc + data["yieldDAY"] = api.yield_day / 1000 + data["yieldYESTERDAY"] = api.yield_yesterday / 1000 + data["yieldMONTH"] = api.yield_month / 1000 + data["yieldYEAR"] = api.yield_year / 1000 + data["yieldTOTAL"] = api.yield_total / 1000 + data["consumptionAC"] = api.consumption_ac + data["consumptionDAY"] = api.consumption_day / 1000 + data["consumptionYESTERDAY"] = api.consumption_yesterday / 1000 + data["consumptionMONTH"] = api.consumption_month / 1000 + data["consumptionYEAR"] = api.consumption_year / 1000 + data["consumptionTOTAL"] = api.consumption_total / 1000 + data["totalPOWER"] = api.total_power + data["alternatorLOSS"] = api.alternator_loss + data["CAPACITY"] = round(api.capacity * 100, 0) + data["EFFICIENCY"] = round(api.efficiency * 100, 0) + data["powerAVAILABLE"] = api.power_available + data["USAGE"] = round(api.usage * 100, 0) + except AttributeError as err: + raise update_coordinator.UpdateFailed( + f"Missing details data in Solarlog response: {err}" + ) from err + + _LOGGER.debug("Updated Solarlog overview data: %s", data) + return data diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index e4e10b3a7e6..eecf73b6a09 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import timedelta from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -23,13 +22,10 @@ from homeassistant.const import ( DOMAIN = "solarlog" -"""Default config for solarlog.""" +# Default config for solarlog. DEFAULT_HOST = "http://solar-log" DEFAULT_NAME = "solarlog" -"""Fixed constants.""" -SCAN_INTERVAL = timedelta(seconds=60) - @dataclass class SolarlogRequiredKeysMixin: diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index e87977f64e5..ee7425cf2d7 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -1,133 +1,42 @@ """Platform for solarlog sensors.""" -import logging -from urllib.parse import ParseResult, urlparse - -from requests.exceptions import HTTPError, Timeout -from sunwatcher.solarlog.solarlog import SolarLog - from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_HOST -from homeassistant.util import Throttle +from homeassistant.helpers import update_coordinator +from homeassistant.helpers.entity import StateType -from .const import DOMAIN, SCAN_INTERVAL, SENSOR_TYPES, SolarLogSensorEntityDescription - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the solarlog platform.""" - _LOGGER.warning( - "Configuration of the solarlog platform in configuration.yaml is deprecated " - "in Home Assistant 0.119. Please remove entry from your configuration" - ) +from . import SolarlogData +from .const import DOMAIN, SENSOR_TYPES, SolarLogSensorEntityDescription async def async_setup_entry(hass, entry, async_add_entities): """Add solarlog entry.""" - host_entry = entry.data[CONF_HOST] - device_name = entry.title - - url = urlparse(host_entry, "http") - netloc = url.netloc or url.path - path = url.path if url.netloc else "" - url = ParseResult("http", netloc, path, *url[3:]) - host = url.geturl() - - try: - api = await hass.async_add_executor_job(SolarLog, host) - _LOGGER.debug("Connected to Solar-Log device, setting up entries") - except (OSError, HTTPError, Timeout): - _LOGGER.error( - "Could not connect to Solar-Log device at %s, check host ip address", host - ) - return - - # Create solarlog data service which will retrieve and update the data. - data = await hass.async_add_executor_job(SolarlogData, hass, api, host) - - # Create a new sensor for each sensor type. - entities = [ - SolarlogSensor(entry.entry_id, device_name, data, description) - for description in SENSOR_TYPES - ] - async_add_entities(entities, True) - return True + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + SolarlogSensor(coordinator, description) for description in SENSOR_TYPES + ) -class SolarlogData: - """Get and update the latest data.""" - - def __init__(self, hass, api, host): - """Initialize the data object.""" - self.api = api - self.hass = hass - self.host = host - self.update = Throttle(SCAN_INTERVAL)(self._update) - self.data = {} - - def _update(self): - """Update the data from the SolarLog device.""" - try: - self.api = SolarLog(self.host) - response = self.api.time - _LOGGER.debug( - "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", - response, - ) - except (OSError, Timeout, HTTPError): - _LOGGER.error("Connection error, Could not retrieve data, skipping update") - return - - try: - self.data["TIME"] = self.api.time - self.data["powerAC"] = self.api.power_ac - self.data["powerDC"] = self.api.power_dc - self.data["voltageAC"] = self.api.voltage_ac - self.data["voltageDC"] = self.api.voltage_dc - self.data["yieldDAY"] = self.api.yield_day / 1000 - self.data["yieldYESTERDAY"] = self.api.yield_yesterday / 1000 - self.data["yieldMONTH"] = self.api.yield_month / 1000 - self.data["yieldYEAR"] = self.api.yield_year / 1000 - self.data["yieldTOTAL"] = self.api.yield_total / 1000 - self.data["consumptionAC"] = self.api.consumption_ac - self.data["consumptionDAY"] = self.api.consumption_day / 1000 - self.data["consumptionYESTERDAY"] = self.api.consumption_yesterday / 1000 - self.data["consumptionMONTH"] = self.api.consumption_month / 1000 - self.data["consumptionYEAR"] = self.api.consumption_year / 1000 - self.data["consumptionTOTAL"] = self.api.consumption_total / 1000 - self.data["totalPOWER"] = self.api.total_power - self.data["alternatorLOSS"] = self.api.alternator_loss - self.data["CAPACITY"] = round(self.api.capacity * 100, 0) - self.data["EFFICIENCY"] = round(self.api.efficiency * 100, 0) - self.data["powerAVAILABLE"] = self.api.power_available - self.data["USAGE"] = round(self.api.usage * 100, 0) - _LOGGER.debug("Updated Solarlog overview data: %s", self.data) - except AttributeError: - _LOGGER.error("Missing details data in Solarlog response") - - -class SolarlogSensor(SensorEntity): +class SolarlogSensor(update_coordinator.CoordinatorEntity, SensorEntity): """Representation of a Sensor.""" + entity_description: SolarLogSensorEntityDescription + def __init__( self, - entry_id: str, - device_name: str, - data: SolarlogData, + coordinator: SolarlogData, description: SolarLogSensorEntityDescription, ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = description - self.data = data - self._attr_name = f"{device_name} {description.name}" - self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_name = f"{coordinator.name} {description.name}" + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" self._attr_device_info = { - "identifiers": {(DOMAIN, entry_id)}, - "name": device_name, + "identifiers": {(DOMAIN, coordinator.unique_id)}, + "name": coordinator.name, "manufacturer": "Solar-Log", } - def update(self): - """Get the latest data from the sensor and update the state.""" - self.data.update() - self._attr_native_value = self.data.data[self.entity_description.json_key] + @property + def native_value(self) -> StateType: + """Return the native sensor value.""" + return self.coordinator.data[self.entity_description.json_key] From d96e416d260732ce6e46147d2b992a2012792a5c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 27 Aug 2021 16:00:17 -0600 Subject: [PATCH 042/168] Ensure ReCollect Waste starts up even if no future pickup is found (#55349) --- .../components/recollect_waste/config_flow.py | 2 +- tests/components/recollect_waste/test_config_flow.py | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 92f94a314ee..5d6b66d8abd 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -59,7 +59,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - await client.async_get_next_pickup_event() + await client.async_get_pickup_events() except RecollectError as err: LOGGER.error("Error during setup of integration: %s", err) return self.async_show_form( diff --git a/tests/components/recollect_waste/test_config_flow.py b/tests/components/recollect_waste/test_config_flow.py index cabcb1a8f9e..22f32983055 100644 --- a/tests/components/recollect_waste/test_config_flow.py +++ b/tests/components/recollect_waste/test_config_flow.py @@ -36,7 +36,7 @@ async def test_invalid_place_or_service_id(hass): conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} with patch( - "aiorecollect.client.Client.async_get_next_pickup_event", + "aiorecollect.client.Client.async_get_pickup_events", side_effect=RecollectError, ): result = await hass.config_entries.flow.async_init( @@ -87,9 +87,7 @@ async def test_step_import(hass): with patch( "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch( - "aiorecollect.client.Client.async_get_next_pickup_event", return_value=True - ): + ), patch("aiorecollect.client.Client.async_get_pickup_events", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=conf ) @@ -105,9 +103,7 @@ async def test_step_user(hass): with patch( "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch( - "aiorecollect.client.Client.async_get_next_pickup_event", return_value=True - ): + ), patch("aiorecollect.client.Client.async_get_pickup_events", return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) From a275e7aa6725bf5559a57eae399bf1c743c0cfb6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Aug 2021 14:59:28 -0700 Subject: [PATCH 043/168] Fix wolflink super call (#55359) --- homeassistant/components/wolflink/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 92f18e04de4..975ddbdd068 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -148,7 +148,7 @@ class WolfLinkState(WolfLinkSensor): @property def native_value(self): """Return the state converting with supported values.""" - state = super().state + state = super().native_value resolved_state = [ item for item in self.wolf_object.items if item.value == int(state) ] From bde4c0e46fabca6c93789ae528fa4d85d1d8084d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 27 Aug 2021 21:58:21 -0600 Subject: [PATCH 044/168] Bump pylitterbot to 2021.8.1 (#55360) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index facf79a7bd7..543a15736fe 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -3,7 +3,7 @@ "name": "Litter-Robot", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", - "requirements": ["pylitterbot==2021.8.0"], + "requirements": ["pylitterbot==2021.8.1"], "codeowners": ["@natekspencer"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index f17978a3c80..5dbfe7494ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1578,7 +1578,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.8.0 +pylitterbot==2021.8.1 # homeassistant.components.loopenergy pyloopenergy==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb6f5c4c2ce..f73f8d63238 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -906,7 +906,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.8.0 +pylitterbot==2021.8.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.11.0 From c1bce68549c3af891cdce051371f9b9c00f73da5 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Fri, 27 Aug 2021 20:34:32 -0400 Subject: [PATCH 045/168] close connection on connection retry, bump onvif lib (#55363) --- homeassistant/components/onvif/device.py | 1 + homeassistant/components/onvif/manifest.json | 6 +----- requirements_all.txt | 5 +---- requirements_test_all.txt | 5 +---- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 87b68508fa1..9ebf87a4132 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -130,6 +130,7 @@ class ONVIFDevice: err, ) self.available = False + await self.device.close() except Fault as err: LOGGER.error( "Couldn't connect to camera '%s', please verify " diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 641497f5204..a7faa60cdcd 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -2,11 +2,7 @@ "domain": "onvif", "name": "ONVIF", "documentation": "https://www.home-assistant.io/integrations/onvif", - "requirements": [ - "onvif-zeep-async==1.0.0", - "WSDiscovery==2.0.0", - "zeep[async]==4.0.0" - ], + "requirements": ["onvif-zeep-async==1.2.0", "WSDiscovery==2.0.0"], "dependencies": ["ffmpeg"], "codeowners": ["@hunterjm"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 5dbfe7494ef..fe9979c28d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.0.0 +onvif-zeep-async==1.2.0 # homeassistant.components.opengarage open-garage==0.1.5 @@ -2449,9 +2449,6 @@ youless-api==0.12 # homeassistant.components.media_extractor youtube_dl==2021.04.26 -# homeassistant.components.onvif -zeep[async]==4.0.0 - # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f73f8d63238..013fae0eb26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -632,7 +632,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.0.0 +onvif-zeep-async==1.2.0 # homeassistant.components.openerz openerz-api==0.1.0 @@ -1372,9 +1372,6 @@ yeelight==0.7.4 # homeassistant.components.youless youless-api==0.12 -# homeassistant.components.onvif -zeep[async]==4.0.0 - # homeassistant.components.zeroconf zeroconf==0.36.0 From baf0d9b2d973d51991f3a2b5bb97338abc74b4b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 28 Aug 2021 15:00:14 +0200 Subject: [PATCH 046/168] Pin regex to 2021.8.28 (#55368) --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37649dcf42f..510d27ccccb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -72,3 +72,8 @@ uuid==1000000000.0.0 # Temporary constraint on pandas, to unblock 2021.7 releases # until we have fixed the wheels builds for newer versions. pandas==1.3.0 + +# regex causes segfault with version 2021.8.27 +# https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error +# This is fixed in 2021.8.28 +regex==2021.8.28 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c2c98191a85..f535958412d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -93,6 +93,11 @@ uuid==1000000000.0.0 # Temporary constraint on pandas, to unblock 2021.7 releases # until we have fixed the wheels builds for newer versions. pandas==1.3.0 + +# regex causes segfault with version 2021.8.27 +# https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error +# This is fixed in 2021.8.28 +regex==2021.8.28 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From 910cb5865a839310293c217062201f8175998bd1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 28 Aug 2021 17:49:34 +0200 Subject: [PATCH 047/168] Address late review for Tractive integration (#55371) --- homeassistant/components/tractive/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index aa578bf046b..ba2f330f894 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -29,7 +29,6 @@ from .entity import TractiveEntity class TractiveSensorEntityDescription(SensorEntityDescription): """Class describing Tractive sensor entities.""" - attributes: tuple = () entity_class: type[TractiveSensor] | None = None @@ -88,10 +87,6 @@ class TractiveActivitySensor(TractiveSensor): def handle_activity_status_update(self, event): """Handle activity status update.""" self._attr_native_value = event[self.entity_description.key] - self._attr_extra_state_attributes = { - attr: event[attr] if attr in event else None - for attr in self.entity_description.attributes - } self._attr_available = True self.async_write_ha_state() @@ -128,7 +123,13 @@ SENSOR_TYPES = ( name="Minutes Active", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=TIME_MINUTES, - attributes=(ATTR_DAILY_GOAL,), + entity_class=TractiveActivitySensor, + ), + TractiveSensorEntityDescription( + key=ATTR_DAILY_GOAL, + name="Daily Goal", + icon="mdi:flag-checkered", + native_unit_of_measurement=TIME_MINUTES, entity_class=TractiveActivitySensor, ), ) From adaebdeea888069016dd8e022122a64023a566ec Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Aug 2021 08:59:25 -0700 Subject: [PATCH 048/168] Bumped version to 2021.9.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c523d88606a..973af31f77d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From fbd144de466e33e542e5e5e951a4fd7f4028caba Mon Sep 17 00:00:00 2001 From: Matt Krasowski <4535195+mkrasowski@users.noreply.github.com> Date: Sun, 29 Aug 2021 08:52:12 -0400 Subject: [PATCH 049/168] Handle incorrect values reported by some Shelly devices (#55042) --- homeassistant/components/shelly/binary_sensor.py | 6 ++++-- homeassistant/components/shelly/sensor.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 96d62152830..f4b2daf8159 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -1,7 +1,7 @@ """Binary sensor for Shelly.""" from __future__ import annotations -from typing import Final +from typing import Final, cast from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, @@ -46,7 +46,9 @@ SENSORS: Final = { name="Overpowering", device_class=DEVICE_CLASS_PROBLEM ), ("sensor", "dwIsOpened"): BlockAttributeDescription( - name="Door", device_class=DEVICE_CLASS_OPENING + name="Door", + device_class=DEVICE_CLASS_OPENING, + available=lambda block: cast(bool, block.dwIsOpened != -1), ), ("sensor", "flood"): BlockAttributeDescription( name="Flood", device_class=DEVICE_CLASS_MOISTURE diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 13cf56d3b3d..d8d530ed94c 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -40,6 +40,7 @@ SENSORS: Final = { device_class=sensor.DEVICE_CLASS_BATTERY, state_class=sensor.STATE_CLASS_MEASUREMENT, removal_condition=lambda settings, _: settings.get("external_power") == 1, + available=lambda block: cast(bool, block.battery != -1), ), ("device", "deviceTemp"): BlockAttributeDescription( name="Device Temperature", @@ -176,6 +177,7 @@ SENSORS: Final = { unit=LIGHT_LUX, device_class=sensor.DEVICE_CLASS_ILLUMINANCE, state_class=sensor.STATE_CLASS_MEASUREMENT, + available=lambda block: cast(bool, block.luminosity != -1), ), ("sensor", "tilt"): BlockAttributeDescription( name="Tilt", From ff6015ff8992fe4e0fe78486322c98cada9d4773 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 22:38:41 -0500 Subject: [PATCH 050/168] Implement import of consider_home in nmap_tracker to avoid breaking change (#55379) --- .../components/nmap_tracker/__init__.py | 46 +++++++++++++++---- .../components/nmap_tracker/config_flow.py | 15 +++++- .../components/nmap_tracker/device_tracker.py | 15 +++++- .../components/nmap_tracker/strings.json | 1 + .../nmap_tracker/translations/en.json | 4 +- .../nmap_tracker/test_config_flow.py | 10 +++- 6 files changed, 75 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index dfd8987484c..21469f197f4 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -14,7 +14,11 @@ from getmac import get_mac_address from mac_vendor_lookup import AsyncMacLookup from nmap import PortScanner, PortScannerError -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback @@ -37,7 +41,6 @@ from .const import ( # Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true" MAX_SCAN_ATTEMPTS: Final = 16 -OFFLINE_SCANS_TO_MARK_UNAVAILABLE: Final = 3 def short_hostname(hostname: str) -> str: @@ -65,7 +68,7 @@ class NmapDevice: manufacturer: str reason: str last_update: datetime - offline_scans: int + first_offline: datetime | None class NmapTrackedDevices: @@ -137,6 +140,7 @@ class NmapDeviceScanner: """Initialize the scanner.""" self.devices = devices self.home_interval = None + self.consider_home = DEFAULT_CONSIDER_HOME self._hass = hass self._entry = entry @@ -170,6 +174,10 @@ class NmapDeviceScanner: self.home_interval = timedelta( minutes=cv.positive_int(config[CONF_HOME_INTERVAL]) ) + if config.get(CONF_CONSIDER_HOME): + self.consider_home = timedelta( + seconds=cv.positive_float(config[CONF_CONSIDER_HOME]) + ) self._scan_lock = asyncio.Lock() if self._hass.state == CoreState.running: await self._async_start_scanner() @@ -320,16 +328,35 @@ class NmapDeviceScanner: return result @callback - def _async_increment_device_offline(self, ipv4, reason): + def _async_device_offline(self, ipv4: str, reason: str, now: datetime) -> None: """Mark an IP offline.""" if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)): return if not (device := self.devices.tracked.get(formatted_mac)): # Device was unloaded return - device.offline_scans += 1 - if device.offline_scans < OFFLINE_SCANS_TO_MARK_UNAVAILABLE: + if not device.first_offline: + _LOGGER.debug( + "Setting first_offline for %s (%s) to: %s", ipv4, formatted_mac, now + ) + device.first_offline = now return + if device.first_offline + self.consider_home > now: + _LOGGER.debug( + "Device %s (%s) has NOT been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) + return + _LOGGER.debug( + "Device %s (%s) has been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) device.reason = reason async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False) del self.devices.ipv4_last_mac[ipv4] @@ -347,7 +374,7 @@ class NmapDeviceScanner: status = info["status"] reason = status["reason"] if status["state"] != "up": - self._async_increment_device_offline(ipv4, reason) + self._async_device_offline(ipv4, reason, now) continue # Mac address only returned if nmap ran as root mac = info["addresses"].get( @@ -356,12 +383,11 @@ class NmapDeviceScanner: partial(get_mac_address, ip=ipv4) ) if mac is None: - self._async_increment_device_offline(ipv4, "No MAC address found") + self._async_device_offline(ipv4, "No MAC address found", now) _LOGGER.info("No MAC address found for %s", ipv4) continue formatted_mac = format_mac(mac) - if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) != entry_id @@ -372,7 +398,7 @@ class NmapDeviceScanner: vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) name = human_readable_name(hostname, vendor, mac) device = NmapDevice( - formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 + formatted_mac, hostname, name, ipv4, vendor, reason, now, None ) new = formatted_mac not in devices.tracked diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 2d25b62f1d2..c9e9706e4ba 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -8,7 +8,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import network -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.components.network.const import MDNS_TARGET_IP from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS @@ -24,6 +28,8 @@ from .const import ( TRACKER_SCAN_INTERVAL, ) +MAX_SCAN_INTERVAL = 3600 +MAX_CONSIDER_HOME = MAX_SCAN_INTERVAL * 6 DEFAULT_NETWORK_PREFIX = 24 @@ -116,7 +122,12 @@ async def _async_build_schema_with_user_input( vol.Optional( CONF_SCAN_INTERVAL, default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), - ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), + ): vol.All(vol.Coerce(int), vol.Range(min=10, max=MAX_SCAN_INTERVAL)), + vol.Optional( + CONF_CONSIDER_HOME, + default=user_input.get(CONF_CONSIDER_HOME) + or DEFAULT_CONSIDER_HOME.total_seconds(), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=MAX_CONSIDER_HOME)), } ) return vol.Schema(schema) diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 5ec9f2fcb9a..e475afd24c8 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import ( SOURCE_TYPE_ROUTER, ) from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -38,6 +42,9 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): cv.ensure_list, vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, + vol.Required( + CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds() + ): cv.time_period, vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string, } @@ -53,9 +60,15 @@ async def async_get_scanner(hass: HomeAssistant, config: ConfigType) -> None: else: scan_interval = TRACKER_SCAN_INTERVAL + if CONF_CONSIDER_HOME in validated_config: + consider_home = validated_config[CONF_CONSIDER_HOME].total_seconds() + else: + consider_home = DEFAULT_CONSIDER_HOME.total_seconds() + import_config = { CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], + CONF_CONSIDER_HOME: consider_home, CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), CONF_OPTIONS: validated_config[CONF_OPTIONS], CONF_SCAN_INTERVAL: scan_interval, diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index d42e1067503..ed5a8cb0b05 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -7,6 +7,7 @@ "data": { "hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]", "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]", "interval_seconds": "Scan interval" diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index 6b83532a0e2..feeea1ff8be 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -25,12 +25,12 @@ "step": { "init": { "data": { + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "Network addresses (comma seperated) to exclude from scanning", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "hosts": "Network addresses (comma seperated) to scan", "interval_seconds": "Scan interval", - "scan_options": "Raw configurable scan options for Nmap", - "track_new_devices": "Track new devices" + "scan_options": "Raw configurable scan options for Nmap" }, "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32)." } diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 6365dd7407a..74997df5a4f 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -4,7 +4,10 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, +) from homeassistant.components.nmap_tracker.const import ( CONF_HOME_INTERVAL, CONF_OPTIONS, @@ -206,6 +209,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_EXCLUDE: "4.4.4.4", CONF_HOME_INTERVAL: 3, CONF_HOSTS: "192.168.1.0/24", + CONF_CONSIDER_HOME: 180, CONF_SCAN_INTERVAL: 120, CONF_OPTIONS: "-F -T4 --min-rate 10 --host-timeout 5s", } @@ -219,6 +223,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: user_input={ CONF_HOSTS: "192.168.1.0/24, 192.168.2.0/24", CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", CONF_SCAN_INTERVAL: 10, @@ -230,6 +235,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert config_entry.options == { CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4,5.5.5.5", CONF_SCAN_INTERVAL: 10, @@ -250,6 +256,7 @@ async def test_import(hass: HomeAssistant) -> None: data={ CONF_HOSTS: "1.2.3.4/20", CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", CONF_SCAN_INTERVAL: 2000, @@ -263,6 +270,7 @@ async def test_import(hass: HomeAssistant) -> None: assert result["options"] == { CONF_HOSTS: "1.2.3.4/20", CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4,6.4.3.2", CONF_SCAN_INTERVAL: 2000, From 4b7803ed03df6bd6dc43ba4b756c7a0d1396ac9d Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 28 Aug 2021 12:57:02 -0600 Subject: [PATCH 051/168] Bump simplisafe-python to 11.0.6 (#55385) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 2d524d4c381..c6bc3ae61fa 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==11.0.5"], + "requirements": ["simplisafe-python==11.0.6"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index fe9979c28d2..9a2b1c033a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2131,7 +2131,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.5 +simplisafe-python==11.0.6 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 013fae0eb26..28a2a7b8e3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1191,7 +1191,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.5 +simplisafe-python==11.0.6 # homeassistant.components.slack slackclient==2.5.0 From 69d8f94e3bf0b0f05c1dfd2ba53f0e28b679a05e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 02:01:04 -0500 Subject: [PATCH 052/168] Show device_id in HomeKit when the device registry entry is missing a name (#55391) - Reported at: https://community.home-assistant.io/t/homekit-unknown-error-occurred/333385 --- homeassistant/components/homekit/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index fdad10f873f..03df55a9026 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -498,7 +498,10 @@ async def _async_get_supported_devices(hass): """Return all supported devices.""" results = await device_automation.async_get_device_automations(hass, "trigger") dev_reg = device_registry.async_get(hass) - unsorted = {device_id: dev_reg.async_get(device_id).name for device_id in results} + unsorted = { + device_id: dev_reg.async_get(device_id).name or device_id + for device_id in results + } return dict(sorted(unsorted.items(), key=lambda item: item[1])) From 47e2d1caa50b83407da1c4fc9c784c2d2e087ef4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 30 Aug 2021 05:30:54 +0200 Subject: [PATCH 053/168] Fix device_class - qnap drive_temp sensor (#55409) --- homeassistant/components/qnap/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 333ce46599a..b02c977d98d 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -91,7 +91,7 @@ _DRIVE_MON_COND = { "mdi:checkbox-marked-circle-outline", None, ], - "drive_temp": ["Temperature", TEMP_CELSIUS, None, None, DEVICE_CLASS_TEMPERATURE], + "drive_temp": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], } _VOLUME_MON_COND = { "volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie", None], From 6cf799459b5c0c02429d0c648996931f9c5fc89f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 29 Aug 2021 21:27:34 -0600 Subject: [PATCH 054/168] Ensure ReCollect Waste shows pickups for midnight on the actual day (#55424) --- .../components/recollect_waste/sensor.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 434d24be22a..9c9bc9d6bf4 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -1,6 +1,8 @@ """Support for ReCollect Waste sensors.""" from __future__ import annotations +from datetime import date, datetime, time + from aiorecollect.client import PickupType import voluptuous as vol @@ -20,6 +22,7 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util.dt import as_utc from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER @@ -73,6 +76,12 @@ async def async_setup_platform( ) +@callback +def async_get_utc_midnight(target_date: date) -> datetime: + """Get UTC midnight for a given date.""" + return as_utc(datetime.combine(target_date, time(0))) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -123,7 +132,9 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names( self._entry, next_pickup_event.pickup_types ), - ATTR_NEXT_PICKUP_DATE: next_pickup_event.date.isoformat(), + ATTR_NEXT_PICKUP_DATE: async_get_utc_midnight( + next_pickup_event.date + ).isoformat(), } ) - self._attr_native_value = pickup_event.date.isoformat() + self._attr_native_value = async_get_utc_midnight(pickup_event.date).isoformat() From 10df9f354205ddfd9a026fb890e1b14e1fff73c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 22:29:46 -0500 Subject: [PATCH 055/168] Bump zeroconf to 0.36.1 (#55425) - Fixes duplicate records in the cache - Changelog: https://github.com/jstasiak/python-zeroconf/compare/0.36.0...0.36.1 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 84f9f4698e9..dea3b3c356e 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.0"], + "requirements": ["zeroconf==0.36.1"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 510d27ccccb..8beb6789b54 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.0 +zeroconf==0.36.1 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 9a2b1c033a9..4492479fa1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2453,7 +2453,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.0 +zeroconf==0.36.1 # homeassistant.components.zha zha-quirks==0.0.60 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28a2a7b8e3c..94bd3c02094 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1373,7 +1373,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.0 +zeroconf==0.36.1 # homeassistant.components.zha zha-quirks==0.0.60 From 2c0d9105ac3630a4c66f4de336b4a50ab1cd9189 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 30 Aug 2021 05:29:37 +0200 Subject: [PATCH 056/168] Update entity names for P1 Monitor integration (#55430) --- .../components/p1_monitor/manifest.json | 2 +- homeassistant/components/p1_monitor/sensor.py | 20 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/p1_monitor/test_sensor.py | 16 +++++++-------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json index 1a4beb36f5d..00b50bb029b 100644 --- a/homeassistant/components/p1_monitor/manifest.json +++ b/homeassistant/components/p1_monitor/manifest.json @@ -3,7 +3,7 @@ "name": "P1 Monitor", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/p1_monitor", - "requirements": ["p1monitor==0.2.0"], + "requirements": ["p1monitor==1.0.0"], "codeowners": ["@klaasnicolaas"], "quality_scale": "platinum", "iot_class": "local_polling" diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 36a991c7333..ea18854f748 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -192,33 +192,33 @@ SENSORS: dict[ ), SERVICE_SETTINGS: ( SensorEntityDescription( - key="gas_consumption_tariff", - name="Gas Consumption - Tariff", + key="gas_consumption_price", + name="Gas Consumption Price", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_consumption_low_tariff", - name="Energy Consumption - Low Tariff", + key="energy_consumption_price_low", + name="Energy Consumption Price - Low", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_consumption_high_tariff", - name="Energy Consumption - High Tariff", + key="energy_consumption_price_high", + name="Energy Consumption Price - High", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_production_low_tariff", - name="Energy Production - Low Tariff", + key="energy_production_price_low", + name="Energy Production Price - Low", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), SensorEntityDescription( - key="energy_production_high_tariff", - name="Energy Production - High Tariff", + key="energy_production_price_high", + name="Energy Production Price - High", device_class=DEVICE_CLASS_MONETARY, native_unit_of_measurement=CURRENCY_EURO, ), diff --git a/requirements_all.txt b/requirements_all.txt index 4492479fa1d..0264d83665b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1139,7 +1139,7 @@ orvibo==1.1.1 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor==0.2.0 +p1monitor==1.0.0 # homeassistant.components.mqtt # homeassistant.components.shiftr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94bd3c02094..dec96024ce1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -641,7 +641,7 @@ openerz-api==0.1.0 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor==0.2.0 +p1monitor==1.0.0 # homeassistant.components.mqtt # homeassistant.components.shiftr diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py index baf73811636..90733ce8941 100644 --- a/tests/components/p1_monitor/test_sensor.py +++ b/tests/components/p1_monitor/test_sensor.py @@ -151,23 +151,23 @@ async def test_settings( entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) - state = hass.states.get("sensor.monitor_energy_consumption_low_tariff") - entry = entity_registry.async_get("sensor.monitor_energy_consumption_low_tariff") + state = hass.states.get("sensor.monitor_energy_consumption_price_low") + entry = entity_registry.async_get("sensor.monitor_energy_consumption_price_low") assert entry assert state - assert entry.unique_id == f"{entry_id}_settings_energy_consumption_low_tariff" + assert entry.unique_id == f"{entry_id}_settings_energy_consumption_price_low" assert state.state == "0.20522" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - Low Tariff" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption Price - Low" assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO - state = hass.states.get("sensor.monitor_energy_production_low_tariff") - entry = entity_registry.async_get("sensor.monitor_energy_production_low_tariff") + state = hass.states.get("sensor.monitor_energy_production_price_low") + entry = entity_registry.async_get("sensor.monitor_energy_production_price_low") assert entry assert state - assert entry.unique_id == f"{entry_id}_settings_energy_production_low_tariff" + assert entry.unique_id == f"{entry_id}_settings_energy_production_price_low" assert state.state == "0.20522" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production - Low Tariff" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production Price - Low" assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO From 948f191f16a1edc7927e779a4af315439b12e114 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 29 Aug 2021 23:25:47 -0400 Subject: [PATCH 057/168] Make zwave_js discovery log message more descriptive (#55432) --- homeassistant/components/zwave_js/__init__.py | 2 +- homeassistant/components/zwave_js/discovery.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index c8f2bd19776..f38594c1594 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -145,7 +145,7 @@ async def async_setup_entry( # noqa: C901 value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {} # run discovery on all node values and create/update entities - for disc_info in async_discover_values(node): + for disc_info in async_discover_values(node, device): platform = disc_info.platform # This migration logic was added in 2021.3 to handle a breaking change to diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7a4955d693a..7232279f4c6 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -13,6 +13,7 @@ from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry from .const import LOGGER from .discovery_data_template import ( @@ -667,7 +668,9 @@ DISCOVERY_SCHEMAS = [ @callback -def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None, None]: +def async_discover_values( + node: ZwaveNode, device: DeviceEntry +) -> Generator[ZwaveDiscoveryInfo, None, None]: """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): for schema in DISCOVERY_SCHEMAS: @@ -758,7 +761,11 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None resolved_data = schema.data_template.resolve_data(value) except UnknownValueData as err: LOGGER.error( - "Discovery for value %s will be skipped: %s", value, err + "Discovery for value %s on device '%s' (%s) will be skipped: %s", + value, + device.name_by_user or device.name, + node, + err, ) continue additional_value_ids_to_watch = schema.data_template.value_ids_to_watch( From fb06acf39d84f731d702c1c1ea7a8b1109c8e079 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 29 Aug 2021 20:45:45 -0700 Subject: [PATCH 058/168] Bumped version to 2021.9.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 973af31f77d..d3c6fb3d606 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 5dcc760755a45dbcc24510915415f79b56c9adee Mon Sep 17 00:00:00 2001 From: Christopher Kochan <5183896+crkochan@users.noreply.github.com> Date: Mon, 30 Aug 2021 10:01:26 -0500 Subject: [PATCH 059/168] Add Sense energy sensors (#54833) Co-authored-by: Paulus Schoutsen --- homeassistant/components/sense/const.py | 10 ++++ homeassistant/components/sense/sensor.py | 71 +++++++++++++++++------- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index 783fcb5508a..af8454bbeab 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -23,6 +23,16 @@ CONSUMPTION_NAME = "Usage" CONSUMPTION_ID = "usage" PRODUCTION_NAME = "Production" PRODUCTION_ID = "production" +PRODUCTION_PCT_NAME = "Net Production Percentage" +PRODUCTION_PCT_ID = "production_pct" +NET_PRODUCTION_NAME = "Net Production" +NET_PRODUCTION_ID = "net_production" +TO_GRID_NAME = "To Grid" +TO_GRID_ID = "to_grid" +FROM_GRID_NAME = "From Grid" +FROM_GRID_ID = "from_grid" +SOLAR_POWERED_NAME = "Solar Powered Percentage" +SOLAR_POWERED_ID = "solar_powered" ICON = "mdi:flash" diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 6be24a73a21..ce22551eff2 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, + PERCENTAGE, POWER_WATT, ) from homeassistant.core import callback @@ -22,15 +23,25 @@ from .const import ( CONSUMPTION_ID, CONSUMPTION_NAME, DOMAIN, + FROM_GRID_ID, + FROM_GRID_NAME, ICON, MDI_ICONS, + NET_PRODUCTION_ID, + NET_PRODUCTION_NAME, PRODUCTION_ID, PRODUCTION_NAME, + PRODUCTION_PCT_ID, + PRODUCTION_PCT_NAME, SENSE_DATA, SENSE_DEVICE_UPDATE, SENSE_DEVICES_DATA, SENSE_DISCOVERED_DEVICES_DATA, SENSE_TRENDS_COORDINATOR, + SOLAR_POWERED_ID, + SOLAR_POWERED_NAME, + TO_GRID_ID, + TO_GRID_NAME, ) @@ -55,7 +66,16 @@ TRENDS_SENSOR_TYPES = { } # Production/consumption variants -SENSOR_VARIANTS = [PRODUCTION_ID, CONSUMPTION_ID] +SENSOR_VARIANTS = [(PRODUCTION_ID, PRODUCTION_NAME), (CONSUMPTION_ID, CONSUMPTION_NAME)] + +# Trend production/consumption variants +TREND_SENSOR_VARIANTS = SENSOR_VARIANTS + [ + (PRODUCTION_PCT_ID, PRODUCTION_PCT_NAME), + (NET_PRODUCTION_ID, NET_PRODUCTION_NAME), + (FROM_GRID_ID, FROM_GRID_NAME), + (TO_GRID_ID, TO_GRID_NAME), + (SOLAR_POWERED_ID, SOLAR_POWERED_NAME), +] def sense_to_mdi(sense_icon): @@ -86,15 +106,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if device["tags"]["DeviceListAllowed"] == "true" ] - for var in SENSOR_VARIANTS: + for variant_id, variant_name in SENSOR_VARIANTS: name = ACTIVE_SENSOR_TYPE.name sensor_type = ACTIVE_SENSOR_TYPE.sensor_type - is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-active-{var}" + unique_id = f"{sense_monitor_id}-active-{variant_id}" devices.append( SenseActiveSensor( - data, name, sensor_type, is_production, sense_monitor_id, var, unique_id + data, + name, + sensor_type, + sense_monitor_id, + variant_id, + variant_name, + unique_id, ) ) @@ -102,18 +127,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices.append(SenseVoltageSensor(data, i, sense_monitor_id)) for type_id, typ in TRENDS_SENSOR_TYPES.items(): - for var in SENSOR_VARIANTS: + for variant_id, variant_name in TREND_SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type - is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-{type_id}-{var}" + unique_id = f"{sense_monitor_id}-{type_id}-{variant_id}" devices.append( SenseTrendsSensor( data, name, sensor_type, - is_production, + variant_id, + variant_name, trends_coordinator, unique_id, ) @@ -137,19 +162,19 @@ class SenseActiveSensor(SensorEntity): data, name, sensor_type, - is_production, sense_monitor_id, - sensor_id, + variant_id, + variant_name, unique_id, ): """Initialize the Sense sensor.""" - name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._attr_name = f"{name} {name_type}" + self._attr_name = f"{name} {variant_name}" self._attr_unique_id = unique_id self._data = data self._sense_monitor_id = sense_monitor_id self._sensor_type = sensor_type - self._is_production = is_production + self._variant_id = variant_id + self._variant_name = variant_name async def async_added_to_hass(self): """Register callbacks.""" @@ -166,7 +191,7 @@ class SenseActiveSensor(SensorEntity): """Update the sensor from the data. Must not do I/O.""" new_state = round( self._data.active_solar_power - if self._is_production + if self._variant_id == PRODUCTION_ID else self._data.active_power ) if self._attr_available and self._attr_native_value == new_state: @@ -235,24 +260,30 @@ class SenseTrendsSensor(SensorEntity): data, name, sensor_type, - is_production, + variant_id, + variant_name, trends_coordinator, unique_id, ): """Initialize the Sense sensor.""" - name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._attr_name = f"{name} {name_type}" + self._attr_name = f"{name} {variant_name}" self._attr_unique_id = unique_id self._data = data self._sensor_type = sensor_type self._coordinator = trends_coordinator - self._is_production = is_production + self._variant_id = variant_id self._had_any_update = False + if variant_id in [PRODUCTION_PCT_ID, SOLAR_POWERED_ID]: + self._attr_native_unit_of_measurement = PERCENTAGE + self._attr_entity_registry_enabled_default = False + self._attr_state_class = None + self._attr_device_class = None + @property def native_value(self): """Return the state of the sensor.""" - return round(self._data.get_trend(self._sensor_type, self._is_production), 1) + return round(self._data.get_trend(self._sensor_type, self._variant_id), 1) @property def available(self): From b546fc5067e767cd27f8e731d383dfee1ee2e971 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 Aug 2021 11:48:36 -0400 Subject: [PATCH 060/168] Don't set zwave_js sensor device class to energy when unit is wrong (#55434) --- homeassistant/components/zwave_js/const.py | 2 ++ .../zwave_js/discovery_data_template.py | 16 ++++++++++++++++ homeassistant/components/zwave_js/sensor.py | 12 ++++++++++++ 3 files changed, 30 insertions(+) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 8e545975faa..e4486a681e1 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -96,3 +96,5 @@ ENTITY_DESC_KEY_SIGNAL_STRENGTH = "signal_strength" ENTITY_DESC_KEY_TEMPERATURE = "temperature" ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" ENTITY_DESC_KEY_TIMESTAMP = "timestamp" +ENTITY_DESC_KEY_MEASUREMENT = "measurement" +ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 3ef74a7e17d..dd338de63eb 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -24,6 +24,7 @@ from zwave_js_server.const import ( VOLTAGE_METER_TYPES, VOLTAGE_SENSORS, CommandClass, + ElectricScale, MeterScaleType, MultilevelSensorType, ) @@ -43,6 +44,7 @@ from .const import ( ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_MEASUREMENT, ENTITY_DESC_KEY_POWER, ENTITY_DESC_KEY_POWER_FACTOR, ENTITY_DESC_KEY_PRESSURE, @@ -50,6 +52,7 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, ) @@ -187,6 +190,19 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): if value.command_class == CommandClass.METER: scale_type = get_meter_scale_type(value) + # We do this because even though these are energy scales, they don't meet + # the unit requirements for the energy device class. + if scale_type in ( + ElectricScale.PULSE, + ElectricScale.KILOVOLT_AMPERE_HOUR, + ElectricScale.KILOVOLT_AMPERE_REACTIVE_HOUR, + ): + return ENTITY_DESC_KEY_TOTAL_INCREASING + # We do this because even though these are power scales, they don't meet + # the unit requirements for the energy power class. + if scale_type == ElectricScale.KILOVOLT_AMPERE_REACTIVE: + return ENTITY_DESC_KEY_MEASUREMENT + for key, scale_type_set in METER_DEVICE_CLASS_MAP.items(): if scale_type in scale_type_set: return key diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 40159b383a6..c71a1d87653 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -63,6 +63,7 @@ from .const import ( ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_MEASUREMENT, ENTITY_DESC_KEY_POWER, ENTITY_DESC_KEY_POWER_FACTOR, ENTITY_DESC_KEY_PRESSURE, @@ -70,6 +71,7 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, SERVICE_RESET_METER, ) @@ -168,6 +170,16 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, ZwaveSensorEntityDescription] = { device_class=DEVICE_CLASS_TEMPERATURE, state_class=None, ), + ENTITY_DESC_KEY_MEASUREMENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_MEASUREMENT, + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TOTAL_INCREASING: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TOTAL_INCREASING, + device_class=None, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } From 4052a0db8915e591046fc9659c3400be5455e207 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 12:51:46 +0200 Subject: [PATCH 061/168] Improve statistics error messages when sensor's unit is changing (#55436) * Improve error messages when sensor's unit is changing * Improve test coverage --- homeassistant/components/sensor/recorder.py | 13 +++- tests/components/sensor/test_recorder.py | 85 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 2b59592dd17..6ab75f88dbd 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -200,11 +200,18 @@ def _normalize_states( hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: hass.data[WARN_UNSTABLE_UNIT].add(entity_id) + extra = "" + if old_metadata := statistics.get_metadata(hass, entity_id): + extra = ( + " and matches the unit of already compiled statistics " + f"({old_metadata['unit_of_measurement']})" + ) _LOGGER.warning( - "The unit of %s is changing, got %s, generation of long term " - "statistics will be suppressed unless the unit is stable", + "The unit of %s is changing, got multiple %s, generation of long term " + "statistics will be suppressed unless the unit is stable%s", entity_id, all_units, + extra, ) return None, [] unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -320,7 +327,7 @@ def compile_statistics( entity_id, unit, old_metadata["unit_of_measurement"], - unit, + old_metadata["unit_of_measurement"], ) continue diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 2e300b9c748..6c4c899eb14 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1028,6 +1028,7 @@ def test_compile_hourly_statistics_changing_units_2( recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(minutes=30)) wait_recording_done(hass) assert "The unit of sensor.test1 is changing" in caplog.text + assert "and matches the unit of already compiled statistics" not in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ {"statistic_id": "sensor.test1", "unit_of_measurement": "cats"} @@ -1038,6 +1039,90 @@ def test_compile_hourly_statistics_changing_units_2( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + (None, "%", "%", 16.440677, 10, 30), + ("battery", "%", "%", 16.440677, 10, 30), + ("battery", None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_units_3( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change from one hour to the next.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + four, _states = record_states( + hass, zero + timedelta(hours=1), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, zero + timedelta(hours=2), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + assert "does not match the unit of already compiled" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + assert "The unit of sensor.test1 is changing" in caplog.text + assert f"matches the unit of already compiled statistics ({unit})" in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ From 65ad99d51cba12b7bccd02666c4a805f6bdf050f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 10:29:39 +0200 Subject: [PATCH 062/168] Fix crash in buienradar sensor due to self.hass not set (#55438) --- homeassistant/components/buienradar/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 6dfbef9f931..2c6390f959b 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -699,7 +699,7 @@ class BrSensor(SensorEntity): @callback def data_updated(self, data): """Update data.""" - if self._load_data(data) and self.hass: + if self.hass and self._load_data(data): self.async_write_ha_state() @callback From a474534c085d75d16dd302d69b522489ef0eb04c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 12:08:21 +0200 Subject: [PATCH 063/168] Fix exception when shutting down DSMR (#55441) * Fix exception when shutting down DSMR * Update homeassistant/components/dsmr/sensor.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/dsmr/sensor.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index d3dfb68d425..bd02be7d63e 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.helpers.typing import ConfigType, EventType, StateType from homeassistant.util import Throttle from .const import ( @@ -146,8 +146,15 @@ async def async_setup_entry( if transport: # Register listener to close transport on HA shutdown + @callback + def close_transport(_event: EventType) -> None: + """Close the transport on HA shutdown.""" + if not transport: + return + transport.close() + stop_listener = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, transport.close + EVENT_HOMEASSISTANT_STOP, close_transport ) # Wait for reader to close From 707778229b6bda7708d542dd94aa11fd11a2c3b9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 30 Aug 2021 17:43:11 +0200 Subject: [PATCH 064/168] Fix noise/attenuation units to UI display for Fritz (#55447) --- homeassistant/components/fritz/sensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 7b6a6528eab..bc579b1125e 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -113,28 +113,28 @@ def _retrieve_link_noise_margin_sent_state( status: FritzStatus, last_value: str ) -> float: """Return upload noise margin.""" - return status.noise_margin[0] # type: ignore[no-any-return] + return status.noise_margin[0] / 10 # type: ignore[no-any-return] def _retrieve_link_noise_margin_received_state( status: FritzStatus, last_value: str ) -> float: """Return download noise margin.""" - return status.noise_margin[1] # type: ignore[no-any-return] + return status.noise_margin[1] / 10 # type: ignore[no-any-return] def _retrieve_link_attenuation_sent_state( status: FritzStatus, last_value: str ) -> float: """Return upload line attenuation.""" - return status.attenuation[0] # type: ignore[no-any-return] + return status.attenuation[0] / 10 # type: ignore[no-any-return] def _retrieve_link_attenuation_received_state( status: FritzStatus, last_value: str ) -> float: """Return download line attenuation.""" - return status.attenuation[1] # type: ignore[no-any-return] + return status.attenuation[1] / 10 # type: ignore[no-any-return] class SensorData(TypedDict, total=False): From 3b0fe9adde09223698e3e2d28420d063943d0593 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 16:58:48 +0200 Subject: [PATCH 065/168] Revert "Deprecate last_reset options in MQTT sensor" (#55457) This reverts commit f9fa5fa804291cdc3c2ab9592b3841fb2444bb72. --- homeassistant/components/mqtt/sensor.py | 29 ++++++++++--------------- tests/components/mqtt/test_sensor.py | 22 ------------------- 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 16c19c8fc51..eac136d3f84 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -53,23 +53,18 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -PLATFORM_SCHEMA = vol.All( - # Deprecated, remove in Home Assistant 2021.11 - cv.deprecated(CONF_LAST_RESET_TOPIC), - cv.deprecated(CONF_LAST_RESET_VALUE_TEMPLATE), - mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } - ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), -) +PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) async def async_setup_platform( diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 724dec1c93f..15ca9870077 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -306,28 +306,6 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" -async def test_last_reset_deprecated(hass, mqtt_mock, caplog): - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", - "last_reset_value_template": "{{ value_json.last_reset }}", - } - }, - ) - await hass.async_block_till_done() - - assert "The 'last_reset_topic' option is deprecated" in caplog.text - assert "The 'last_reset_value_template' option is deprecated" in caplog.text - - async def test_force_update_disabled(hass, mqtt_mock): """Test force update option.""" assert await async_setup_component( From 39f11bb46d622bceada33688a8d139f841b015db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Aug 2021 10:59:41 -0500 Subject: [PATCH 066/168] Bump zeroconf to 0.36.2 (#55459) - Now sends NSEC records when requesting non-existent address types Implements RFC6762 sec 6.2 (http://datatracker.ietf.org/doc/html/rfc6762#section-6.2) - This solves a case where a HomeKit bridge can take a while to update because it is waiting to see if an AAAA (IPv6) address is available --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index dea3b3c356e..6ed4c8d09dd 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.36.1"], + "requirements": ["zeroconf==0.36.2"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8beb6789b54..c91a512fdd0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.36.1 +zeroconf==0.36.2 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 0264d83665b..6548dbf1a92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2453,7 +2453,7 @@ youtube_dl==2021.04.26 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.1 +zeroconf==0.36.2 # homeassistant.components.zha zha-quirks==0.0.60 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dec96024ce1..20b1b1d96e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1373,7 +1373,7 @@ yeelight==0.7.4 youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.36.1 +zeroconf==0.36.2 # homeassistant.components.zha zha-quirks==0.0.60 From 46ce4e92f6d87222ad0463c1980d4c5eb8b050b8 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 Aug 2021 12:40:56 -0400 Subject: [PATCH 067/168] Bump zwave-js-server-python to 0.29.1 (#55460) --- homeassistant/components/zwave_js/climate.py | 4 +-- homeassistant/components/zwave_js/cover.py | 2 +- .../components/zwave_js/discovery.py | 5 ++- .../zwave_js/discovery_data_template.py | 32 ++++++++++--------- homeassistant/components/zwave_js/light.py | 3 +- homeassistant/components/zwave_js/lock.py | 4 +-- .../components/zwave_js/manifest.json | 8 ++--- homeassistant/components/zwave_js/select.py | 3 +- homeassistant/components/zwave_js/sensor.py | 5 ++- homeassistant/components/zwave_js/siren.py | 2 +- homeassistant/components/zwave_js/switch.py | 4 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_lock.py | 2 +- 14 files changed, 43 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 1621e87cfab..1ec5ccbcc01 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -4,14 +4,14 @@ from __future__ import annotations from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, THERMOSTAT_MODE_PROPERTY, THERMOSTAT_MODE_SETPOINT_MAP, THERMOSTAT_MODES, THERMOSTAT_OPERATING_STATE_PROPERTY, THERMOSTAT_SETPOINT_PROPERTY, - CommandClass, ThermostatMode, ThermostatOperatingState, ThermostatSetpointType, diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index f8e575521dc..7fceaf64c0e 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -5,7 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import BarrierState +from zwave_js_server.const.command_class.barrior_operator import BarrierState from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7232279f4c6..d5af1c072ee 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -6,7 +6,10 @@ from dataclasses import asdict, dataclass, field from typing import Any from awesomeversion import AwesomeVersion -from zwave_js_server.const import THERMOSTAT_CURRENT_TEMP_PROPERTY, CommandClass +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( + THERMOSTAT_CURRENT_TEMP_PROPERTY, +) from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index dd338de63eb..974cd2bfa44 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -5,27 +5,29 @@ from collections.abc import Iterable from dataclasses import dataclass from typing import Any -from zwave_js_server.const import ( - CO2_SENSORS, - CO_SENSORS, +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.meter import ( CURRENT_METER_TYPES, - CURRENT_SENSORS, - ENERGY_METER_TYPES, - ENERGY_SENSORS, - HUMIDITY_SENSORS, - ILLUMINANCE_SENSORS, + ENERGY_TOTAL_INCREASING_METER_TYPES, POWER_FACTOR_METER_TYPES, POWER_METER_TYPES, + VOLTAGE_METER_TYPES, + ElectricScale, + MeterScaleType, +) +from zwave_js_server.const.command_class.multilevel_sensor import ( + CO2_SENSORS, + CO_SENSORS, + CURRENT_SENSORS, + ENERGY_MEASUREMENT_SENSORS, + HUMIDITY_SENSORS, + ILLUMINANCE_SENSORS, POWER_SENSORS, PRESSURE_SENSORS, SIGNAL_STRENGTH_SENSORS, TEMPERATURE_SENSORS, TIMESTAMP_SENSORS, - VOLTAGE_METER_TYPES, VOLTAGE_SENSORS, - CommandClass, - ElectricScale, - MeterScaleType, MultilevelSensorType, ) from zwave_js_server.model.node import Node as ZwaveNode @@ -59,7 +61,7 @@ from .const import ( METER_DEVICE_CLASS_MAP: dict[str, set[MeterScaleType]] = { ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES, ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_METER_TYPES, - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ENERGY_METER_TYPES, + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ENERGY_TOTAL_INCREASING_METER_TYPES, ENTITY_DESC_KEY_POWER: POWER_METER_TYPES, ENTITY_DESC_KEY_POWER_FACTOR: POWER_FACTOR_METER_TYPES, } @@ -68,7 +70,7 @@ MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, set[MultilevelSensorType]] = { ENTITY_DESC_KEY_CO: CO_SENSORS, ENTITY_DESC_KEY_CO2: CO2_SENSORS, ENTITY_DESC_KEY_CURRENT: CURRENT_SENSORS, - ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ENERGY_SENSORS, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ENERGY_MEASUREMENT_SENSORS, ENTITY_DESC_KEY_HUMIDITY: HUMIDITY_SENSORS, ENTITY_DESC_KEY_ILLUMINANCE: ILLUMINANCE_SENSORS, ENTITY_DESC_KEY_POWER: POWER_SENSORS, @@ -193,7 +195,7 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): # We do this because even though these are energy scales, they don't meet # the unit requirements for the energy device class. if scale_type in ( - ElectricScale.PULSE, + ElectricScale.PULSE_COUNT, ElectricScale.KILOVOLT_AMPERE_HOUR, ElectricScale.KILOVOLT_AMPERE_REACTIVE_HOUR, ): diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 91a7f191e5d..0857b43e4ee 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -5,7 +5,8 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ColorComponent, CommandClass +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.color_switch import ColorComponent from homeassistant.components.light import ( ATTR_BRIGHTNESS, diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 696310b5ad1..0f2a0862d7f 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -6,12 +6,12 @@ from typing import Any import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import ( ATTR_CODE_SLOT, ATTR_USERCODE, LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP, LOCK_CMD_CLASS_TO_PROPERTY_MAP, - CommandClass, DoorLockMode, ) from zwave_js_server.model.value import Value as ZwaveValue diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 23a1546a421..7953e33d6e3 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,13 +3,13 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.29.0"], + "requirements": ["zwave-js-server-python==0.29.1"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", "usb": [ - {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, - {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, - {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} + {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, + {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, + {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} ] } diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 7aedc6521d9..fae87fd24de 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -2,7 +2,8 @@ from __future__ import annotations from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ToneID +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index c71a1d87653..09d44f7f24a 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -8,11 +8,10 @@ from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, - CommandClass, - ConfigurationValueType, ) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index c1b354f4faa..4ef89b9f4cd 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ToneID +from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN, SirenEntity from homeassistant.components.siren.const import ( diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 0bc6b8d5349..bd86a3b8377 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -5,7 +5,9 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import BarrierEventSignalingSubsystemState +from zwave_js_server.const.command_class.barrior_operator import ( + BarrierEventSignalingSubsystemState, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry diff --git a/requirements_all.txt b/requirements_all.txt index 6548dbf1a92..df5f1b50474 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2486,4 +2486,4 @@ zigpy==0.37.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.0 +zwave-js-server-python==0.29.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20b1b1d96e1..fa0cf6c526e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1397,4 +1397,4 @@ zigpy-znp==0.5.4 zigpy==0.37.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.0 +zwave-js-server-python==0.29.1 diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 3727ab9d288..9a0735d3dc6 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -1,5 +1,5 @@ """Test the Z-Wave JS lock platform.""" -from zwave_js_server.const import ATTR_CODE_SLOT, ATTR_USERCODE +from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE from zwave_js_server.event import Event from zwave_js_server.model.node import NodeStatus From 8be40cbb00ee854a17a26f52855a116e6b81b0bb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Aug 2021 09:41:51 -0700 Subject: [PATCH 068/168] Bumped version to 2021.9.0b5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d3c6fb3d606..8b82b13478b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 802f5613c4edda5f3598cd0c2aa4356c13ea61db Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 30 Aug 2021 12:52:29 -0700 Subject: [PATCH 069/168] Add IoTaWatt integration (#55364) Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 1 + homeassistant/components/iotawatt/__init__.py | 24 ++ .../components/iotawatt/config_flow.py | 107 +++++++++ homeassistant/components/iotawatt/const.py | 12 + .../components/iotawatt/coordinator.py | 56 +++++ .../components/iotawatt/manifest.json | 13 ++ homeassistant/components/iotawatt/sensor.py | 213 ++++++++++++++++++ .../components/iotawatt/strings.json | 23 ++ .../components/iotawatt/translations/en.json | 24 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/iotawatt/__init__.py | 21 ++ tests/components/iotawatt/conftest.py | 27 +++ tests/components/iotawatt/test_config_flow.py | 143 ++++++++++++ tests/components/iotawatt/test_init.py | 31 +++ tests/components/iotawatt/test_sensor.py | 76 +++++++ 17 files changed, 778 insertions(+) create mode 100644 homeassistant/components/iotawatt/__init__.py create mode 100644 homeassistant/components/iotawatt/config_flow.py create mode 100644 homeassistant/components/iotawatt/const.py create mode 100644 homeassistant/components/iotawatt/coordinator.py create mode 100644 homeassistant/components/iotawatt/manifest.json create mode 100644 homeassistant/components/iotawatt/sensor.py create mode 100644 homeassistant/components/iotawatt/strings.json create mode 100644 homeassistant/components/iotawatt/translations/en.json create mode 100644 tests/components/iotawatt/__init__.py create mode 100644 tests/components/iotawatt/conftest.py create mode 100644 tests/components/iotawatt/test_config_flow.py create mode 100644 tests/components/iotawatt/test_init.py create mode 100644 tests/components/iotawatt/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 121d1875202..a1b12a81127 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -248,6 +248,7 @@ homeassistant/components/integration/* @dgomes homeassistant/components/intent/* @home-assistant/core homeassistant/components/intesishome/* @jnimmo homeassistant/components/ios/* @robbiet480 +homeassistant/components/iotawatt/* @gtdiehl homeassistant/components/iperf3/* @rohankapoorcom homeassistant/components/ipma/* @dgomes @abmantis homeassistant/components/ipp/* @ctalkington diff --git a/homeassistant/components/iotawatt/__init__.py b/homeassistant/components/iotawatt/__init__.py new file mode 100644 index 00000000000..7987004e594 --- /dev/null +++ b/homeassistant/components/iotawatt/__init__.py @@ -0,0 +1,24 @@ +"""The iotawatt integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import IotawattUpdater + +PLATFORMS = ("sensor",) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up iotawatt from a config entry.""" + coordinator = IotawattUpdater(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/iotawatt/config_flow.py b/homeassistant/components/iotawatt/config_flow.py new file mode 100644 index 00000000000..9ec860ea76a --- /dev/null +++ b/homeassistant/components/iotawatt/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for iotawatt integration.""" +from __future__ import annotations + +import logging + +from iotawattpy.iotawatt import Iotawatt +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import httpx_client + +from .const import CONNECTION_ERRORS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, str] +) -> dict[str, str]: + """Validate the user input allows us to connect.""" + iotawatt = Iotawatt( + "", + data[CONF_HOST], + httpx_client.get_async_client(hass), + data.get(CONF_USERNAME), + data.get(CONF_PASSWORD), + ) + try: + is_connected = await iotawatt.connect() + except CONNECTION_ERRORS: + return {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return {"base": "unknown"} + + if not is_connected: + return {"base": "invalid_auth"} + + return {} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for iotawatt.""" + + VERSION = 1 + + def __init__(self): + """Initialize.""" + self._data = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + user_input = {} + + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + } + ) + if not user_input: + return self.async_show_form(step_id="user", data_schema=schema) + + if not (errors := await validate_input(self.hass, user_input)): + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) + + if errors == {"base": "invalid_auth"}: + self._data.update(user_input) + return await self.async_step_auth() + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_auth(self, user_input=None): + """Authenticate user if authentication is enabled on the IoTaWatt device.""" + if user_input is None: + user_input = {} + + data_schema = vol.Schema( + { + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, + } + ) + if not user_input: + return self.async_show_form(step_id="auth", data_schema=data_schema) + + data = {**self._data, **user_input} + + if errors := await validate_input(self.hass, data): + return self.async_show_form( + step_id="auth", data_schema=data_schema, errors=errors + ) + + return self.async_create_entry(title=data[CONF_HOST], data=data) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/iotawatt/const.py b/homeassistant/components/iotawatt/const.py new file mode 100644 index 00000000000..db847f3dfe8 --- /dev/null +++ b/homeassistant/components/iotawatt/const.py @@ -0,0 +1,12 @@ +"""Constants for the IoTaWatt integration.""" +from __future__ import annotations + +import json + +import httpx + +DOMAIN = "iotawatt" +VOLT_AMPERE_REACTIVE = "VAR" +VOLT_AMPERE_REACTIVE_HOURS = "VARh" + +CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py new file mode 100644 index 00000000000..1a722d52a1e --- /dev/null +++ b/homeassistant/components/iotawatt/coordinator.py @@ -0,0 +1,56 @@ +"""IoTaWatt DataUpdateCoordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from iotawattpy.iotawatt import Iotawatt + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import httpx_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONNECTION_ERRORS + +_LOGGER = logging.getLogger(__name__) + + +class IotawattUpdater(DataUpdateCoordinator): + """Class to manage fetching update data from the IoTaWatt Energy Device.""" + + api: Iotawatt | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize IotaWattUpdater object.""" + self.entry = entry + super().__init__( + hass=hass, + logger=_LOGGER, + name=entry.title, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self): + """Fetch sensors from IoTaWatt device.""" + if self.api is None: + api = Iotawatt( + self.entry.title, + self.entry.data[CONF_HOST], + httpx_client.get_async_client(self.hass), + self.entry.data.get(CONF_USERNAME), + self.entry.data.get(CONF_PASSWORD), + ) + try: + is_authenticated = await api.connect() + except CONNECTION_ERRORS as err: + raise UpdateFailed("Connection failed") from err + + if not is_authenticated: + raise UpdateFailed("Authentication error") + + self.api = api + + await self.api.update() + return self.api.getSensors() diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json new file mode 100644 index 00000000000..d78e546d71f --- /dev/null +++ b/homeassistant/components/iotawatt/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "iotawatt", + "name": "IoTaWatt", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/iotawatt", + "requirements": [ + "iotawattpy==0.0.8" + ], + "codeowners": [ + "@gtdiehl" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py new file mode 100644 index 00000000000..8a8c92a8c51 --- /dev/null +++ b/homeassistant/components/iotawatt/sensor.py @@ -0,0 +1,213 @@ +"""Support for IoTaWatt Energy monitor.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from iotawattpy.sensor import Sensor + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_VOLT_AMPERE, + POWER_WATT, +) +from homeassistant.core import callback +from homeassistant.helpers import entity, entity_registry, update_coordinator +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .coordinator import IotawattUpdater + + +@dataclass +class IotaWattSensorEntityDescription(SensorEntityDescription): + """Class describing IotaWatt sensor entities.""" + + value: Callable | None = None + + +ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { + "Amps": IotaWattSensorEntityDescription( + "Amps", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_CURRENT, + ), + "Hz": IotaWattSensorEntityDescription( + "Hz", + native_unit_of_measurement=FREQUENCY_HERTZ, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "PF": IotaWattSensorEntityDescription( + "PF", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER_FACTOR, + value=lambda value: value * 100, + ), + "Watts": IotaWattSensorEntityDescription( + "Watts", + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER, + ), + "WattHours": IotaWattSensorEntityDescription( + "WattHours", + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + ), + "VA": IotaWattSensorEntityDescription( + "VA", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "VAR": IotaWattSensorEntityDescription( + "VAR", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "VARh": IotaWattSensorEntityDescription( + "VARh", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + ), + "Volts": IotaWattSensorEntityDescription( + "Volts", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_VOLTAGE, + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add sensors for passed config_entry in HA.""" + coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id] + created = set() + + @callback + def _create_entity(key: str) -> IotaWattSensor: + """Create a sensor entity.""" + created.add(key) + return IotaWattSensor( + coordinator=coordinator, + key=key, + mac_address=coordinator.data["sensors"][key].hub_mac_address, + name=coordinator.data["sensors"][key].getName(), + entity_description=ENTITY_DESCRIPTION_KEY_MAP.get( + coordinator.data["sensors"][key].getUnit(), + IotaWattSensorEntityDescription("base_sensor"), + ), + ) + + async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) + + @callback + def new_data_received(): + """Check for new sensors.""" + entities = [ + _create_entity(key) + for key in coordinator.data["sensors"] + if key not in created + ] + if entities: + async_add_entities(entities) + + coordinator.async_add_listener(new_data_received) + + +class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): + """Defines a IoTaWatt Energy Sensor.""" + + entity_description: IotaWattSensorEntityDescription + _attr_force_update = True + + def __init__( + self, + coordinator, + key, + mac_address, + name, + entity_description: IotaWattSensorEntityDescription, + ): + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + + self._key = key + data = self._sensor_data + if data.getType() == "Input": + self._attr_unique_id = ( + f"{data.hub_mac_address}-input-{data.getChannel()}-{data.getUnit()}" + ) + self.entity_description = entity_description + + @property + def _sensor_data(self) -> Sensor: + """Return sensor data.""" + return self.coordinator.data["sensors"][self._key] + + @property + def name(self) -> str | None: + """Return name of the entity.""" + return self._sensor_data.getName() + + @property + def device_info(self) -> entity.DeviceInfo | None: + """Return device info.""" + return { + "connections": { + (CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address) + }, + "manufacturer": "IoTaWatt", + "model": "IoTaWatt", + } + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._key not in self.coordinator.data["sensors"]: + if self._attr_unique_id: + entity_registry.async_get(self.hass).async_remove(self.entity_id) + else: + self.hass.async_create_task(self.async_remove()) + return + + super()._handle_coordinator_update() + + @property + def extra_state_attributes(self): + """Return the extra state attributes of the entity.""" + data = self._sensor_data + attrs = {"type": data.getType()} + if attrs["type"] == "Input": + attrs["channel"] = data.getChannel() + + return attrs + + @property + def native_value(self) -> entity.StateType: + """Return the state of the sensor.""" + if func := self.entity_description.value: + return func(self._sensor_data.getValue()) + + return self._sensor_data.getValue() diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json new file mode 100644 index 00000000000..f21dfe0cd09 --- /dev/null +++ b/homeassistant/components/iotawatt/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "auth": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/iotawatt/translations/en.json b/homeassistant/components/iotawatt/translations/en.json new file mode 100644 index 00000000000..cbda4b41bea --- /dev/null +++ b/homeassistant/components/iotawatt/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "auth": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button." + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "title": "iotawatt" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ec2947443de..2eb4e43fe32 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -130,6 +130,7 @@ FLOWS = [ "ifttt", "insteon", "ios", + "iotawatt", "ipma", "ipp", "iqvia", diff --git a/requirements_all.txt b/requirements_all.txt index df5f1b50474..002d5a7e2c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -864,6 +864,9 @@ influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 +# homeassistant.components.iotawatt +iotawattpy==0.0.8 + # homeassistant.components.iperf3 iperf3==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa0cf6c526e..baeac83042c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -504,6 +504,9 @@ influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 +# homeassistant.components.iotawatt +iotawattpy==0.0.8 + # homeassistant.components.gogogate2 ismartgate==4.0.0 diff --git a/tests/components/iotawatt/__init__.py b/tests/components/iotawatt/__init__.py new file mode 100644 index 00000000000..3d1afe1b88b --- /dev/null +++ b/tests/components/iotawatt/__init__.py @@ -0,0 +1,21 @@ +"""Tests for the IoTaWatt integration.""" +from iotawattpy.sensor import Sensor + +INPUT_SENSOR = Sensor( + channel="1", + name="My Sensor", + io_type="Input", + unit="WattHours", + value="23", + begin="", + mac_addr="mock-mac", +) +OUTPUT_SENSOR = Sensor( + channel="N/A", + name="My WattHour Sensor", + io_type="Output", + unit="WattHours", + value="243", + begin="", + mac_addr="mock-mac", +) diff --git a/tests/components/iotawatt/conftest.py b/tests/components/iotawatt/conftest.py new file mode 100644 index 00000000000..f96201ba50e --- /dev/null +++ b/tests/components/iotawatt/conftest.py @@ -0,0 +1,27 @@ +"""Test fixtures for IoTaWatt.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.iotawatt import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def entry(hass): + """Mock config entry added to HA.""" + entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_iotawatt(entry): + """Mock iotawatt.""" + with patch("homeassistant.components.iotawatt.coordinator.Iotawatt") as mock: + instance = mock.return_value + instance.connect = AsyncMock(return_value=True) + instance.update = AsyncMock() + instance.getSensors.return_value = {"sensors": {}} + yield instance diff --git a/tests/components/iotawatt/test_config_flow.py b/tests/components/iotawatt/test_config_flow.py new file mode 100644 index 00000000000..e028f365431 --- /dev/null +++ b/tests/components/iotawatt/test_config_flow.py @@ -0,0 +1,143 @@ +"""Test the IoTawatt config flow.""" +from unittest.mock import patch + +import httpx + +from homeassistant import config_entries, setup +from homeassistant.components.iotawatt.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.iotawatt.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "host": "1.1.1.1", + } + + +async def test_form_auth(hass: HomeAssistant) -> None: + """Test we handle auth.""" + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "auth" + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=False, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "mock-user", + "password": "mock-pass", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["step_id"] == "auth" + assert result3["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.iotawatt.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=True, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "mock-user", + "password": "mock-pass", + }, + ) + await hass.async_block_till_done() + + assert result4["type"] == RESULT_TYPE_CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + assert result4["data"] == { + "host": "1.1.1.1", + "username": "mock-user", + "password": "mock-pass", + } + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + side_effect=httpx.HTTPError("any"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_setup_exception(hass: HomeAssistant) -> None: + """Test we handle broad exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/iotawatt/test_init.py b/tests/components/iotawatt/test_init.py new file mode 100644 index 00000000000..b43a3d9aa88 --- /dev/null +++ b/tests/components/iotawatt/test_init.py @@ -0,0 +1,31 @@ +"""Test init.""" +import httpx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.setup import async_setup_component + +from . import INPUT_SENSOR + + +async def test_setup_unload(hass, mock_iotawatt, entry): + """Test we can setup and unload an entry.""" + mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_setup_connection_failed(hass, mock_iotawatt, entry): + """Test connection error during startup.""" + mock_iotawatt.connect.side_effect = httpx.ConnectError("") + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_setup_auth_failed(hass, mock_iotawatt, entry): + """Test auth error during startup.""" + mock_iotawatt.connect.return_value = False + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py new file mode 100644 index 00000000000..556da8cc2b0 --- /dev/null +++ b/tests/components/iotawatt/test_sensor.py @@ -0,0 +1,76 @@ +"""Test setting up sensors.""" +from datetime import timedelta + +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DEVICE_CLASS_ENERGY, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_WATT_HOUR, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from . import INPUT_SENSOR, OUTPUT_SENSOR + +from tests.common import async_fire_time_changed + + +async def test_sensor_type_input(hass, mock_iotawatt): + """Test input sensors work.""" + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 0 + + # Discover this sensor during a regular update. + mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get("sensor.my_sensor") + assert state is not None + assert state.state == "23" + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["channel"] == "1" + assert state.attributes["type"] == "Input" + + mock_iotawatt.getSensors.return_value["sensors"].pop("my_sensor_key") + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.my_sensor") is None + + +async def test_sensor_type_output(hass, mock_iotawatt): + """Tests the sensor type of Output.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_sensor_key" + ] = OUTPUT_SENSOR + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + state = hass.states.get("sensor.my_watthour_sensor") + assert state is not None + assert state.state == "243" + assert state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Sensor" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes["type"] == "Output" + + mock_iotawatt.getSensors.return_value["sensors"].pop("my_watthour_sensor_key") + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.my_watthour_sensor") is None From 84f3b1514f1ca815b031690918cc65fd230ec027 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Aug 2021 23:32:35 +0200 Subject: [PATCH 070/168] Fix race in MQTT sensor when last_reset_topic is configured (#55463) --- homeassistant/components/mqtt/sensor.py | 100 +++++++++++++++++------- tests/components/mqtt/test_sensor.py | 46 ++++++++++- 2 files changed, 117 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index eac136d3f84..4a0ea75de21 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -53,18 +53,48 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +def validate_options(conf): + """Validate options. + + If last reset topic is present it must be same as the state topic. + """ + if ( + CONF_LAST_RESET_TOPIC in conf + and CONF_STATE_TOPIC in conf + and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC] + ): + _LOGGER.warning( + "'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC + ) + + if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf: + _LOGGER.warning( + "'%s' must be set if '%s' is set", + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_LAST_RESET_TOPIC, + ) + + return conf + + +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_LAST_RESET_TOPIC), + mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } + ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), + validate_options, +) async def async_setup_platform( @@ -127,10 +157,7 @@ class MqttSensor(MqttEntity, SensorEntity): """(Re)Subscribe to topics.""" topics = {} - @callback - @log_messages(self.hass, self.entity_id) - def message_received(msg): - """Handle new MQTT messages.""" + def _update_state(msg): payload = msg.payload # auto-expire enabled? expire_after = self._config.get(CONF_EXPIRE_AFTER) @@ -159,18 +186,8 @@ class MqttSensor(MqttEntity, SensorEntity): variables=variables, ) self._state = payload - self.async_write_ha_state() - topics["state_topic"] = { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - } - - @callback - @log_messages(self.hass, self.entity_id) - def last_reset_message_received(msg): - """Handle new last_reset messages.""" + def _update_last_reset(msg): payload = msg.payload template = self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE) @@ -193,9 +210,36 @@ class MqttSensor(MqttEntity, SensorEntity): _LOGGER.warning( "Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic ) + + @callback + @log_messages(self.hass, self.entity_id) + def message_received(msg): + """Handle new MQTT messages.""" + _update_state(msg) + if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and ( + CONF_LAST_RESET_TOPIC not in self._config + or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC] + ): + _update_last_reset(msg) self.async_write_ha_state() - if CONF_LAST_RESET_TOPIC in self._config: + topics["state_topic"] = { + "topic": self._config[CONF_STATE_TOPIC], + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + } + + @callback + @log_messages(self.hass, self.entity_id) + def last_reset_message_received(msg): + """Handle new last_reset messages.""" + _update_last_reset(msg) + self.async_write_ha_state() + + if ( + CONF_LAST_RESET_TOPIC in self._config + and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC] + ): topics["last_reset_topic"] = { "topic": self._config[CONF_LAST_RESET_TOPIC], "msg_callback": last_reset_message_received, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 15ca9870077..46c06f0d3b3 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -208,7 +208,7 @@ async def test_setting_sensor_value_via_mqtt_json_message(hass, mqtt_mock): assert state.state == "100" -async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock): +async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock, caplog): """Test the setting of the last_reset property via MQTT.""" assert await async_setup_component( hass, @@ -228,6 +228,11 @@ async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock): async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") state = hass.states.get("sensor.test") assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" + assert "'last_reset_topic' must be same as 'state_topic'" in caplog.text + assert ( + "'last_reset_value_template' must be set if 'last_reset_topic' is set" + in caplog.text + ) @pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) @@ -306,6 +311,45 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" +@pytest.mark.parametrize("extra", [{}, {"last_reset_topic": "test-topic"}]) +async def test_setting_sensor_last_reset_via_mqtt_json_message_2( + hass, mqtt_mock, caplog, extra +): + """Test the setting of the value via MQTT with JSON payload.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + **{ + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + }, + **extra, + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message( + hass, + "test-topic", + '{"type":"minute","time":1629385500000,"value":947.7706166666667}', + ) + state = hass.states.get("sensor.test") + assert float(state.state) == pytest.approx(0.015796176944444445) + assert state.attributes.get("last_reset") == "2021-08-19T15:05:00+00:00" + assert "'last_reset_topic' must be same as 'state_topic'" not in caplog.text + assert ( + "'last_reset_value_template' must be set if 'last_reset_topic' is set" + not in caplog.text + ) + + async def test_force_update_disabled(hass, mqtt_mock): """Test force update option.""" assert await async_setup_component( From 275f9c8a289cf72c73bf181f61de82e69cdaef26 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 14:12:27 -0600 Subject: [PATCH 071/168] Bump pyopenuv to 2.2.0 (#55464) --- homeassistant/components/openuv/__init__.py | 1 + homeassistant/components/openuv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index d14760d6cb1..5d165c498e2 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -66,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation), session=websession, + logger=LOGGER, ), ) await openuv.async_update() diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index 842d4966805..24af3f3a3af 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -3,7 +3,7 @@ "name": "OpenUV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openuv", - "requirements": ["pyopenuv==2.1.0"], + "requirements": ["pyopenuv==2.2.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 002d5a7e2c4..aada554c2be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1668,7 +1668,7 @@ pyobihai==1.3.1 pyombi==0.1.10 # homeassistant.components.openuv -pyopenuv==2.1.0 +pyopenuv==2.2.0 # homeassistant.components.opnsense pyopnsense==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index baeac83042c..c955af2d5e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -966,7 +966,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.openuv -pyopenuv==2.1.0 +pyopenuv==2.2.0 # homeassistant.components.opnsense pyopnsense==0.2.0 From 0d9fbf864faf27439544c61dd066b0cb5e2adc46 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 14:12:09 -0600 Subject: [PATCH 072/168] Bump pyiqvia to 1.1.0 (#55466) --- homeassistant/components/iqvia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index da50819c9a0..e8914507657 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.21.1", "pyiqvia==1.0.0"], + "requirements": ["numpy==1.21.1", "pyiqvia==1.1.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index aada554c2be..a3ac41e41f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1530,7 +1530,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.0.0 +pyiqvia==1.1.0 # homeassistant.components.irish_rail_transport pyirishrail==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c955af2d5e4..01f91b59bd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -879,7 +879,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.0.0 +pyiqvia==1.1.0 # homeassistant.components.isy994 pyisy==3.0.0 From f92c7b1aeae40c020e35cdf6fb5765eb7d050faf Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 Aug 2021 15:05:28 -0600 Subject: [PATCH 073/168] Bump aioambient to 1.3.0 (#55468) --- homeassistant/components/ambient_station/__init__.py | 1 + homeassistant/components/ambient_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index d719f9b3728..68b8579f731 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -319,6 +319,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_API_KEY], config_entry.data[CONF_APP_KEY], session=session, + logger=LOGGER, ), ) hass.loop.create_task(ambient.ws_connect()) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 35b4770e872..b95f4a8f13c 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.2.6"], + "requirements": ["aioambient==1.3.0"], "codeowners": ["@bachya"], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index a3ac41e41f8..80b84f80093 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -136,7 +136,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.6 +aioambient==1.3.0 # homeassistant.components.asuswrt aioasuswrt==1.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01f91b59bd2..1ff7c137e73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,7 +78,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.6 +aioambient==1.3.0 # homeassistant.components.asuswrt aioasuswrt==1.3.4 From 8ab801a7b461fb821d3b83093a11f37797f80439 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 Aug 2021 16:09:41 -0400 Subject: [PATCH 074/168] Fix area_id and area_name template functions (#55470) --- homeassistant/helpers/template.py | 23 ++++++++++++++++++----- tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index a831e8d156d..ade580694c8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -957,6 +957,7 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: return area.id ent_reg = entity_registry.async_get(hass) + dev_reg = device_registry.async_get(hass) # Import here, not at top-level to avoid circular import from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel config_validation as cv, @@ -968,10 +969,14 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: pass else: if entity := ent_reg.async_get(lookup_value): - return entity.area_id + # If entity has an area ID, return that + if entity.area_id: + return entity.area_id + # If entity has a device ID, return the area ID for the device + if entity.device_id and (device := dev_reg.async_get(entity.device_id)): + return device.area_id - # Check if this could be a device ID (hex string) - dev_reg = device_registry.async_get(hass) + # Check if this could be a device ID if device := dev_reg.async_get(lookup_value): return device.area_id @@ -992,6 +997,7 @@ def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: if area: return area.name + dev_reg = device_registry.async_get(hass) ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel @@ -1004,11 +1010,18 @@ def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: pass else: if entity := ent_reg.async_get(lookup_value): + # If entity has an area ID, get the area name for that if entity.area_id: return _get_area_name(area_reg, entity.area_id) - return None + # If entity has a device ID and the device exists with an area ID, get the + # area name for that + if ( + entity.device_id + and (device := dev_reg.async_get(entity.device_id)) + and device.area_id + ): + return _get_area_name(area_reg, device.area_id) - dev_reg = device_registry.async_get(hass) if (device := dev_reg.async_get(lookup_value)) and device.area_id: return _get_area_name(area_reg, device.area_id) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 7a2776fd5b2..64b075b685a 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1828,6 +1828,16 @@ async def test_area_id(hass): assert_result_info(info, area_entry_entity_id.id) assert info.rate_limit is None + # Make sure that when entity doesn't have an area but its device does, that's what + # gets returned + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_entity_id.id + ) + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + async def test_area_name(hass): """Test area_name function.""" @@ -1897,6 +1907,16 @@ async def test_area_name(hass): assert_result_info(info, area_entry.name) assert info.rate_limit is None + # Make sure that when entity doesn't have an area but its device does, that's what + # gets returned + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=None + ) + + info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + def test_closest_function_to_coord(hass): """Test closest function to coord.""" From 92b045374959b33e8038b40f1d2a41886a47a794 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 30 Aug 2021 23:32:19 +0200 Subject: [PATCH 075/168] Update frontend to 20210830.0 (#55472) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6224916246a..076420656fd 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210825.0" + "home-assistant-frontend==20210830.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c91a512fdd0..cb6d10e4084 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==3.3.2 defusedxml==0.7.1 emoji==1.2.0 hass-nabucasa==0.46.0 -home-assistant-frontend==20210825.0 +home-assistant-frontend==20210830.0 httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 80b84f80093..611713006ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -793,7 +793,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210825.0 +home-assistant-frontend==20210830.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ff7c137e73..2ec55d5184e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -462,7 +462,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210825.0 +home-assistant-frontend==20210830.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 4c48ad91089ba2980a2430133ffdd0300f900ea8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 30 Aug 2021 23:35:50 +0200 Subject: [PATCH 076/168] Bumped version to Bumped version to 2021.9.0b6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8b82b13478b..6c096f06769 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From f0c0cfcac0fe909400873f1c3ef6fe31223d97f2 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Tue, 31 Aug 2021 00:32:26 -0700 Subject: [PATCH 077/168] Wemo Insight devices need polling when off (#55348) --- homeassistant/components/wemo/wemo_device.py | 21 ++++++-- tests/components/wemo/test_wemo_device.py | 56 +++++++++++++++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 9423d0b8d1c..1690d30e082 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta import logging -from pywemo import WeMoDevice +from pywemo import Insight, WeMoDevice from pywemo.exceptions import ActionException from pywemo.subscribe import EVENT_TYPE_LONG_PRESS @@ -81,11 +81,26 @@ class DeviceCoordinator(DataUpdateCoordinator): else: self.async_set_updated_data(None) + @property + def should_poll(self) -> bool: + """Return True if polling is needed to update the state for the device. + + The alternative, when this returns False, is to rely on the subscription + "push updates" to update the device state in Home Assistant. + """ + if isinstance(self.wemo, Insight) and self.wemo.get_state() == 0: + # The WeMo Insight device does not send subscription updates for the + # insight_params values when the device is off. Polling is required in + # this case so the Sensor entities are properly populated. + return True + + registry = self.hass.data[DOMAIN]["registry"] + return not (registry.is_subscribed(self.wemo) and self.last_update_success) + async def _async_update_data(self) -> None: """Update WeMo state.""" # No need to poll if the device will push updates. - registry = self.hass.data[DOMAIN]["registry"] - if registry.is_subscribed(self.wemo) and self.last_update_success: + if not self.should_poll: return # If an update is in progress, we don't do anything. diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 6f3cc12a81a..e756e816a47 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -1,6 +1,7 @@ """Tests for wemo_device.py.""" import asyncio -from unittest.mock import patch +from datetime import timedelta +from unittest.mock import call, patch import async_timeout import pytest @@ -14,9 +15,12 @@ from homeassistant.core import callback from homeassistant.helpers import device_registry from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from .conftest import MOCK_HOST +from tests.common import async_fire_time_changed + asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(True)) @@ -148,3 +152,53 @@ async def test_async_update_data_subscribed( pywemo_device.get_state.reset_mock() await device._async_update_data() pywemo_device.get_state.assert_not_called() + + +class TestInsight: + """Tests specific to the WeMo Insight device.""" + + @pytest.fixture + def pywemo_model(self): + """Pywemo Dimmer models use the light platform (WemoDimmer class).""" + return "Insight" + + @pytest.fixture(name="pywemo_device") + def pywemo_device_fixture(self, pywemo_device): + """Fixture for WeMoDevice instances.""" + pywemo_device.insight_params = { + "currentpower": 1.0, + "todaymw": 200000000.0, + "state": 0, + "onfor": 0, + "ontoday": 0, + "ontotal": 0, + "powerthreshold": 0, + } + yield pywemo_device + + @pytest.mark.parametrize( + "subscribed,state,expected_calls", + [ + (False, 0, [call(), call(True), call(), call()]), + (False, 1, [call(), call(True), call(), call()]), + (True, 0, [call(), call(True), call(), call()]), + (True, 1, [call(), call(), call()]), + ], + ) + async def test_should_poll( + self, + hass, + subscribed, + state, + expected_calls, + wemo_entity, + pywemo_device, + pywemo_registry, + ): + """Validate the should_poll returns the correct value.""" + pywemo_registry.is_subscribed.return_value = subscribed + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.return_value = state + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + pywemo_device.get_state.assert_has_calls(expected_calls) From b8770c395812718ac1b234d39ed0ff577f3d9937 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Aug 2021 10:45:17 +0200 Subject: [PATCH 078/168] Make new cycles for sensor sum statistics start with 0 as zero-point (#55473) --- homeassistant/components/sensor/recorder.py | 7 ++----- tests/components/sensor/test_recorder.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 6ab75f88dbd..cc1b6865d81 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -403,11 +403,8 @@ def compile_statistics( # ..and update the starting point new_state = fstate old_last_reset = last_reset - # Force a new cycle for STATE_CLASS_TOTAL_INCREASING to start at 0 - if ( - state_class == STATE_CLASS_TOTAL_INCREASING - and old_state is not None - ): + # Force a new cycle for an existing sensor to start at 0 + if old_state is not None: old_state = 0.0 else: old_state = new_state diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 6c4c899eb14..b3f0ab075c6 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -258,7 +258,7 @@ def test_compile_hourly_sum_statistics_amount( "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[5]), - "sum": approx(factor * 10.0), + "sum": approx(factor * 40.0), }, { "statistic_id": "sensor.test1", @@ -268,7 +268,7 @@ def test_compile_hourly_sum_statistics_amount( "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[8]), - "sum": approx(factor * 40.0), + "sum": approx(factor * 70.0), }, ] } @@ -512,7 +512,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(40.0), }, { "statistic_id": "sensor.test1", @@ -522,7 +522,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(70.0), }, ] } @@ -595,7 +595,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(40.0), }, { "statistic_id": "sensor.test1", @@ -605,7 +605,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(70.0), }, ], "sensor.test2": [ @@ -627,7 +627,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(45.0), - "sum": approx(-95.0), + "sum": approx(-65.0), }, { "statistic_id": "sensor.test2", @@ -637,7 +637,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(75.0), - "sum": approx(-65.0), + "sum": approx(-35.0), }, ], "sensor.test3": [ @@ -659,7 +659,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(50.0 / 1000), - "sum": approx(30.0 / 1000), + "sum": approx(60.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -669,7 +669,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(90.0 / 1000), - "sum": approx(70.0 / 1000), + "sum": approx(100.0 / 1000), }, ], } From ef001783393540b52b5bb5a2d40eb36491478b21 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Aug 2021 23:45:35 -0700 Subject: [PATCH 079/168] Add Eagle 200 name back (#55477) * Add Eagle 200 name back * add comment * update tests --- homeassistant/components/rainforest_eagle/sensor.py | 7 ++++--- tests/components/rainforest_eagle/test_sensor.py | 12 ++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 4b24a3abdaa..6f6b496cfca 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -38,21 +38,22 @@ _LOGGER = logging.getLogger(__name__) SENSORS = ( SensorEntityDescription( key="zigbee:InstantaneousDemand", - name="Meter Power Demand", + # We can drop the "Eagle-200" part of the name in HA 2021.12 + name="Eagle-200 Meter Power Demand", native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="zigbee:CurrentSummationDelivered", - name="Total Meter Energy Delivered", + name="Eagle-200 Total Meter Energy Delivered", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), SensorEntityDescription( key="zigbee:CurrentSummationReceived", - name="Total Meter Energy Received", + name="Eagle-200 Total Meter Energy Received", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index a090c6dc318..e895f2ac4fc 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -114,17 +114,17 @@ async def test_sensors_200(hass, setup_rainforest_200): """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.meter_power_demand") + demand = hass.states.get("sensor.eagle_200_meter_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.total_meter_energy_received") + received = hass.states.get("sensor.eagle_200_total_meter_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" @@ -147,17 +147,17 @@ async def test_sensors_100(hass, setup_rainforest_100): """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.meter_power_demand") + demand = hass.states.get("sensor.eagle_200_meter_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.total_meter_energy_received") + received = hass.states.get("sensor.eagle_200_total_meter_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" From a724bc21b6ffb23eeadee6bec7bc496f9cd2d3f0 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Mon, 30 Aug 2021 20:33:06 -0700 Subject: [PATCH 080/168] Assistant sensors (#55480) --- .../components/google_assistant/const.py | 1 + .../components/google_assistant/trait.py | 59 +++++++++++++++++++ .../components/google_assistant/test_trait.py | 53 +++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 2e43e20f124..d23560b85c1 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -133,6 +133,7 @@ DOMAIN_TO_GOOGLE_TYPES = { media_player.DOMAIN: TYPE_SETTOP, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, + sensor.DOMAIN: TYPE_SENSOR, select.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index dda8a04c2ed..d1ed328703e 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -108,6 +108,7 @@ TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState" TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel" TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator" TRAIT_ENERGYSTORAGE = f"{PREFIX_TRAITS}EnergyStorage" +TRAIT_SENSOR_STATE = f"{PREFIX_TRAITS}SensorState" PREFIX_COMMANDS = "action.devices.commands." COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff" @@ -2286,3 +2287,61 @@ class ChannelTrait(_Trait): blocking=True, context=data.context, ) + + +@register_trait +class SensorStateTrait(_Trait): + """Trait to get sensor state. + + https://developers.google.com/actions/smarthome/traits/sensorstate + """ + + sensor_types = { + sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"), + sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: ( + "VolatileOrganicCompounds", + "PARTS_PER_MILLION", + ), + } + + name = TRAIT_SENSOR_STATE + commands = [] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == sensor.DOMAIN and device_class in ( + sensor.DEVICE_CLASS_AQI, + sensor.DEVICE_CLASS_CO, + sensor.DEVICE_CLASS_CO2, + sensor.DEVICE_CLASS_PM25, + sensor.DEVICE_CLASS_PM10, + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ) + + def sync_attributes(self): + """Return attributes for a sync request.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + data = self.sensor_types.get(device_class) + if data is not None: + return { + "sensorStatesSupported": { + "name": data[0], + "numericCapabilities": {"rawValueUnit": data[1]}, + } + } + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + data = self.sensor_types.get(device_class) + if data is not None: + return { + "currentSensorStateData": [ + {"name": data[0], "rawValue": self.state.state} + ] + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 50006060f51..290aa00bb47 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -3003,3 +3003,56 @@ async def test_channel(hass): with pytest.raises(SmartHomeError, match="Unsupported command"): await trt.execute("Unknown command", BASIC_DATA, {"channelNumber": "1"}, {}) assert len(media_player_calls) == 1 + + +async def test_sensorstate(hass): + """Test SensorState trait support for sensor domain.""" + sensor_types = { + sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"), + sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: ( + "VolatileOrganicCompounds", + "PARTS_PER_MILLION", + ), + } + + for sensor_type in sensor_types: + assert helpers.get_google_type(sensor.DOMAIN, None) is not None + assert trait.SensorStateTrait.supported(sensor.DOMAIN, None, sensor_type, None) + + trt = trait.SensorStateTrait( + hass, + State( + "sensor.test", + 100.0, + { + "device_class": sensor_type, + }, + ), + BASIC_CONFIG, + ) + + name = sensor_types[sensor_type][0] + unit = sensor_types[sensor_type][1] + + assert trt.sync_attributes() == { + "sensorStatesSupported": { + "name": name, + "numericCapabilities": {"rawValueUnit": unit}, + } + } + + assert trt.query_attributes() == { + "currentSensorStateData": [{"name": name, "rawValue": "100.0"}] + } + + assert helpers.get_google_type(sensor.DOMAIN, None) is not None + assert ( + trait.SensorStateTrait.supported( + sensor.DOMAIN, None, sensor.DEVICE_CLASS_MONETARY, None + ) + is False + ) From d9056c01a6891c24c9aa8323b167d39fe0c7f8f1 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Tue, 31 Aug 2021 23:30:05 +0800 Subject: [PATCH 081/168] Fix ArestSwitchBase missing is on attribute (#55483) --- homeassistant/components/arest/switch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index d20eb7a5f8d..ecbf24c23ca 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -88,6 +88,7 @@ class ArestSwitchBase(SwitchEntity): self._resource = resource self._attr_name = f"{location.title()} {name.title()}" self._attr_available = True + self._attr_is_on = False class ArestSwitchFunction(ArestSwitchBase): From e87b7e24b4192bdb56ad0a61e25297ee6da7ac18 Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 31 Aug 2021 21:24:09 +0200 Subject: [PATCH 082/168] Increase YouLess polling interval (#55490) --- homeassistant/components/youless/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index 83c8209f558..0980e451028 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, name="youless_gateway", update_method=async_update_data, - update_interval=timedelta(seconds=2), + update_interval=timedelta(seconds=10), ) await coordinator.async_config_entry_first_refresh() From 29110fe157660d64a31dbcb6a47f69b9401ad28e Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 31 Aug 2021 19:22:00 +0200 Subject: [PATCH 083/168] Remove Youless native unit of measurement (#55492) --- homeassistant/components/youless/sensor.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 22fecfe1ec6..0b081ab15a2 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -82,14 +82,6 @@ class YoulessBaseSensor(CoordinatorEntity, SensorEntity): """Property to get the underlying sensor object.""" return None - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement for the sensor.""" - if self.get_sensor is None: - return None - - return self.get_sensor.unit_of_measurement - @property def native_value(self) -> StateType: """Determine the state value, only if a sensor is initialized.""" From 83a51f7f308c3ace368e61ef59c970a08bd5d777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 31 Aug 2021 14:45:28 +0200 Subject: [PATCH 084/168] Add cache-control headers to supervisor entrypoint (#55493) --- homeassistant/components/hassio/http.py | 10 ++++++++-- tests/components/hassio/test_http.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 4a0def62b4d..fe01cbe3197 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -10,6 +10,7 @@ import aiohttp from aiohttp import web from aiohttp.client import ClientTimeout from aiohttp.hdrs import ( + CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, @@ -51,6 +52,8 @@ NO_AUTH = re.compile( r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$" ) +NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") + class HassIOView(HomeAssistantView): """Hass.io view to handle base part.""" @@ -104,7 +107,7 @@ class HassIOView(HomeAssistantView): # Stream response response = web.StreamResponse( - status=client.status, headers=_response_header(client) + status=client.status, headers=_response_header(client, path) ) response.content_type = client.content_type @@ -139,7 +142,7 @@ def _init_header(request: web.Request) -> dict[str, str]: return headers -def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: +def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: """Create response header.""" headers = {} @@ -153,6 +156,9 @@ def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: continue headers[name] = value + if NO_STORE.match(path): + headers[CACHE_CONTROL] = "no-store, max-age=0" + return headers diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index f411b465774..16121393170 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -185,3 +185,21 @@ async def test_stream(hassio_client, aioclient_mock): aioclient_mock.get("http://127.0.0.1/test") await hassio_client.get("/api/hassio/test", data="test") assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader) + + +async def test_entrypoint_cache_control(hassio_client, aioclient_mock): + """Test that we return cache control for requests to the entrypoint only.""" + aioclient_mock.get("http://127.0.0.1/app/entrypoint.js") + aioclient_mock.get("http://127.0.0.1/app/entrypoint.fdhkusd8y43r.js") + + resp1 = await hassio_client.get("/api/hassio/app/entrypoint.js") + resp2 = await hassio_client.get("/api/hassio/app/entrypoint.fdhkusd8y43r.js") + + # Check we got right response + assert resp1.status == 200 + assert resp2.status == 200 + + assert len(aioclient_mock.mock_calls) == 2 + assert resp1.headers["Cache-Control"] == "no-store, max-age=0" + + assert "Cache-Control" not in resp2.headers From 4045eee2e5e6e3af6341e3b86471647e79c03f4c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Sep 2021 06:30:52 +0200 Subject: [PATCH 085/168] Correct sum statistics when only last_reset has changed (#55498) Co-authored-by: Paulus Schoutsen --- homeassistant/components/sensor/recorder.py | 60 +++++++++---- tests/components/sensor/test_recorder.py | 93 +++++++++++++++++++++ 2 files changed, 138 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index cc1b6865d81..558596fbc84 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -282,7 +282,22 @@ def reset_detected( return state < 0.9 * previous_state -def compile_statistics( +def _wanted_statistics( + entities: list[tuple[str, str, str | None]] +) -> dict[str, set[str]]: + """Prepare a dict with wanted statistics for entities.""" + wanted_statistics = {} + for entity_id, state_class, device_class in entities: + if device_class in DEVICE_CLASS_STATISTICS[state_class]: + wanted_statistics[entity_id] = DEVICE_CLASS_STATISTICS[state_class][ + device_class + ] + else: + wanted_statistics[entity_id] = DEFAULT_STATISTICS[state_class] + return wanted_statistics + + +def compile_statistics( # noqa: C901 hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> dict: """Compile statistics for all entities during start-end. @@ -293,17 +308,32 @@ def compile_statistics( entities = _get_entities(hass) + wanted_statistics = _wanted_statistics(entities) + # Get history between start and end - history_list = history.get_significant_states( # type: ignore - hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities] - ) + entities_full_history = [i[0] for i in entities if "sum" in wanted_statistics[i[0]]] + history_list = {} + if entities_full_history: + history_list = history.get_significant_states( # type: ignore + hass, + start - datetime.timedelta.resolution, + end, + entity_ids=entities_full_history, + significant_changes_only=False, + ) + entities_significant_history = [ + i[0] for i in entities if "sum" not in wanted_statistics[i[0]] + ] + if entities_significant_history: + _history_list = history.get_significant_states( # type: ignore + hass, + start - datetime.timedelta.resolution, + end, + entity_ids=entities_significant_history, + ) + history_list = {**history_list, **_history_list} for entity_id, state_class, device_class in entities: - if device_class in DEVICE_CLASS_STATISTICS[state_class]: - wanted_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class] - else: - wanted_statistics = DEFAULT_STATISTICS[state_class] - if entity_id not in history_list: continue @@ -336,21 +366,21 @@ def compile_statistics( # Set meta data result[entity_id]["meta"] = { "unit_of_measurement": unit, - "has_mean": "mean" in wanted_statistics, - "has_sum": "sum" in wanted_statistics, + "has_mean": "mean" in wanted_statistics[entity_id], + "has_sum": "sum" in wanted_statistics[entity_id], } # Make calculations stat: dict = {} - if "max" in wanted_statistics: + if "max" in wanted_statistics[entity_id]: stat["max"] = max(*itertools.islice(zip(*fstates), 1)) - if "min" in wanted_statistics: + if "min" in wanted_statistics[entity_id]: stat["min"] = min(*itertools.islice(zip(*fstates), 1)) - if "mean" in wanted_statistics: + if "mean" in wanted_statistics[entity_id]: stat["mean"] = _time_weighted_average(fstates, start, end) - if "sum" in wanted_statistics: + if "sum" in wanted_statistics[entity_id]: last_reset = old_last_reset = None new_state = old_state = None _sum = 0 diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index b3f0ab075c6..37a1001c4f5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -275,6 +275,77 @@ def test_compile_hourly_sum_statistics_amount( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("monetary", "EUR", "EUR", 1), + ("monetary", "SEK", "SEK", 1), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), + ], +) +def test_compile_hourly_sum_statistics_amount_reset_every_state_change( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + "last_reset": None, + } + seq = [10, 15, 15, 15, 20, 20, 20, 10] + # Make sure the sequence has consecutive equal states + assert seq[1] == seq[2] == seq[3] + + states = {"sensor.test1": []} + one = zero + for i in range(len(seq)): + one = one + timedelta(minutes=1) + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(one), + "state": approx(factor * seq[7]), + "sum": approx(factor * (sum(seq) - seq[0])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -1303,6 +1374,28 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): return four, eight, states +def record_meter_state(hass, zero, entity_id, _attributes, seq): + """Record test state. + + We inject a state update for meter sensor. + """ + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + attributes = dict(_attributes) + attributes["last_reset"] = zero.isoformat() + + states = {entity_id: []} + with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=zero): + states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) + + return states + + def record_states_partially_unavailable(hass, zero, entity_id, attributes): """Record some test states. From d4aadd8af06f84c4d167e8214a464f9d1c163f98 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Aug 2021 19:15:22 +0200 Subject: [PATCH 086/168] Improve log for sum statistics (#55502) --- homeassistant/components/sensor/recorder.py | 15 ++++++++++++++- tests/components/sensor/test_recorder.py | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 558596fbc84..0054b01abd2 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -406,6 +406,19 @@ def compile_statistics( # noqa: C901 and (last_reset := state.attributes.get("last_reset")) != old_last_reset ): + if old_state is None: + _LOGGER.info( + "Compiling initial sum statistics for %s, zero point set to %s", + entity_id, + fstate, + ) + else: + _LOGGER.info( + "Detected new cycle for %s, last_reset set to %s (old last_reset %s)", + entity_id, + last_reset, + old_last_reset, + ) reset = True elif old_state is None and last_reset is None: reset = True @@ -420,7 +433,7 @@ def compile_statistics( # noqa: C901 ): reset = True _LOGGER.info( - "Detected new cycle for %s, zero point set to %s (old zero point %s)", + "Detected new cycle for %s, value dropped from %s to %s", entity_id, fstate, new_state, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 37a1001c4f5..115473c23de 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -273,6 +273,9 @@ def test_compile_hourly_sum_statistics_amount( ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert "Detected new cycle for sensor.test1, last_reset set to" in caplog.text + assert "Compiling initial sum statistics for sensor.test1" in caplog.text + assert "Detected new cycle for sensor.test1, value dropped" not in caplog.text @pytest.mark.parametrize("state_class", ["measurement"]) @@ -424,6 +427,9 @@ def test_compile_hourly_sum_statistics_total_increasing( ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert "Detected new cycle for sensor.test1, last_reset set to" not in caplog.text + assert "Compiling initial sum statistics for sensor.test1" in caplog.text + assert "Detected new cycle for sensor.test1, value dropped" in caplog.text @pytest.mark.parametrize( From 05cf223146205699091aa03bca4e1fa77395e384 Mon Sep 17 00:00:00 2001 From: muppet3000 Date: Wed, 1 Sep 2021 06:18:20 +0100 Subject: [PATCH 087/168] Added trailing slash to US growatt URL (#55504) --- homeassistant/components/growatt_server/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 0b11e9994ca..e0297de5eff 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -7,7 +7,7 @@ DEFAULT_NAME = "Growatt" SERVER_URLS = [ "https://server.growatt.com/", - "https://server-us.growatt.com", + "https://server-us.growatt.com/", "http://server.smten.com/", ] From 22f745b17cf1928c8502edf4f7738ed38c70f3ac Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel <41558831+felipediel@users.noreply.github.com> Date: Wed, 1 Sep 2021 02:49:56 -0300 Subject: [PATCH 088/168] Fix BroadlinkSwitch._attr_assumed_state (#55505) --- homeassistant/components/broadlink/switch.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 9fb7215e2a9..5ed1e424f53 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -142,9 +142,6 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): super().__init__(device) self._command_on = command_on self._command_off = command_off - - self._attr_assumed_state = True - self._attr_device_class = DEVICE_CLASS_SWITCH self._attr_name = f"{device.name} Switch" async def async_added_to_hass(self): From ba9ef004c89a37bd7034d1c82a45f1752f9244df Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 1 Sep 2021 15:50:32 +1000 Subject: [PATCH 089/168] Add missing device class for temperature sensor in Advantage Air (#55508) --- homeassistant/components/advantage_air/sensor.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 5912101fd65..4f3258e824e 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -1,7 +1,11 @@ """Sensor platform for Advantage Air integration.""" import voluptuous as vol -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv, entity_platform @@ -138,11 +142,11 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): - """Representation of Advantage Air Zone wireless signal sensor.""" + """Representation of Advantage Air Zone temperature sensor.""" _attr_native_unit_of_measurement = TEMP_CELSIUS + _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_icon = "mdi:thermometer" _attr_entity_registry_enabled_default = False def __init__(self, instance, ac_key, zone_key): From a315fd059aa1b6ac4dba411ab40216c3a1fa1e65 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 Aug 2021 22:57:33 -0700 Subject: [PATCH 090/168] Bumped version to 2021.9.0b7 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6c096f06769..e4e84ccadca 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 3b9859940f857f646d09d2e989302370f4433e0e Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Wed, 1 Sep 2021 10:03:41 +0200 Subject: [PATCH 091/168] ESPHome light color mode use capabilities (#55206) Co-authored-by: Oxan van Leeuwen --- homeassistant/components/esphome/light.py | 195 +++++++++++++----- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 150 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 73339769121..9e7f544f610 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, cast -from aioesphomeapi import APIVersion, LightColorMode, LightInfo, LightState +from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -34,12 +34,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -59,20 +54,81 @@ async def async_setup_entry( ) -_COLOR_MODES: EsphomeEnumMapper[LightColorMode, str] = EsphomeEnumMapper( - { - LightColorMode.UNKNOWN: COLOR_MODE_UNKNOWN, - LightColorMode.ON_OFF: COLOR_MODE_ONOFF, - LightColorMode.BRIGHTNESS: COLOR_MODE_BRIGHTNESS, - LightColorMode.WHITE: COLOR_MODE_WHITE, - LightColorMode.COLOR_TEMPERATURE: COLOR_MODE_COLOR_TEMP, - LightColorMode.COLD_WARM_WHITE: COLOR_MODE_COLOR_TEMP, - LightColorMode.RGB: COLOR_MODE_RGB, - LightColorMode.RGB_WHITE: COLOR_MODE_RGBW, - LightColorMode.RGB_COLOR_TEMPERATURE: COLOR_MODE_RGBWW, - LightColorMode.RGB_COLD_WARM_WHITE: COLOR_MODE_RGBWW, - } -) +_COLOR_MODE_MAPPING = { + COLOR_MODE_ONOFF: [ + LightColorCapability.ON_OFF, + ], + COLOR_MODE_BRIGHTNESS: [ + LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + # for compatibility with older clients (2021.8.x) + LightColorCapability.BRIGHTNESS, + ], + COLOR_MODE_COLOR_TEMP: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_RGB: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB, + ], + COLOR_MODE_RGBW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE, + ], + COLOR_MODE_RGBWW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_WHITE: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.WHITE + ], +} + + +def _color_mode_to_ha(mode: int) -> str: + """Convert an esphome color mode to a HA color mode constant. + + Choses the color mode that best matches the feature-set. + """ + candidates = [] + for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items(): + for caps in cap_lists: + if caps == mode: + # exact match + return ha_mode + if (mode & caps) == caps: + # all requirements met + candidates.append((ha_mode, caps)) + + if not candidates: + return COLOR_MODE_UNKNOWN + + # choose the color mode with the most bits set + candidates.sort(key=lambda key: bin(key[1]).count("1")) + return candidates[-1][0] + + +def _filter_color_modes( + supported: list[int], features: LightColorCapability +) -> list[int]: + """Filter the given supported color modes, excluding all values that don't have the requested features.""" + return [mode for mode in supported if mode & features] # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property @@ -95,10 +151,17 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" data: dict[str, Any] = {"key": self._static_info.key, "state": True} + # The list of color modes that would fit this service call + color_modes = self._native_supported_color_modes + try_keep_current_mode = True + # rgb/brightness input is in range 0-255, but esphome uses 0-1 if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None: data["brightness"] = brightness_ha / 255 + color_modes = _filter_color_modes( + color_modes, LightColorCapability.BRIGHTNESS + ) if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None: rgb = tuple(x / 255 for x in rgb_ha) @@ -106,8 +169,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + try_keep_current_mode = False if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: # pylint: disable=invalid-name @@ -117,8 +180,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["white"] = w data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.RGB | LightColorCapability.WHITE + ) + try_keep_current_mode = False if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: # pylint: disable=invalid-name @@ -126,14 +191,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): color_bri = max(rgb) # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) - modes = self._native_supported_color_modes - if ( - self._supports_color_mode - and LightColorMode.RGB_COLD_WARM_WHITE in modes - ): + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + if _filter_color_modes(color_modes, LightColorCapability.COLD_WARM_WHITE): + # Device supports setting cwww values directly data["cold_white"] = cw data["warm_white"] = ww - target_mode = LightColorMode.RGB_COLD_WARM_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) else: # need to convert cw+ww part to white+color_temp white = data["white"] = max(cw, ww) @@ -142,11 +207,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): max_ct = self.max_mireds ct_ratio = ww / (cw + ww) data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) - target_mode = LightColorMode.RGB_COLOR_TEMPERATURE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.WHITE, + ) + try_keep_current_mode = False data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = target_mode if (flash := kwargs.get(ATTR_FLASH)) is not None: data["flash_length"] = FLASH_LENGTHS[flash] @@ -156,12 +223,15 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: data["color_temperature"] = color_temp - if self._supports_color_mode: - supported_modes = self._native_supported_color_modes - if LightColorMode.COLOR_TEMPERATURE in supported_modes: - data["color_mode"] = LightColorMode.COLOR_TEMPERATURE - elif LightColorMode.COLD_WARM_WHITE in supported_modes: - data["color_mode"] = LightColorMode.COLD_WARM_WHITE + if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLOR_TEMPERATURE + ) + else: + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) + try_keep_current_mode = False if (effect := kwargs.get(ATTR_EFFECT)) is not None: data["effect"] = effect @@ -171,7 +241,30 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # HA only sends `white` in turn_on, and reads total brightness through brightness property data["brightness"] = white_ha / 255 data["white"] = 1.0 - data["color_mode"] = LightColorMode.WHITE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE, + ) + try_keep_current_mode = False + + if self._supports_color_mode and color_modes: + # try the color mode with the least complexity (fewest capabilities set) + # popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671 + color_modes.sort(key=lambda mode: bin(mode).count("1")) + data["color_mode"] = color_modes[0] + if self._supports_color_mode and color_modes: + if ( + try_keep_current_mode + and self._state is not None + and self._state.color_mode in color_modes + ): + # if possible, stay with the color mode that is already set + data["color_mode"] = self._state.color_mode + else: + # otherwise try the color mode with the least complexity (fewest capabilities set) + # popcount with bin() function because it appears to be the best way: https://stackoverflow.com/a/9831671 + color_modes.sort(key=lambda mode: bin(mode).count("1")) + data["color_mode"] = color_modes[0] await self._client.light_command(**data) @@ -198,7 +291,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return None return next(iter(supported)) - return _COLOR_MODES.from_esphome(self._state.color_mode) + return _color_mode_to_ha(self._state.color_mode) @esphome_state_property def rgb_color(self) -> tuple[int, int, int] | None: @@ -227,9 +320,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" rgb = cast("tuple[int, int, int]", self.rgb_color) - if ( - not self._supports_color_mode - or self._state.color_mode != LightColorMode.RGB_COLD_WARM_WHITE + if not _filter_color_modes( + self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE ): # Try to reverse white + color temp to cwww min_ct = self._static_info.min_mireds @@ -262,7 +354,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return self._state.effect @property - def _native_supported_color_modes(self) -> list[LightColorMode]: + def _native_supported_color_modes(self) -> list[int]: return self._static_info.supported_color_modes_compat(self._api_version) @property @@ -272,7 +364,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # All color modes except UNKNOWN,ON_OFF support transition modes = self._native_supported_color_modes - if any(m not in (LightColorMode.UNKNOWN, LightColorMode.ON_OFF) for m in modes): + if any(m not in (0, LightColorCapability.ON_OFF) for m in modes): flags |= SUPPORT_TRANSITION if self._static_info.effects: flags |= SUPPORT_EFFECT @@ -281,7 +373,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property def supported_color_modes(self) -> set[str] | None: """Flag supported color modes.""" - return set(map(_COLOR_MODES.from_esphome, self._native_supported_color_modes)) + supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) + if COLOR_MODE_ONOFF in supported and len(supported) > 1: + supported.remove(COLOR_MODE_ONOFF) + if COLOR_MODE_BRIGHTNESS in supported and len(supported) > 1: + supported.remove(COLOR_MODE_BRIGHTNESS) + if COLOR_MODE_WHITE in supported and len(supported) == 1: + supported.remove(COLOR_MODE_WHITE) + return supported @property def effect_list(self) -> list[str]: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 96ac632d990..a78d2efb763 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==7.0.0"], + "requirements": ["aioesphomeapi==8.0.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index 611713006ff..ee9f09d69ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==7.0.0 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ec55d5184e..e83fdc13817 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==7.0.0 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1 From 576cece7a9f7daceef13086e25544e50f7e78f8e Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Wed, 1 Sep 2021 02:26:09 -0400 Subject: [PATCH 092/168] Fix None support_color_modes TypeError (#55497) * Fix None support_color_modes TypeError https://github.com/home-assistant/core/issues/55451 * Update __init__.py --- homeassistant/components/light/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 6865ae165bc..4a0025126c8 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -445,7 +445,11 @@ async def async_setup(hass, config): # noqa: C901 ) # If both white and brightness are specified, override white - if ATTR_WHITE in params and COLOR_MODE_WHITE in supported_color_modes: + if ( + supported_color_modes + and ATTR_WHITE in params + and COLOR_MODE_WHITE in supported_color_modes + ): params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE]) # Remove deprecated white value if the light supports color mode From af68802c17121257b404068236553c38d192a394 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Sep 2021 04:18:50 -0700 Subject: [PATCH 093/168] Tweaks for the iotawatt integration (#55510) --- homeassistant/components/iotawatt/sensor.py | 9 +++++++-- tests/components/iotawatt/test_sensor.py | 8 ++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 8a8c92a8c51..1b4c166eb27 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -8,7 +8,6 @@ from iotawattpy.sensor import Sensor from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -47,12 +46,14 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_CURRENT, + entity_registry_enabled_default=False, ), "Hz": IotaWattSensorEntityDescription( "Hz", native_unit_of_measurement=FREQUENCY_HERTZ, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "PF": IotaWattSensorEntityDescription( "PF", @@ -60,6 +61,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_POWER_FACTOR, value=lambda value: value * 100, + entity_registry_enabled_default=False, ), "Watts": IotaWattSensorEntityDescription( "Watts", @@ -70,7 +72,6 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { "WattHours": IotaWattSensorEntityDescription( "WattHours", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, ), "VA": IotaWattSensorEntityDescription( @@ -78,24 +79,28 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { native_unit_of_measurement=POWER_VOLT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "VAR": IotaWattSensorEntityDescription( "VAR", native_unit_of_measurement=VOLT_AMPERE_REACTIVE, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "VARh": IotaWattSensorEntityDescription( "VARh", native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, state_class=STATE_CLASS_MEASUREMENT, icon="mdi:flash", + entity_registry_enabled_default=False, ), "Volts": IotaWattSensorEntityDescription( "Volts", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_VOLTAGE, + entity_registry_enabled_default=False, ), } diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index 556da8cc2b0..a5fc2250b84 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -1,11 +1,7 @@ """Test setting up sensors.""" from datetime import timedelta -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DEVICE_CLASS_ENERGY, - STATE_CLASS_TOTAL_INCREASING, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -37,7 +33,7 @@ async def test_sensor_type_input(hass, mock_iotawatt): state = hass.states.get("sensor.my_sensor") assert state is not None assert state.state == "23" - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert ATTR_STATE_CLASS not in state.attributes assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY From 493309daa74214eb2b3bb41d5cce73c6da5a56bc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Sep 2021 19:40:48 +0200 Subject: [PATCH 094/168] Bumped version to 2021.9.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e4e84ccadca..9cd5ecc4fec 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 7193e82963bd4c4d8537423c1d136b7a621e4e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 2 Sep 2021 11:40:32 +0200 Subject: [PATCH 095/168] Bump pyuptimerobot to 21.9.0 (#55546) --- homeassistant/components/uptimerobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 279bf6eb43e..66b1dc9abe4 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -3,7 +3,7 @@ "name": "Uptime Robot", "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "requirements": [ - "pyuptimerobot==21.8.2" + "pyuptimerobot==21.9.0" ], "codeowners": [ "@ludeeus" diff --git a/requirements_all.txt b/requirements_all.txt index ee9f09d69ce..24793de6687 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1968,7 +1968,7 @@ pytrafikverket==0.1.6.2 pyudev==0.22.0 # homeassistant.components.uptimerobot -pyuptimerobot==21.8.2 +pyuptimerobot==21.9.0 # homeassistant.components.keyboard # pyuserinput==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e83fdc13817..23926a1a747 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1109,7 +1109,7 @@ pytradfri[async]==7.0.6 pyudev==0.22.0 # homeassistant.components.uptimerobot -pyuptimerobot==21.8.2 +pyuptimerobot==21.9.0 # homeassistant.components.vera pyvera==0.3.13 From 6aa771e5e826bd5bd1bd7b414d0fd93f7e2d26c6 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 2 Sep 2021 00:18:12 +0200 Subject: [PATCH 096/168] xiaomi_miio: bump python-miio dependency (#55549) --- homeassistant/components/xiaomi_miio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 18aa7f75ce1..28f3c2da0c5 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Miio", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.7"], + "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.8"], "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 24793de6687..c09604740ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1880,7 +1880,7 @@ python-juicenet==1.0.2 # python-lirc==1.2.3 # homeassistant.components.xiaomi_miio -python-miio==0.5.7 +python-miio==0.5.8 # homeassistant.components.mpd python-mpd2==3.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 23926a1a747..67e30937a72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1067,7 +1067,7 @@ python-izone==1.1.6 python-juicenet==1.0.2 # homeassistant.components.xiaomi_miio -python-miio==0.5.7 +python-miio==0.5.8 # homeassistant.components.nest python-nest==4.1.0 From 8f85472df3861304b21bb07fadbfc5693ba73752 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 1 Sep 2021 22:54:35 -0400 Subject: [PATCH 097/168] Pick right coordinator (#55555) --- homeassistant/components/zha/core/gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index d093c02d568..50da16802b3 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -174,7 +174,7 @@ class ZHAGateway: """Restore ZHA devices from zigpy application state.""" for zigpy_device in self.application_controller.devices.values(): zha_device = self._async_get_or_create_device(zigpy_device, restored=True) - if zha_device.nwk == 0x0000: + if zha_device.ieee == self.application_controller.ieee: self.coordinator_zha_device = zha_device zha_dev_entry = self.zha_storage.devices.get(str(zigpy_device.ieee)) delta_msg = "not known" From 89b7be52af8d4d1194e8d683f84341cbe7ca254c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 2 Sep 2021 13:53:38 +0200 Subject: [PATCH 098/168] Correct duplicate address. (#55578) --- homeassistant/components/modbus/validators.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index fdfffaebd61..a4177a7ff30 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -10,6 +10,8 @@ import voluptuous as vol from homeassistant.const import ( CONF_ADDRESS, + CONF_COMMAND_OFF, + CONF_COMMAND_ON, CONF_COUNT, CONF_HOST, CONF_NAME, @@ -201,15 +203,19 @@ def scan_interval_validator(config: dict) -> dict: def duplicate_entity_validator(config: dict) -> dict: """Control scan_interval.""" for hub_index, hub in enumerate(config): - addresses: set[str] = set() for component, conf_key in PLATFORMS: if conf_key not in hub: continue names: set[str] = set() errors: list[int] = [] + addresses: set[str] = set() for index, entry in enumerate(hub[conf_key]): name = entry[CONF_NAME] addr = str(entry[CONF_ADDRESS]) + if CONF_COMMAND_ON in entry: + addr += "_" + str(entry[CONF_COMMAND_ON]) + if CONF_COMMAND_OFF in entry: + addr += "_" + str(entry[CONF_COMMAND_OFF]) if CONF_SLAVE in entry: addr += "_" + str(entry[CONF_SLAVE]) if addr in addresses: From 1592408a4be7d096c14e478de3bcbebf19d46da9 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 2 Sep 2021 18:09:30 +0200 Subject: [PATCH 099/168] Downgrade sqlite-libs on docker image (#55591) --- Dockerfile | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Dockerfile b/Dockerfile index 6bcb080a06e..c802ba9b273 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,21 @@ RUN \ -e ./homeassistant \ && python3 -m compileall homeassistant/homeassistant +# Fix Bug with Alpine 3.14 and sqlite 3.35 +# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524 +ARG BUILD_ARCH +RUN \ + if [ "${BUILD_ARCH}" = "amd64" ]; then \ + export APK_ARCH=x86_64; \ + elif [ "${BUILD_ARCH}" = "i386" ]; then \ + export APK_ARCH=x86; \ + else \ + export APK_ARCH=${BUILD_ARCH}; \ + fi \ + && curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \ + && apk add --no-cache sqlite-libs-3.34.1-r0.apk \ + && rm -f sqlite-libs-3.34.1-r0.apk + # Home Assistant S6-Overlay COPY rootfs / From 5b705dba36739a69beeb0ba09de858e96d65d8ba Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Sep 2021 12:50:56 -0700 Subject: [PATCH 100/168] Bumped version to 2021.9.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9cd5ecc4fec..1ec5ae0e311 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 88ca83a30bed4704573cb2a4e3c943449810a61d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Sep 2021 06:06:07 -1000 Subject: [PATCH 101/168] Ignore missing devices when in ssdp unsee (#55553) --- homeassistant/components/ssdp/__init__.py | 2 +- tests/components/ssdp/test_init.py | 113 +++++++++++++++++++++- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 6e9441534ab..63ad6acb181 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -289,7 +289,7 @@ class Scanner: def _async_unsee(self, header_st: str | None, header_location: str | None) -> None: """If we see a device in a new location, unsee the original location.""" if header_st is not None: - self.seen.remove((header_st, header_location)) + self.seen.discard((header_st, header_location)) async def _async_process_entry(self, headers: Mapping[str, str]) -> None: """Process SSDP entries.""" diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 43b7fd98cd0..b285d3b0f3c 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -991,9 +991,6 @@ async def test_location_change_evicts_prior_location_from_cache(hass, aioclient_ @callback def _callback(*_): - import pprint - - pprint.pprint(mock_ssdp_response) hass.async_create_task(listener.async_callback(mock_ssdp_response)) listener.async_start = _async_callback @@ -1050,3 +1047,113 @@ async def test_location_change_evicts_prior_location_from_cache(hass, aioclient_ mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] == mock_good_ip_ssdp_response["location"] ) + + +async def test_location_change_with_overlapping_udn_st_combinations( + hass, aioclient_mock +): + """Test handling when a UDN and ST broadcast multiple locations.""" + mock_get_ssdp = { + "test_integration": [ + {"manufacturer": "test_manufacturer", "modelName": "test_model"} + ] + } + + hue_response = """ + + +test_manufacturer +test_model + + + """ + + aioclient_mock.get( + "http://192.168.72.1:49154/wps_device.xml", + text=hue_response.format(ip_address="192.168.72.1"), + ) + aioclient_mock.get( + "http://192.168.72.1:49152/wps_device.xml", + text=hue_response.format(ip_address="192.168.72.1"), + ) + ssdp_response_without_location = { + "ST": "upnp:rootdevice", + "_udn": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6", + "USN": "uuid:a793d3cc-e802-44fb-84f4-5a30f33115b6::upnp:rootdevice", + "EXT": "", + } + + port_49154_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://192.168.72.1:49154/wps_device.xml"}, + ) + port_49152_response = CaseInsensitiveDict( + **ssdp_response_without_location, + **{"LOCATION": "http://192.168.72.1:49152/wps_device.xml"}, + ) + mock_ssdp_response = port_49154_response + + def _generate_fake_ssdp_listener(*args, **kwargs): + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + pass + + @callback + def _callback(*_): + hass.async_create_task(listener.async_callback(mock_ssdp_response)) + + listener.async_start = _async_callback + listener.async_search = _callback + return listener + + with patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value=mock_get_ssdp, + ), patch( + "homeassistant.components.ssdp.SSDPListener", + new=_generate_fake_ssdp_listener, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_init: + assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "test_integration" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == port_49154_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = port_49152_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=400)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "test_integration" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == port_49152_response["location"] + ) + + mock_init.reset_mock() + mock_ssdp_response = port_49154_response + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=600)) + await hass.async_block_till_done() + assert mock_init.mock_calls[0][1][0] == "test_integration" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_SSDP + } + assert ( + mock_init.mock_calls[0][2]["data"][ssdp.ATTR_SSDP_LOCATION] + == port_49154_response["location"] + ) From 2ef607651d6244f7b77d20b82ec205883c5f2499 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 3 Sep 2021 08:05:37 +0200 Subject: [PATCH 102/168] Disable observer for USB on containers (#55570) * Disable observer for USB on containers * remove operating system test Co-authored-by: J. Nick Koston --- homeassistant/components/usb/__init__.py | 2 +- tests/components/usb/test_init.py | 49 ------------------------ 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 679f2e1caa2..13f18216cca 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -112,7 +112,7 @@ class USBDiscovery: if not sys.platform.startswith("linux"): return info = await system_info.async_get_system_info(self.hass) - if info.get("docker") and not info.get("hassio"): + if info.get("docker"): return from pyudev import ( # pylint: disable=import-outside-toplevel diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 6ba21222052..b09dad9ebe4 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -52,55 +52,6 @@ def mock_venv(): yield -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) -async def test_discovered_by_observer_before_started(hass, operating_system): - """Test a device is discovered by the observer before started.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="add", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) - return MagicMock() - - new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - - mock_comports = [ - MagicMock( - device=slae_sh_device.device, - vid=12345, - pid=12345, - serial_number=slae_sh_device.serial_number, - manufacturer=slae_sh_device.manufacturer, - description=slae_sh_device.description, - ) - ] - - with patch( - "homeassistant.components.usb.async_get_usb", return_value=new_usb - ), patch( - "homeassistant.components.usb.comports", return_value=mock_comports - ), patch( - "pyudev.MonitorObserver", new=_create_mock_monitor_observer - ): - assert await async_setup_component(hass, "usb", {"usb": {}}) - await hass.async_block_till_done() - - with patch("homeassistant.components.usb.comports", return_value=[]), patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_flow: - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "test1" - - @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Only works on linux", From da3ee9ed4b959b377a1447e39cc0da1321f0f897 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 3 Sep 2021 14:11:19 +0200 Subject: [PATCH 103/168] Fix CONFIG_SCHEMA validation in Speedtest.net (#55612) --- homeassistant/components/speedtestdotnet/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index b049b3a2d2c..62f7b2dbd73 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -32,6 +32,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] + CONFIG_SCHEMA = vol.Schema( vol.All( # Deprecated in Home Assistant 2021.6 @@ -46,8 +48,8 @@ CONFIG_SCHEMA = vol.Schema( ): cv.positive_time_period, vol.Optional(CONF_MANUAL, default=False): cv.boolean, vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES) - ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + CONF_MONITORED_CONDITIONS, default=list(SENSOR_KEYS) + ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_KEYS))]), } ) }, From 93c2a7dd708f0c0a3775b4acc1dcd2c6f218b333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Sep 2021 17:11:03 -1000 Subject: [PATCH 104/168] Narrow zwave_js USB discovery (#55613) - Avoid triggering discovery when we can know in advance the device is not a Z-Wave stick --- homeassistant/components/zwave_js/config_flow.py | 5 ----- homeassistant/components/zwave_js/manifest.json | 2 +- homeassistant/generated/usb.py | 3 ++- tests/components/zwave_js/test_config_flow.py | 5 +---- 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 55266d02389..a4f7343f0e0 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -326,11 +326,6 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): device = discovery_info["device"] manufacturer = discovery_info["manufacturer"] description = discovery_info["description"] - # The Nortek sticks are a special case since they - # have a Z-Wave and a Zigbee radio. We need to reject - # the Zigbee radio. - if vid == "10C4" and pid == "8A2A" and "Z-Wave" not in description: - return self.async_abort(reason="not_zwave_device") # Zooz uses this vid/pid, but so do 2652 sticks if vid == "10C4" and pid == "EA60" and "2652" in description: return self.async_abort(reason="not_zwave_device") diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 7953e33d6e3..ad8ec22befb 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "usb": [ {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, - {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, + {"vid":"10C4","pid":"8A2A","description":"*z-wave*","known_devices":["Nortek HUSBZB-1"]}, {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} ] } diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 477a762ae62..844c09fea40 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -32,7 +32,8 @@ USB = [ { "domain": "zwave_js", "vid": "10C4", - "pid": "8A2A" + "pid": "8A2A", + "description": "*z-wave*" }, { "domain": "zwave_js", diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 5e994a2ac7a..757dc6d5364 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -756,10 +756,7 @@ async def test_usb_discovery_already_running(hass, supervisor, addon_running): @pytest.mark.parametrize( "discovery_info", - [ - NORTEK_ZIGBEE_DISCOVERY_INFO, - CP2652_ZIGBEE_DISCOVERY_INFO, - ], + [CP2652_ZIGBEE_DISCOVERY_INFO], ) async def test_abort_usb_discovery_aborts_specific_devices( hass, supervisor, addon_options, discovery_info From ff91ff4cd25a631bb62f943e5116ea2d1abdea0a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 00:02:45 -0700 Subject: [PATCH 105/168] Fix template sensor availability (#55635) --- .../components/template/trigger_entity.py | 2 +- tests/components/template/test_sensor.py | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 84ad4072b66..c80620b0453 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -69,7 +69,7 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): # We make a copy so our initial render is 'unknown' and not 'unavailable' self._rendered = dict(self._static_rendered) - self._parse_result = set() + self._parse_result = {CONF_AVAILABILITY} @property def name(self): diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index df5c43aa58b..a606c2ec62b 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1038,6 +1038,7 @@ async def test_trigger_entity(hass): "unique_id": "via_list-id", "device_class": "battery", "unit_of_measurement": "%", + "availability": "{{ True }}", "state": "{{ trigger.event.data.beer + 1 }}", "picture": "{{ '/local/dogs.png' }}", "icon": "{{ 'mdi:pirate' }}", @@ -1197,3 +1198,44 @@ async def test_config_top_level(hass): assert state.state == "5" assert state.attributes["device_class"] == "battery" assert state.attributes["state_class"] == "measurement" + + +async def test_trigger_entity_available(hass): + """Test trigger entity availability works.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Maybe Available", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ trigger.event.data.beer }}", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.maybe_available") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.maybe_available") + assert state.state == "2" + + hass.bus.async_fire("test_event", {"beer": 1}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.maybe_available") + assert state.state == "unavailable" From 80c074ca826f00f9b3995ff4026b9f51a8734825 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 10:15:57 -0700 Subject: [PATCH 106/168] Better handle invalid trigger config (#55637) --- .../components/device_automation/trigger.py | 11 +++++--- .../components/hue/device_trigger.py | 16 +++++++----- homeassistant/scripts/check_config.py | 4 +++ tests/scripts/test_check_config.py | 25 +++++++++++-------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index a1b6e53c5c3..1a63dcb9e9b 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -7,6 +7,8 @@ from homeassistant.components.device_automation import ( ) from homeassistant.const import CONF_DOMAIN +from .exceptions import InvalidDeviceAutomationConfig + # mypy: allow-untyped-defs, no-check-untyped-defs TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) @@ -17,10 +19,13 @@ async def async_validate_trigger_config(hass, config): platform = await async_get_device_automation_platform( hass, config[CONF_DOMAIN], "trigger" ) - if hasattr(platform, "async_validate_trigger_config"): - return await getattr(platform, "async_validate_trigger_config")(hass, config) + if not hasattr(platform, "async_validate_trigger_config"): + return platform.TRIGGER_SCHEMA(config) - return platform.TRIGGER_SCHEMA(config) + try: + return await getattr(platform, "async_validate_trigger_config")(hass, config) + except InvalidDeviceAutomationConfig as err: + raise vol.Invalid(str(err) or "Invalid trigger configuration") from err async def async_attach_trigger(hass, config, action, automation_info): diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index ea91cd07d8c..77561e47dc5 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -118,12 +118,16 @@ async def async_validate_trigger_config(hass, config): trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - if ( - not device - or device.model not in REMOTES - or trigger not in REMOTES[device.model] - ): - raise InvalidDeviceAutomationConfig + if not device: + raise InvalidDeviceAutomationConfig("Device {config[CONF_DEVICE_ID]} not found") + + if device.model not in REMOTES: + raise InvalidDeviceAutomationConfig( + f"Device model {device.model} is not a remote" + ) + + if trigger not in REMOTES[device.model]: + raise InvalidDeviceAutomationConfig("Device does not support trigger {trigger}") return config diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 551f91b2b54..0ff339169a7 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -14,6 +14,7 @@ from unittest.mock import patch from homeassistant import core from homeassistant.config import get_default_config_dir from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import area_registry, device_registry, entity_registry from homeassistant.helpers.check_config import async_check_ha_config_file from homeassistant.util.yaml import Secrets import homeassistant.util.yaml.loader as yaml_loader @@ -229,6 +230,9 @@ async def async_check_config(config_dir): """Check the HA config.""" hass = core.HomeAssistant() hass.config.config_dir = config_dir + await area_registry.async_load(hass) + await device_registry.async_load(hass) + await entity_registry.async_load(hass) components = await async_check_ha_config_file(hass) await hass.async_stop(force=True) return components diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index ea6048dfc9e..1a96568f8ef 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -27,14 +27,23 @@ async def apply_stop_hass(stop_hass): """Make sure all hass are stopped.""" +@pytest.fixture +def mock_is_file(): + """Mock is_file.""" + # All files exist except for the old entity registry file + with patch( + "os.path.isfile", lambda path: not path.endswith("entity_registry.yaml") + ): + yield + + def normalize_yaml_files(check_dict): """Remove configuration path from ['yaml_files'].""" root = get_test_config_dir() return [key.replace(root, "...") for key in sorted(check_dict["yaml_files"].keys())] -@patch("os.path.isfile", return_value=True) -def test_bad_core_config(isfile_patch, loop): +def test_bad_core_config(mock_is_file, loop): """Test a bad core config setup.""" files = {YAML_CONFIG_FILE: BAD_CORE_CONFIG} with patch_yaml_files(files): @@ -43,8 +52,7 @@ def test_bad_core_config(isfile_patch, loop): assert res["except"]["homeassistant"][1] == {"unit_system": "bad"} -@patch("os.path.isfile", return_value=True) -def test_config_platform_valid(isfile_patch, loop): +def test_config_platform_valid(mock_is_file, loop): """Test a valid platform setup.""" files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: demo"} with patch_yaml_files(files): @@ -57,8 +65,7 @@ def test_config_platform_valid(isfile_patch, loop): assert len(res["yaml_files"]) == 1 -@patch("os.path.isfile", return_value=True) -def test_component_platform_not_found(isfile_patch, loop): +def test_component_platform_not_found(mock_is_file, loop): """Test errors if component or platform not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} @@ -89,8 +96,7 @@ def test_component_platform_not_found(isfile_patch, loop): assert len(res["yaml_files"]) == 1 -@patch("os.path.isfile", return_value=True) -def test_secrets(isfile_patch, loop): +def test_secrets(mock_is_file, loop): """Test secrets config checking method.""" secrets_path = get_test_config_dir("secrets.yaml") @@ -121,8 +127,7 @@ def test_secrets(isfile_patch, loop): ] -@patch("os.path.isfile", return_value=True) -def test_package_invalid(isfile_patch, loop): +def test_package_invalid(mock_is_file, loop): """Test an invalid package.""" files = { YAML_CONFIG_FILE: BASE_CONFIG + (" packages:\n p1:\n" ' group: ["a"]') From f61a1ecae7a33f2bd2971bc58de878db2af23934 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 09:04:50 -0700 Subject: [PATCH 107/168] Guard for unexpected exceptions in device automation (#55639) * Guard for unexpected exceptions in device automation * merge Co-authored-by: J. Nick Koston --- .../components/device_automation/__init__.py | 9 ++++++- .../components/device_automation/test_init.py | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 945774da0b4..89a3f8f6408 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable, Mapping from functools import wraps +import logging from types import ModuleType from typing import Any @@ -27,7 +28,6 @@ from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig DOMAIN = "device_automation" - DEVICE_TRIGGER_BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "device", @@ -174,6 +174,13 @@ async def _async_get_device_automations( device_results, InvalidDeviceAutomationConfig ): continue + if isinstance(device_results, Exception): + logging.getLogger(__name__).error( + "Unexpected error fetching device %ss", + automation_type, + exc_info=device_results, + ) + continue for automation in device_results: combined_results[automation["device_id"]].append(automation) diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 13190ed4b32..93d64e97959 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,4 +1,6 @@ """The test for light device automation.""" +from unittest.mock import patch + import pytest from homeassistant.components import device_automation @@ -443,6 +445,28 @@ async def test_async_get_device_automations_all_devices_action( assert len(result[device_entry.id]) == 3 +async def test_async_get_device_automations_all_devices_action_exception_throw( + hass, device_reg, entity_reg, caplog +): + """Test we get can fetch all the actions when no device id is passed and can handle one throwing an exception.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + with patch( + "homeassistant.components.light.device_trigger.async_get_triggers", + side_effect=KeyError, + ): + result = await device_automation.async_get_device_automations(hass, "trigger") + assert device_entry.id in result + assert len(result[device_entry.id]) == 0 + assert "KeyError" in caplog.text + + async def test_websocket_get_trigger_capabilities( hass, hass_ws_client, device_reg, entity_reg ): From b36e86d95cc3dad86280268a1584aa4ee9ab1926 Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Fri, 3 Sep 2021 15:04:56 +0300 Subject: [PATCH 108/168] Fix Starline sensor state AttributeError (#55654) * Fix starline sensors state * Black --- homeassistant/components/starline/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 26834cc384c..9ce3aa3bc08 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -90,7 +90,8 @@ async def async_setup_entry(hass, entry, async_add_entities): sensor for device in account.api.devices.values() for description in SENSOR_TYPES - if (sensor := StarlineSensor(account, device, description)).state is not None + if (sensor := StarlineSensor(account, device, description)).native_value + is not None ] async_add_entities(entities) From 8592d94a3cafd6d05b136301ea74d98adb5b0aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 3 Sep 2021 18:17:41 +0200 Subject: [PATCH 109/168] Fix hdmi_cec switches (#55666) --- homeassistant/components/hdmi_cec/switch.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index 3764766275e..a268d7cfe79 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.const import STATE_OFF, STATE_ON from . import ATTR_NEW, CecEntity @@ -34,17 +35,25 @@ class CecSwitchEntity(CecEntity, SwitchEntity): def turn_on(self, **kwargs) -> None: """Turn device on.""" self._device.turn_on() - self._attr_is_on = True + self._state = STATE_ON self.schedule_update_ha_state(force_refresh=False) def turn_off(self, **kwargs) -> None: """Turn device off.""" self._device.turn_off() - self._attr_is_on = False + self._state = STATE_OFF self.schedule_update_ha_state(force_refresh=False) def toggle(self, **kwargs): """Toggle the entity.""" self._device.toggle() - self._attr_is_on = not self._attr_is_on + if self._state == STATE_ON: + self._state = STATE_OFF + else: + self._state = STATE_ON self.schedule_update_ha_state(force_refresh=False) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._state == STATE_ON From 3008ff03b29bf2de6fa1f21d5ff970daeb5ffb2f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 09:53:47 -0700 Subject: [PATCH 110/168] Guard for doRollover failing (#55669) --- homeassistant/bootstrap.py | 6 +++++- tests/test_bootstrap.py | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index f1136123999..66312f7283a 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -342,7 +342,11 @@ def async_enable_logging( err_log_path, backupCount=1 ) - err_handler.doRollover() + try: + err_handler.doRollover() + except OSError as err: + _LOGGER.error("Error rolling over log file: %s", err) + err_handler.setLevel(logging.INFO if verbose else logging.WARNING) err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 929cbbf6e81..3eeb06d056c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -56,11 +56,14 @@ async def test_home_assistant_core_config_validation(hass): assert result is None -async def test_async_enable_logging(hass): +async def test_async_enable_logging(hass, caplog): """Test to ensure logging is migrated to the queue handlers.""" with patch("logging.getLogger"), patch( "homeassistant.bootstrap.async_activate_log_queue_handler" - ) as mock_async_activate_log_queue_handler: + ) as mock_async_activate_log_queue_handler, patch( + "homeassistant.bootstrap.logging.handlers.RotatingFileHandler.doRollover", + side_effect=OSError, + ): bootstrap.async_enable_logging(hass) mock_async_activate_log_queue_handler.assert_called_once() mock_async_activate_log_queue_handler.reset_mock() @@ -75,6 +78,8 @@ async def test_async_enable_logging(hass): for f in glob.glob("testing_config/home-assistant.log*"): os.remove(f) + assert "Error rolling over log file" in caplog.text + async def test_load_hassio(hass): """Test that we load Hass.io component.""" From e3405d226a9ced2dca67cddc6b074eb33a5f8921 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Sep 2021 10:16:36 -0700 Subject: [PATCH 111/168] Bumped version to 2021.9.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1ec5ae0e311..ae0e3255beb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From a37c3af2b40f9b6ee7bdbf3b8a7011d53a01765c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Sep 2021 00:23:35 -0700 Subject: [PATCH 112/168] better detect legacy eagly devices (#55706) --- homeassistant/components/rainforest_eagle/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index 76ddb2d25d7..70c2bddb4b3 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -54,7 +54,7 @@ async def async_get_type(hass, cloud_id, install_code, host): meters = await hub.get_device_list() except aioeagle.BadAuth as err: raise InvalidAuth from err - except aiohttp.ClientError: + except (KeyError, aiohttp.ClientError): # This can happen if it's an eagle-100 meters = None From a4f2c5583da896f3272f6a2565b7e3cb0cc461e7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 4 Sep 2021 10:47:42 +0200 Subject: [PATCH 113/168] Handle negative numbers in sensor long term statistics (#55708) * Handle negative numbers in sensor long term statistics * Use negative states in tests --- homeassistant/components/sensor/recorder.py | 44 +++++++------- tests/components/sensor/test_recorder.py | 64 ++++++++++----------- 2 files changed, 52 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 0054b01abd2..8bf251ffb18 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -129,13 +129,6 @@ def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str | None]]: return entity_ids -# Faster than try/except -# From https://stackoverflow.com/a/23639915 -def _is_number(s: str) -> bool: # pylint: disable=invalid-name - """Return True if string is a number.""" - return s.replace(".", "", 1).isdigit() - - def _time_weighted_average( fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime ) -> float: @@ -190,9 +183,13 @@ def _normalize_states( if device_class not in UNIT_CONVERSIONS: # We're not normalizing this device class, return the state as they are - fstates = [ - (float(el.state), el) for el in entity_history if _is_number(el.state) - ] + fstates = [] + for state in entity_history: + try: + fstates.append((float(state.state), state)) + except ValueError: + continue + if fstates: all_units = _get_units(fstates) if len(all_units) > 1: @@ -220,23 +217,22 @@ def _normalize_states( fstates = [] for state in entity_history: - # Exclude non numerical states from statistics - if not _is_number(state.state): - continue + try: + fstate = float(state.state) + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + # Exclude unsupported units from statistics + if unit not in UNIT_CONVERSIONS[device_class]: + if WARN_UNSUPPORTED_UNIT not in hass.data: + hass.data[WARN_UNSUPPORTED_UNIT] = set() + if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: + hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) + _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + continue - fstate = float(state.state) - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - # Exclude unsupported units from statistics - if unit not in UNIT_CONVERSIONS[device_class]: - if WARN_UNSUPPORTED_UNIT not in hass.data: - hass.data[WARN_UNSUPPORTED_UNIT] = set() - if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: - hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) - _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) + except ValueError: continue - fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) - return DEVICE_CLASS_UNITS[device_class], fstates diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 115473c23de..aeeab317eb1 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -50,18 +50,18 @@ GAS_SENSOR_ATTRIBUTES = { @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, "%", "%", 16.440677, 10, 30), - ("battery", "%", "%", 16.440677, 10, 30), - ("battery", None, None, 16.440677, 10, 30), - ("humidity", "%", "%", 16.440677, 10, 30), - ("humidity", None, None, 16.440677, 10, 30), - ("pressure", "Pa", "Pa", 16.440677, 10, 30), - ("pressure", "hPa", "Pa", 1644.0677, 1000, 3000), - ("pressure", "mbar", "Pa", 1644.0677, 1000, 3000), - ("pressure", "inHg", "Pa", 55674.53, 33863.89, 101591.67), - ("pressure", "psi", "Pa", 113354.48, 68947.57, 206842.71), - ("temperature", "°C", "°C", 16.440677, 10, 30), - ("temperature", "°F", "°C", -8.644068, -12.22222, -1.111111), + (None, "%", "%", 13.050847, -10, 30), + ("battery", "%", "%", 13.050847, -10, 30), + ("battery", None, None, 13.050847, -10, 30), + ("humidity", "%", "%", 13.050847, -10, 30), + ("humidity", None, None, 13.050847, -10, 30), + ("pressure", "Pa", "Pa", 13.050847, -10, 30), + ("pressure", "hPa", "Pa", 1305.0847, -1000, 3000), + ("pressure", "mbar", "Pa", 1305.0847, -1000, 3000), + ("pressure", "inHg", "Pa", 44195.25, -33863.89, 101591.67), + ("pressure", "psi", "Pa", 89982.42, -68947.57, 206842.71), + ("temperature", "°C", "°C", 13.050847, -10, 30), + ("temperature", "°F", "°C", -10.52731, -23.33333, -1.111111), ], ) def test_compile_hourly_statistics( @@ -155,8 +155,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(zero), - "mean": approx(16.440677966101696), - "min": approx(10.0), + "mean": approx(13.050847), + "min": approx(-10.0), "max": approx(30.0), "last_reset": None, "state": None, @@ -167,8 +167,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test6", "start": process_timestamp_to_utc_isoformat(zero), - "mean": approx(16.440677966101696), - "min": approx(10.0), + "mean": approx(13.050847), + "min": approx(-10.0), "max": approx(30.0), "last_reset": None, "state": None, @@ -179,8 +179,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes { "statistic_id": "sensor.test7", "start": process_timestamp_to_utc_isoformat(zero), - "mean": approx(16.440677966101696), - "min": approx(10.0), + "mean": approx(13.050847), + "min": approx(-10.0), "max": approx(30.0), "last_reset": None, "state": None, @@ -988,10 +988,10 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes): @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, None, None, 16.440677, 10, 30), - (None, "%", "%", 16.440677, 10, 30), - ("battery", "%", "%", 16.440677, 10, 30), - ("battery", None, None, 16.440677, 10, 30), + (None, None, None, 13.050847, -10, 30), + (None, "%", "%", 13.050847, -10, 30), + ("battery", "%", "%", 13.050847, -10, 30), + ("battery", None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_1( @@ -1074,10 +1074,10 @@ def test_compile_hourly_statistics_changing_units_1( @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, None, None, 16.440677, 10, 30), - (None, "%", "%", 16.440677, 10, 30), - ("battery", "%", "%", 16.440677, 10, 30), - ("battery", None, None, 16.440677, 10, 30), + (None, None, None, 13.050847, -10, 30), + (None, "%", "%", 13.050847, -10, 30), + ("battery", "%", "%", 13.050847, -10, 30), + ("battery", None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_2( @@ -1119,10 +1119,10 @@ def test_compile_hourly_statistics_changing_units_2( @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, None, None, 16.440677, 10, 30), - (None, "%", "%", 16.440677, 10, 30), - ("battery", "%", "%", 16.440677, 10, 30), - ("battery", None, None, 16.440677, 10, 30), + (None, None, None, 13.050847, -10, 30), + (None, "%", "%", 13.050847, -10, 30), + ("battery", "%", "%", 13.050847, -10, 30), + ("battery", None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_3( @@ -1203,7 +1203,7 @@ def test_compile_hourly_statistics_changing_units_3( @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ - (None, None, None, 16.440677, 10, 30), + (None, None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_statistics( @@ -1309,7 +1309,7 @@ def record_states(hass, zero, entity_id, attributes): states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): - states[entity_id].append(set_state(entity_id, "10", attributes=attributes)) + states[entity_id].append(set_state(entity_id, "-10", attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=two): states[entity_id].append(set_state(entity_id, "15", attributes=attributes)) From 9d5431fba13e524f51f18fb0dcd45d2cbbdaf7cf Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 4 Sep 2021 22:56:59 +0200 Subject: [PATCH 114/168] Handle Fritz InternalError (#55711) --- homeassistant/components/fritz/sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index bc579b1125e..53efc7a83f3 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -9,6 +9,7 @@ from fritzconnection.core.exceptions import ( FritzActionError, FritzActionFailedError, FritzConnectionException, + FritzInternalError, FritzServiceError, ) from fritzconnection.lib.fritzstatus import FritzStatus @@ -273,7 +274,12 @@ async def async_setup_entry( "GetInfo", ) dsl = dslinterface["NewEnable"] - except (FritzActionError, FritzActionFailedError, FritzServiceError): + except ( + FritzInternalError, + FritzActionError, + FritzActionFailedError, + FritzServiceError, + ): pass for sensor_type, sensor_data in SENSOR_DATA.items(): From eb48e75fc561d07d2c5b3493cc5846e9e009593d Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 4 Sep 2021 13:18:23 +0200 Subject: [PATCH 115/168] Fix LIFX firmware version information (#55713) --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 847c75b4fa5..2dc46615f3a 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.6.10", "aiolifx_effects==0.2.2"], + "requirements": ["aiolifx==0.7.0", "aiolifx_effects==0.2.2"], "homekit": { "models": ["LIFX"] }, diff --git a/requirements_all.txt b/requirements_all.txt index c09604740ab..11f70be6c1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aiokafka==0.6.0 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.6.10 +aiolifx==0.7.0 # homeassistant.components.lifx aiolifx_effects==0.2.2 From 04816fe26d93bc8c878319b13fc931f9c2dd3967 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 4 Sep 2021 22:58:34 +0200 Subject: [PATCH 116/168] Fix SamsungTV sendkey when not connected (#55723) --- homeassistant/components/samsungtv/bridge.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 095d3339428..0d00a0cb94f 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -240,7 +240,8 @@ class SamsungTVLegacyBridge(SamsungTVBridge): def _send_key(self, key): """Send the key using legacy protocol.""" - self._get_remote().control(key) + if remote := self._get_remote(): + remote.control(key) def stop(self): """Stop Bridge.""" @@ -315,7 +316,8 @@ class SamsungTVWSBridge(SamsungTVBridge): """Send the key using websocket protocol.""" if key == "KEY_POWEROFF": key = "KEY_POWER" - self._get_remote().send_key(key) + if remote := self._get_remote(): + remote.send_key(key) def _get_remote(self, avoid_open: bool = False): """Create or return a remote control instance.""" From 37cf295e20892d6ba5e0f64f5ac15d5b30268ddd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Sep 2021 14:13:37 -0700 Subject: [PATCH 117/168] Bumped version to 2021.9.3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ae0e3255beb..c7bac6e5d1d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From bd53185bedfa3d41d70ba57b32653210faca0d57 Mon Sep 17 00:00:00 2001 From: Joshi <42069141+Joshi425@users.noreply.github.com> Date: Mon, 6 Sep 2021 21:03:46 +0200 Subject: [PATCH 118/168] Fix switch name attribute for thinkingcleaner (#55730) --- homeassistant/components/thinkingcleaner/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 75cfc51a511..cad94b72023 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -80,7 +80,7 @@ class ThinkingCleanerSwitch(SwitchEntity): self.last_lock_time = None self.graceful_state = False - self._attr_name = f"{tc_object} {description.name}" + self._attr_name = f"{tc_object.name} {description.name}" def lock_update(self): """Lock the update since TC clean takes some time to update.""" From eb70354ee72209904a74d3da0f1166c08637e70f Mon Sep 17 00:00:00 2001 From: Tatham Oddie Date: Tue, 7 Sep 2021 06:39:39 +1000 Subject: [PATCH 119/168] Fix logbook entity_matches_only query mode (#55761) The string matching template needs to match the same compact JSON format as the data is now written in. --- homeassistant/components/logbook/__init__.py | 2 +- tests/components/logbook/test_init.py | 39 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 8992ca2d7fc..8bbfd08314d 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -48,7 +48,7 @@ from homeassistant.helpers.integration_platform import ( from homeassistant.loader import bind_hass import homeassistant.util.dt as dt_util -ENTITY_ID_JSON_TEMPLATE = '"entity_id": ?"{}"' +ENTITY_ID_JSON_TEMPLATE = '"entity_id":"{}"' ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": ?"([^"]+)"') DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"') ICON_JSON_EXTRACT = re.compile('"icon": ?"([^"]+)"') diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 3dab7e6c2fb..b95ef2e148a 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -1289,6 +1289,45 @@ async def test_logbook_entity_matches_only(hass, hass_client): assert json_dict[1]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" +async def test_custom_log_entry_discoverable_via_entity_matches_only(hass, hass_client): + """Test if a custom log entry is later discoverable via entity_matches_only.""" + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", {}) + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + logbook.async_log_entry( + hass, + "Alarm", + "is triggered", + "switch", + "switch.test_switch", + ) + await hass.async_block_till_done() + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + client = await hass_client() + + # Today time 00:00:00 + start = dt_util.utcnow().date() + start_date = datetime(start.year, start.month, start.day) + + # Test today entries with filter by end_time + end_time = start + timedelta(hours=24) + response = await client.get( + f"/api/logbook/{start_date.isoformat()}?end_time={end_time.isoformat()}&entity=switch.test_switch&entity_matches_only" + ) + assert response.status == 200 + json_dict = await response.json() + + assert len(json_dict) == 1 + + assert json_dict[0]["name"] == "Alarm" + assert json_dict[0]["message"] == "is triggered" + assert json_dict[0]["entity_id"] == "switch.test_switch" + + async def test_logbook_entity_matches_only_multiple(hass, hass_client): """Test the logbook view with a multiple entities and entity_matches_only.""" await hass.async_add_executor_job(init_recorder_component, hass) From a6bb0eadca90c4ec538db53925aadd08659616b9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 6 Sep 2021 22:40:15 +0200 Subject: [PATCH 120/168] Allow same IP if ports are different on modbus (#55766) --- homeassistant/components/modbus/validators.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index a4177a7ff30..cced6f780ea 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -242,7 +242,10 @@ def duplicate_modbus_validator(config: list) -> list: errors = [] for index, hub in enumerate(config): name = hub.get(CONF_NAME, DEFAULT_HUB) - host = hub[CONF_PORT] if hub[CONF_TYPE] == SERIAL else hub[CONF_HOST] + if hub[CONF_TYPE] == SERIAL: + host = hub[CONF_PORT] + else: + host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" if host in hosts: err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" _LOGGER.warning(err) From 823fd60991eef4e63f6b76dcd6d3367097607a0c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 6 Sep 2021 22:35:40 +0200 Subject: [PATCH 121/168] Allow same address different register types in modbus (#55767) --- homeassistant/components/modbus/validators.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index cced6f780ea..df4fe3c1e62 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -25,9 +25,11 @@ from homeassistant.const import ( from .const import ( CONF_DATA_TYPE, + CONF_INPUT_TYPE, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_NONE, + CONF_WRITE_TYPE, DATA_TYPE_CUSTOM, DATA_TYPE_FLOAT, DATA_TYPE_FLOAT16, @@ -212,6 +214,10 @@ def duplicate_entity_validator(config: dict) -> dict: for index, entry in enumerate(hub[conf_key]): name = entry[CONF_NAME] addr = str(entry[CONF_ADDRESS]) + if CONF_INPUT_TYPE in entry: + addr += "_" + str(entry[CONF_INPUT_TYPE]) + elif CONF_WRITE_TYPE in entry: + addr += "_" + str(entry[CONF_WRITE_TYPE]) if CONF_COMMAND_ON in entry: addr += "_" + str(entry[CONF_COMMAND_ON]) if CONF_COMMAND_OFF in entry: From 899d8164b05cb10c965bddadfb93e27a79c690ad Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Sep 2021 15:30:03 +0200 Subject: [PATCH 122/168] Fix xiaomi miio Air Quality Monitor initialization (#55773) --- homeassistant/components/xiaomi_miio/const.py | 7 +++ .../components/xiaomi_miio/sensor.py | 59 ++++++++++--------- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index b670582c069..dce1fdd5e95 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -116,6 +116,13 @@ MODEL_AIRQUALITYMONITOR_B1 = "cgllc.airmonitor.b1" MODEL_AIRQUALITYMONITOR_S1 = "cgllc.airmonitor.s1" MODEL_AIRQUALITYMONITOR_CGDN1 = "cgllc.airm.cgdn1" +MODELS_AIR_QUALITY_MONITOR = [ + MODEL_AIRQUALITYMONITOR_V1, + MODEL_AIRQUALITYMONITOR_B1, + MODEL_AIRQUALITYMONITOR_S1, + MODEL_AIRQUALITYMONITOR_CGDN1, +] + # Light Models MODELS_LIGHT_EYECARE = ["philips.light.sread1"] MODELS_LIGHT_CEILING = ["philips.light.ceiling", "philips.light.zyceiling"] diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 63535e88a2d..3a50ffe89c0 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -66,6 +66,7 @@ from .const import ( MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, + MODELS_AIR_QUALITY_MONITOR, MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, @@ -371,23 +372,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] model = config_entry.data[CONF_MODEL] - device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE) - sensors = [] + if model in (MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, MODEL_FAN_P5): return - if model in MODEL_TO_SENSORS_MAP: - sensors = MODEL_TO_SENSORS_MAP[model] - elif model in MODELS_HUMIDIFIER_MIOT: - sensors = HUMIDIFIER_MIOT_SENSORS - elif model in MODELS_HUMIDIFIER_MJJSQ: - sensors = HUMIDIFIER_MJJSQ_SENSORS - elif model in MODELS_HUMIDIFIER_MIIO: - sensors = HUMIDIFIER_MIIO_SENSORS - elif model in MODELS_PURIFIER_MIIO: - sensors = PURIFIER_MIIO_SENSORS - elif model in MODELS_PURIFIER_MIOT: - sensors = PURIFIER_MIOT_SENSORS - else: + + if model in MODELS_AIR_QUALITY_MONITOR: unique_id = config_entry.unique_id name = config_entry.title _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) @@ -399,19 +388,35 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name, device, config_entry, unique_id, description ) ) - for sensor, description in SENSOR_TYPES.items(): - if sensor not in sensors: - continue - entities.append( - XiaomiGenericSensor( - f"{config_entry.title} {description.name}", - device, - config_entry, - f"{sensor}_{config_entry.unique_id}", - hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], - description, + else: + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + sensors = [] + if model in MODEL_TO_SENSORS_MAP: + sensors = MODEL_TO_SENSORS_MAP[model] + elif model in MODELS_HUMIDIFIER_MIOT: + sensors = HUMIDIFIER_MIOT_SENSORS + elif model in MODELS_HUMIDIFIER_MJJSQ: + sensors = HUMIDIFIER_MJJSQ_SENSORS + elif model in MODELS_HUMIDIFIER_MIIO: + sensors = HUMIDIFIER_MIIO_SENSORS + elif model in MODELS_PURIFIER_MIIO: + sensors = PURIFIER_MIIO_SENSORS + elif model in MODELS_PURIFIER_MIOT: + sensors = PURIFIER_MIOT_SENSORS + + for sensor, description in SENSOR_TYPES.items(): + if sensor not in sensors: + continue + entities.append( + XiaomiGenericSensor( + f"{config_entry.title} {description.name}", + device, + config_entry, + f"{sensor}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, + ) ) - ) async_add_entities(entities) From 00878467ccb184a3c180c63d40a3c64b5be6acbc Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 6 Sep 2021 11:26:20 +0100 Subject: [PATCH 123/168] Fix incomfort min/max temperatures (#55806) --- homeassistant/components/incomfort/water_heater.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 84ed0212d3b..469e3571334 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -67,13 +67,13 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity): @property def min_temp(self) -> float: - """Return max valid temperature that can be set.""" - return 80.0 + """Return min valid temperature that can be set.""" + return 30.0 @property def max_temp(self) -> float: """Return max valid temperature that can be set.""" - return 30.0 + return 80.0 @property def temperature_unit(self) -> str: From 7a5bc2784a11a07bdc6dbf3ec38baa8b7fa5472a Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Mon, 6 Sep 2021 14:53:03 -0400 Subject: [PATCH 124/168] Upgrade pymazda to 0.2.1 (#55820) --- homeassistant/components/mazda/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json index cc12653f5cb..7eb85f722ae 100644 --- a/homeassistant/components/mazda/manifest.json +++ b/homeassistant/components/mazda/manifest.json @@ -3,7 +3,7 @@ "name": "Mazda Connected Services", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mazda", - "requirements": ["pymazda==0.2.0"], + "requirements": ["pymazda==0.2.1"], "codeowners": ["@bdr99"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 11f70be6c1c..107c67a9cf6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1599,7 +1599,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.2.0 +pymazda==0.2.1 # homeassistant.components.mediaroom pymediaroom==0.6.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67e30937a72..a86f425ab8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -921,7 +921,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.2.0 +pymazda==0.2.1 # homeassistant.components.melcloud pymelcloud==2.5.3 From d6eda653027a9cb9de3b793ee191bd8bb10c0a23 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 6 Sep 2021 22:37:12 +0200 Subject: [PATCH 125/168] Bump zwave-js-server-python to 0.30.0 (#55831) --- homeassistant/components/zwave_js/cover.py | 2 +- .../components/zwave_js/discovery_data_template.py | 4 ++-- homeassistant/components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/sensor.py | 2 +- homeassistant/components/zwave_js/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 8 ++++---- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 7fceaf64c0e..9060e13a9a5 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -5,7 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const.command_class.barrior_operator import BarrierState +from zwave_js_server.const.command_class.barrier_operator import BarrierState from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 974cd2bfa44..5c3c49e894f 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -32,8 +32,8 @@ from zwave_js_server.const.command_class.multilevel_sensor import ( ) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue, get_value_id -from zwave_js_server.util.command_class import ( - get_meter_scale_type, +from zwave_js_server.util.command_class.meter import get_meter_scale_type +from zwave_js_server.util.command_class.multilevel_sensor import ( get_multilevel_sensor_type, ) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index ad8ec22befb..c7b2b35837b 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.29.1"], + "requirements": ["zwave-js-server-python==0.30.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 09d44f7f24a..6532da8a5e0 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -15,7 +15,7 @@ from zwave_js_server.const.command_class.meter import ( ) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue -from zwave_js_server.util.command_class import get_meter_type +from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index bd86a3b8377..44aa3a5566f 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -5,7 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const.command_class.barrior_operator import ( +from zwave_js_server.const.command_class.barrier_operator import ( BarrierEventSignalingSubsystemState, ) diff --git a/requirements_all.txt b/requirements_all.txt index 107c67a9cf6..6562f28a5c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2489,4 +2489,4 @@ zigpy==0.37.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.1 +zwave-js-server-python==0.30.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a86f425ab8f..5b8a0dbb6ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1400,4 +1400,4 @@ zigpy-znp==0.5.4 zigpy==0.37.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.29.1 +zwave-js-server-python==0.30.0 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index ee05724a9cb..b3bb924413d 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -338,7 +338,7 @@ async def test_add_node_secure( assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "controller.begin_inclusion", - "options": {"inclusionStrategy": InclusionStrategy.SECURITY_S0}, + "options": {"strategy": InclusionStrategy.SECURITY_S0}, } client.async_send_command.reset_mock() @@ -363,7 +363,7 @@ async def test_add_node( assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "controller.begin_inclusion", - "options": {"inclusionStrategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.INSECURE}, } event = Event( @@ -671,7 +671,7 @@ async def test_replace_failed_node_secure( assert client.async_send_command.call_args[0][0] == { "command": "controller.replace_failed_node", "nodeId": nortek_thermostat.node_id, - "options": {"inclusionStrategy": InclusionStrategy.SECURITY_S0}, + "options": {"strategy": InclusionStrategy.SECURITY_S0}, } client.async_send_command.reset_mock() @@ -720,7 +720,7 @@ async def test_replace_failed_node( assert client.async_send_command.call_args[0][0] == { "command": "controller.replace_failed_node", "nodeId": nortek_thermostat.node_id, - "options": {"inclusionStrategy": InclusionStrategy.INSECURE}, + "options": {"strategy": InclusionStrategy.INSECURE}, } client.async_send_command.reset_mock() From 3df6dfecab3ff3ac9036455e9e7c85adf92b3b56 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Sep 2021 12:33:34 +0200 Subject: [PATCH 126/168] Fix a lazy preset mode update for Xiaomi Miio fans (#55837) --- homeassistant/components/xiaomi_miio/fan.py | 27 ++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 42828943d93..70f51019ec9 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -405,36 +405,42 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode elif self._model == MODEL_AIRPURIFIER_PRO_V7: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO_V7 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode elif self._model in [MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON self._preset_modes = PRESET_MODES_AIRPURIFIER_2S self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode elif self._model in MODELS_PURIFIER_MIOT: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIOT self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT self._preset_modes = PRESET_MODES_AIRPURIFIER_MIOT self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE self._speed_count = 3 + self._operation_mode_class = AirpurifierMiotOperationMode elif self._model == MODEL_AIRPURIFIER_V3: self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 self._preset_modes = PRESET_MODES_AIRPURIFIER_V3 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode else: self._device_features = FEATURE_FLAGS_AIRPURIFIER_MIIO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER self._preset_modes = PRESET_MODES_AIRPURIFIER self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 + self._operation_mode_class = AirpurifierOperationMode self._state_attrs.update( {attribute: None for attribute in self._available_attributes} @@ -446,7 +452,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): def preset_mode(self): """Get the active preset mode.""" if self._state: - preset_mode = AirpurifierOperationMode(self._state_attrs[ATTR_MODE]).name + preset_mode = self._operation_mode_class(self._mode).name return preset_mode if preset_mode in self._preset_modes else None return None @@ -455,7 +461,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): def percentage(self): """Return the current percentage based speed.""" if self._state: - mode = AirpurifierOperationMode(self._state_attrs[ATTR_MODE]) + mode = self._operation_mode_class(self._state_attrs[ATTR_MODE]) if mode in self.REVERSE_SPEED_MODE_MAPPING: return ranged_value_to_percentage( (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] @@ -479,7 +485,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, - AirpurifierOperationMode(self.SPEED_MODE_MAPPING[speed_mode]), + self._operation_mode_class(self.SPEED_MODE_MAPPING[speed_mode]), ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -490,11 +496,13 @@ class XiaomiAirPurifier(XiaomiGenericDevice): if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, self.PRESET_MODE_MAPPING[preset_mode], - ) + ): + self._mode = self._operation_mode_class[preset_mode].value + self.async_write_ha_state() async def async_set_extra_features(self, features: int = 1): """Set the extra features.""" @@ -538,15 +546,6 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): return None - @property - def preset_mode(self): - """Get the active preset mode.""" - if self._state: - preset_mode = AirpurifierMiotOperationMode(self._mode).name - return preset_mode if preset_mode in self._preset_modes else None - - return None - async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan. From 5f289434d3d8e7dc4a525921abc553db93953780 Mon Sep 17 00:00:00 2001 From: mrwhite31 <46862347+mrwhite31@users.noreply.github.com> Date: Mon, 6 Sep 2021 15:11:12 +0200 Subject: [PATCH 127/168] Fix typo in in rfxtrx Barometer sensor (#55839) Fix typo in sensor.py to fix barometer unavailability --- homeassistant/components/rfxtrx/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 7ce986d7082..fd3be53bfda 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -75,7 +75,7 @@ class RfxtrxSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES = ( RfxtrxSensorEntityDescription( - key="Barameter", + key="Barometer", device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PRESSURE_HPA, From 8523f569c087470403e9856412415865c67746f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 6 Sep 2021 22:36:45 +0200 Subject: [PATCH 128/168] Surepetcare, bug fix (#55842) --- homeassistant/components/surepetcare/__init__.py | 2 +- tests/components/surepetcare/conftest.py | 12 ++++++++---- tests/components/surepetcare/test_sensor.py | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 58890090d57..00c45701423 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -144,7 +144,7 @@ class SurePetcareAPI: """Get the latest data from Sure Petcare.""" try: - self.states = await self.surepy.get_entities() + self.states = await self.surepy.get_entities(refresh=True) except SurePetcareError as error: _LOGGER.error("Unable to fetch data: %s", error) return diff --git a/tests/components/surepetcare/conftest.py b/tests/components/surepetcare/conftest.py index 43738f22587..cecdaababa9 100644 --- a/tests/components/surepetcare/conftest.py +++ b/tests/components/surepetcare/conftest.py @@ -7,12 +7,16 @@ from surepy import MESTART_RESOURCE from . import MOCK_API_DATA +async def _mock_call(method, resource): + if method == "GET" and resource == MESTART_RESOURCE: + return {"data": MOCK_API_DATA} + + @pytest.fixture async def surepetcare(): """Mock the SurePetcare for easier testing.""" - with patch("surepy.SureAPIClient", autospec=True) as mock_client_class, patch( - "surepy.find_token" - ): + with patch("surepy.SureAPIClient", autospec=True) as mock_client_class: client = mock_client_class.return_value - client.resources = {MESTART_RESOURCE: {"data": MOCK_API_DATA}} + client.resources = {} + client.call = _mock_call yield client diff --git a/tests/components/surepetcare/test_sensor.py b/tests/components/surepetcare/test_sensor.py index 8e7160364ea..cbf69bb97dc 100644 --- a/tests/components/surepetcare/test_sensor.py +++ b/tests/components/surepetcare/test_sensor.py @@ -12,7 +12,7 @@ EXPECTED_ENTITY_IDS = { } -async def test_binary_sensors(hass, surepetcare) -> None: +async def test_sensors(hass, surepetcare) -> None: """Test the generation of unique ids.""" assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) await hass.async_block_till_done() From 450652a501cc25ca0cbaf58ddc1db81c51dafbe2 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Sep 2021 22:36:18 +0200 Subject: [PATCH 129/168] Fix target humidity step for Xiaomi MJJSQ humidifiers (#55858) --- homeassistant/components/xiaomi_miio/humidifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index aa26faae2b3..584d5caf6b5 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -210,7 +210,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): self._available_modes = AVAILABLE_MODES_MJJSQ self._min_humidity = 30 self._max_humidity = 80 - self._humidity_steps = 10 + self._humidity_steps = 100 else: self._available_modes = AVAILABLE_MODES_OTHER self._min_humidity = 30 From be0f767c348e2ff2b665bbe25ded844f7950b529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Sep 2021 10:35:24 -1000 Subject: [PATCH 130/168] Fix exception during rediscovery of ignored zha config entries (#55859) Fixes #55709 --- homeassistant/components/zha/config_flow.py | 4 +- tests/components/zha/test_config_flow.py | 59 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 4bf255e95a0..b98878da776 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -108,7 +108,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured( updates={ CONF_DEVICE: { - **current_entry.data[CONF_DEVICE], + **current_entry.data.get(CONF_DEVICE, {}), CONF_DEVICE_PATH: dev_path, }, } @@ -172,7 +172,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured( updates={ CONF_DEVICE: { - **current_entry.data[CONF_DEVICE], + **current_entry.data.get(CONF_DEVICE, {}), CONF_DEVICE_PATH: device_path, }, } diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 732b7cf440d..f551e9dac1f 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -113,6 +113,34 @@ async def test_discovery_via_zeroconf_ip_change(detect_mock, hass): } +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_zeroconf_ip_change_ignored(detect_mock, hass): + """Test zeroconf flow that was ignored gets updated.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="tube_zb_gw_cc2652p2_poe", + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + service_info = { + "host": "192.168.1.22", + "port": 6053, + "hostname": "tube_zb_gw_cc2652p2_poe.local.", + "properties": {"address": "tube_zb_gw_cc2652p2_poe.local"}, + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_DEVICE] == { + CONF_DEVICE_PATH: "socket://192.168.1.22:6638", + } + + @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_via_usb(detect_mock, hass): """Test usb flow -- radio detected.""" @@ -317,6 +345,37 @@ async def test_discovery_via_usb_deconz_ignored(detect_mock, hass): assert result["step_id"] == "confirm" +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_zha_ignored_updates(detect_mock, hass): + """Test usb flow that was ignored gets updated.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + data={}, + unique_id="AAAA:AAAA_1234_test_zigbee radio", + ) + entry.add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_DEVICE] == { + CONF_DEVICE_PATH: "/dev/ttyZIGBEE", + } + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_already_setup(detect_mock, hass): From 68131a5c0017ac4a93eb9cd476c9a7d5e4bee289 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 6 Sep 2021 21:41:01 +0100 Subject: [PATCH 131/168] Integration Sensor unit of measurement overwrite (#55869) --- .../components/integration/sensor.py | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index b8e72c3be5c..cd3e376b792 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -110,16 +110,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._method = integration_method self._name = name if name is not None else f"{source_entity} integral" - - if unit_of_measurement is None: - self._unit_template = ( - f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}" - ) - # we postpone the definition of unit_of_measurement to later - self._unit_of_measurement = None - else: - self._unit_of_measurement = unit_of_measurement - + self._unit_template = ( + f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}" + ) + self._unit_of_measurement = unit_of_measurement self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] self._attr_state_class = STATE_CLASS_TOTAL_INCREASING @@ -135,10 +129,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity): _LOGGER.warning("Could not restore last state: %s", err) else: self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) - - self._unit_of_measurement = state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT - ) + if self._unit_of_measurement is None: + self._unit_of_measurement = state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) @callback def calc_integration(event): From 823c3735cef51c0260641587d3f97c15a72df430 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 6 Sep 2021 13:41:39 -0700 Subject: [PATCH 132/168] Bumped version to 2021.9.4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c7bac6e5d1d..edba8a1a739 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 7f3adce675af39892f6afb79d0daeb53fc22a606 Mon Sep 17 00:00:00 2001 From: RDFurman Date: Tue, 7 Sep 2021 08:32:26 -0600 Subject: [PATCH 133/168] Try to avoid rate limiting in honeywell (#55304) * Limit parallel update and sleep loop * Use asyncio sleep instead * Extract sleep to const for testing * Make loop sleep 0 in test --- homeassistant/components/honeywell/__init__.py | 17 ++++++++++------- homeassistant/components/honeywell/climate.py | 4 +++- tests/components/honeywell/test_init.py | 4 ++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 29f0dbb8392..03dc9ea9c8c 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1,4 +1,5 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" +import asyncio from datetime import timedelta import somecomfort @@ -9,7 +10,8 @@ from homeassistant.util import Throttle from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) +UPDATE_LOOP_SLEEP_TIME = 5 +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) PLATFORMS = ["climate"] @@ -42,7 +44,7 @@ async def async_setup_entry(hass, config): return False data = HoneywellData(hass, client, username, password, devices) - await data.update() + await data.async_update() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = data hass.config_entries.async_setup_platforms(config, PLATFORMS) @@ -102,18 +104,19 @@ class HoneywellData: self.devices = devices return True - def _refresh_devices(self): + async def _refresh_devices(self): """Refresh each enabled device.""" for device in self.devices: - device.refresh() + await self._hass.async_add_executor_job(device.refresh) + await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME) @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def update(self) -> None: + async def async_update(self) -> None: """Update the state.""" retries = 3 while retries > 0: try: - await self._hass.async_add_executor_job(self._refresh_devices) + await self._refresh_devices() break except ( somecomfort.client.APIRateLimited, @@ -124,7 +127,7 @@ class HoneywellData: if retries == 0: raise exp - result = await self._hass.async_add_executor_job(self._retry()) + result = await self._retry() if not result: raise exp diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 230aa8ec424..8088a73506d 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -107,6 +107,8 @@ HW_FAN_MODE_TO_HA = { "follow schedule": FAN_AUTO, } +PARALLEL_UPDATES = 1 + async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): """Set up the Honeywell thermostat.""" @@ -384,4 +386,4 @@ class HoneywellUSThermostat(ClimateEntity): async def async_update(self): """Get the latest state from the service.""" - await self._data.update() + await self._data.async_update() diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 7cc6b64cd63..619d770c59e 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -1,11 +1,14 @@ """Test honeywell setup process.""" +from unittest.mock import patch + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry): """Initialize the config entry.""" config_entry.add_to_hass(hass) @@ -15,6 +18,7 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry): assert hass.states.async_entity_ids_count() == 1 +@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) async def test_setup_multiple_thermostats( hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device ) -> None: From 9ecb75dc70348bfdf79015de16377aba152f2336 Mon Sep 17 00:00:00 2001 From: Pascal Winters Date: Wed, 8 Sep 2021 05:54:40 +0200 Subject: [PATCH 134/168] Edit unit of measurement for gas/electricity supplier prices (#55771) Co-authored-by: Paulus Schoutsen --- .../components/dsmr_reader/definitions.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 533b2f0dd38..1e9834e7e5e 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Callable +from typing import Callable, Final from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -24,6 +24,9 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) +PRICE_EUR_KWH: Final = f"EUR/{ENERGY_KILO_WATT_HOUR}" +PRICE_EUR_M3: Final = f"EUR/{VOLUME_CUBIC_METERS}" + def dsmr_transform(value): """Transform DSMR version value to right format.""" @@ -301,31 +304,31 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", name="Low tariff delivered price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", name="High tariff delivered price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", name="Low tariff returned price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", name="High tariff returned price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_gas", name="Gas price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_M3, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/fixed_cost", From e7fd24eadee037e8d2bb2199251dc03341ceadc3 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 7 Sep 2021 07:12:54 +0100 Subject: [PATCH 135/168] Integration Sensor Initial State (#55875) * initial state is UNAVAILABLE * update tests --- homeassistant/components/integration/sensor.py | 11 ++++++++--- tests/components/integration/test_sensor.py | 7 +++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index cd3e376b792..d36e2da54c1 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -106,7 +106,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): """Initialize the integration sensor.""" self._sensor_source_id = source_entity self._round_digits = round_digits - self._state = 0 + self._state = STATE_UNAVAILABLE self._method = integration_method self._name = name if name is not None else f"{source_entity} integral" @@ -187,7 +187,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity): except AssertionError as err: _LOGGER.error("Could not calculate integral: %s", err) else: - self._state += integral + if isinstance(self._state, Decimal): + self._state += integral + else: + self._state = integral self.async_write_ha_state() async_track_state_change_event( @@ -202,7 +205,9 @@ class IntegrationSensor(RestoreEntity, SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - return round(self._state, self._round_digits) + if isinstance(self._state, Decimal): + return round(self._state, self._round_digits) + return self._state @property def native_unit_of_measurement(self): diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index e8aaf906936..58df0a53a00 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -81,7 +81,6 @@ async def test_restore_state(hass: HomeAssistant) -> None: "platform": "integration", "name": "integration", "source": "sensor.power", - "unit": ENERGY_KILO_WATT_HOUR, "round": 2, } } @@ -114,7 +113,6 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: "platform": "integration", "name": "integration", "source": "sensor.power", - "unit": ENERGY_KILO_WATT_HOUR, } } @@ -123,9 +121,10 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: state = hass.states.get("sensor.integration") assert state - assert state.state == "0" - assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR + assert state.state == "unavailable" + assert state.attributes.get("unit_of_measurement") is None assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING + assert "device_class" not in state.attributes From 980fcef36f63e5d78cdb449dea4bc808918e24ca Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 7 Sep 2021 11:34:41 +0200 Subject: [PATCH 136/168] Fix available property for Xiaomi Miio fan platform (#55889) * Fix available * Suggested change --- homeassistant/components/xiaomi_miio/fan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 70f51019ec9..ae25fd389b1 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -301,7 +301,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): @property def available(self): """Return true when state is known.""" - return self._available + return super().available and self._available @property def extra_state_attributes(self): From 21ebf4f3e61547a2d6cd1ea0550f3bce6148f9e4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 7 Sep 2021 17:50:07 -0400 Subject: [PATCH 137/168] Allow multiple template.select platform entries (#55908) --- homeassistant/components/template/select.py | 26 +++++++-------- tests/components/template/test_select.py | 36 +++++++++++++++++++-- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 944c80cbfa4..96e86e8caec 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -46,27 +46,27 @@ SELECT_SCHEMA = vol.Schema( async def _async_create_entities( - hass: HomeAssistant, entities: list[dict[str, Any]], unique_id_prefix: str | None + hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None ) -> list[TemplateSelect]: """Create the Template select.""" - for entity in entities: - unique_id = entity.get(CONF_UNIQUE_ID) - + entities = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) if unique_id and unique_id_prefix: unique_id = f"{unique_id_prefix}-{unique_id}" - - return [ + entities.append( TemplateSelect( hass, - entity.get(CONF_NAME, DEFAULT_NAME), - entity[CONF_STATE], - entity.get(CONF_AVAILABILITY), - entity[CONF_SELECT_OPTION], - entity[ATTR_OPTIONS], - entity.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), + definition.get(CONF_NAME, DEFAULT_NAME), + definition[CONF_STATE], + definition.get(CONF_AVAILABILITY), + definition[CONF_SELECT_OPTION], + definition[ATTR_OPTIONS], + definition.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), unique_id, ) - ] + ) + return entities async def async_setup_platform( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index eb94a9284f4..ca4a30b1cd6 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -60,6 +60,38 @@ async def test_missing_optional_config(hass, calls): _verify(hass, "a", ["a", "b"]) +async def test_multiple_configs(hass, calls): + """Test: multiple select entities get created.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "select": [ + { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + }, + { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + }, + ] + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, "a", ["a", "b"]) + _verify(hass, "a", ["a", "b"], f"{_TEST_SELECT}_2") + + async def test_missing_required_keys(hass, calls): """Test: missing required fields will fail.""" with assert_setup_component(0, "template"): @@ -250,9 +282,9 @@ async def test_trigger_select(hass): assert events[0].event_type == "test_number_event" -def _verify(hass, expected_current_option, expected_options): +def _verify(hass, expected_current_option, expected_options, entity_name=_TEST_SELECT): """Verify select's state.""" - state = hass.states.get(_TEST_SELECT) + state = hass.states.get(entity_name) attributes = state.attributes assert state.state == str(expected_current_option) assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options From 19d7cb4439a3d2e45d4bbffa4f8ab867089f13ad Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 8 Sep 2021 17:54:44 +0300 Subject: [PATCH 138/168] Bump aioswitcher to 2.0.5 (#55934) --- homeassistant/components/switcher_kis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index e982855e497..33ec7a67d92 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,7 +3,7 @@ "name": "Switcher", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "codeowners": ["@tomerfi","@thecode"], - "requirements": ["aioswitcher==2.0.4"], + "requirements": ["aioswitcher==2.0.5"], "iot_class": "local_push", "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 6562f28a5c3..53ab6ff1197 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.4 +aioswitcher==2.0.5 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b8a0dbb6ba..859bdbf2d8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.4 +aioswitcher==2.0.5 # homeassistant.components.syncthing aiosyncthing==0.5.1 From 8ee4b49aa9b894b18c93d3a5acc46ebddaee2743 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 16:55:40 +0200 Subject: [PATCH 139/168] Do not let one bad statistic spoil the bunch (#55942) --- .../components/recorder/statistics.py | 10 +- tests/components/recorder/test_statistics.py | 105 +++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ddc542d23b7..21c286f8eb6 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -8,6 +8,7 @@ import logging from typing import TYPE_CHECKING, Any, Callable from sqlalchemy import bindparam +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext import baked from sqlalchemy.orm.scoping import scoped_session @@ -215,7 +216,14 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: metadata_id = _update_or_add_metadata( instance.hass, session, entity_id, stat["meta"] ) - session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + try: + session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + except SQLAlchemyError: + _LOGGER.exception( + "Unexpected exception when inserting statistics %s:%s ", + metadata_id, + stat, + ) session.add(StatisticsRuns(start=start)) return True diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 318d82422d7..ac1681e2628 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -3,11 +3,15 @@ from datetime import timedelta from unittest.mock import patch, sentinel +import pytest from pytest import approx from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat +from homeassistant.components.recorder.models import ( + Statistics, + process_timestamp_to_utc_isoformat, +) from homeassistant.components.recorder.statistics import ( get_last_statistics, statistics_during_period, @@ -94,6 +98,105 @@ def test_compile_hourly_statistics(hass_recorder): assert stats == {} +@pytest.fixture +def mock_sensor_statistics(): + """Generate some fake statistics.""" + sensor_stats = { + "meta": {"unit_of_measurement": "dogs", "has_mean": True, "has_sum": False}, + "stat": {}, + } + + def get_fake_stats(): + return { + "sensor.test1": sensor_stats, + "sensor.test2": sensor_stats, + "sensor.test3": sensor_stats, + } + + with patch( + "homeassistant.components.sensor.recorder.compile_statistics", + return_value=get_fake_stats(), + ): + yield + + +@pytest.fixture +def mock_from_stats(): + """Mock out Statistics.from_stats.""" + counter = 0 + real_from_stats = Statistics.from_stats + + def from_stats(metadata_id, start, stats): + nonlocal counter + if counter == 0 and metadata_id == 2: + counter += 1 + return None + return real_from_stats(metadata_id, start, stats) + + with patch( + "homeassistant.components.recorder.statistics.Statistics.from_stats", + side_effect=from_stats, + autospec=True, + ): + yield + + +def test_compile_hourly_statistics_exception( + hass_recorder, mock_sensor_statistics, mock_from_stats +): + """Test exception handling when compiling hourly statistics.""" + + def mock_from_stats(): + raise ValueError + + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + + now = dt_util.utcnow() + recorder.do_adhoc_statistics(period="hourly", start=now) + recorder.do_adhoc_statistics(period="hourly", start=now + timedelta(hours=1)) + wait_recording_done(hass) + expected_1 = { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(now), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + expected_2 = { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(now + timedelta(hours=1)), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + expected_stats1 = [ + {**expected_1, "statistic_id": "sensor.test1"}, + {**expected_2, "statistic_id": "sensor.test1"}, + ] + expected_stats2 = [ + {**expected_2, "statistic_id": "sensor.test2"}, + ] + expected_stats3 = [ + {**expected_1, "statistic_id": "sensor.test3"}, + {**expected_2, "statistic_id": "sensor.test3"}, + ] + + stats = statistics_during_period(hass, now) + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.test3": expected_stats3, + } + + def test_rename_entity(hass_recorder): """Test statistics is migrated when entity_id is changed.""" hass = hass_recorder() From 81462d8655bb1ce76f5c0604afbd2ac555771edd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 17:05:16 +0200 Subject: [PATCH 140/168] Do not allow `inf` or `nan` sensor states in statistics (#55943) --- homeassistant/components/sensor/recorder.py | 38 +++++++----- tests/components/sensor/test_recorder.py | 65 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 8bf251ffb18..ae4da8e7fe2 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -4,6 +4,7 @@ from __future__ import annotations import datetime import itertools import logging +import math from typing import Callable from homeassistant.components.recorder import history, statistics @@ -172,6 +173,14 @@ def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} +def _parse_float(state: str) -> float: + """Parse a float string, throw on inf or nan.""" + fstate = float(state) + if math.isnan(fstate) or math.isinf(fstate): + raise ValueError + return fstate + + def _normalize_states( hass: HomeAssistant, entity_history: list[State], @@ -186,9 +195,10 @@ def _normalize_states( fstates = [] for state in entity_history: try: - fstates.append((float(state.state), state)) - except ValueError: + fstate = _parse_float(state.state) + except (ValueError, TypeError): # TypeError to guard for NULL state in DB continue + fstates.append((fstate, state)) if fstates: all_units = _get_units(fstates) @@ -218,20 +228,20 @@ def _normalize_states( for state in entity_history: try: - fstate = float(state.state) - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - # Exclude unsupported units from statistics - if unit not in UNIT_CONVERSIONS[device_class]: - if WARN_UNSUPPORTED_UNIT not in hass.data: - hass.data[WARN_UNSUPPORTED_UNIT] = set() - if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: - hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) - _LOGGER.warning("%s has unknown unit %s", entity_id, unit) - continue - - fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) + fstate = _parse_float(state.state) except ValueError: continue + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + # Exclude unsupported units from statistics + if unit not in UNIT_CONVERSIONS[device_class]: + if WARN_UNSUPPORTED_UNIT not in hass.data: + hass.data[WARN_UNSUPPORTED_UNIT] = set() + if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: + hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) + _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + continue + + fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) return DEVICE_CLASS_UNITS[device_class], fstates diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index aeeab317eb1..0633a9db471 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,6 +1,7 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name from datetime import timedelta +import math from unittest.mock import patch import pytest @@ -349,6 +350,70 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ], +) +def test_compile_hourly_sum_statistics_nan_inf_state( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics with nan and inf states.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + "last_reset": None, + } + seq = [10, math.nan, 15, 15, 20, math.inf, 20, 10] + + states = {"sensor.test1": []} + one = zero + for i in range(len(seq)): + one = one + timedelta(minutes=1) + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(one), + "state": approx(factor * seq[7]), + "sum": approx(factor * (seq[2] + seq[3] + seq[4] + seq[6] + seq[7])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ From 413430bdba302d404fcdcc89970633746ea42d99 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 17:08:48 +0200 Subject: [PATCH 141/168] Fix handling of imperial units in long term statistics (#55959) --- .../components/recorder/statistics.py | 21 ++++++++++----- homeassistant/components/sensor/recorder.py | 2 +- tests/components/recorder/test_statistics.py | 14 +++++----- tests/components/sensor/test_recorder.py | 26 ++++++++++++------- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 21c286f8eb6..db82eb1ee39 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -377,11 +377,11 @@ def statistics_during_period( ) if not stats: return {} - return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) + return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata, True) def get_last_statistics( - hass: HomeAssistant, number_of_stats: int, statistic_id: str + hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool ) -> dict[str, list[dict]]: """Return the last number_of_stats statistics for a statistic_id.""" statistic_ids = [statistic_id] @@ -411,7 +411,9 @@ def get_last_statistics( if not stats: return {} - return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) + return _sorted_statistics_to_dict( + hass, stats, statistic_ids, metadata, convert_units + ) def _sorted_statistics_to_dict( @@ -419,11 +421,16 @@ def _sorted_statistics_to_dict( stats: list, statistic_ids: list[str] | None, metadata: dict[str, StatisticMetaData], + convert_units: bool, ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) units = hass.config.units + def no_conversion(val: Any, _: Any) -> float | None: + """Return x.""" + return val # type: ignore + # Set all statistic IDs to empty lists in result set to maintain the order if statistic_ids is not None: for stat_id in statistic_ids: @@ -436,9 +443,11 @@ def _sorted_statistics_to_dict( for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore unit = metadata[meta_id]["unit_of_measurement"] statistic_id = metadata[meta_id]["statistic_id"] - convert: Callable[[Any, Any], float | None] = UNIT_CONVERSIONS.get( - unit, lambda x, units: x # type: ignore - ) + convert: Callable[[Any, Any], float | None] + if convert_units: + convert = UNIT_CONVERSIONS.get(unit, lambda x, units: x) # type: ignore + else: + convert = no_conversion ent_results = result[meta_id] ent_results.extend( { diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index ae4da8e7fe2..4ba33d7a902 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -390,7 +390,7 @@ def compile_statistics( # noqa: C901 last_reset = old_last_reset = None new_state = old_state = None _sum = 0 - last_stats = statistics.get_last_statistics(hass, 1, entity_id) + last_stats = statistics.get_last_statistics(hass, 1, entity_id, False) if entity_id in last_stats: # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ac1681e2628..0580460a537 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -36,7 +36,7 @@ def test_compile_hourly_statistics(hass_recorder): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): stats = statistics_during_period(hass, zero, **kwargs) assert stats == {} - stats = get_last_statistics(hass, 0, "sensor.test1") + stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} recorder.do_adhoc_statistics(period="hourly", start=zero) @@ -82,19 +82,19 @@ def test_compile_hourly_statistics(hass_recorder): assert stats == {} # Test get_last_statistics - stats = get_last_statistics(hass, 0, "sensor.test1") + stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} - stats = get_last_statistics(hass, 1, "sensor.test1") + stats = get_last_statistics(hass, 1, "sensor.test1", True) assert stats == {"sensor.test1": [{**expected_2, "statistic_id": "sensor.test1"}]} - stats = get_last_statistics(hass, 2, "sensor.test1") + stats = get_last_statistics(hass, 2, "sensor.test1", True) assert stats == {"sensor.test1": expected_stats1[::-1]} - stats = get_last_statistics(hass, 3, "sensor.test1") + stats = get_last_statistics(hass, 3, "sensor.test1", True) assert stats == {"sensor.test1": expected_stats1[::-1]} - stats = get_last_statistics(hass, 1, "sensor.test3") + stats = get_last_statistics(hass, 1, "sensor.test3", True) assert stats == {} @@ -219,7 +219,7 @@ def test_rename_entity(hass_recorder): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): stats = statistics_during_period(hass, zero, **kwargs) assert stats == {} - stats = get_last_statistics(hass, 0, "sensor.test1") + stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} recorder.do_adhoc_statistics(period="hourly", start=zero) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 0633a9db471..c886007bc1c 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -18,6 +18,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM from tests.components.recorder.common import wait_recording_done @@ -194,22 +195,29 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes @pytest.mark.parametrize("state_class", ["measurement"]) @pytest.mark.parametrize( - "device_class,unit,native_unit,factor", + "units,device_class,unit,display_unit,factor", [ - ("energy", "kWh", "kWh", 1), - ("energy", "Wh", "kWh", 1 / 1000), - ("monetary", "EUR", "EUR", 1), - ("monetary", "SEK", "SEK", 1), - ("gas", "m³", "m³", 1), - ("gas", "ft³", "m³", 0.0283168466), + (IMPERIAL_SYSTEM, "energy", "kWh", "kWh", 1), + (IMPERIAL_SYSTEM, "energy", "Wh", "kWh", 1 / 1000), + (IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", 1), + (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", 1), + (IMPERIAL_SYSTEM, "gas", "m³", "ft³", 35.314666711), + (IMPERIAL_SYSTEM, "gas", "ft³", "ft³", 1), + (METRIC_SYSTEM, "energy", "kWh", "kWh", 1), + (METRIC_SYSTEM, "energy", "Wh", "kWh", 1 / 1000), + (METRIC_SYSTEM, "monetary", "EUR", "EUR", 1), + (METRIC_SYSTEM, "monetary", "SEK", "SEK", 1), + (METRIC_SYSTEM, "gas", "m³", "m³", 1), + (METRIC_SYSTEM, "gas", "ft³", "m³", 0.0283168466), ], ) def test_compile_hourly_sum_statistics_amount( - hass_recorder, caplog, state_class, device_class, unit, native_unit, factor + hass_recorder, caplog, units, state_class, device_class, unit, display_unit, factor ): """Test compiling hourly statistics.""" zero = dt_util.utcnow() hass = hass_recorder() + hass.config.units = units recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) attributes = { @@ -236,7 +244,7 @@ def test_compile_hourly_sum_statistics_amount( wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ - {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + {"statistic_id": "sensor.test1", "unit_of_measurement": display_unit} ] stats = statistics_during_period(hass, zero) assert stats == { From 5cba7932f36870b1ffcddae8ce48eb748a2d38cc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Sep 2021 08:22:38 -0700 Subject: [PATCH 142/168] Bumped version to 2021.9.5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index edba8a1a739..ee903de1abd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "4" +PATCH_VERSION: Final = "5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From e3815c6c2e827af14978512963d09cb05cafd3ac Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Tue, 7 Sep 2021 19:29:17 +0100 Subject: [PATCH 143/168] Pin setuptools<58 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ec7aeb7afb0..d91c18c2e6b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -580,7 +580,7 @@ jobs: python -m venv venv . venv/bin/activate - pip install -U "pip<20.3" setuptools wheel + pip install -U "pip<20.3" "setuptools<58" wheel pip install -r requirements_all.txt pip install -r requirements_test.txt pip install -e . From a17d2d7c71a5fe780b3aae85c96b8eda834685e6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 7 Sep 2021 20:53:43 -0700 Subject: [PATCH 144/168] Fix gas validation (#55886) --- homeassistant/components/energy/validate.py | 83 +++++++++++++++++---- tests/components/energy/test_validate.py | 56 ++++++++++++++ 2 files changed, 125 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 01709081d68..9ee6df30b5e 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -1,6 +1,7 @@ """Validate the energy preferences provide valid data.""" from __future__ import annotations +from collections.abc import Sequence import dataclasses from typing import Any @@ -10,12 +11,24 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, STATE_UNAVAILABLE, STATE_UNKNOWN, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, callback, valid_entity_id from . import data from .const import DOMAIN +ENERGY_USAGE_UNITS = (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) +ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" +GAS_USAGE_UNITS = ( + ENERGY_WATT_HOUR, + ENERGY_KILO_WATT_HOUR, + VOLUME_CUBIC_METERS, + VOLUME_CUBIC_FEET, +) +GAS_UNIT_ERROR = "entity_unexpected_unit_gas" + @dataclasses.dataclass class ValidationIssue: @@ -43,8 +56,12 @@ class EnergyPreferencesValidation: @callback -def _async_validate_energy_stat( - hass: HomeAssistant, stat_value: str, result: list[ValidationIssue] +def _async_validate_usage_stat( + hass: HomeAssistant, + stat_value: str, + allowed_units: Sequence[str], + unit_error: str, + result: list[ValidationIssue], ) -> None: """Validate a statistic.""" has_entity_source = valid_entity_id(stat_value) @@ -91,10 +108,8 @@ def _async_validate_energy_stat( unit = state.attributes.get("unit_of_measurement") - if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR): - result.append( - ValidationIssue("entity_unexpected_unit_energy", stat_value, unit) - ) + if unit not in allowed_units: + result.append(ValidationIssue(unit_error, stat_value, unit)) state_class = state.attributes.get("state_class") @@ -211,8 +226,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if source["type"] == "grid": for flow in source["flow_from"]: - _async_validate_energy_stat( - hass, flow["stat_energy_from"], source_result + _async_validate_usage_stat( + hass, + flow["stat_energy_from"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, ) if flow.get("stat_cost") is not None: @@ -229,7 +248,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) for flow in source["flow_to"]: - _async_validate_energy_stat(hass, flow["stat_energy_to"], source_result) + _async_validate_usage_stat( + hass, + flow["stat_energy_to"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) if flow.get("stat_compensation") is not None: _async_validate_cost_stat( @@ -247,7 +272,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) elif source["type"] == "gas": - _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + _async_validate_usage_stat( + hass, + source["stat_energy_from"], + GAS_USAGE_UNITS, + GAS_UNIT_ERROR, + source_result, + ) if source.get("stat_cost") is not None: _async_validate_cost_stat(hass, source["stat_cost"], source_result) @@ -263,15 +294,39 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) elif source["type"] == "solar": - _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + _async_validate_usage_stat( + hass, + source["stat_energy_from"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) elif source["type"] == "battery": - _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) - _async_validate_energy_stat(hass, source["stat_energy_to"], source_result) + _async_validate_usage_stat( + hass, + source["stat_energy_from"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) + _async_validate_usage_stat( + hass, + source["stat_energy_to"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) for device in manager.data["device_consumption"]: device_result: list[ValidationIssue] = [] result.device_consumption.append(device_result) - _async_validate_energy_stat(hass, device["stat_consumption"], device_result) + _async_validate_usage_stat( + hass, + device["stat_consumption"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + device_result, + ) return result diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 9a0b2105007..31f40bd24ea 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -441,3 +441,59 @@ async def test_validation_grid_price_errors( ], "device_consumption": [], } + + +async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded): + """Test validating gas with sensors for energy and cost/compensation.""" + mock_is_entity_recorded["sensor.gas_cost_1"] = False + mock_is_entity_recorded["sensor.gas_compensation_1"] = False + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_1", + "stat_cost": "sensor.gas_cost_1", + }, + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_2", + "stat_cost": "sensor.gas_cost_2", + }, + ] + } + ) + hass.states.async_set( + "sensor.gas_consumption_1", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.gas_consumption_2", + "10.10", + {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.gas_cost_2", + "10.10", + {"unit_of_measurement": "EUR/kWh", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_gas", + "identifier": "sensor.gas_consumption_1", + "value": "beers", + }, + { + "type": "recorder_untracked", + "identifier": "sensor.gas_cost_1", + "value": None, + }, + ], + [], + ], + "device_consumption": [], + } From cbe4b2dc1d17502843e2623427ab06f54e5e1a43 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 21:46:28 +0200 Subject: [PATCH 145/168] Add support for state class measurement to energy cost sensor (#55962) --- homeassistant/components/energy/sensor.py | 52 +++- homeassistant/components/energy/validate.py | 17 +- tests/components/energy/test_sensor.py | 288 +++++++++++++++++++- tests/components/energy/test_validate.py | 11 +- 4 files changed, 332 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 45ef8ea5c17..5db085343bc 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -1,13 +1,16 @@ """Helper sensor for calculating utility costs.""" from __future__ import annotations +import copy from dataclasses import dataclass import logging from typing import Any, Final, Literal, TypeVar, cast from homeassistant.components.sensor import ( + ATTR_LAST_RESET, ATTR_STATE_CLASS, DEVICE_CLASS_MONETARY, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) @@ -18,14 +21,19 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, VOLUME_CUBIC_METERS, ) -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +import homeassistant.util.dt as dt_util from .const import DOMAIN from .data import EnergyManager, async_get_manager +SUPPORTED_STATE_CLASSES = [ + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +] _LOGGER = logging.getLogger(__name__) @@ -206,15 +214,16 @@ class EnergyCostSensor(SensorEntity): f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" ) self._attr_device_class = DEVICE_CLASS_MONETARY - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._attr_state_class = STATE_CLASS_MEASUREMENT self._config = config - self._last_energy_sensor_state: StateType | None = None + self._last_energy_sensor_state: State | None = None self._cur_value = 0.0 - def _reset(self, energy_state: StateType) -> None: + def _reset(self, energy_state: State) -> None: """Reset the cost sensor.""" self._attr_native_value = 0.0 self._cur_value = 0.0 + self._attr_last_reset = dt_util.utcnow() self._last_energy_sensor_state = energy_state self.async_write_ha_state() @@ -228,9 +237,8 @@ class EnergyCostSensor(SensorEntity): if energy_state is None: return - if ( - state_class := energy_state.attributes.get(ATTR_STATE_CLASS) - ) != STATE_CLASS_TOTAL_INCREASING: + state_class = energy_state.attributes.get(ATTR_STATE_CLASS) + if state_class not in SUPPORTED_STATE_CLASSES: if not self._wrong_state_class_reported: self._wrong_state_class_reported = True _LOGGER.warning( @@ -240,6 +248,13 @@ class EnergyCostSensor(SensorEntity): ) return + # last_reset must be set if the sensor is STATE_CLASS_MEASUREMENT + if ( + state_class == STATE_CLASS_MEASUREMENT + and ATTR_LAST_RESET not in energy_state.attributes + ): + return + try: energy = float(energy_state.state) except ValueError: @@ -273,7 +288,7 @@ class EnergyCostSensor(SensorEntity): if self._last_energy_sensor_state is None: # Initialize as it's the first time all required entities are in place. - self._reset(energy_state.state) + self._reset(energy_state) return energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -298,20 +313,29 @@ class EnergyCostSensor(SensorEntity): ) return - if reset_detected( + if state_class != STATE_CLASS_TOTAL_INCREASING and energy_state.attributes.get( + ATTR_LAST_RESET + ) != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET): + # Energy meter was reset, reset cost sensor too + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) + elif state_class == STATE_CLASS_TOTAL_INCREASING and reset_detected( self.hass, cast(str, self._config[self._adapter.entity_energy_key]), energy, - float(self._last_energy_sensor_state), + float(self._last_energy_sensor_state.state), ): # Energy meter was reset, reset cost sensor too - self._reset(0) + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) # Update with newly incurred cost - old_energy_value = float(self._last_energy_sensor_state) + old_energy_value = float(self._last_energy_sensor_state.state) self._cur_value += (energy - old_energy_value) * energy_price self._attr_native_value = round(self._cur_value, 2) - self._last_energy_sensor_state = energy_state.state + self._last_energy_sensor_state = energy_state async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 9ee6df30b5e..7097788aa30 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -113,7 +113,11 @@ def _async_validate_usage_stat( state_class = state.attributes.get("state_class") - if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + supported_state_classes = [ + sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL_INCREASING, + ] + if state_class not in supported_state_classes: result.append( ValidationIssue( "entity_unexpected_state_class_total_increasing", @@ -140,16 +144,13 @@ def _async_validate_price_entity( return try: - value: float | None = float(state.state) + float(state.state) except ValueError: result.append( ValidationIssue("entity_state_non_numeric", entity_id, state.state) ) return - if value is not None and value < 0: - result.append(ValidationIssue("entity_negative_state", entity_id, value)) - unit = state.attributes.get("unit_of_measurement") if unit is None or not unit.endswith( @@ -203,7 +204,11 @@ def _async_validate_cost_entity( state_class = state.attributes.get("state_class") - if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + supported_state_classes = [ + sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL_INCREASING, + ] + if state_class not in supported_state_classes: result.append( ValidationIssue( "entity_unexpected_state_class_total_increasing", entity_id, state_class diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 542ea3296ce..31c73fc5b7a 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -77,7 +77,7 @@ async def test_cost_sensor_no_states(hass, hass_storage) -> None: ), ], ) -async def test_cost_sensor_price_entity( +async def test_cost_sensor_price_entity_total_increasing( hass, hass_storage, hass_ws_client, @@ -89,7 +89,7 @@ async def test_cost_sensor_price_entity( cost_sensor_entity_id, flow_type, ) -> None: - """Test energy cost price from sensor entity.""" + """Test energy cost price from total_increasing type sensor entity.""" def _compile_statistics(_): return compile_statistics(hass, now, now + timedelta(seconds=1)) @@ -136,6 +136,7 @@ async def test_cost_sensor_price_entity( } now = dt_util.utcnow() + last_reset_cost_sensor = now.isoformat() # Optionally initialize dependent entities if initial_energy is not None: @@ -152,7 +153,9 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + if initial_cost != "unknown": + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -168,7 +171,8 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -185,6 +189,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Nothing happens when price changes if price_entity is not None: @@ -199,6 +204,7 @@ async def test_cost_sensor_price_entity( assert msg["success"] state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Additional consumption is using the new price hass.states.async_set( @@ -209,6 +215,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -225,6 +232,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point hass.states.async_set( @@ -235,6 +243,8 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR + assert state.attributes["last_reset"] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes["last_reset"] # Energy use bumped to 10 kWh hass.states.async_set( @@ -245,6 +255,213 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0 + + +@pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")]) +@pytest.mark.parametrize( + "price_entity,fixed_price", [("sensor.energy_price", None), (None, 1)] +) +@pytest.mark.parametrize( + "usage_sensor_entity_id,cost_sensor_entity_id,flow_type", + [ + ("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"), + ( + "sensor.energy_production", + "sensor.energy_production_compensation", + "flow_to", + ), + ], +) +@pytest.mark.parametrize("energy_state_class", ["measurement"]) +async def test_cost_sensor_price_entity_total( + hass, + hass_storage, + hass_ws_client, + initial_energy, + initial_cost, + price_entity, + fixed_price, + usage_sensor_entity_id, + cost_sensor_entity_id, + flow_type, + energy_state_class, +) -> None: + """Test energy cost price from total type sensor entity.""" + + def _compile_statistics(_): + return compile_statistics(hass, now, now + timedelta(seconds=1)) + + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: energy_state_class, + } + + await async_init_recorder_component(hass) + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_from" + else [], + "flow_to": [ + { + "stat_energy_to": "sensor.energy_production", + "entity_energy_to": "sensor.energy_production", + "stat_compensation": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_to" + else [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + last_reset = dt_util.utc_from_timestamp(0).isoformat() + last_reset_cost_sensor = now.isoformat() + + # Optionally initialize dependent entities + if initial_energy is not None: + hass.states.async_set( + usage_sensor_entity_id, + initial_energy, + {**energy_attributes, **{"last_reset": last_reset}}, + ) + hass.states.async_set("sensor.energy_price", "1") + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == initial_cost + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + if initial_cost != "unknown": + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # Optional late setup of dependent entities + if initial_energy is None: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + usage_sensor_entity_id, + "0", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "0.0" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # # Unique ID temp disabled + # # entity_registry = er.async_get(hass) + # # entry = entity_registry.async_get(cost_sensor_entity_id) + # # assert entry.unique_id == "energy_energy_consumption cost" + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Nothing happens when price changes + if price_entity is not None: + hass.states.async_set(price_entity, "2") + await hass.async_block_till_done() + else: + energy_data = copy.deepcopy(energy_data) + energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2 + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data}) + msg = await client.receive_json() + assert msg["success"] + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Additional consumption is using the new price + hass.states.async_set( + usage_sensor_entity_id, + "14.5", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + + # Energy sensor has a small dip + hass.states.async_set( + usage_sensor_entity_id, + "14", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point + last_reset = (now + timedelta(seconds=1)).isoformat() + hass.states.async_set( + usage_sensor_entity_id, + "4", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR + assert state.attributes["last_reset"] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes["last_reset"] + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -284,6 +501,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: now = dt_util.utcnow() + # Initial state: 10kWh hass.states.async_set( "sensor.energy_consumption", 10000, @@ -296,7 +514,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "0.0" - # Energy use bumped to 10 kWh + # Energy use bumped by 10 kWh hass.states.async_set( "sensor.energy_consumption", 20000, @@ -361,7 +579,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: async def test_cost_sensor_wrong_state_class( hass, hass_storage, caplog, state_class ) -> None: - """Test energy sensor rejects wrong state_class.""" + """Test energy sensor rejects state_class with wrong state_class.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, @@ -417,3 +635,61 @@ async def test_cost_sensor_wrong_state_class( state = hass.states.get("sensor.energy_consumption_cost") assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize("state_class", ["measurement"]) +async def test_cost_sensor_state_class_measurement_no_reset( + hass, hass_storage, caplog, state_class +) -> None: + """Test energy sensor rejects state_class with no last_reset.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: state_class, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + + hass.states.async_set( + "sensor.energy_consumption", + 10000, + energy_attributes, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN + + # Energy use bumped to 10 kWh + hass.states.async_set( + "sensor.energy_consumption", + 20000, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 31f40bd24ea..8c67f3eabaf 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -382,15 +382,6 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): "value": "123,123.12", }, ), - ( - "-100", - "$/kWh", - { - "type": "entity_negative_state", - "identifier": "sensor.grid_price_1", - "value": -100.0, - }, - ), ( "123", "$/Ws", @@ -414,7 +405,7 @@ async def test_validation_grid_price_errors( hass.states.async_set( "sensor.grid_price_1", state, - {"unit_of_measurement": unit, "state_class": "total_increasing"}, + {"unit_of_measurement": unit, "state_class": "measurement"}, ) await mock_energy_manager.async_update( { From 8f344252c454da0c547090a3c51bb68e1c21b93a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 21:47:48 +0200 Subject: [PATCH 146/168] Add significant change support to AQI type sensors (#55833) --- .../components/google_assistant/trait.py | 14 +-- .../components/light/significant_change.py | 18 ++-- .../components/sensor/significant_change.py | 60 ++++++++++-- homeassistant/helpers/significant_change.py | 50 ++++++++-- .../sensor/test_significant_change.py | 93 ++++++++++--------- 5 files changed, 152 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index d1ed328703e..393f8b22fbe 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2311,16 +2311,12 @@ class SensorStateTrait(_Trait): name = TRAIT_SENSOR_STATE commands = [] - @staticmethod - def supported(domain, features, device_class, _): + @classmethod + def supported(cls, domain, features, device_class, _): """Test if state is supported.""" - return domain == sensor.DOMAIN and device_class in ( - sensor.DEVICE_CLASS_AQI, - sensor.DEVICE_CLASS_CO, - sensor.DEVICE_CLASS_CO2, - sensor.DEVICE_CLASS_PM25, - sensor.DEVICE_CLASS_PM10, - sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + return ( + domain == sensor.DOMAIN + and device_class in SensorStateTrait.sensor_types.keys() ) def sync_attributes(self): diff --git a/homeassistant/components/light/significant_change.py b/homeassistant/components/light/significant_change.py index 9e0f10fae47..79f447f5794 100644 --- a/homeassistant/components/light/significant_change.py +++ b/homeassistant/components/light/significant_change.py @@ -4,10 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.significant_change import ( - check_numeric_changed, - either_one_none, -) +from homeassistant.helpers.significant_change import check_absolute_change from . import ( ATTR_BRIGHTNESS, @@ -37,24 +34,21 @@ def async_check_significant_change( old_color = old_attrs.get(ATTR_HS_COLOR) new_color = new_attrs.get(ATTR_HS_COLOR) - if either_one_none(old_color, new_color): - return True - if old_color and new_color: # Range 0..360 - if check_numeric_changed(old_color[0], new_color[0], 5): + if check_absolute_change(old_color[0], new_color[0], 5): return True # Range 0..100 - if check_numeric_changed(old_color[1], new_color[1], 3): + if check_absolute_change(old_color[1], new_color[1], 3): return True - if check_numeric_changed( + if check_absolute_change( old_attrs.get(ATTR_BRIGHTNESS), new_attrs.get(ATTR_BRIGHTNESS), 3 ): return True - if check_numeric_changed( + if check_absolute_change( # Default range 153..500 old_attrs.get(ATTR_COLOR_TEMP), new_attrs.get(ATTR_COLOR_TEMP), @@ -62,7 +56,7 @@ def async_check_significant_change( ): return True - if check_numeric_changed( + if check_absolute_change( # Range 0..255 old_attrs.get(ATTR_WHITE_VALUE), new_attrs.get(ATTR_WHITE_VALUE), diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index cda80991242..5c180be62f3 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -9,8 +9,33 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_percentage_change, +) -from . import DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE +from . import ( + DEVICE_CLASS_AQI, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, +) + + +def _absolute_and_relative_change( + old_state: int | float | None, + new_state: int | float | None, + absolute_change: int | float, + percentage_change: int | float, +) -> bool: + return check_absolute_change( + old_state, new_state, absolute_change + ) and check_percentage_change(old_state, new_state, percentage_change) @callback @@ -28,20 +53,35 @@ def async_check_significant_change( if device_class is None: return None + absolute_change: float | None = None + percentage_change: float | None = None if device_class == DEVICE_CLASS_TEMPERATURE: if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_FAHRENHEIT: - change: float | int = 1 + absolute_change = 1.0 else: - change = 0.5 - - old_value = float(old_state) - new_value = float(new_state) - return abs(old_value - new_value) >= change + absolute_change = 0.5 if device_class in (DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY): - old_value = float(old_state) - new_value = float(new_state) + absolute_change = 1.0 - return abs(old_value - new_value) >= 1 + if device_class in ( + DEVICE_CLASS_AQI, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PM10, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ): + absolute_change = 1.0 + percentage_change = 2.0 + + if absolute_change is not None and percentage_change is not None: + return _absolute_and_relative_change( + float(old_state), float(new_state), absolute_change, percentage_change + ) + if absolute_change is not None: + return check_absolute_change( + float(old_state), float(new_state), absolute_change + ) return None diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index b34df0075a3..d2791def987 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -95,25 +95,55 @@ def either_one_none(val1: Any | None, val2: Any | None) -> bool: return (val1 is None and val2 is not None) or (val1 is not None and val2 is None) -def check_numeric_changed( +def _check_numeric_change( + old_state: int | float | None, + new_state: int | float | None, + change: int | float, + metric: Callable[[int | float, int | float], int | float], +) -> bool: + """Check if two numeric values have changed.""" + if old_state is None and new_state is None: + return False + + if either_one_none(old_state, new_state): + return True + + assert old_state is not None + assert new_state is not None + + if metric(old_state, new_state) >= change: + return True + + return False + + +def check_absolute_change( val1: int | float | None, val2: int | float | None, change: int | float, ) -> bool: """Check if two numeric values have changed.""" - if val1 is None and val2 is None: - return False + return _check_numeric_change( + val1, val2, change, lambda val1, val2: abs(val1 - val2) + ) - if either_one_none(val1, val2): - return True - assert val1 is not None - assert val2 is not None +def check_percentage_change( + old_state: int | float | None, + new_state: int | float | None, + change: int | float, +) -> bool: + """Check if two numeric values have changed.""" - if abs(val1 - val2) >= change: - return True + def percentage_change(old_state: int | float, new_state: int | float) -> float: + if old_state == new_state: + return 0 + try: + return (abs(new_state - old_state) / old_state) * 100.0 + except ZeroDivisionError: + return float("inf") - return False + return _check_numeric_change(old_state, new_state, change, percentage_change) class SignificantlyChangedChecker: diff --git a/tests/components/sensor/test_significant_change.py b/tests/components/sensor/test_significant_change.py index 12b74345011..22a2c22ecc7 100644 --- a/tests/components/sensor/test_significant_change.py +++ b/tests/components/sensor/test_significant_change.py @@ -1,5 +1,8 @@ """Test the sensor significant change platform.""" +import pytest + from homeassistant.components.sensor.significant_change import ( + DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -12,48 +15,54 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) +AQI_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_AQI, +} -async def test_significant_change_temperature(): +BATTERY_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, +} + +HUMIDITY_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, +} + +TEMP_CELSIUS_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, +} + +TEMP_FREEDOM_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, +} + + +@pytest.mark.parametrize( + "old_state,new_state,attrs,result", + [ + ("0", "1", AQI_ATTRS, True), + ("1", "0", AQI_ATTRS, True), + ("0.1", "0.5", AQI_ATTRS, False), + ("0.5", "0.1", AQI_ATTRS, False), + ("99", "100", AQI_ATTRS, False), + ("100", "99", AQI_ATTRS, False), + ("101", "99", AQI_ATTRS, False), + ("99", "101", AQI_ATTRS, True), + ("100", "100", BATTERY_ATTRS, False), + ("100", "99", BATTERY_ATTRS, True), + ("100", "100", HUMIDITY_ATTRS, False), + ("100", "99", HUMIDITY_ATTRS, True), + ("12", "12", TEMP_CELSIUS_ATTRS, False), + ("12", "13", TEMP_CELSIUS_ATTRS, True), + ("12.1", "12.2", TEMP_CELSIUS_ATTRS, False), + ("70", "71", TEMP_FREEDOM_ATTRS, True), + ("70", "70.5", TEMP_FREEDOM_ATTRS, False), + ], +) +async def test_significant_change_temperature(old_state, new_state, attrs, result): """Detect temperature significant changes.""" - celsius_attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - } - assert not async_check_significant_change( - None, "12", celsius_attrs, "12", celsius_attrs + assert ( + async_check_significant_change(None, old_state, attrs, new_state, attrs) + is result ) - assert async_check_significant_change( - None, "12", celsius_attrs, "13", celsius_attrs - ) - assert not async_check_significant_change( - None, "12.1", celsius_attrs, "12.2", celsius_attrs - ) - - freedom_attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, - } - assert async_check_significant_change( - None, "70", freedom_attrs, "71", freedom_attrs - ) - assert not async_check_significant_change( - None, "70", freedom_attrs, "70.5", freedom_attrs - ) - - -async def test_significant_change_battery(): - """Detect battery significant changes.""" - attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - } - assert not async_check_significant_change(None, "100", attrs, "100", attrs) - assert async_check_significant_change(None, "100", attrs, "99", attrs) - - -async def test_significant_change_humidity(): - """Detect humidity significant changes.""" - attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - } - assert not async_check_significant_change(None, "100", attrs, "100", attrs) - assert async_check_significant_change(None, "100", attrs, "99", attrs) From c424f99aab0e0e0263a696dccbc5b99ba2c54d4d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Sep 2021 08:26:29 +0200 Subject: [PATCH 147/168] Correct confusing log message in sensor statistics (#56016) --- homeassistant/components/sensor/recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 4ba33d7a902..e94cf0f6e46 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -441,8 +441,8 @@ def compile_statistics( # noqa: C901 _LOGGER.info( "Detected new cycle for %s, value dropped from %s to %s", entity_id, - fstate, new_state, + fstate, ) if reset: From 20799563505adb832088114cb4e8010f44f5e40b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Sep 2021 08:59:23 +0200 Subject: [PATCH 148/168] Suppress last_reset deprecation warning for energy cost sensor (#56037) --- homeassistant/components/sensor/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index fafaabbd217..413d9d2152f 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -209,6 +209,9 @@ class SensorEntity(Entity): and not self._last_reset_reported ): self._last_reset_reported = True + if self.platform and self.platform.platform_name == "energy": + return {ATTR_LAST_RESET: last_reset.isoformat()} + report_issue = self._suggest_report_issue() _LOGGER.warning( "Entity %s (%s) with state_class %s has set last_reset. Setting " From fe713b943f427229e5eb84d08d18053bc57b97b0 Mon Sep 17 00:00:00 2001 From: micha91 Date: Fri, 10 Sep 2021 15:03:34 +0200 Subject: [PATCH 149/168] Fix UDP message handling by upgrading aiomusiccast to 0.9.2 (#56041) --- homeassistant/components/yamaha_musiccast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index bd614e368dc..be52b8a4558 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "requirements": [ - "aiomusiccast==0.9.1" + "aiomusiccast==0.9.2" ], "ssdp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 53ab6ff1197..0063f20d950 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,7 +216,7 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.9.1 +aiomusiccast==0.9.2 # homeassistant.components.keyboard_remote aionotify==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 859bdbf2d8c..c58dbdd43a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,7 +140,7 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.9.1 +aiomusiccast==0.9.2 # homeassistant.components.notion aionotion==3.0.2 From ee892beceb9e596de7db86e4640ecdd85cf09ac8 Mon Sep 17 00:00:00 2001 From: Sean Vig Date: Fri, 10 Sep 2021 09:38:01 -0400 Subject: [PATCH 150/168] Bump amcrest version to 1.8.1 (#56058) The current version of the `amcrest` package has a bug in exposing if the video stream is enabled, which leads to the substream status being used to set if the camera is on or off. The updated version of `amcrest` fixes this bug. Fixes #55661 --- homeassistant/components/amcrest/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index acd93c4e2ed..725ff96b3ad 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -2,7 +2,7 @@ "domain": "amcrest", "name": "Amcrest", "documentation": "https://www.home-assistant.io/integrations/amcrest", - "requirements": ["amcrest==1.8.0"], + "requirements": ["amcrest==1.8.1"], "dependencies": ["ffmpeg"], "codeowners": ["@flacjacket"], "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 0063f20d950..0ecebd6d0d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ ambee==0.3.0 ambiclimate==0.2.1 # homeassistant.components.amcrest -amcrest==1.8.0 +amcrest==1.8.1 # homeassistant.components.androidtv androidtv[async]==0.0.60 From 0cfbff3ff976acad5af67167baa3cd78ce6d5456 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 11 Sep 2021 12:02:01 -0700 Subject: [PATCH 151/168] Fix singleton not working with falsey values (#56072) --- homeassistant/helpers/restore_state.py | 70 +++++++++++--------------- homeassistant/helpers/singleton.py | 20 +++----- tests/helpers/test_restore_state.py | 9 ++-- tests/helpers/test_singleton.py | 12 +++-- 4 files changed, 51 insertions(+), 60 deletions(-) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index da4d2bacf15..f1e74e26908 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -6,16 +6,10 @@ from datetime import datetime, timedelta import logging from typing import Any, cast -from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - CoreState, - HomeAssistant, - State, - callback, - valid_entity_id, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry, start from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.json import JSONEncoder @@ -63,42 +57,36 @@ class StoredState: class RestoreStateData: """Helper class for managing the helper saved data.""" - @classmethod - async def async_get_instance(cls, hass: HomeAssistant) -> RestoreStateData: + @staticmethod + @singleton(DATA_RESTORE_STATE_TASK) + async def async_get_instance(hass: HomeAssistant) -> RestoreStateData: """Get the singleton instance of this data helper.""" + data = RestoreStateData(hass) - @singleton(DATA_RESTORE_STATE_TASK) - async def load_instance(hass: HomeAssistant) -> RestoreStateData: - """Get the singleton instance of this data helper.""" - data = cls(hass) + try: + stored_states = await data.store.async_load() + except HomeAssistantError as exc: + _LOGGER.error("Error loading last states", exc_info=exc) + stored_states = None - try: - stored_states = await data.store.async_load() - except HomeAssistantError as exc: - _LOGGER.error("Error loading last states", exc_info=exc) - stored_states = None + if stored_states is None: + _LOGGER.debug("Not creating cache - no saved states found") + data.last_states = {} + else: + data.last_states = { + item["state"]["entity_id"]: StoredState.from_dict(item) + for item in stored_states + if valid_entity_id(item["state"]["entity_id"]) + } + _LOGGER.debug("Created cache with %s", list(data.last_states)) - if stored_states is None: - _LOGGER.debug("Not creating cache - no saved states found") - data.last_states = {} - else: - data.last_states = { - item["state"]["entity_id"]: StoredState.from_dict(item) - for item in stored_states - if valid_entity_id(item["state"]["entity_id"]) - } - _LOGGER.debug("Created cache with %s", list(data.last_states)) + async def hass_start(hass: HomeAssistant) -> None: + """Start the restore state task.""" + data.async_setup_dump() - if hass.state == CoreState.running: - data.async_setup_dump() - else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, data.async_setup_dump - ) + start.async_at_start(hass, hass_start) - return data - - return cast(RestoreStateData, await load_instance(hass)) + return data @classmethod async def async_save_persistent_states(cls, hass: HomeAssistant) -> None: @@ -269,7 +257,9 @@ class RestoreEntity(Entity): # Return None if this entity isn't added to hass yet _LOGGER.warning("Cannot get last state. Entity not added to hass") # type: ignore[unreachable] return None - data = await RestoreStateData.async_get_instance(self.hass) + data = cast( + RestoreStateData, await RestoreStateData.async_get_instance(self.hass) + ) if self.entity_id not in data.last_states: return None return data.last_states[self.entity_id].state diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index a48ea5d64f0..a3cde0b2f27 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -26,31 +26,27 @@ def singleton(data_key: str) -> Callable[[FUNC], FUNC]: @bind_hass @functools.wraps(func) def wrapped(hass: HomeAssistant) -> T: - obj: T | None = hass.data.get(data_key) - if obj is None: - obj = hass.data[data_key] = func(hass) - return obj + if data_key not in hass.data: + hass.data[data_key] = func(hass) + return cast(T, hass.data[data_key]) return wrapped @bind_hass @functools.wraps(func) async def async_wrapped(hass: HomeAssistant) -> T: - obj_or_evt = hass.data.get(data_key) - - if not obj_or_evt: + if data_key not in hass.data: evt = hass.data[data_key] = asyncio.Event() - result = await func(hass) - hass.data[data_key] = result evt.set() return cast(T, result) + obj_or_evt = hass.data[data_key] + if isinstance(obj_or_evt, asyncio.Event): - evt = obj_or_evt - await evt.wait() - return cast(T, hass.data.get(data_key)) + await obj_or_evt.wait() + return cast(T, hass.data[data_key]) return cast(T, obj_or_evt) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index d138a5381da..79719b75326 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -32,7 +32,7 @@ async def test_caching_data(hass): await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load - hass.data[DATA_RESTORE_STATE_TASK] = None + hass.data.pop(DATA_RESTORE_STATE_TASK) entity = RestoreEntity() entity.hass = hass @@ -59,7 +59,7 @@ async def test_periodic_write(hass): await data.store.async_save([]) # Emulate a fresh load - hass.data[DATA_RESTORE_STATE_TASK] = None + hass.data.pop(DATA_RESTORE_STATE_TASK) entity = RestoreEntity() entity.hass = hass @@ -105,7 +105,7 @@ async def test_save_persistent_states(hass): await data.store.async_save([]) # Emulate a fresh load - hass.data[DATA_RESTORE_STATE_TASK] = None + hass.data.pop(DATA_RESTORE_STATE_TASK) entity = RestoreEntity() entity.hass = hass @@ -170,7 +170,8 @@ async def test_hass_starting(hass): await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load - hass.data[DATA_RESTORE_STATE_TASK] = None + hass.state = CoreState.not_running + hass.data.pop(DATA_RESTORE_STATE_TASK) entity = RestoreEntity() entity.hass = hass diff --git a/tests/helpers/test_singleton.py b/tests/helpers/test_singleton.py index c695efd94a8..1d4f496a794 100644 --- a/tests/helpers/test_singleton.py +++ b/tests/helpers/test_singleton.py @@ -12,29 +12,33 @@ def mock_hass(): return Mock(data={}) -async def test_singleton_async(mock_hass): +@pytest.mark.parametrize("result", (object(), {}, [])) +async def test_singleton_async(mock_hass, result): """Test singleton with async function.""" @singleton.singleton("test_key") async def something(hass): - return object() + return result result1 = await something(mock_hass) result2 = await something(mock_hass) + assert result1 is result assert result1 is result2 assert "test_key" in mock_hass.data assert mock_hass.data["test_key"] is result1 -def test_singleton(mock_hass): +@pytest.mark.parametrize("result", (object(), {}, [])) +def test_singleton(mock_hass, result): """Test singleton with function.""" @singleton.singleton("test_key") def something(hass): - return object() + return result result1 = something(mock_hass) result2 = something(mock_hass) + assert result1 is result assert result1 is result2 assert "test_key" in mock_hass.data assert mock_hass.data["test_key"] is result1 From a900c02c103f13820ac3b866413490b49c170927 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Fri, 10 Sep 2021 20:09:54 -0600 Subject: [PATCH 152/168] Bump pymyq to 3.1.4 (#56089) --- homeassistant/components/myq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index fa9313eb9a1..c8e9c29e4e7 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -2,7 +2,7 @@ "domain": "myq", "name": "MyQ", "documentation": "https://www.home-assistant.io/integrations/myq", - "requirements": ["pymyq==3.1.3"], + "requirements": ["pymyq==3.1.4"], "codeowners": ["@bdraco","@ehendrix23"], "config_flow": true, "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index 0ecebd6d0d0..1ebca9da385 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1629,7 +1629,7 @@ pymonoprice==0.3 pymsteams==0.1.12 # homeassistant.components.myq -pymyq==3.1.3 +pymyq==3.1.4 # homeassistant.components.mysensors pymysensors==0.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c58dbdd43a2..ba9d9bf0128 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -942,7 +942,7 @@ pymodbus==2.5.3rc1 pymonoprice==0.3 # homeassistant.components.myq -pymyq==3.1.3 +pymyq==3.1.4 # homeassistant.components.mysensors pymysensors==0.21.0 From 292c05ab9f6ba05ad1632a873aecb199db650257 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 11 Sep 2021 12:02:44 -0700 Subject: [PATCH 153/168] Bumped version to 2021.9.6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ee903de1abd..110c49fbc01 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "5" +PATCH_VERSION: Final = "6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From bd17da0524fbdf52af33225e30a8266810f38329 Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Thu, 9 Sep 2021 09:29:11 -0700 Subject: [PATCH 154/168] Support incoming SMS messages via polling (#54237) * Add support to incomming SMS via polling * Update dependencies * Only send notification for unread messages * Only inform if callback is not getting used * Update gateway.py * Apply PR feedback * Update homeassistant/components/sms/gateway.py Co-authored-by: Martin Hjelmare * Apply PR comments * Make black happy Co-authored-by: Martin Hjelmare --- homeassistant/components/sms/const.py | 1 + homeassistant/components/sms/gateway.py | 71 ++++++++++++++-------- homeassistant/components/sms/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/sms/const.py b/homeassistant/components/sms/const.py index b73e7954fc1..ab2c15a0c49 100644 --- a/homeassistant/components/sms/const.py +++ b/homeassistant/components/sms/const.py @@ -2,3 +2,4 @@ DOMAIN = "sms" SMS_GATEWAY = "SMS_GATEWAY" +SMS_STATE_UNREAD = "UnRead" diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 5003f7019ca..3034580d5e0 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -6,7 +6,7 @@ from gammu.asyncworker import GammuAsyncWorker # pylint: disable=import-error from homeassistant.core import callback -from .const import DOMAIN +from .const import DOMAIN, SMS_STATE_UNREAD _LOGGER = logging.getLogger(__name__) @@ -14,24 +14,39 @@ _LOGGER = logging.getLogger(__name__) class Gateway: """SMS gateway to interact with a GSM modem.""" - def __init__(self, worker, hass): + def __init__(self, config, hass): """Initialize the sms gateway.""" - self._worker = worker + self._worker = GammuAsyncWorker(self.sms_pull) + self._worker.configure(config) self._hass = hass + self._first_pull = True async def init_async(self): """Initialize the sms gateway asynchronously.""" + await self._worker.init_async() try: await self._worker.set_incoming_sms_async() except gammu.ERR_NOTSUPPORTED: - _LOGGER.warning("Your phone does not support incoming SMS notifications!") + _LOGGER.warning("Falling back to pulling method for SMS notifications") except gammu.GSMError: _LOGGER.warning( - "GSM error, your phone does not support incoming SMS notifications!" + "GSM error, falling back to pulling method for SMS notifications" ) else: await self._worker.set_incoming_callback_async(self.sms_callback) + def sms_pull(self, state_machine): + """Pull device. + + @param state_machine: state machine + @type state_machine: gammu.StateMachine + """ + state_machine.ReadDevice() + + _LOGGER.debug("Pulling modem") + self.sms_read_messages(state_machine, self._first_pull) + self._first_pull = False + def sms_callback(self, state_machine, callback_type, callback_data): """Receive notification about incoming event. @@ -45,7 +60,15 @@ class Gateway: _LOGGER.debug( "Received incoming event type:%s,data:%s", callback_type, callback_data ) - entries = self.get_and_delete_all_sms(state_machine) + self.sms_read_messages(state_machine) + + def sms_read_messages(self, state_machine, force=False): + """Read all received SMS messages. + + @param state_machine: state machine which invoked action + @type state_machine: gammu.StateMachine + """ + entries = self.get_and_delete_all_sms(state_machine, force) _LOGGER.debug("SMS entries:%s", entries) data = [] @@ -53,22 +76,25 @@ class Gateway: decoded_entry = gammu.DecodeSMS(entry) message = entry[0] _LOGGER.debug("Processing sms:%s,decoded:%s", message, decoded_entry) - if decoded_entry is None: - text = message["Text"] - else: - text = "" - for inner_entry in decoded_entry["Entries"]: - if inner_entry["Buffer"] is not None: - text = text + inner_entry["Buffer"] + sms_state = message["State"] + _LOGGER.debug("SMS state:%s", sms_state) + if sms_state == SMS_STATE_UNREAD: + if decoded_entry is None: + text = message["Text"] + else: + text = "" + for inner_entry in decoded_entry["Entries"]: + if inner_entry["Buffer"] is not None: + text += inner_entry["Buffer"] - event_data = { - "phone": message["Number"], - "date": str(message["DateTime"]), - "message": text, - } + event_data = { + "phone": message["Number"], + "date": str(message["DateTime"]), + "message": text, + } - _LOGGER.debug("Append event data:%s", event_data) - data.append(event_data) + _LOGGER.debug("Append event data:%s", event_data) + data.append(event_data) self._hass.add_job(self._notify_incoming_sms, data) @@ -161,10 +187,7 @@ class Gateway: async def create_sms_gateway(config, hass): """Create the sms gateway.""" try: - worker = GammuAsyncWorker() - worker.configure(config) - await worker.init_async() - gateway = Gateway(worker, hass) + gateway = Gateway(config, hass) await gateway.init_async() return gateway except gammu.GSMError as exc: diff --git a/homeassistant/components/sms/manifest.json b/homeassistant/components/sms/manifest.json index 9a466236758..6d736ac44e7 100644 --- a/homeassistant/components/sms/manifest.json +++ b/homeassistant/components/sms/manifest.json @@ -3,7 +3,7 @@ "name": "SMS notifications via GSM-modem", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sms", - "requirements": ["python-gammu==3.1"], + "requirements": ["python-gammu==3.2.3"], "codeowners": ["@ocalvo"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 1ebca9da385..3220de21329 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1856,7 +1856,7 @@ python-family-hub-local==0.0.2 python-forecastio==1.4.0 # homeassistant.components.sms -# python-gammu==3.1 +# python-gammu==3.2.3 # homeassistant.components.gc100 python-gc100==1.0.3a0 From 2b58a266cc7a2efd276464060b4f6f5897652b0b Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Mon, 13 Sep 2021 16:27:06 -0400 Subject: [PATCH 155/168] Fix generic thermostat switch state initialization (#56073) --- .../components/generic_thermostat/climate.py | 27 +++++--- .../generic_thermostat/test_climate.py | 68 ++++++++++++++++++- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index e83852d122f..f296b861aa4 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -224,6 +224,12 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ): self._async_update_temp(sensor_state) self.async_write_ha_state() + switch_state = self.hass.states.get(self.heater_entity_id) + if switch_state and switch_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self.hass.create_task(self._check_switch_initial_state()) if self.hass.state == CoreState.running: _async_startup() @@ -267,14 +273,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity): if not self._hvac_mode: self._hvac_mode = HVAC_MODE_OFF - # Prevent the device from keep running if HVAC_MODE_OFF - if self._hvac_mode == HVAC_MODE_OFF and self._is_device_active: - await self._async_heater_turn_off() - _LOGGER.warning( - "The climate mode is OFF, but the switch device is ON. Turning off device %s", - self.heater_entity_id, - ) - @property def should_poll(self): """Return the polling state.""" @@ -408,12 +406,24 @@ class GenericThermostat(ClimateEntity, RestoreEntity): await self._async_control_heating() self.async_write_ha_state() + async def _check_switch_initial_state(self): + """Prevent the device from keep running if HVAC_MODE_OFF.""" + if self._hvac_mode == HVAC_MODE_OFF and self._is_device_active: + _LOGGER.warning( + "The climate mode is OFF, but the switch device is ON. Turning off device %s", + self.heater_entity_id, + ) + await self._async_heater_turn_off() + @callback def _async_switch_changed(self, event): """Handle heater switch state changes.""" new_state = event.data.get("new_state") + old_state = event.data.get("old_state") if new_state is None: return + if old_state is None: + self.hass.create_task(self._check_switch_initial_state()) self.async_write_ha_state() @callback @@ -433,7 +443,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity): if not self._active and None not in ( self._cur_temp, self._target_temp, - self._is_device_active, ): self._active = True _LOGGER.info( diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 4b9fbca41e2..317f9d3e74e 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -42,6 +42,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from tests.common import ( assert_setup_component, async_fire_time_changed, + async_mock_service, mock_restore_cache, ) from tests.components.climate import common @@ -1174,14 +1175,15 @@ async def test_custom_setup_params(hass): assert state.attributes.get("temperature") == TARGET_TEMP -async def test_restore_state(hass): +@pytest.mark.parametrize("hvac_mode", [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_COOL]) +async def test_restore_state(hass, hvac_mode): """Ensure states are restored on startup.""" mock_restore_cache( hass, ( State( "climate.test_thermostat", - HVAC_MODE_OFF, + hvac_mode, {ATTR_TEMPERATURE: "20", ATTR_PRESET_MODE: PRESET_AWAY}, ), ), @@ -1206,7 +1208,7 @@ async def test_restore_state(hass): state = hass.states.get("climate.test_thermostat") assert state.attributes[ATTR_TEMPERATURE] == 20 assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY - assert state.state == HVAC_MODE_OFF + assert state.state == hvac_mode async def test_no_restore_state(hass): @@ -1332,6 +1334,66 @@ async def test_restore_will_turn_off_(hass): assert hass.states.get(heater_switch).state == STATE_ON +async def test_restore_will_turn_off_when_loaded_second(hass): + """Ensure that restored state is coherent with real situation. + + Switch is not available until after component is loaded + """ + heater_switch = "input_boolean.test" + mock_restore_cache( + hass, + ( + State( + "climate.test_thermostat", + HVAC_MODE_HEAT, + {ATTR_TEMPERATURE: "18", ATTR_PRESET_MODE: PRESET_NONE}, + ), + State(heater_switch, STATE_ON, {}), + ), + ) + + hass.state = CoreState.starting + + await hass.async_block_till_done() + assert hass.states.get(heater_switch) is None + + _setup_sensor(hass, 16) + + await async_setup_component( + hass, + DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test_thermostat", + "heater": heater_switch, + "target_sensor": ENT_SENSOR, + "target_temp": 20, + "initial_hvac_mode": HVAC_MODE_OFF, + } + }, + ) + await hass.async_block_till_done() + state = hass.states.get("climate.test_thermostat") + assert state.attributes[ATTR_TEMPERATURE] == 20 + assert state.state == HVAC_MODE_OFF + + calls_on = async_mock_service(hass, ha.DOMAIN, SERVICE_TURN_ON) + calls_off = async_mock_service(hass, ha.DOMAIN, SERVICE_TURN_OFF) + + assert await async_setup_component( + hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} + ) + await hass.async_block_till_done() + # heater must be switched off + assert len(calls_on) == 0 + assert len(calls_off) == 1 + call = calls_off[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == "input_boolean.test" + + async def test_restore_state_uncoherence_case(hass): """ Test restore from a strange state. From 6e80d2777016b7fccdcb3b8c834b64582d4e9f15 Mon Sep 17 00:00:00 2001 From: muppet3000 Date: Wed, 15 Sep 2021 09:08:15 +0100 Subject: [PATCH 156/168] Bump growattServer to 1.1.0 (#56084) --- homeassistant/components/growatt_server/config_flow.py | 2 +- homeassistant/components/growatt_server/manifest.json | 2 +- homeassistant/components/growatt_server/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/growatt_server/test_config_flow.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index d6b2c7db9fe..c4a97a81f0a 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -47,7 +47,7 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not login_response["success"] and login_response["errCode"] == "102": return self._async_show_user_form({"base": "invalid_auth"}) - self.user_id = login_response["userId"] + self.user_id = login_response["user"]["id"] self.data = user_input return await self.async_step_plant() diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index ab2d07c147b..79472359ab9 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -3,7 +3,7 @@ "name": "Growatt", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server/", - "requirements": ["growattServer==1.0.1"], + "requirements": ["growattServer==1.1.0"], "codeowners": ["@indykoning", "@muppet3000", "@JasperPlant"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 03da4fe4b57..f2eea640e99 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -861,7 +861,7 @@ def get_device_list(api, config): if not login_response["success"] and login_response["errCode"] == "102": _LOGGER.error("Username, Password or URL may be incorrect!") return - user_id = login_response["userId"] + user_id = login_response["user"]["id"] if plant_id == DEFAULT_PLANT_ID: plant_info = api.plant_list(user_id) plant_id = plant_info["data"][0]["plantId"] diff --git a/requirements_all.txt b/requirements_all.txt index 3220de21329..d39130cefa9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -739,7 +739,7 @@ greeneye_monitor==2.1 greenwavereality==0.5.1 # homeassistant.components.growatt_server -growattServer==1.0.1 +growattServer==1.1.0 # homeassistant.components.gstreamer gstreamer-player==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba9d9bf0128..79db4959f63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -423,7 +423,7 @@ googlemaps==2.5.1 greeclimate==0.11.8 # homeassistant.components.growatt_server -growattServer==1.0.1 +growattServer==1.1.0 # homeassistant.components.profiler guppy3==3.1.0 diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index 096052fd6cf..db46ed36911 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -40,7 +40,7 @@ GROWATT_PLANT_LIST_RESPONSE = { }, "success": True, } -GROWATT_LOGIN_RESPONSE = {"userId": 123456, "userLevel": 1, "success": True} +GROWATT_LOGIN_RESPONSE = {"user": {"id": 123456}, "userLevel": 1, "success": True} async def test_show_authenticate_form(hass): From 162fab91b26e0777669703e78f84b335c0fa3046 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Sep 2021 20:38:34 -0700 Subject: [PATCH 157/168] Ensure rainmachine device name is a string (#56121) --- homeassistant/components/rainmachine/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index fac929e7e99..d5eceab05fc 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -189,7 +189,7 @@ class RainMachineEntity(CoordinatorEntity): self._attr_device_info = { "identifiers": {(DOMAIN, controller.mac)}, "connections": {(dr.CONNECTION_NETWORK_MAC, controller.mac)}, - "name": controller.name, + "name": str(controller.name), "manufacturer": "RainMachine", "model": ( f"Version {controller.hardware_version} " From c0c85a66411e3860cc0a942355977417ab6a5e34 Mon Sep 17 00:00:00 2001 From: Martin Ilievski Date: Mon, 13 Sep 2021 22:00:10 +0200 Subject: [PATCH 158/168] Bump pykodi to 0.2.6 (#56148) --- homeassistant/components/kodi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kodi/manifest.json b/homeassistant/components/kodi/manifest.json index 78d0c6e5998..f88a893c7fa 100644 --- a/homeassistant/components/kodi/manifest.json +++ b/homeassistant/components/kodi/manifest.json @@ -2,7 +2,7 @@ "domain": "kodi", "name": "Kodi", "documentation": "https://www.home-assistant.io/integrations/kodi", - "requirements": ["pykodi==0.2.5"], + "requirements": ["pykodi==0.2.6"], "codeowners": ["@OnFreund", "@cgtobi"], "zeroconf": ["_xbmc-jsonrpc-h._tcp.local."], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index d39130cefa9..acb096c9a95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1551,7 +1551,7 @@ pykira==0.1.1 pykmtronic==0.3.0 # homeassistant.components.kodi -pykodi==0.2.5 +pykodi==0.2.6 # homeassistant.components.kraken pykrakenapi==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79db4959f63..721e2681fa8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -891,7 +891,7 @@ pykira==0.1.1 pykmtronic==0.3.0 # homeassistant.components.kodi -pykodi==0.2.5 +pykodi==0.2.6 # homeassistant.components.kraken pykrakenapi==0.1.8 From 07ba24ac5a488dfe41eee1042ac5f43661e314f5 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 13 Sep 2021 08:46:54 -0500 Subject: [PATCH 159/168] Bump plexapi to 4.7.1 (#56163) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 40d8ecc675e..27461d0d8ad 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plex", "requirements": [ - "plexapi==4.7.0", + "plexapi==4.7.1", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/requirements_all.txt b/requirements_all.txt index acb096c9a95..81bf740cd2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1200,7 +1200,7 @@ pillow==8.2.0 pizzapi==0.0.3 # homeassistant.components.plex -plexapi==4.7.0 +plexapi==4.7.1 # homeassistant.components.plex plexauth==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 721e2681fa8..777fc2e77d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -678,7 +678,7 @@ pilight==0.1.1 pillow==8.2.0 # homeassistant.components.plex -plexapi==4.7.0 +plexapi==4.7.1 # homeassistant.components.plex plexauth==0.0.6 From 6f40759ed09f2e0d7eb0ec7e3ccdd36a4c358beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 13 Sep 2021 21:11:10 +0200 Subject: [PATCH 160/168] Update docker base image to 2021.09.0 (#56191) --- build.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/build.json b/build.json index bdb59943d72..1b9c72e8675 100644 --- a/build.json +++ b/build.json @@ -2,11 +2,11 @@ "image": "homeassistant/{arch}-homeassistant", "shadow_repository": "ghcr.io/home-assistant", "build_from": { - "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.08.0", - "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.08.0", - "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.08.0", - "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.08.0", - "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.08.0" + "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.09.0", + "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.09.0", + "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.09.0", + "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.09.0", + "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.09.0" }, "labels": { "io.hass.type": "core", @@ -19,4 +19,4 @@ "org.opencontainers.image.licenses": "Apache License 2.0" }, "version_tag": true -} +} \ No newline at end of file From 3224fc8710b776d8c0cfd6a972f6f0870643c4db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Sep 2021 19:47:06 -1000 Subject: [PATCH 161/168] Fix HomeKit requests with hvac mode and temperature in the same call (#56239) --- .../components/homekit/type_thermostats.py | 17 ++- .../homekit/test_type_thermostats.py | 113 ++++++++++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index c36a32b0d5b..95c5f87b6c2 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -245,7 +245,7 @@ class Thermostat(HomeAccessory): def _set_chars(self, char_values): _LOGGER.debug("Thermostat _set_chars: %s", char_values) events = [] - params = {} + params = {ATTR_ENTITY_ID: self.entity_id} service = None state = self.hass.states.get(self.entity_id) features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -285,12 +285,20 @@ class Thermostat(HomeAccessory): target_hc = hc_fallback break - service = SERVICE_SET_HVAC_MODE_THERMOSTAT - hass_value = self.hc_homekit_to_hass[target_hc] - params = {ATTR_HVAC_MODE: hass_value} + params[ATTR_HVAC_MODE] = self.hc_homekit_to_hass[target_hc] events.append( f"{CHAR_TARGET_HEATING_COOLING} to {char_values[CHAR_TARGET_HEATING_COOLING]}" ) + # Many integrations do not actually implement `hvac_mode` for the + # `SERVICE_SET_TEMPERATURE_THERMOSTAT` service so we made a call to + # `SERVICE_SET_HVAC_MODE_THERMOSTAT` before calling `SERVICE_SET_TEMPERATURE_THERMOSTAT` + # to ensure the device is in the right mode before setting the temp. + self.async_call_service( + DOMAIN_CLIMATE, + SERVICE_SET_HVAC_MODE_THERMOSTAT, + params.copy(), + ", ".join(events), + ) if CHAR_TARGET_TEMPERATURE in char_values: hc_target_temp = char_values[CHAR_TARGET_TEMPERATURE] @@ -357,7 +365,6 @@ class Thermostat(HomeAccessory): ) if service: - params[ATTR_ENTITY_ID] = self.entity_id self.async_call_service( DOMAIN_CLIMATE, service, diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index d9c2a6bf0ed..e73465b0ab0 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -560,6 +560,119 @@ async def test_thermostat_auto(hass, hk_driver, events): ) +async def test_thermostat_mode_and_temp_change(hass, hk_driver, events): + """Test if accessory where the mode and temp change in the same call.""" + entity_id = "climate.test" + + # support_auto = True + hass.states.async_set( + entity_id, + HVAC_MODE_OFF, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], + }, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + + hass.states.async_set( + entity_id, + HVAC_MODE_COOL, + { + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + ATTR_HVAC_ACTION: CURRENT_HVAC_COOL, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], + }, + ) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_current_heat_cool.value == HC_HEAT_COOL_COOL + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL + assert acc.char_current_temp.value == 21.0 + assert acc.char_display_units.value == 0 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, "set_temperature") + call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") + + char_heating_thresh_temp_iid = acc.char_heating_thresh_temp.to_HAP()[HAP_REPR_IID] + char_cooling_thresh_temp_iid = acc.char_cooling_thresh_temp.to_HAP()[HAP_REPR_IID] + char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_heating_thresh_temp_iid, + HAP_REPR_VALUE: 20.0, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_cooling_thresh_temp_iid, + HAP_REPR_VALUE: 25.0, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: HC_HEAT_COOL_AUTO, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_hvac_mode[0] + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT_COOL + assert call_set_temperature[0] + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 20.0 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 25.0 + assert acc.char_heating_thresh_temp.value == 20.0 + assert acc.char_cooling_thresh_temp.value == 25.0 + assert len(events) == 2 + assert events[-2].data[ATTR_VALUE] == "TargetHeatingCoolingState to 3" + assert ( + events[-1].data[ATTR_VALUE] + == "TargetHeatingCoolingState to 3, CoolingThresholdTemperature to 25.0°C, HeatingThresholdTemperature to 20.0°C" + ) + + async def test_thermostat_humidity(hass, hk_driver, events): """Test if accessory and HA are updated accordingly with humidity.""" entity_id = "climate.test" From 0442ed54e879ae9ae14a2002655ba775b6883c11 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 15 Sep 2021 13:05:12 -0600 Subject: [PATCH 162/168] Bump pyopenuv to 2.2.1 (#56270) --- homeassistant/components/openuv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index 24af3f3a3af..207bd307d21 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -3,7 +3,7 @@ "name": "OpenUV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openuv", - "requirements": ["pyopenuv==2.2.0"], + "requirements": ["pyopenuv==2.2.1"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 81bf740cd2e..95dc3851080 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1668,7 +1668,7 @@ pyobihai==1.3.1 pyombi==0.1.10 # homeassistant.components.openuv -pyopenuv==2.2.0 +pyopenuv==2.2.1 # homeassistant.components.opnsense pyopnsense==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 777fc2e77d0..2e591614104 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -966,7 +966,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.openuv -pyopenuv==2.2.0 +pyopenuv==2.2.1 # homeassistant.components.opnsense pyopnsense==0.2.0 From 9c4d6afb3c02e7087607ca2a0e3722b3d94663bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Sep 2021 19:01:02 +0200 Subject: [PATCH 163/168] Bump pychromecast to 9.2.1 (#56296) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 78f7bcf485c..092d122d5cf 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==9.2.0"], + "requirements": ["pychromecast==9.2.1"], "after_dependencies": [ "cloud", "http", diff --git a/requirements_all.txt b/requirements_all.txt index 95dc3851080..b866093cab7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1373,7 +1373,7 @@ pycfdns==1.2.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==9.2.0 +pychromecast==9.2.1 # homeassistant.components.pocketcasts pycketcasts==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e591614104..ba91c1bc54c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -788,7 +788,7 @@ pybotvac==0.0.22 pycfdns==1.2.1 # homeassistant.components.cast -pychromecast==9.2.0 +pychromecast==9.2.1 # homeassistant.components.climacell pyclimacell==0.18.2 From 8c936b320e2e865baa2de3f63ced29751ac451f0 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 17 Sep 2021 06:50:46 +0200 Subject: [PATCH 164/168] Avoid sending Standby when already off (#56306) --- homeassistant/components/philips_js/media_player.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index e4512fc52f0..4499fb61e2a 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -213,9 +213,12 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): async def async_turn_off(self): """Turn off the device.""" - await self._tv.sendKey("Standby") - self._state = STATE_OFF - await self._async_update_soon() + if self._state == STATE_ON: + await self._tv.sendKey("Standby") + self._state = STATE_OFF + await self._async_update_soon() + else: + _LOGGER.debug("Ignoring turn off when already in expected state") async def async_volume_up(self): """Send volume up command.""" From 22307066bb42f1ba86f611d157069c8553c9b989 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 18 Sep 2021 08:42:58 +0300 Subject: [PATCH 165/168] Bump aioswitcher to 2.0.6 (#56358) --- homeassistant/components/switcher_kis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 33ec7a67d92..88d3d2a3ad4 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,7 +3,7 @@ "name": "Switcher", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "codeowners": ["@tomerfi","@thecode"], - "requirements": ["aioswitcher==2.0.5"], + "requirements": ["aioswitcher==2.0.6"], "iot_class": "local_push", "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index b866093cab7..deafc44258e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.5 +aioswitcher==2.0.6 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba91c1bc54c..2e48ce94e0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.5 +aioswitcher==2.0.6 # homeassistant.components.syncthing aiosyncthing==0.5.1 From 3046adf3b2a0af132bb0776f8836f7e440ea34f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Sep 2021 19:26:25 -1000 Subject: [PATCH 166/168] Fix yeelight nightlight mode (#56363) --- homeassistant/components/yeelight/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index a0deb0fdf21..ed4ce0cbf58 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -70,8 +70,8 @@ ACTION_RECOVER = "recover" ACTION_STAY = "stay" ACTION_OFF = "off" -ACTIVE_MODE_NIGHTLIGHT = "1" -ACTIVE_COLOR_FLOWING = "1" +ACTIVE_MODE_NIGHTLIGHT = 1 +ACTIVE_COLOR_FLOWING = 1 NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" @@ -610,7 +610,7 @@ class YeelightDevice: # Only ceiling lights have active_mode, from SDK docs: # active_mode 0: daylight mode / 1: moonlight mode (ceiling light only) if self._active_mode is not None: - return self._active_mode == ACTIVE_MODE_NIGHTLIGHT + return int(self._active_mode) == ACTIVE_MODE_NIGHTLIGHT if self._nightlight_brightness is not None: return int(self._nightlight_brightness) > 0 @@ -620,7 +620,7 @@ class YeelightDevice: @property def is_color_flow_enabled(self) -> bool: """Return true / false if color flow is currently running.""" - return self._color_flow == ACTIVE_COLOR_FLOWING + return int(self._color_flow) == ACTIVE_COLOR_FLOWING @property def _active_mode(self): From f8d62866e50b61dd87f63fc2c08e58b8ece1096e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 17 Sep 2021 22:48:16 -0700 Subject: [PATCH 167/168] Bumped version to 2021.9.7 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 110c49fbc01..dde2c8db2f5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "6" +PATCH_VERSION: Final = "7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 0a5948dc8bf8006084d07229bc764c5a7a4c1257 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 16 Sep 2021 18:19:41 +0200 Subject: [PATCH 168/168] Fetch the data a second time when -9999 error occurs in Xiaomi Miio integration (#56288) --- homeassistant/components/xiaomi_miio/__init__.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index cde597432df..7dac02aaa53 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -171,12 +171,23 @@ async def async_create_miio_device_and_coordinator( async def async_update_data(): """Fetch data from the device using async_add_executor_job.""" - try: + + async def _async_fetch_data(): + """Fetch data from the device.""" async with async_timeout.timeout(10): state = await hass.async_add_executor_job(device.status) _LOGGER.debug("Got new state: %s", state) return state + try: + return await _async_fetch_data() + except DeviceException as ex: + if getattr(ex, "code", None) != -9999: + raise UpdateFailed(ex) from ex + _LOGGER.info("Got exception while fetching the state, trying again: %s", ex) + # Try to fetch the data a second time after error code -9999 + try: + return await _async_fetch_data() except DeviceException as ex: raise UpdateFailed(ex) from ex