From fdd815995570c99708c5342b519ca280a23ddf78 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 10 Feb 2021 22:32:48 +0100 Subject: [PATCH 01/13] Fix SNMP engine memory leak in Brother integration (#46272) * Fix SNMP engine memory leak * Fix pylint error --- homeassistant/components/brother/__init__.py | 42 ++++++++----------- .../components/brother/config_flow.py | 10 +++-- homeassistant/components/brother/const.py | 4 ++ .../components/brother/manifest.json | 2 +- homeassistant/components/brother/sensor.py | 3 +- homeassistant/components/brother/utils.py | 30 +++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 63 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/brother/utils.py diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 8c5cdb2d7ed..d7cf906a87c 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -6,12 +6,13 @@ import logging from brother import Brother, SnmpError, UnsupportedModel from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_TYPE, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import Config, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DATA_CONFIG_ENTRY, DOMAIN, SNMP +from .utils import get_snmp_engine PLATFORMS = ["sensor"] @@ -30,15 +31,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): host = entry.data[CONF_HOST] kind = entry.data[CONF_TYPE] - coordinator = BrotherDataUpdateCoordinator(hass, host=host, kind=kind) + snmp_engine = get_snmp_engine(hass) + + coordinator = BrotherDataUpdateCoordinator( + hass, host=host, kind=kind, snmp_engine=snmp_engine + ) await coordinator.async_refresh() if not coordinator.last_update_success: - coordinator.shutdown() raise ConfigEntryNotReady hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data[DOMAIN].setdefault(DATA_CONFIG_ENTRY, {}) + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = coordinator + hass.data[DOMAIN][SNMP] = snmp_engine for component in PLATFORMS: hass.async_create_task( @@ -59,7 +65,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ) ) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id).shutdown() + hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) + if not hass.data[DOMAIN][DATA_CONFIG_ENTRY]: + hass.data[DOMAIN].pop(SNMP) + hass.data[DOMAIN].pop(DATA_CONFIG_ENTRY) return unload_ok @@ -67,12 +76,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class BrotherDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Brother data from the printer.""" - def __init__(self, hass, host, kind): + def __init__(self, hass, host, kind, snmp_engine): """Initialize.""" - self.brother = Brother(host, kind=kind) - self._unsub_stop = hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop - ) + self.brother = Brother(host, kind=kind, snmp_engine=snmp_engine) super().__init__( hass, @@ -83,22 +89,8 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update data via library.""" - # Race condition on shutdown. Stop all the fetches. - if self._unsub_stop is None: - return None - try: await self.brother.async_update() except (ConnectionError, SnmpError, UnsupportedModel) as error: raise UpdateFailed(error) from error return self.brother.data - - def shutdown(self): - """Shutdown the Brother coordinator.""" - self._unsub_stop() - self._unsub_stop = None - self.brother.shutdown() - - def _handle_ha_stop(self, _): - """Handle Home Assistant stopping.""" - self.shutdown() diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index aa9d7ce53a3..6a9d2ca6746 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -9,6 +9,7 @@ from homeassistant import config_entries, exceptions from homeassistant.const import CONF_HOST, CONF_TYPE from .const import DOMAIN, PRINTER_TYPES # pylint:disable=unused-import +from .utils import get_snmp_engine DATA_SCHEMA = vol.Schema( { @@ -48,9 +49,10 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not host_valid(user_input[CONF_HOST]): raise InvalidHost() - brother = Brother(user_input[CONF_HOST]) + snmp_engine = get_snmp_engine(self.hass) + + brother = Brother(user_input[CONF_HOST], snmp_engine=snmp_engine) await brother.async_update() - brother.shutdown() await self.async_set_unique_id(brother.serial.lower()) self._abort_if_unique_id_configured() @@ -83,7 +85,9 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Hostname is format: brother.local. self.host = discovery_info["hostname"].rstrip(".") - self.brother = Brother(self.host) + snmp_engine = get_snmp_engine(self.hass) + + self.brother = Brother(self.host, snmp_engine=snmp_engine) try: await self.brother.async_update() except (ConnectionError, SnmpError, UnsupportedModel): diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index 5aecde16327..5ae459c79aa 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -41,12 +41,16 @@ ATTR_YELLOW_DRUM_REMAINING_PAGES = "yellow_drum_remaining_pages" ATTR_YELLOW_INK_REMAINING = "yellow_ink_remaining" ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining" +DATA_CONFIG_ENTRY = "config_entry" + DOMAIN = "brother" UNIT_PAGES = "p" PRINTER_TYPES = ["laser", "ink"] +SNMP = "snmp" + SENSOR_TYPES = { ATTR_STATUS: { ATTR_ICON: "mdi:printer", diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 9bb9ba00261..3f275338949 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -3,7 +3,7 @@ "name": "Brother Printer", "documentation": "https://www.home-assistant.io/integrations/brother", "codeowners": ["@bieniu"], - "requirements": ["brother==0.1.20"], + "requirements": ["brother==0.1.21"], "zeroconf": [{ "type": "_printer._tcp.local.", "name": "brother*" }], "config_flow": true, "quality_scale": "platinum" diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 40e2deae67d..a379d9b4154 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -24,6 +24,7 @@ from .const import ( ATTR_YELLOW_DRUM_COUNTER, ATTR_YELLOW_DRUM_REMAINING_LIFE, ATTR_YELLOW_DRUM_REMAINING_PAGES, + DATA_CONFIG_ENTRY, DOMAIN, SENSOR_TYPES, ) @@ -37,7 +38,7 @@ ATTR_SERIAL = "serial" async def async_setup_entry(hass, config_entry, async_add_entities): """Add Brother entities from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] sensors = [] diff --git a/homeassistant/components/brother/utils.py b/homeassistant/components/brother/utils.py new file mode 100644 index 00000000000..3a53f4c04a2 --- /dev/null +++ b/homeassistant/components/brother/utils.py @@ -0,0 +1,30 @@ +"""Brother helpers functions.""" +import logging + +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio.cmdgen import lcd + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback +from homeassistant.helpers import singleton + +from .const import DOMAIN, SNMP + +_LOGGER = logging.getLogger(__name__) + + +@singleton.singleton("snmp_engine") +def get_snmp_engine(hass): + """Get SNMP engine.""" + _LOGGER.debug("Creating SNMP engine") + snmp_engine = hlapi.SnmpEngine() + + @callback + def shutdown_listener(ev): + if hass.data.get(DOMAIN): + _LOGGER.debug("Unconfiguring SNMP engine") + lcd.unconfigure(hass.data[DOMAIN][SNMP], None) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener) + + return snmp_engine diff --git a/requirements_all.txt b/requirements_all.txt index 258474f97f3..b3e9b89a30c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -383,7 +383,7 @@ bravia-tv==1.0.8 broadlink==0.16.0 # homeassistant.components.brother -brother==0.1.20 +brother==0.1.21 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9583d901193..d6341905512 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -210,7 +210,7 @@ bravia-tv==1.0.8 broadlink==0.16.0 # homeassistant.components.brother -brother==0.1.20 +brother==0.1.21 # homeassistant.components.bsblan bsblan==0.4.0 From db557a094cd01d785a40da084decb18cb1c48a79 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Wed, 10 Feb 2021 10:01:24 -0800 Subject: [PATCH 02/13] Use oauthv3 for Tesla (#45766) --- homeassistant/components/tesla/__init__.py | 4 ++++ homeassistant/components/tesla/config_flow.py | 2 ++ homeassistant/components/tesla/manifest.json | 8 ++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tesla/test_config_flow.py | 2 ++ 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index 51090d34271..8981b269a56 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -103,6 +103,8 @@ async def async_setup(hass, base_config): _update_entry( email, data={ + CONF_USERNAME: email, + CONF_PASSWORD: password, CONF_ACCESS_TOKEN: info[CONF_ACCESS_TOKEN], CONF_TOKEN: info[CONF_TOKEN], }, @@ -136,6 +138,8 @@ async def async_setup_entry(hass, config_entry): try: controller = TeslaAPI( websession, + email=config.get(CONF_USERNAME), + password=config.get(CONF_PASSWORD), refresh_token=config[CONF_TOKEN], access_token=config[CONF_ACCESS_TOKEN], update_interval=config_entry.options.get( diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py index debe896c9cf..683ef314a06 100644 --- a/homeassistant/components/tesla/config_flow.py +++ b/homeassistant/components/tesla/config_flow.py @@ -140,6 +140,8 @@ async def validate_input(hass: core.HomeAssistant, data): (config[CONF_TOKEN], config[CONF_ACCESS_TOKEN]) = await controller.connect( test_login=True ) + config[CONF_USERNAME] = data[CONF_USERNAME] + config[CONF_PASSWORD] = data[CONF_PASSWORD] except TeslaException as ex: if ex.code == HTTP_UNAUTHORIZED: _LOGGER.error("Invalid credentials: %s", ex) diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json index 3679c0f74d1..9236aae7fb6 100644 --- a/homeassistant/components/tesla/manifest.json +++ b/homeassistant/components/tesla/manifest.json @@ -3,11 +3,11 @@ "name": "Tesla", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tesla", - "requirements": ["teslajsonpy==0.10.4"], + "requirements": ["teslajsonpy==0.11.5"], "codeowners": ["@zabuldon", "@alandtse"], "dhcp": [ - {"hostname":"tesla_*","macaddress":"4CFCAA*"}, - {"hostname":"tesla_*","macaddress":"044EAF*"}, - {"hostname":"tesla_*","macaddress":"98ED5C*"} + { "hostname": "tesla_*", "macaddress": "4CFCAA*" }, + { "hostname": "tesla_*", "macaddress": "044EAF*" }, + { "hostname": "tesla_*", "macaddress": "98ED5C*" } ] } diff --git a/requirements_all.txt b/requirements_all.txt index b3e9b89a30c..684c8f0f50a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2181,7 +2181,7 @@ temperusb==1.5.3 tesla-powerwall==0.3.3 # homeassistant.components.tesla -teslajsonpy==0.10.4 +teslajsonpy==0.11.5 # homeassistant.components.tensorflow # tf-models-official==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6341905512..8f65280eefa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1090,7 +1090,7 @@ tellduslive==0.10.11 tesla-powerwall==0.3.3 # homeassistant.components.tesla -teslajsonpy==0.10.4 +teslajsonpy==0.11.5 # homeassistant.components.toon toonapi==0.2.0 diff --git a/tests/components/tesla/test_config_flow.py b/tests/components/tesla/test_config_flow.py index 7fb308ecc43..136633c9a5c 100644 --- a/tests/components/tesla/test_config_flow.py +++ b/tests/components/tesla/test_config_flow.py @@ -48,6 +48,8 @@ async def test_form(hass): assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result2["title"] == "test@email.com" assert result2["data"] == { + CONF_USERNAME: "test@email.com", + CONF_PASSWORD: "test", CONF_TOKEN: "test-refresh-token", CONF_ACCESS_TOKEN: "test-access-token", } From d2d2bed16be2847d8e5196d6d4c435a4f3b9b49d Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Wed, 10 Feb 2021 13:30:52 -0700 Subject: [PATCH 03/13] Bump pymyq to 3.0.1 (#46079) Co-authored-by: J. Nick Koston --- homeassistant/components/myq/__init__.py | 12 +- homeassistant/components/myq/binary_sensor.py | 7 +- homeassistant/components/myq/const.py | 20 ++-- homeassistant/components/myq/cover.py | 103 ++++++++---------- homeassistant/components/myq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/myq/util.py | 32 ++++-- 8 files changed, 95 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index 959000da3b3..6b3a52ba7b0 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, PLATFORMS, UPDATE_INTERVAL @@ -40,11 +40,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): except MyQError as err: raise ConfigEntryNotReady from err + # Called by DataUpdateCoordinator, allows to capture any MyQError exceptions and to throw an HASS UpdateFailed + # exception instead, preventing traceback in HASS logs. + async def async_update_data(): + try: + return await myq.update_device_info() + except MyQError as err: + raise UpdateFailed(str(err)) from err + coordinator = DataUpdateCoordinator( hass, _LOGGER, name="myq devices", - update_method=myq.update_device_info, + update_method=async_update_data, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/myq/binary_sensor.py b/homeassistant/components/myq/binary_sensor.py index 57bd2451d2a..e3832458b9b 100644 --- a/homeassistant/components/myq/binary_sensor.py +++ b/homeassistant/components/myq/binary_sensor.py @@ -1,7 +1,5 @@ """Support for MyQ gateways.""" from pymyq.const import ( - DEVICE_FAMILY as MYQ_DEVICE_FAMILY, - DEVICE_FAMILY_GATEWAY as MYQ_DEVICE_FAMILY_GATEWAY, DEVICE_STATE as MYQ_DEVICE_STATE, DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, KNOWN_MODELS, @@ -25,9 +23,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] - for device in myq.devices.values(): - if device.device_json[MYQ_DEVICE_FAMILY] == MYQ_DEVICE_FAMILY_GATEWAY: - entities.append(MyQBinarySensorEntity(coordinator, device)) + for device in myq.gateways.values(): + entities.append(MyQBinarySensorEntity(coordinator, device)) async_add_entities(entities, True) diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py index 9251bce7447..6189b1601ea 100644 --- a/homeassistant/components/myq/const.py +++ b/homeassistant/components/myq/const.py @@ -1,9 +1,9 @@ """The MyQ integration.""" -from pymyq.device import ( - STATE_CLOSED as MYQ_STATE_CLOSED, - STATE_CLOSING as MYQ_STATE_CLOSING, - STATE_OPEN as MYQ_STATE_OPEN, - STATE_OPENING as MYQ_STATE_OPENING, +from pymyq.garagedoor import ( + STATE_CLOSED as MYQ_COVER_STATE_CLOSED, + STATE_CLOSING as MYQ_COVER_STATE_CLOSING, + STATE_OPEN as MYQ_COVER_STATE_OPEN, + STATE_OPENING as MYQ_COVER_STATE_OPENING, ) from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING @@ -13,10 +13,10 @@ DOMAIN = "myq" PLATFORMS = ["cover", "binary_sensor"] MYQ_TO_HASS = { - MYQ_STATE_CLOSED: STATE_CLOSED, - MYQ_STATE_CLOSING: STATE_CLOSING, - MYQ_STATE_OPEN: STATE_OPEN, - MYQ_STATE_OPENING: STATE_OPENING, + MYQ_COVER_STATE_CLOSED: STATE_CLOSED, + MYQ_COVER_STATE_CLOSING: STATE_CLOSING, + MYQ_COVER_STATE_OPEN: STATE_OPEN, + MYQ_COVER_STATE_OPENING: STATE_OPENING, } MYQ_GATEWAY = "myq_gateway" @@ -24,7 +24,7 @@ MYQ_COORDINATOR = "coordinator" # myq has some ratelimits in place # and 61 seemed to be work every time -UPDATE_INTERVAL = 61 +UPDATE_INTERVAL = 15 # Estimated time it takes myq to start transition from one # state to the next. diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index 6fef6b25bab..e26a969e724 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -1,14 +1,14 @@ """Support for MyQ-Enabled Garage Doors.""" -import time +import logging from pymyq.const import ( DEVICE_STATE as MYQ_DEVICE_STATE, DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, - DEVICE_TYPE as MYQ_DEVICE_TYPE, DEVICE_TYPE_GATE as MYQ_DEVICE_TYPE_GATE, KNOWN_MODELS, MANUFACTURER, ) +from pymyq.errors import MyQError from homeassistant.components.cover import ( DEVICE_CLASS_GARAGE, @@ -17,19 +17,12 @@ from homeassistant.components.cover import ( SUPPORT_OPEN, CoverEntity, ) -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING -from homeassistant.core import callback -from homeassistant.helpers.event import async_call_later +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DOMAIN, - MYQ_COORDINATOR, - MYQ_GATEWAY, - MYQ_TO_HASS, - TRANSITION_COMPLETE_DURATION, - TRANSITION_START_DURATION, -) +from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -50,13 +43,11 @@ class MyQDevice(CoordinatorEntity, CoverEntity): """Initialize with API object, device id.""" super().__init__(coordinator) self._device = device - self._last_action_timestamp = 0 - self._scheduled_transition_update = None @property def device_class(self): """Define this cover as a garage door.""" - device_type = self._device.device_json.get(MYQ_DEVICE_TYPE) + device_type = self._device.device_type if device_type is not None and device_type == MYQ_DEVICE_TYPE_GATE: return DEVICE_CLASS_GATE return DEVICE_CLASS_GARAGE @@ -87,6 +78,11 @@ class MyQDevice(CoordinatorEntity, CoverEntity): """Return if the cover is closing or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSING + @property + def is_open(self): + """Return if the cover is opening or not.""" + return MYQ_TO_HASS.get(self._device.state) == STATE_OPEN + @property def is_opening(self): """Return if the cover is opening or not.""" @@ -104,37 +100,48 @@ class MyQDevice(CoordinatorEntity, CoverEntity): async def async_close_cover(self, **kwargs): """Issue close command to cover.""" - self._last_action_timestamp = time.time() - await self._device.close() - self._async_schedule_update_for_transition() + if self.is_closing or self.is_closed: + return + + try: + wait_task = await self._device.close(wait_for_state=False) + except MyQError as err: + _LOGGER.error( + "Closing of cover %s failed with error: %s", self._device.name, str(err) + ) + + return + + # Write closing state to HASS + self.async_write_ha_state() + + if not await wait_task: + _LOGGER.error("Closing of cover %s failed", self._device.name) + + # Write final state to HASS + self.async_write_ha_state() async def async_open_cover(self, **kwargs): """Issue open command to cover.""" - self._last_action_timestamp = time.time() - await self._device.open() - self._async_schedule_update_for_transition() + if self.is_opening or self.is_open: + return - @callback - def _async_schedule_update_for_transition(self): + try: + wait_task = await self._device.open(wait_for_state=False) + except MyQError as err: + _LOGGER.error( + "Opening of cover %s failed with error: %s", self._device.name, str(err) + ) + return + + # Write opening state to HASS self.async_write_ha_state() - # Cancel any previous updates - if self._scheduled_transition_update: - self._scheduled_transition_update() + if not await wait_task: + _LOGGER.error("Opening of cover %s failed", self._device.name) - # Schedule an update for when we expect the transition - # to be completed so the garage door or gate does not - # seem like its closing or opening for a long time - self._scheduled_transition_update = async_call_later( - self.hass, - TRANSITION_COMPLETE_DURATION, - self._async_complete_schedule_update, - ) - - async def _async_complete_schedule_update(self, _): - """Update status of the cover via coordinator.""" - self._scheduled_transition_update = None - await self.coordinator.async_request_refresh() + # Write final state to HASS + self.async_write_ha_state() @property def device_info(self): @@ -152,22 +159,8 @@ class MyQDevice(CoordinatorEntity, CoverEntity): device_info["via_device"] = (DOMAIN, self._device.parent_device_id) return device_info - @callback - def _async_consume_update(self): - if time.time() - self._last_action_timestamp <= TRANSITION_START_DURATION: - # If we just started a transition we need - # to prevent a bouncy state - return - - self.async_write_ha_state() - async def async_added_to_hass(self): """Subscribe to updates.""" self.async_on_remove( - self.coordinator.async_add_listener(self._async_consume_update) + self.coordinator.async_add_listener(self.async_write_ha_state) ) - - async def async_will_remove_from_hass(self): - """Undo subscription.""" - if self._scheduled_transition_update: - self._scheduled_transition_update() diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index aba2f24b5bd..9dc8719ed4e 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==2.0.14"], + "requirements": ["pymyq==3.0.1"], "codeowners": ["@bdraco"], "config_flow": true, "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index 684c8f0f50a..4aca655d2e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1545,7 +1545,7 @@ pymsteams==0.1.12 pymusiccast==0.1.6 # homeassistant.components.myq -pymyq==2.0.14 +pymyq==3.0.1 # homeassistant.components.mysensors pymysensors==0.18.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f65280eefa..bd6aeec6894 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -802,7 +802,7 @@ pymodbus==2.3.0 pymonoprice==0.3 # homeassistant.components.myq -pymyq==2.0.14 +pymyq==3.0.1 # homeassistant.components.nut pynut2==2.1.2 diff --git a/tests/components/myq/util.py b/tests/components/myq/util.py index 84e85723918..8cb0d17f592 100644 --- a/tests/components/myq/util.py +++ b/tests/components/myq/util.py @@ -1,14 +1,18 @@ """Tests for the myq integration.""" - import json +import logging from unittest.mock import patch +from pymyq.const import ACCOUNTS_ENDPOINT, DEVICES_ENDPOINT + from homeassistant.components.myq.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +_LOGGER = logging.getLogger(__name__) + async def async_init_integration( hass: HomeAssistant, @@ -20,16 +24,24 @@ async def async_init_integration( devices_json = load_fixture(devices_fixture) devices_dict = json.loads(devices_json) - def _handle_mock_api_request(method, endpoint, **kwargs): - if endpoint == "Login": - return {"SecurityToken": 1234} - if endpoint == "My": - return {"Account": {"Id": 1}} - if endpoint == "Accounts/1/Devices": - return devices_dict - return {} + def _handle_mock_api_oauth_authenticate(): + return 1234, 1800 - with patch("pymyq.api.API.request", side_effect=_handle_mock_api_request): + def _handle_mock_api_request(method, returns, url, **kwargs): + _LOGGER.debug("URL: %s", url) + if url == ACCOUNTS_ENDPOINT: + _LOGGER.debug("Accounts") + return None, {"accounts": [{"id": 1, "name": "mock"}]} + if url == DEVICES_ENDPOINT.format(account_id=1): + _LOGGER.debug("Devices") + return None, devices_dict + _LOGGER.debug("Something else") + return None, {} + + with patch( + "pymyq.api.API._oauth_authenticate", + side_effect=_handle_mock_api_oauth_authenticate, + ), patch("pymyq.api.API.request", side_effect=_handle_mock_api_request): entry = MockConfigEntry( domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} ) From 30ddfd837a9f28f8826625d654b425acd8c27f55 Mon Sep 17 00:00:00 2001 From: "J.P. Hutchins" <34154542+JPHutchins@users.noreply.github.com> Date: Wed, 10 Feb 2021 11:53:31 -0800 Subject: [PATCH 04/13] Revert transmission to check torrent lists by name rather than object (#46190) --- .../components/transmission/__init__.py | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index d020bfe9745..76d9aedd8d5 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -397,42 +397,49 @@ class TransmissionData: def check_completed_torrent(self): """Get completed torrent functionality.""" + old_completed_torrent_names = { + torrent.name for torrent in self._completed_torrents + } + current_completed_torrents = [ torrent for torrent in self._torrents if torrent.status == "seeding" ] - freshly_completed_torrents = set(current_completed_torrents).difference( - self._completed_torrents - ) - self._completed_torrents = current_completed_torrents - for torrent in freshly_completed_torrents: - self.hass.bus.fire( - EVENT_DOWNLOADED_TORRENT, {"name": torrent.name, "id": torrent.id} - ) + for torrent in current_completed_torrents: + if torrent.name not in old_completed_torrent_names: + self.hass.bus.fire( + EVENT_DOWNLOADED_TORRENT, {"name": torrent.name, "id": torrent.id} + ) + + self._completed_torrents = current_completed_torrents def check_started_torrent(self): """Get started torrent functionality.""" + old_started_torrent_names = {torrent.name for torrent in self._started_torrents} + current_started_torrents = [ torrent for torrent in self._torrents if torrent.status == "downloading" ] - freshly_started_torrents = set(current_started_torrents).difference( - self._started_torrents - ) - self._started_torrents = current_started_torrents - for torrent in freshly_started_torrents: - self.hass.bus.fire( - EVENT_STARTED_TORRENT, {"name": torrent.name, "id": torrent.id} - ) + for torrent in current_started_torrents: + if torrent.name not in old_started_torrent_names: + self.hass.bus.fire( + EVENT_STARTED_TORRENT, {"name": torrent.name, "id": torrent.id} + ) + + self._started_torrents = current_started_torrents def check_removed_torrent(self): """Get removed torrent functionality.""" - freshly_removed_torrents = set(self._all_torrents).difference(self._torrents) - self._all_torrents = self._torrents - for torrent in freshly_removed_torrents: - self.hass.bus.fire( - EVENT_REMOVED_TORRENT, {"name": torrent.name, "id": torrent.id} - ) + current_torrent_names = {torrent.name for torrent in self._torrents} + + for torrent in self._all_torrents: + if torrent.name not in current_torrent_names: + self.hass.bus.fire( + EVENT_REMOVED_TORRENT, {"name": torrent.name, "id": torrent.id} + ) + + self._all_torrents = self._torrents.copy() def start_torrents(self): """Start all torrents.""" From b5bdd7f2cffec0eebcea151aec0175fed32c68ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Feb 2021 09:50:38 -1000 Subject: [PATCH 05/13] Update powerwall for new authentication requirements (#46254) Co-authored-by: badguy99 <61918526+badguy99@users.noreply.github.com> --- .../components/powerwall/__init__.py | 82 ++++++++++++---- .../components/powerwall/config_flow.py | 58 ++++++++---- .../components/powerwall/manifest.json | 2 +- .../components/powerwall/strings.json | 10 +- .../components/powerwall/translations/en.json | 10 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/powerwall/test_config_flow.py | 94 ++++++++++++++++--- 8 files changed, 201 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 54b7310b7ad..b392b713741 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -4,10 +4,15 @@ from datetime import timedelta import logging import requests -from tesla_powerwall import MissingAttributeError, Powerwall, PowerwallUnreachableError +from tesla_powerwall import ( + AccessDeniedError, + MissingAttributeError, + Powerwall, + PowerwallUnreachableError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry @@ -93,11 +98,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN].setdefault(entry_id, {}) http_session = requests.Session() + + password = entry.data.get(CONF_PASSWORD) power_wall = Powerwall(entry.data[CONF_IP_ADDRESS], http_session=http_session) try: - await hass.async_add_executor_job(power_wall.detect_and_pin_version) - await hass.async_add_executor_job(_fetch_powerwall_data, power_wall) - powerwall_data = await hass.async_add_executor_job(call_base_info, power_wall) + powerwall_data = await hass.async_add_executor_job( + _login_and_fetch_base_info, power_wall, password + ) except PowerwallUnreachableError as err: http_session.close() raise ConfigEntryNotReady from err @@ -105,6 +112,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): http_session.close() await _async_handle_api_changed_error(hass, err) return False + except AccessDeniedError as err: + _LOGGER.debug("Authentication failed", exc_info=err) + http_session.close() + _async_start_reauth(hass, entry) + return False await _migrate_old_unique_ids(hass, entry_id, powerwall_data) @@ -112,22 +124,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Fetch data from API endpoint.""" # Check if we had an error before _LOGGER.debug("Checking if update failed") - if not hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED]: - _LOGGER.debug("Updating data") - try: - return await hass.async_add_executor_job( - _fetch_powerwall_data, power_wall - ) - except PowerwallUnreachableError as err: - raise UpdateFailed("Unable to fetch data from powerwall") from err - except MissingAttributeError as err: - await _async_handle_api_changed_error(hass, err) - hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED] = True - # Returns the cached data. This data can also be None - return hass.data[DOMAIN][entry.entry_id][POWERWALL_COORDINATOR].data - else: + if hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED]: return hass.data[DOMAIN][entry.entry_id][POWERWALL_COORDINATOR].data + _LOGGER.debug("Updating data") + try: + return await _async_update_powerwall_data(hass, entry, power_wall) + except AccessDeniedError: + if password is None: + raise + + # If the session expired, relogin, and try again + await hass.async_add_executor_job(power_wall.login, "", password) + return await _async_update_powerwall_data(hass, entry, power_wall) + coordinator = DataUpdateCoordinator( hass, _LOGGER, @@ -156,6 +166,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True +async def _async_update_powerwall_data( + hass: HomeAssistant, entry: ConfigEntry, power_wall: Powerwall +): + """Fetch updated powerwall data.""" + try: + return await hass.async_add_executor_job(_fetch_powerwall_data, power_wall) + except PowerwallUnreachableError as err: + raise UpdateFailed("Unable to fetch data from powerwall") from err + except MissingAttributeError as err: + await _async_handle_api_changed_error(hass, err) + hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED] = True + # Returns the cached data. This data can also be None + return hass.data[DOMAIN][entry.entry_id][POWERWALL_COORDINATOR].data + + +def _async_start_reauth(hass: HomeAssistant, entry: ConfigEntry): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data=entry.data, + ) + ) + _LOGGER.error("Password is no longer valid. Please reauthenticate") + + +def _login_and_fetch_base_info(power_wall: Powerwall, password: str): + """Login to the powerwall and fetch the base info.""" + if password is not None: + power_wall.login("", password) + power_wall.detect_and_pin_version() + return call_base_info(power_wall) + + def call_base_info(power_wall): """Wrap powerwall properties to be a callable.""" serial_numbers = power_wall.get_serial_numbers() diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 37ee2730bb4..b649b160085 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -1,12 +1,17 @@ """Config flow for Tesla Powerwall integration.""" import logging -from tesla_powerwall import MissingAttributeError, Powerwall, PowerwallUnreachableError +from tesla_powerwall import ( + AccessDeniedError, + MissingAttributeError, + Powerwall, + PowerwallUnreachableError, +) import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.components.dhcp import IP_ADDRESS -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import callback from .const import DOMAIN # pylint:disable=unused-import @@ -14,6 +19,14 @@ from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) +def _login_and_fetch_site_info(power_wall: Powerwall, password: str): + """Login to the powerwall and fetch the base info.""" + if password is not None: + power_wall.login("", password) + power_wall.detect_and_pin_version() + return power_wall.get_site_info() + + async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect. @@ -21,12 +34,12 @@ async def validate_input(hass: core.HomeAssistant, data): """ power_wall = Powerwall(data[CONF_IP_ADDRESS]) + password = data[CONF_PASSWORD] try: - await hass.async_add_executor_job(power_wall.detect_and_pin_version) - site_info = await hass.async_add_executor_job(power_wall.get_site_info) - except PowerwallUnreachableError as err: - raise CannotConnect from err + site_info = await hass.async_add_executor_job( + _login_and_fetch_site_info, power_wall, password + ) except MissingAttributeError as err: # Only log the exception without the traceback _LOGGER.error(str(err)) @@ -62,27 +75,44 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" + except PowerwallUnreachableError: + errors[CONF_IP_ADDRESS] = "cannot_connect" except WrongVersion: errors["base"] = "wrong_version" + except AccessDeniedError: + errors[CONF_PASSWORD] = "invalid_auth" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - if "base" not in errors: - await self.async_set_unique_id(user_input[CONF_IP_ADDRESS]) - self._abort_if_unique_id_configured() + if not errors: + existing_entry = await self.async_set_unique_id( + user_input[CONF_IP_ADDRESS] + ) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=user_input + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( step_id="user", data_schema=vol.Schema( - {vol.Required(CONF_IP_ADDRESS, default=self.ip_address): str} + { + vol.Required(CONF_IP_ADDRESS, default=self.ip_address): str, + vol.Optional(CONF_PASSWORD): str, + } ), errors=errors, ) + async def async_step_reauth(self, data): + """Handle configuration by re-auth.""" + self.ip_address = data[CONF_IP_ADDRESS] + return await self.async_step_user() + @callback def _async_ip_address_already_configured(self, ip_address): """See if we already have an entry matching the ip_address.""" @@ -92,9 +122,5 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return False -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - - class WrongVersion(exceptions.HomeAssistantError): """Error to indicate the powerwall uses a software version we cannot interact with.""" diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 6b7b147d3c5..40d0a6c50fe 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -3,7 +3,7 @@ "name": "Tesla Powerwall", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/powerwall", - "requirements": ["tesla-powerwall==0.3.3"], + "requirements": ["tesla-powerwall==0.3.5"], "codeowners": ["@bdraco", "@jrester"], "dhcp": [ {"hostname":"1118431-*","macaddress":"88DA1A*"}, diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index ac0d9568154..c576d931756 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -4,18 +4,22 @@ "step": { "user": { "title": "Connect to the powerwall", + "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Telsa app; or the last 5 characters of the password found inside the door for Backup Gateway 2.", "data": { - "ip_address": "[%key:common::config_flow::data::ip%]" + "ip_address": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::password%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "wrong_version": "Your powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/powerwall/translations/en.json b/homeassistant/components/powerwall/translations/en.json index 6eb0b77708d..4ebe1e9d5ef 100644 --- a/homeassistant/components/powerwall/translations/en.json +++ b/homeassistant/components/powerwall/translations/en.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", "unknown": "Unexpected error", "wrong_version": "Your powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved." }, @@ -12,10 +14,12 @@ "step": { "user": { "data": { - "ip_address": "IP Address" + "ip_address": "IP Address", + "password": "Password" }, + "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Telsa app; or the last 5 characters of the password found inside the door for Backup Gateway 2.", "title": "Connect to the powerwall" } } } -} \ No newline at end of file +} diff --git a/requirements_all.txt b/requirements_all.txt index 4aca655d2e1..d014df91d46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2178,7 +2178,7 @@ temperusb==1.5.3 # tensorflow==2.3.0 # homeassistant.components.powerwall -tesla-powerwall==0.3.3 +tesla-powerwall==0.3.5 # homeassistant.components.tesla teslajsonpy==0.11.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd6aeec6894..12836fea643 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1087,7 +1087,7 @@ synologydsm-api==1.0.1 tellduslive==0.10.11 # homeassistant.components.powerwall -tesla-powerwall==0.3.3 +tesla-powerwall==0.3.5 # homeassistant.components.tesla teslajsonpy==0.11.5 diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 0955c16c9ec..be071b45947 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -2,17 +2,23 @@ from unittest.mock import patch -from tesla_powerwall import MissingAttributeError, PowerwallUnreachableError +from tesla_powerwall import ( + AccessDeniedError, + MissingAttributeError, + PowerwallUnreachableError, +) from homeassistant import config_entries, setup from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.components.powerwall.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from .mocks import _mock_powerwall_side_effect, _mock_powerwall_site_name from tests.common import MockConfigEntry +VALID_CONFIG = {CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "00GGX"} + async def test_form_source_user(hass): """Test we get config flow setup form as a user.""" @@ -36,13 +42,13 @@ async def test_form_source_user(hass): ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_IP_ADDRESS: "1.2.3.4"}, + VALID_CONFIG, ) await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "My site" - assert result2["data"] == {CONF_IP_ADDRESS: "1.2.3.4"} + assert result2["data"] == VALID_CONFIG assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -61,11 +67,32 @@ async def test_form_cannot_connect(hass): ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_IP_ADDRESS: "1.2.3.4"}, + VALID_CONFIG, ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} + + +async def test_invalid_auth(hass): + """Test we handle invalid auth error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_powerwall = _mock_powerwall_side_effect(site_info=AccessDeniedError("any")) + + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} async def test_form_unknown_exeption(hass): @@ -81,8 +108,7 @@ async def test_form_unknown_exeption(hass): return_value=mock_powerwall, ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_IP_ADDRESS: "1.2.3.4"}, + result["flow_id"], VALID_CONFIG ) assert result2["type"] == "form" @@ -105,7 +131,7 @@ async def test_form_wrong_version(hass): ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_IP_ADDRESS: "1.2.3.4"}, + VALID_CONFIG, ) assert result3["type"] == "form" @@ -178,16 +204,54 @@ async def test_dhcp_discovery(hass): ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_IP_ADDRESS: "1.1.1.1", - }, + VALID_CONFIG, ) await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "Some site" - assert result2["data"] == { - CONF_IP_ADDRESS: "1.1.1.1", - } + assert result2["data"] == VALID_CONFIG + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_reauth(hass): + """Test reauthenticate.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data=VALID_CONFIG, + unique_id="1.2.3.4", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=entry.data + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_powerwall = await _mock_powerwall_site_name(hass, "My site") + + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.powerwall.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "new-test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 From bf2a34600accbe6412eefe248b162a45fa62de1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Feb 2021 09:48:15 -1000 Subject: [PATCH 06/13] Fix Lutron Integration Protocol reconnect logic (#46264) --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index 34ab75dc0cd..88c6eddd0bf 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -3,7 +3,7 @@ "name": "Lutron Caséta", "documentation": "https://www.home-assistant.io/integrations/lutron_caseta", "requirements": [ - "pylutron-caseta==0.9.0", "aiolip==1.0.1" + "pylutron-caseta==0.9.0", "aiolip==1.1.4" ], "config_flow": true, "zeroconf": ["_leap._tcp.local."], diff --git a/requirements_all.txt b/requirements_all.txt index d014df91d46..b41919fe15b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -197,7 +197,7 @@ aiolifx==0.6.9 aiolifx_effects==0.2.2 # homeassistant.components.lutron_caseta -aiolip==1.0.1 +aiolip==1.1.4 # homeassistant.components.keyboard_remote aionotify==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12836fea643..5a7331154b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -116,7 +116,7 @@ aiohue==2.1.0 aiokafka==0.6.0 # homeassistant.components.lutron_caseta -aiolip==1.0.1 +aiolip==1.1.4 # homeassistant.components.notion aionotion==1.1.0 From 1c2f72a453ccfd0f77701f353a029b1c0b394290 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Wed, 10 Feb 2021 09:27:25 +0000 Subject: [PATCH 07/13] Bump roonapi to 0.0.32 (#46286) --- homeassistant/components/roon/manifest.json | 2 +- homeassistant/components/roon/server.py | 11 ----------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index 0d5d0c131ae..e4c4a25dcb5 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roon", "requirements": [ - "roonapi==0.0.31" + "roonapi==0.0.32" ], "codeowners": [ "@pavoni" diff --git a/homeassistant/components/roon/server.py b/homeassistant/components/roon/server.py index d5b8d81c2aa..83b620e176e 100644 --- a/homeassistant/components/roon/server.py +++ b/homeassistant/components/roon/server.py @@ -141,17 +141,6 @@ class RoonServer: async_dispatcher_send(self.hass, "roon_media_player", player_data) self.offline_devices.add(dev_id) - async def async_update_playlists(self): - """Store lists in memory with all playlists - could be used by a custom lovelace card.""" - all_playlists = [] - roon_playlists = self.roonapi.playlists() - if roon_playlists and "items" in roon_playlists: - all_playlists += [item["title"] for item in roon_playlists["items"]] - roon_playlists = self.roonapi.internet_radio() - if roon_playlists and "items" in roon_playlists: - all_playlists += [item["title"] for item in roon_playlists["items"]] - self.all_playlists = all_playlists - async def async_create_player_data(self, zone, output): """Create player object dict by combining zone with output.""" new_dict = zone.copy() diff --git a/requirements_all.txt b/requirements_all.txt index b41919fe15b..83c93c0aa4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1961,7 +1961,7 @@ rokuecp==0.6.0 roombapy==1.6.2 # homeassistant.components.roon -roonapi==0.0.31 +roonapi==0.0.32 # homeassistant.components.rova rova==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a7331154b7..dbc0e12fcb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -983,7 +983,7 @@ rokuecp==0.6.0 roombapy==1.6.2 # homeassistant.components.roon -roonapi==0.0.31 +roonapi==0.0.32 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 From 70af3e4776d8955589e5ea7eddf49119006d0d80 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 10 Feb 2021 16:30:16 +0100 Subject: [PATCH 08/13] Add guards for missing value in binary_sensor platform of zwave_js integration (#46293) --- homeassistant/components/zwave_js/binary_sensor.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index bb2e4355f16..ae5444b7079 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -258,8 +258,10 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Representation of a Z-Wave binary_sensor.""" @property - def is_on(self) -> bool: + def is_on(self) -> Optional[bool]: """Return if the sensor is on or off.""" + if self.info.primary_value.value is None: + return None return bool(self.info.primary_value.value) @property @@ -301,8 +303,10 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): self._mapping_info = self._get_sensor_mapping() @property - def is_on(self) -> bool: + def is_on(self) -> Optional[bool]: """Return if the sensor is on or off.""" + if self.info.primary_value.value is None: + return None return int(self.info.primary_value.value) == int(self.state_key) @property @@ -349,8 +353,10 @@ class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): self._mapping_info = self._get_sensor_mapping() @property - def is_on(self) -> bool: + def is_on(self) -> Optional[bool]: """Return if the sensor is on or off.""" + if self.info.primary_value.value is None: + return None return self.info.primary_value.value in self._mapping_info["on_states"] @property From 9f839b8c613cc2a50b5d4047c59281f13242b0da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Feb 2021 09:55:06 -1000 Subject: [PATCH 09/13] Add reauth support for tesla (#46307) --- homeassistant/components/tesla/__init__.py | 43 ++++++-- homeassistant/components/tesla/config_flow.py | 101 ++++++++++-------- homeassistant/components/tesla/strings.json | 4 + .../components/tesla/translations/en.json | 4 + tests/components/tesla/test_config_flow.py | 39 ++++++- 5 files changed, 136 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index 8981b269a56..b31f8ae6dd3 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -5,10 +5,11 @@ from datetime import timedelta import logging import async_timeout -from teslajsonpy import Controller as TeslaAPI, TeslaException +from teslajsonpy import Controller as TeslaAPI +from teslajsonpy.exceptions import IncompleteCredentials, TeslaException import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, @@ -17,8 +18,9 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME, + HTTP_UNAUTHORIZED, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.update_coordinator import ( @@ -28,12 +30,7 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import slugify -from .config_flow import ( - CannotConnect, - InvalidAuth, - configured_instances, - validate_input, -) +from .config_flow import CannotConnect, InvalidAuth, validate_input from .const import ( CONF_WAKE_ON_START, DATA_LISTENER, @@ -75,6 +72,16 @@ def _async_save_tokens(hass, config_entry, access_token, refresh_token): ) +@callback +def _async_configured_emails(hass): + """Return a set of configured Tesla emails.""" + return { + entry.data[CONF_USERNAME] + for entry in hass.config_entries.async_entries(DOMAIN) + if CONF_USERNAME in entry.data + } + + async def async_setup(hass, base_config): """Set up of Tesla component.""" @@ -95,7 +102,7 @@ async def async_setup(hass, base_config): email = config[CONF_USERNAME] password = config[CONF_PASSWORD] scan_interval = config[CONF_SCAN_INTERVAL] - if email in configured_instances(hass): + if email in _async_configured_emails(hass): try: info = await validate_input(hass, config) except (CannotConnect, InvalidAuth): @@ -151,7 +158,12 @@ async def async_setup_entry(hass, config_entry): CONF_WAKE_ON_START, DEFAULT_WAKE_ON_START ) ) + except IncompleteCredentials: + _async_start_reauth(hass, config_entry) + return False except TeslaException as ex: + if ex.code == HTTP_UNAUTHORIZED: + _async_start_reauth(hass, config_entry) _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) return False _async_save_tokens(hass, config_entry, access_token, refresh_token) @@ -206,6 +218,17 @@ async def async_unload_entry(hass, config_entry) -> bool: return False +def _async_start_reauth(hass: HomeAssistant, entry: ConfigEntry): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data=entry.data, + ) + ) + _LOGGER.error("Credentials are no longer valid. Please reauthenticate") + + async def update_listener(hass, config_entry): """Update when config_entry options update.""" controller = hass.data[DOMAIN][config_entry.entry_id]["coordinator"].controller diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py index 683ef314a06..194ea71a3b7 100644 --- a/homeassistant/components/tesla/config_flow.py +++ b/homeassistant/components/tesla/config_flow.py @@ -20,22 +20,12 @@ from .const import ( CONF_WAKE_ON_START, DEFAULT_SCAN_INTERVAL, DEFAULT_WAKE_ON_START, - DOMAIN, MIN_SCAN_INTERVAL, ) +from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} -) - - -@callback -def configured_instances(hass): - """Return a set of configured Tesla instances.""" - return {entry.title for entry in hass.config_entries.async_entries(DOMAIN)} - class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Tesla.""" @@ -43,46 +33,56 @@ class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + def __init__(self): + """Initialize the tesla flow.""" + self.username = None + async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" + errors = {} - if not user_input: - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={}, - description_placeholders={}, - ) + if user_input is not None: + existing_entry = self._async_entry_for_username(user_input[CONF_USERNAME]) + if ( + existing_entry + and existing_entry.data[CONF_PASSWORD] == user_input[CONF_PASSWORD] + ): + return self.async_abort(reason="already_configured") - if user_input[CONF_USERNAME] in configured_instances(self.hass): - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={CONF_USERNAME: "already_configured"}, - description_placeholders={}, - ) + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={"base": "cannot_connect"}, - description_placeholders={}, - ) - except InvalidAuth: - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={"base": "invalid_auth"}, - description_placeholders={}, - ) - return self.async_create_entry(title=user_input[CONF_USERNAME], data=info) + if not errors: + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=info + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=info + ) + + return self.async_show_form( + step_id="user", + data_schema=self._async_schema(), + errors=errors, + description_placeholders={}, + ) + + async def async_step_reauth(self, data): + """Handle configuration by re-auth.""" + self.username = data[CONF_USERNAME] + return await self.async_step_user() @staticmethod @callback @@ -90,6 +90,23 @@ class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) + @callback + def _async_schema(self): + """Fetch schema with defaults.""" + return vol.Schema( + { + vol.Required(CONF_USERNAME, default=self.username): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + @callback + def _async_entry_for_username(self, username): + """Find an existing entry for a username.""" + for entry in self._async_current_entries(): + if entry.data.get(CONF_USERNAME) == username: + return entry + class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for Tesla.""" diff --git a/homeassistant/components/tesla/strings.json b/homeassistant/components/tesla/strings.json index 503124eedd4..c75562528de 100644 --- a/homeassistant/components/tesla/strings.json +++ b/homeassistant/components/tesla/strings.json @@ -5,6 +5,10 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/tesla/translations/en.json b/homeassistant/components/tesla/translations/en.json index f2b888552b9..53b213ac19b 100644 --- a/homeassistant/components/tesla/translations/en.json +++ b/homeassistant/components/tesla/translations/en.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + }, "error": { "already_configured": "Account is already configured", "cannot_connect": "Failed to connect", diff --git a/tests/components/tesla/test_config_flow.py b/tests/components/tesla/test_config_flow.py index 136633c9a5c..b35ab0039d0 100644 --- a/tests/components/tesla/test_config_flow.py +++ b/tests/components/tesla/test_config_flow.py @@ -97,7 +97,12 @@ async def test_form_cannot_connect(hass): async def test_form_repeat_identifier(hass): """Test we handle repeat identifiers.""" - entry = MockConfigEntry(domain=DOMAIN, title="test-username", data={}, options=None) + entry = MockConfigEntry( + domain=DOMAIN, + title="test-username", + data={"username": "test-username", "password": "test-password"}, + options=None, + ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -112,8 +117,36 @@ async def test_form_repeat_identifier(hass): {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, ) - assert result2["type"] == "form" - assert result2["errors"] == {CONF_USERNAME: "already_configured"} + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + +async def test_form_reauth(hass): + """Test we handle reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="test-username", + data={"username": "test-username", "password": "same"}, + options=None, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH}, + data={"username": "test-username"}, + ) + with patch( + "homeassistant.components.tesla.config_flow.TeslaAPI.connect", + return_value=("test-refresh-token", "test-access-token"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test-username", CONF_PASSWORD: "new-password"}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" async def test_import(hass): From 291746334ae9756b76c8d898d7e73b290cee190d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 10 Feb 2021 15:25:24 +0100 Subject: [PATCH 10/13] Add `already_in_progress` string to roku config flow (#46333) --- homeassistant/components/roku/strings.json | 1 + homeassistant/components/roku/translations/en.json | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 55b533d4f1c..3523615ff33 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -19,6 +19,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/homeassistant/components/roku/translations/en.json b/homeassistant/components/roku/translations/en.json index 08db89f3677..2b54cafe890 100644 --- a/homeassistant/components/roku/translations/en.json +++ b/homeassistant/components/roku/translations/en.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", "unknown": "Unexpected error" }, "error": { From b5061d0232b61f31afbc2b33d9d1827f0940a2fd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 10 Feb 2021 16:30:29 +0100 Subject: [PATCH 11/13] Restore Google/Alexa extra significant change checks (#46335) --- .../components/alexa/state_report.py | 38 ++++++-- .../google_assistant/report_state.py | 37 ++++++-- homeassistant/helpers/significant_change.py | 92 +++++++++++++------ tests/components/alexa/test_state_report.py | 25 ++++- .../google_assistant/test_report_state.py | 18 ++++ tests/helpers/test_significant_change.py | 30 +++++- 6 files changed, 191 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index aa4110ea686..d66906810b2 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -8,7 +8,7 @@ import aiohttp import async_timeout from homeassistant.const import HTTP_ACCEPTED, MATCH_ALL, STATE_ON -from homeassistant.core import State +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.significant_change import create_checker import homeassistant.util.dt as dt_util @@ -28,7 +28,20 @@ async def async_enable_proactive_mode(hass, smart_home_config): # Validate we can get access token. await smart_home_config.async_get_access_token() - checker = await create_checker(hass, DOMAIN) + @callback + def extra_significant_check( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + old_extra_arg: dict, + new_state: str, + new_attrs: dict, + new_extra_arg: dict, + ): + """Check if the serialized data has changed.""" + return old_extra_arg is not None and old_extra_arg != new_extra_arg + + checker = await create_checker(hass, DOMAIN, extra_significant_check) async def async_entity_state_listener( changed_entity: str, @@ -70,15 +83,22 @@ async def async_enable_proactive_mode(hass, smart_home_config): if not should_report and not should_doorbell: return - if not checker.async_is_significant_change(new_state): - return - if should_doorbell: should_report = False + if should_report: + alexa_properties = list(alexa_changed_entity.serialize_properties()) + else: + alexa_properties = None + + if not checker.async_is_significant_change( + new_state, extra_arg=alexa_properties + ): + return + if should_report: await async_send_changereport_message( - hass, smart_home_config, alexa_changed_entity + hass, smart_home_config, alexa_changed_entity, alexa_properties ) elif should_doorbell: @@ -92,7 +112,7 @@ async def async_enable_proactive_mode(hass, smart_home_config): async def async_send_changereport_message( - hass, config, alexa_entity, *, invalidate_access_token=True + hass, config, alexa_entity, alexa_properties, *, invalidate_access_token=True ): """Send a ChangeReport message for an Alexa entity. @@ -107,7 +127,7 @@ async def async_send_changereport_message( payload = { API_CHANGE: { "cause": {"type": Cause.APP_INTERACTION}, - "properties": list(alexa_entity.serialize_properties()), + "properties": alexa_properties, } } @@ -146,7 +166,7 @@ async def async_send_changereport_message( ): config.async_invalidate_access_token() return await async_send_changereport_message( - hass, config, alexa_entity, invalidate_access_token=False + hass, config, alexa_entity, alexa_properties, invalidate_access_token=False ) _LOGGER.error( diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 8943d4d211e..cdfb06c5c39 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -38,42 +38,59 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig if not entity.is_supported(): return - if not checker.async_is_significant_change(new_state): - return - try: entity_data = entity.query_serialize() except SmartHomeError as err: _LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code) return + if not checker.async_is_significant_change(new_state, extra_arg=entity_data): + return + _LOGGER.debug("Reporting state for %s: %s", changed_entity, entity_data) await google_config.async_report_state_all( {"devices": {"states": {changed_entity: entity_data}}} ) + @callback + def extra_significant_check( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + old_extra_arg: dict, + new_state: str, + new_attrs: dict, + new_extra_arg: dict, + ): + """Check if the serialized data has changed.""" + return old_extra_arg != new_extra_arg + async def inital_report(_now): """Report initially all states.""" nonlocal unsub, checker entities = {} - checker = await create_checker(hass, DOMAIN) + checker = await create_checker(hass, DOMAIN, extra_significant_check) for entity in async_get_entities(hass, google_config): if not entity.should_expose(): continue - # Tell our significant change checker that we're reporting - # So it knows with subsequent changes what was already reported. - if not checker.async_is_significant_change(entity.state): - continue - try: - entities[entity.entity_id] = entity.query_serialize() + entity_data = entity.query_serialize() except SmartHomeError: continue + # Tell our significant change checker that we're reporting + # So it knows with subsequent changes what was already reported. + if not checker.async_is_significant_change( + entity.state, extra_arg=entity_data + ): + continue + + entities[entity.entity_id] = entity_data + if not entities: return diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index 0a5b6aae10d..694acfcf2bd 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -27,7 +27,7 @@ The following cases will never be passed to your function: - state adding/removing """ from types import MappingProxyType -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, Dict, Optional, Tuple, Union from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State, callback @@ -47,13 +47,28 @@ CheckTypeFunc = Callable[ Optional[bool], ] +ExtraCheckTypeFunc = Callable[ + [ + HomeAssistant, + str, + Union[dict, MappingProxyType], + Any, + str, + Union[dict, MappingProxyType], + Any, + ], + Optional[bool], +] + async def create_checker( - hass: HomeAssistant, _domain: str + hass: HomeAssistant, + _domain: str, + extra_significant_check: Optional[ExtraCheckTypeFunc] = None, ) -> "SignificantlyChangedChecker": """Create a significantly changed checker for a domain.""" await _initialize(hass) - return SignificantlyChangedChecker(hass) + return SignificantlyChangedChecker(hass, extra_significant_check) # Marked as singleton so multiple calls all wait for same output. @@ -105,34 +120,46 @@ class SignificantlyChangedChecker: Will always compare the entity to the last entity that was considered significant. """ - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, + hass: HomeAssistant, + extra_significant_check: Optional[ExtraCheckTypeFunc] = None, + ) -> None: """Test if an entity has significantly changed.""" self.hass = hass - self.last_approved_entities: Dict[str, State] = {} + self.last_approved_entities: Dict[str, Tuple[State, Any]] = {} + self.extra_significant_check = extra_significant_check @callback - def async_is_significant_change(self, new_state: State) -> bool: - """Return if this was a significant change.""" - old_state: Optional[State] = self.last_approved_entities.get( + def async_is_significant_change( + self, new_state: State, *, extra_arg: Optional[Any] = None + ) -> bool: + """Return if this was a significant change. + + Extra kwargs are passed to the extra significant checker. + """ + old_data: Optional[Tuple[State, Any]] = self.last_approved_entities.get( new_state.entity_id ) # First state change is always ok to report - if old_state is None: - self.last_approved_entities[new_state.entity_id] = new_state + if old_data is None: + self.last_approved_entities[new_state.entity_id] = (new_state, extra_arg) return True + old_state, old_extra_arg = old_data + # Handle state unknown or unavailable if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): if new_state.state == old_state.state: return False - self.last_approved_entities[new_state.entity_id] = new_state + self.last_approved_entities[new_state.entity_id] = (new_state, extra_arg) return True # If last state was unknown/unavailable, also significant. if old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - self.last_approved_entities[new_state.entity_id] = new_state + self.last_approved_entities[new_state.entity_id] = (new_state, extra_arg) return True functions: Optional[Dict[str, CheckTypeFunc]] = self.hass.data.get( @@ -144,23 +171,36 @@ class SignificantlyChangedChecker: check_significantly_changed = functions.get(new_state.domain) - # No platform available means always true. - if check_significantly_changed is None: - self.last_approved_entities[new_state.entity_id] = new_state - return True + if check_significantly_changed is not None: + result = check_significantly_changed( + self.hass, + old_state.state, + old_state.attributes, + new_state.state, + new_state.attributes, + ) - result = check_significantly_changed( - self.hass, - old_state.state, - old_state.attributes, - new_state.state, - new_state.attributes, - ) + if result is False: + return False - if result is False: - return False + if self.extra_significant_check is not None: + result = self.extra_significant_check( + self.hass, + old_state.state, + old_state.attributes, + old_extra_arg, + new_state.state, + new_state.attributes, + extra_arg, + ) + + if result is False: + return False # Result is either True or None. # None means the function doesn't know. For now assume it's True - self.last_approved_entities[new_state.entity_id] = new_state + self.last_approved_entities[new_state.entity_id] = ( + new_state, + extra_arg, + ) return True diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index 809bca5638b..a057eada531 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -178,6 +178,7 @@ async def test_doorbell_event(hass, aioclient_mock): async def test_proactive_mode_filter_states(hass, aioclient_mock): """Test all the cases that filter states.""" + aioclient_mock.post(TEST_URL, text="", status=202) await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) # First state should report @@ -186,7 +187,8 @@ async def test_proactive_mode_filter_states(hass, aioclient_mock): "on", {"friendly_name": "Test Contact Sensor", "device_class": "door"}, ) - assert len(aioclient_mock.mock_calls) == 0 + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 1 aioclient_mock.clear_requests() @@ -238,3 +240,24 @@ async def test_proactive_mode_filter_states(hass, aioclient_mock): await hass.async_block_till_done() await hass.async_block_till_done() assert len(aioclient_mock.mock_calls) == 0 + + # If serializes to same properties, it should not report + aioclient_mock.post(TEST_URL, text="", status=202) + with patch( + "homeassistant.components.alexa.entities.AlexaEntity.serialize_properties", + return_value=[{"same": "info"}], + ): + hass.states.async_set( + "binary_sensor.same_serialize", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + await hass.async_block_till_done() + hass.states.async_set( + "binary_sensor.same_serialize", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index 72130dbfdb9..f464be60bb9 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -46,6 +46,24 @@ async def test_report_state(hass, caplog, legacy_patchable_time): "devices": {"states": {"light.kitchen": {"on": True, "online": True}}} } + # Test that if serialize returns same value, we don't send + with patch( + "homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize", + return_value={"same": "info"}, + ), patch.object(BASIC_CONFIG, "async_report_state_all", AsyncMock()) as mock_report: + # New state, so reported + hass.states.async_set("light.double_report", "on") + await hass.async_block_till_done() + + # Changed, but serialize is same, so filtered out by extra check + hass.states.async_set("light.double_report", "off") + await hass.async_block_till_done() + + assert len(mock_report.mock_calls) == 1 + assert mock_report.mock_calls[0][1][0] == { + "devices": {"states": {"light.double_report": {"same": "info"}}} + } + # Test that only significant state changes are reported with patch.object( BASIC_CONFIG, "async_report_state_all", AsyncMock() diff --git a/tests/helpers/test_significant_change.py b/tests/helpers/test_significant_change.py index e72951d36dd..79f3dd3fe3e 100644 --- a/tests/helpers/test_significant_change.py +++ b/tests/helpers/test_significant_change.py @@ -5,7 +5,6 @@ from homeassistant.components.sensor import DEVICE_CLASS_BATTERY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import State from homeassistant.helpers import significant_change -from homeassistant.setup import async_setup_component @pytest.fixture(name="checker") @@ -26,8 +25,6 @@ async def checker_fixture(hass): async def test_signicant_change(hass, checker): """Test initialize helper works.""" - assert await async_setup_component(hass, "sensor", {}) - ent_id = "test_domain.test_entity" attrs = {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY} @@ -48,3 +45,30 @@ async def test_signicant_change(hass, checker): # State turned unavailable assert checker.async_is_significant_change(State(ent_id, "100", attrs)) assert checker.async_is_significant_change(State(ent_id, STATE_UNAVAILABLE, attrs)) + + +async def test_significant_change_extra(hass, checker): + """Test extra significant checker works.""" + ent_id = "test_domain.test_entity" + attrs = {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY} + + assert checker.async_is_significant_change(State(ent_id, "100", attrs), extra_arg=1) + assert checker.async_is_significant_change(State(ent_id, "200", attrs), extra_arg=1) + + # Reset the last significiant change to 100 to repeat test but with + # extra checker installed. + assert checker.async_is_significant_change(State(ent_id, "100", attrs), extra_arg=1) + + def extra_significant_check( + hass, old_state, old_attrs, old_extra_arg, new_state, new_attrs, new_extra_arg + ): + return old_extra_arg != new_extra_arg + + checker.extra_significant_check = extra_significant_check + + # This is normally a significant change (100 -> 200), but the extra arg check marks it + # as insignificant. + assert not checker.async_is_significant_change( + State(ent_id, "200", attrs), extra_arg=1 + ) + assert checker.async_is_significant_change(State(ent_id, "200", attrs), extra_arg=2) From 953491d509312b9167fcfa928ebcd6f6a5a66b06 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 11 Feb 2021 10:38:21 +0100 Subject: [PATCH 12/13] Bumped version to 2021.2.3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5dba56f0247..12772b1d2d1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 2021 MINOR_VERSION = 2 -PATCH_VERSION = "2" +PATCH_VERSION = "3" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 8, 0) From 4034a274f702ee0bb1f69c593c4dca2e7edb0fd7 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 11 Feb 2021 11:40:03 +0200 Subject: [PATCH 13/13] Fix Shelly relay device set to light appliance type (#46181) --- homeassistant/components/shelly/light.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 0c91ddc1088..5422f3fff05 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -187,6 +187,11 @@ class ShellyLight(ShellyBlockEntity, LightEntity): async def async_turn_on(self, **kwargs) -> None: """Turn on light.""" + if self.block.type == "relay": + self.control_result = await self.block.set_state(turn="on") + self.async_write_ha_state() + return + params = {"turn": "on"} if ATTR_BRIGHTNESS in kwargs: tmp_brightness = int(kwargs[ATTR_BRIGHTNESS] / 255 * 100)