From 28bf9db5a298e58bf3ab08148d647e41b0c0df07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Jun 2022 11:04:43 -1000 Subject: [PATCH 01/12] Filter out forced updates in live logbook when the state has not changed (#73335) --- homeassistant/components/logbook/helpers.py | 20 +-- .../components/logbook/test_websocket_api.py | 114 ++++++++++++++++++ 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index ef322c44e05..221612e1e97 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -189,9 +189,10 @@ def async_subscribe_events( def _forward_state_events_filtered(event: Event) -> None: if event.data.get("old_state") is None or event.data.get("new_state") is None: return - state: State = event.data["new_state"] - if _is_state_filtered(ent_reg, state) or ( - entities_filter and not entities_filter(state.entity_id) + new_state: State = event.data["new_state"] + old_state: State = event.data["old_state"] + if _is_state_filtered(ent_reg, new_state, old_state) or ( + entities_filter and not entities_filter(new_state.entity_id) ): return target(event) @@ -229,17 +230,20 @@ def is_sensor_continuous(ent_reg: er.EntityRegistry, entity_id: str) -> bool: ) -def _is_state_filtered(ent_reg: er.EntityRegistry, state: State) -> bool: +def _is_state_filtered( + ent_reg: er.EntityRegistry, new_state: State, old_state: State +) -> bool: """Check if the logbook should filter a state. Used when we are in live mode to ensure we only get significant changes (state.last_changed != state.last_updated) """ return bool( - split_entity_id(state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS - or state.last_changed != state.last_updated - or ATTR_UNIT_OF_MEASUREMENT in state.attributes - or is_sensor_continuous(ent_reg, state.entity_id) + new_state.state == old_state.state + or split_entity_id(new_state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS + or new_state.last_changed != new_state.last_updated + or ATTR_UNIT_OF_MEASUREMENT in new_state.attributes + or is_sensor_continuous(ent_reg, new_state.entity_id) ) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 4df2f456eb6..ac6a31202e7 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2404,3 +2404,117 @@ async def test_subscribe_entities_some_have_uom_multiple( # Check our listener got unsubscribed assert sum(hass.bus.async_listeners().values()) == init_count + + +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_logbook_stream_ignores_forced_updates( + hass, recorder_mock, hass_ws_client +): + """Test logbook live stream ignores forced updates.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + hass.states.async_set("binary_sensor.is_light", STATE_ON) + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + state: State = hass.states.get("binary_sensor.is_light") + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": "off", + "when": state.last_updated.timestamp(), + } + ] + assert msg["event"]["start_time"] == now.timestamp() + assert msg["event"]["end_time"] > msg["event"]["start_time"] + assert msg["event"]["partial"] is True + + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [] + + hass.states.async_set("binary_sensor.is_light", STATE_ON) + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": STATE_ON, + "when": ANY, + }, + { + "entity_id": "binary_sensor.is_light", + "state": STATE_OFF, + "when": ANY, + }, + ] + + # Now we force an update to make sure we ignore + # forced updates when the state has not actually changed + + hass.states.async_set("binary_sensor.is_light", STATE_ON) + for _ in range(3): + hass.states.async_set("binary_sensor.is_light", STATE_OFF, force_update=True) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": STATE_ON, + "when": ANY, + }, + # We should only get the first one and ignore + # the other forced updates since the state + # has not actually changed + { + "entity_id": "binary_sensor.is_light", + "state": STATE_OFF, + "when": ANY, + }, + ] + + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count From 003de09c48e78ebd6d002e97898b79db7c6d1720 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 11 Jun 2022 02:13:50 -0400 Subject: [PATCH 02/12] Fix zwave_js add node schemas (#73343) * Fix zwave_js add node schemas * Code cleanup * Add test --- homeassistant/components/zwave_js/api.py | 32 +++++++++-- tests/components/zwave_js/test_api.py | 70 ++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 5b8b95e951c..27a0c065c0b 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -14,6 +14,7 @@ from zwave_js_server.const import ( InclusionStrategy, LogLevel, Protocols, + ProvisioningEntryStatus, QRCodeVersion, SecurityClass, ZwaveFeature, @@ -148,6 +149,8 @@ MAX_INCLUSION_REQUEST_INTERVAL = "max_inclusion_request_interval" UUID = "uuid" SUPPORTED_PROTOCOLS = "supported_protocols" ADDITIONAL_PROPERTIES = "additional_properties" +STATUS = "status" +REQUESTED_SECURITY_CLASSES = "requested_security_classes" FEATURE = "feature" UNPROVISION = "unprovision" @@ -160,19 +163,22 @@ def convert_planned_provisioning_entry(info: dict) -> ProvisioningEntry: """Handle provisioning entry dict to ProvisioningEntry.""" return ProvisioningEntry( dsk=info[DSK], - security_classes=[SecurityClass(sec_cls) for sec_cls in info[SECURITY_CLASSES]], + security_classes=info[SECURITY_CLASSES], + status=info[STATUS], + requested_security_classes=info.get(REQUESTED_SECURITY_CLASSES), additional_properties={ - k: v for k, v in info.items() if k not in (DSK, SECURITY_CLASSES) + k: v + for k, v in info.items() + if k not in (DSK, SECURITY_CLASSES, STATUS, REQUESTED_SECURITY_CLASSES) }, ) def convert_qr_provisioning_information(info: dict) -> QRProvisioningInformation: """Convert QR provisioning information dict to QRProvisioningInformation.""" - protocols = [Protocols(proto) for proto in info.get(SUPPORTED_PROTOCOLS, [])] return QRProvisioningInformation( - version=QRCodeVersion(info[VERSION]), - security_classes=[SecurityClass(sec_cls) for sec_cls in info[SECURITY_CLASSES]], + version=info[VERSION], + security_classes=info[SECURITY_CLASSES], dsk=info[DSK], generic_device_class=info[GENERIC_DEVICE_CLASS], specific_device_class=info[SPECIFIC_DEVICE_CLASS], @@ -183,7 +189,9 @@ def convert_qr_provisioning_information(info: dict) -> QRProvisioningInformation application_version=info[APPLICATION_VERSION], max_inclusion_request_interval=info.get(MAX_INCLUSION_REQUEST_INTERVAL), uuid=info.get(UUID), - supported_protocols=protocols if protocols else None, + supported_protocols=info.get(SUPPORTED_PROTOCOLS), + status=info[STATUS], + requested_security_classes=info.get(REQUESTED_SECURITY_CLASSES), additional_properties=info.get(ADDITIONAL_PROPERTIES, {}), ) @@ -197,6 +205,12 @@ PLANNED_PROVISIONING_ENTRY_SCHEMA = vol.All( cv.ensure_list, [vol.Coerce(SecurityClass)], ), + vol.Optional(STATUS, default=ProvisioningEntryStatus.ACTIVE): vol.Coerce( + ProvisioningEntryStatus + ), + vol.Optional(REQUESTED_SECURITY_CLASSES): vol.All( + cv.ensure_list, [vol.Coerce(SecurityClass)] + ), }, # Provisioning entries can have extra keys for SmartStart extra=vol.ALLOW_EXTRA, @@ -226,6 +240,12 @@ QR_PROVISIONING_INFORMATION_SCHEMA = vol.All( cv.ensure_list, [vol.Coerce(Protocols)], ), + vol.Optional(STATUS, default=ProvisioningEntryStatus.ACTIVE): vol.Coerce( + ProvisioningEntryStatus + ), + vol.Optional(REQUESTED_SECURITY_CLASSES): vol.All( + cv.ensure_list, [vol.Coerce(SecurityClass)] + ), vol.Optional(ADDITIONAL_PROPERTIES): dict, } ), diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index e59a923ff44..e2ab9cc9503 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -10,6 +10,7 @@ from zwave_js_server.const import ( InclusionStrategy, LogLevel, Protocols, + ProvisioningEntryStatus, QRCodeVersion, SecurityClass, ZwaveFeature, @@ -63,8 +64,10 @@ from homeassistant.components.zwave_js.api import ( PROPERTY_KEY, QR_CODE_STRING, QR_PROVISIONING_INFORMATION, + REQUESTED_SECURITY_CLASSES, SECURITY_CLASSES, SPECIFIC_DEVICE_CLASS, + STATUS, TYPE, UNPROVISION, VALUE, @@ -619,13 +622,68 @@ async def test_add_node( client.async_send_command.reset_mock() client.async_send_command.return_value = {"success": True} - # Test S2 QR code string + # Test S2 QR provisioning information await ws_client.send_json( { ID: 4, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + QR_PROVISIONING_INFORMATION: { + VERSION: 0, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + STATUS: 1, + REQUESTED_SECURITY_CLASSES: [0], + }, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_inclusion", + "options": { + "strategy": InclusionStrategy.SECURITY_S2, + "provisioning": QRProvisioningInformation( + version=QRCodeVersion.S2, + security_classes=[SecurityClass.S2_UNAUTHENTICATED], + dsk="test", + generic_device_class=1, + specific_device_class=1, + installer_icon_type=1, + manufacturer_id=1, + product_type=1, + product_id=1, + application_version="test", + max_inclusion_request_interval=None, + uuid=None, + supported_protocols=None, + status=ProvisioningEntryStatus.INACTIVE, + requested_security_classes=[SecurityClass.S2_UNAUTHENTICATED], + ).to_dict(), + }, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test S2 QR code string + await ws_client.send_json( + { + ID: 5, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", } ) @@ -648,7 +706,7 @@ async def test_add_node( # Test Smart Start QR provisioning information with S2 inclusion strategy fails await ws_client.send_json( { - ID: 5, + ID: 6, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, @@ -678,7 +736,7 @@ async def test_add_node( # Test QR provisioning information with S0 inclusion strategy fails await ws_client.send_json( { - ID: 5, + ID: 7, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S0, @@ -708,7 +766,7 @@ async def test_add_node( # Test ValueError is caught as failure await ws_client.send_json( { - ID: 6, + ID: 8, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, @@ -728,7 +786,7 @@ async def test_add_node( ): await ws_client.send_json( { - ID: 7, + ID: 9, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, } @@ -744,7 +802,7 @@ async def test_add_node( await hass.async_block_till_done() await ws_client.send_json( - {ID: 8, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} + {ID: 10, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() From 8dc61a8c2e650a6e4cca2c1f26b2b38153636698 Mon Sep 17 00:00:00 2001 From: Khole Date: Sat, 11 Jun 2022 20:43:57 +0100 Subject: [PATCH 03/12] Hive Bump pyhiveapi to 0.5.10 for credentials fix (#73365) --- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index e0faa9e0f20..bc07a251779 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -3,7 +3,7 @@ "name": "Hive", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.5.9"], + "requirements": ["pyhiveapi==0.5.10"], "codeowners": ["@Rendili", "@KJonline"], "iot_class": "cloud_polling", "loggers": ["apyhiveapi"] diff --git a/requirements_all.txt b/requirements_all.txt index 86e7ea1fc44..828e0882df9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1538,7 +1538,7 @@ pyheos==0.7.2 pyhik==0.3.0 # homeassistant.components.hive -pyhiveapi==0.5.9 +pyhiveapi==0.5.10 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d656be540fb..2b5b0ac2bd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1029,7 +1029,7 @@ pyhaversion==22.4.1 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.9 +pyhiveapi==0.5.10 # homeassistant.components.homematic pyhomematic==0.1.77 From 7b7fc125132557406e057b1ac072344cda7c4cc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jun 2022 17:29:44 -1000 Subject: [PATCH 04/12] Fix reload race in yeelight when updating the ip address (#73390) --- .../components/yeelight/config_flow.py | 5 ++- tests/components/yeelight/test_config_flow.py | 31 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 8a3a5b41320..440b717fd8c 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -96,7 +96,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_HOST: self._discovered_ip} ) - reload = True + reload = entry.state in ( + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, + ) if reload: self.hass.async_create_task( self.hass.config_entries.async_reload(entry.entry_id) diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 80acaa6f10e..1c19a5e7dfd 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -739,7 +739,7 @@ async def test_discovered_zeroconf(hass): async def test_discovery_updates_ip(hass: HomeAssistant): - """Test discovery updtes ip.""" + """Test discovery updates ip.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.2.3"}, unique_id=ID ) @@ -761,6 +761,35 @@ async def test_discovery_updates_ip(hass: HomeAssistant): assert config_entry.data[CONF_HOST] == IP_ADDRESS +async def test_discovery_updates_ip_no_reload_setup_in_progress(hass: HomeAssistant): + """Test discovery updates ip does not reload if setup is an an error state.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.2.3"}, + unique_id=ID, + state=config_entries.ConfigEntryState.SETUP_ERROR, + ) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + with patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry, _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == IP_ADDRESS + assert len(mock_setup_entry.mock_calls) == 0 + + async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant): """Test discovery adds missing ip.""" config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_ID: ID}) From c59b03e3dc7ed1abb55c117f52ecf9a9cae23bfe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jun 2022 17:04:17 -1000 Subject: [PATCH 05/12] Only update unifiprotect ips from discovery when the console is offline (#73411) --- .../components/unifiprotect/config_flow.py | 32 ++++++++++++++- homeassistant/components/unifiprotect/data.py | 10 +++++ .../components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../unifiprotect/test_config_flow.py | 40 ++++++++++++++++++- 6 files changed, 83 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index daaae214df9..d9fd3a87cb0 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -7,6 +7,7 @@ from typing import Any from aiohttp import CookieJar from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient from pyunifiprotect.data import NVR +from unifi_discovery import async_console_is_alive import voluptuous as vol from homeassistant import config_entries @@ -21,7 +22,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.aiohttp_client import ( + async_create_clientsession, + async_get_clientsession, +) from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_integration from homeassistant.util.network import is_ip_address @@ -36,11 +40,17 @@ from .const import ( MIN_REQUIRED_PROTECT_V, OUTDATED_LOG_MESSAGE, ) +from .data import async_last_update_was_successful from .discovery import async_start_discovery from .utils import _async_resolve, _async_short_mac, _async_unifi_mac_from_hass _LOGGER = logging.getLogger(__name__) +ENTRY_FAILURE_STATES = ( + config_entries.ConfigEntryState.SETUP_ERROR, + config_entries.ConfigEntryState.SETUP_RETRY, +) + async def async_local_user_documentation_url(hass: HomeAssistant) -> str: """Get the documentation url for creating a local user.""" @@ -53,6 +63,25 @@ def _host_is_direct_connect(host: str) -> bool: return host.endswith(".ui.direct") +async def _async_console_is_offline( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, +) -> bool: + """Check if a console is offline. + + We define offline by the config entry + is in a failure/retry state or the updates + are failing and the console is unreachable + since protect may be updating. + """ + return bool( + entry.state in ENTRY_FAILURE_STATES + or not async_last_update_was_successful(hass, entry) + ) and not await async_console_is_alive( + async_get_clientsession(hass, verify_ssl=False), entry.data[CONF_HOST] + ) + + class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a UniFi Protect config flow.""" @@ -110,6 +139,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): not entry_has_direct_connect and is_ip_address(entry_host) and entry_host != source_ip + and await _async_console_is_offline(self.hass, entry) ): new_host = source_ip if new_host: diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 371c1c7831b..02cfa6c16ff 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -25,6 +25,16 @@ from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES _LOGGER = logging.getLogger(__name__) +@callback +def async_last_update_was_successful(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Check if the last update was successful for a config entry.""" + return bool( + DOMAIN in hass.data + and entry.entry_id in hass.data[DOMAIN] + and hass.data[DOMAIN][entry.entry_id].last_update_success + ) + + class ProtectData: """Coordinate updates.""" diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 199298d76ca..2554d12c866 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==3.9.2", "unifi-discovery==1.1.3"], + "requirements": ["pyunifiprotect==3.9.2", "unifi-discovery==1.1.4"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index 828e0882df9..c129fb2cb54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2358,7 +2358,7 @@ twitchAPI==2.5.2 uasiren==0.0.1 # homeassistant.components.unifiprotect -unifi-discovery==1.1.3 +unifi-discovery==1.1.4 # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b5b0ac2bd2..ea6cd6a144a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1546,7 +1546,7 @@ twitchAPI==2.5.2 uasiren==0.0.1 # homeassistant.components.unifiprotect -unifi-discovery==1.1.3 +unifi-discovery==1.1.4 # homeassistant.components.upb upb_lib==0.4.12 diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 80e845591b1..75f08acb37c 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -402,7 +402,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin ) mock_config.add_to_hass(hass) - with _patch_discovery(): + with _patch_discovery(), patch( + "homeassistant.components.unifiprotect.config_flow.async_console_is_alive", + return_value=False, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, @@ -415,6 +418,41 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin assert mock_config.data[CONF_HOST] == "127.0.0.1" +async def test_discovered_by_unifi_discovery_does_not_update_ip_when_console_is_still_online( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery does not update the ip unless the console at the old ip is offline.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.2.2.2", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + }, + version=2, + unique_id=DEVICE_MAC_ADDRESS.replace(":", "").upper(), + ) + mock_config.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.unifiprotect.config_flow.async_console_is_alive", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert mock_config.data[CONF_HOST] == "1.2.2.2" + + async def test_discovered_host_not_updated_if_existing_is_a_hostname( hass: HomeAssistant, mock_nvr: NVR ) -> None: From 8822feaf854c75605bdf85176e6976c8841e7f87 Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Sun, 12 Jun 2022 22:27:18 -0300 Subject: [PATCH 06/12] Fix smart by bond detection with v3 firmware (#73414) --- homeassistant/components/bond/manifest.json | 2 +- homeassistant/components/bond/utils.py | 5 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bond/common.py | 11 +++++- tests/components/bond/test_config_flow.py | 24 ++++++------ tests/components/bond/test_diagnostics.py | 2 +- tests/components/bond/test_init.py | 41 ++++++++++++++------- tests/components/bond/test_light.py | 2 +- 9 files changed, 58 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 52e9dd1763f..a5625d7b642 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,7 +3,7 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-async==0.1.20"], + "requirements": ["bond-async==0.1.22"], "zeroconf": ["_bond._tcp.local."], "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "quality_scale": "platinum", diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index cba213d9450..c426bf64577 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -5,7 +5,7 @@ import logging from typing import Any, cast from aiohttp import ClientResponseError -from bond_async import Action, Bond +from bond_async import Action, Bond, BondType from homeassistant.util.async_ import gather_with_concurrency @@ -224,4 +224,5 @@ class BondHub: @property def is_bridge(self) -> bool: """Return if the Bond is a Bond Bridge.""" - return bool(self._bridge) + bondid = self._version["bondid"] + return bool(BondType.is_bridge_from_serial(bondid)) diff --git a/requirements_all.txt b/requirements_all.txt index c129fb2cb54..a580a474b68 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ blockchain==1.4.4 # bluepy==1.3.0 # homeassistant.components.bond -bond-async==0.1.20 +bond-async==0.1.22 # homeassistant.components.bosch_shc boschshcpy==0.2.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea6cd6a144a..bdbe9ba1e7c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -318,7 +318,7 @@ blebox_uniapi==1.3.3 blinkpy==0.19.0 # homeassistant.components.bond -bond-async==0.1.20 +bond-async==0.1.22 # homeassistant.components.bosch_shc boschshcpy==0.2.30 diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 4b45a4016c0..909fb35a1e2 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -113,7 +113,7 @@ def patch_bond_version( return nullcontext() if return_value is None: - return_value = {"bondid": "test-bond-id"} + return_value = {"bondid": "ZXXX12345"} return patch( "homeassistant.components.bond.Bond.version", @@ -246,3 +246,12 @@ async def help_test_entity_available( async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + +def ceiling_fan(name: str): + """Create a ceiling fan with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": ["SetSpeed", "SetDirection"], + } diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 5d3b357b9f7..519fa9dec9d 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -35,7 +35,7 @@ async def test_user_form(hass: core.HomeAssistant): assert result["errors"] == {} with patch_bond_version( - return_value={"bondid": "test-bond-id"} + return_value={"bondid": "ZXXX12345"} ), patch_bond_device_ids( return_value=["f6776c11", "f6776c12"] ), patch_bond_bridge(), patch_bond_device_properties(), patch_bond_device(), _patch_async_setup_entry() as mock_setup_entry: @@ -64,7 +64,7 @@ async def test_user_form_with_non_bridge(hass: core.HomeAssistant): assert result["errors"] == {} with patch_bond_version( - return_value={"bondid": "test-bond-id"} + return_value={"bondid": "KXXX12345"} ), patch_bond_device_ids( return_value=["f6776c11"] ), patch_bond_device_properties(), patch_bond_device( @@ -96,7 +96,7 @@ async def test_user_form_invalid_auth(hass: core.HomeAssistant): ) with patch_bond_version( - return_value={"bond_id": "test-bond-id"} + return_value={"bond_id": "ZXXX12345"} ), patch_bond_bridge(), patch_bond_device_ids( side_effect=ClientResponseError(Mock(), Mock(), status=401), ): @@ -203,7 +203,7 @@ async def test_zeroconf_form(hass: core.HomeAssistant): host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -213,7 +213,7 @@ async def test_zeroconf_form(hass: core.HomeAssistant): assert result["errors"] == {} with patch_bond_version( - return_value={"bondid": "test-bond-id"} + return_value={"bondid": "ZXXX12345"} ), patch_bond_bridge(), patch_bond_device_ids(), _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -241,7 +241,7 @@ async def test_zeroconf_form_token_unavailable(hass: core.HomeAssistant): host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -270,7 +270,7 @@ async def test_zeroconf_form_token_unavailable(hass: core.HomeAssistant): async def test_zeroconf_form_with_token_available(hass: core.HomeAssistant): """Test we get the discovery form when we can get the token.""" - with patch_bond_version(return_value={"bondid": "test-bond-id"}), patch_bond_token( + with patch_bond_version(return_value={"bondid": "ZXXX12345"}), patch_bond_token( return_value={"token": "discovered-token"} ), patch_bond_bridge( return_value={"name": "discovered-name"} @@ -282,7 +282,7 @@ async def test_zeroconf_form_with_token_available(hass: core.HomeAssistant): host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -323,7 +323,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -341,7 +341,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "test-bond-id" + assert result2["title"] == "ZXXX12345" assert result2["data"] == { CONF_HOST: "test-host", CONF_ACCESS_TOKEN: "discovered-token", @@ -472,7 +472,7 @@ async def test_zeroconf_form_unexpected_error(hass: core.HomeAssistant): host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -497,7 +497,7 @@ async def _help_test_form_unexpected_error( ) with patch_bond_version( - return_value={"bond_id": "test-bond-id"} + return_value={"bond_id": "ZXXX12345"} ), patch_bond_device_ids(side_effect=error): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input diff --git a/tests/components/bond/test_diagnostics.py b/tests/components/bond/test_diagnostics.py index 88d33ff2cc0..b738c72ee8c 100644 --- a/tests/components/bond/test_diagnostics.py +++ b/tests/components/bond/test_diagnostics.py @@ -39,5 +39,5 @@ async def test_diagnostics(hass, hass_client): "data": {"access_token": "**REDACTED**", "host": "some host"}, "title": "Mock Title", }, - "hub": {"version": {"bondid": "test-bond-id"}}, + "hub": {"version": {"bondid": "ZXXX12345"}}, } diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 03eb490b65e..56087d4bf11 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -7,13 +7,15 @@ from bond_async import DeviceType import pytest from homeassistant.components.bond.const import DOMAIN +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.const import ATTR_ASSUMED_STATE, CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .common import ( + ceiling_fan, patch_bond_bridge, patch_bond_device, patch_bond_device_ids, @@ -23,6 +25,7 @@ from .common import ( patch_setup_entry, patch_start_bpup, setup_bond_entity, + setup_platform, ) from tests.common import MockConfigEntry @@ -81,7 +84,7 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss with patch_bond_bridge(), patch_bond_version( return_value={ - "bondid": "test-bond-id", + "bondid": "ZXXX12345", "target": "test-model", "fw_ver": "test-version", "mcu_ver": "test-hw-version", @@ -99,11 +102,11 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id == "test-bond-id" + assert config_entry.unique_id == "ZXXX12345" # verify hub device is registered correctly device_registry = dr.async_get(hass) - hub = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) + hub = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert hub.name == "bond-name" assert hub.manufacturer == "Olibra" assert hub.model == "test-model" @@ -151,7 +154,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant): ) old_identifers = (DOMAIN, "device_id") - new_identifiers = (DOMAIN, "test-bond-id", "device_id") + new_identifiers = (DOMAIN, "ZXXX12345", "device_id") device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -164,7 +167,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant): with patch_bond_bridge(), patch_bond_version( return_value={ - "bondid": "test-bond-id", + "bondid": "ZXXX12345", "target": "test-model", "fw_ver": "test-version", } @@ -185,7 +188,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant): assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id == "test-bond-id" + assert config_entry.unique_id == "ZXXX12345" # verify the device info is cleaned up assert device_registry.async_get_device(identifiers={old_identifers}) is None @@ -205,7 +208,7 @@ async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant): side_effect=ClientResponseError(Mock(), Mock(), status=404) ), patch_bond_version( return_value={ - "bondid": "test-bond-id", + "bondid": "KXXX12345", "target": "test-model", "fw_ver": "test-version", } @@ -227,10 +230,10 @@ async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant): assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id == "test-bond-id" + assert config_entry.unique_id == "KXXX12345" device_registry = dr.async_get(hass) - device = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "KXXX12345")}) assert device is not None assert device.suggested_area == "Den" @@ -251,7 +254,7 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant): } ), patch_bond_version( return_value={ - "bondid": "test-bond-id", + "bondid": "ZXXX12345", "target": "test-model", "fw_ver": "test-version", } @@ -273,9 +276,21 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant): assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id == "test-bond-id" + assert config_entry.unique_id == "ZXXX12345" device_registry = dr.async_get(hass) - device = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert device is not None assert device.suggested_area == "Office" + + +async def test_smart_by_bond_v3_firmware(hass: HomeAssistant) -> None: + """Test we can detect smart by bond with the v3 firmware.""" + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_version={"bondid": "KXXXX12345", "target": "breck-northstar"}, + bond_device_id="test-device-id", + ) + assert ATTR_ASSUMED_STATE not in hass.states.get("fan.name_1").attributes diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index c7d8f195423..7577b1d70ab 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -249,7 +249,7 @@ async def test_sbb_trust_state(hass: core.HomeAssistant): """Assumed state should be False if device is a Smart by Bond.""" version = { "model": "MR123A", - "bondid": "test-bond-id", + "bondid": "KXXX12345", } await setup_platform( hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_version=version, bridge={} From 1ab91bcf0f440d0f91c6ded3d631f69812d6bc13 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 12 Jun 2022 22:33:45 -0700 Subject: [PATCH 07/12] Bump aiohue to 4.4.2 (#73420) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index d3b492f3b9e..b3dbe4df50a 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==4.4.1"], + "requirements": ["aiohue==4.4.2"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index a580a474b68..c90229fdb76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -169,7 +169,7 @@ aiohomekit==0.7.17 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.4.1 +aiohue==4.4.2 # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bdbe9ba1e7c..881aa1cef55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -153,7 +153,7 @@ aiohomekit==0.7.17 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.4.1 +aiohue==4.4.2 # homeassistant.components.apache_kafka aiokafka==0.6.0 From d6bfb86da266b8ac2c167178be4b6d403610b5d3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 14 Jun 2022 06:19:22 -0700 Subject: [PATCH 08/12] Fix fan support in nest, removing FAN_ONLY which isn't supported (#73422) * Fix fan support in nest, removing FAN_ONLY which isn't supported * Revert change to make supported features dynamic --- homeassistant/components/nest/climate_sdm.py | 37 ++-- tests/components/nest/test_climate_sdm.py | 195 ++++++++++--------- 2 files changed, 119 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index 8a56f78028b..6ee988b714f 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -70,6 +70,7 @@ FAN_MODE_MAP = { "OFF": FAN_OFF, } FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} +FAN_INV_MODES = list(FAN_INV_MODE_MAP) MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API MIN_TEMP = 10 @@ -99,7 +100,7 @@ class ThermostatEntity(ClimateEntity): """Initialize ThermostatEntity.""" self._device = device self._device_info = NestDeviceInfo(device) - self._supported_features = 0 + self._attr_supported_features = 0 @property def should_poll(self) -> bool: @@ -124,7 +125,7 @@ class ThermostatEntity(ClimateEntity): async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" - self._supported_features = self._get_supported_features() + self._attr_supported_features = self._get_supported_features() self.async_on_remove( self._device.add_update_listener(self.async_write_ha_state) ) @@ -198,8 +199,6 @@ class ThermostatEntity(ClimateEntity): trait = self._device.traits[ThermostatModeTrait.NAME] if trait.mode in THERMOSTAT_MODE_MAP: hvac_mode = THERMOSTAT_MODE_MAP[trait.mode] - if hvac_mode == HVACMode.OFF and self.fan_mode == FAN_ON: - hvac_mode = HVACMode.FAN_ONLY return hvac_mode @property @@ -209,8 +208,6 @@ class ThermostatEntity(ClimateEntity): for mode in self._get_device_hvac_modes: if mode in THERMOSTAT_MODE_MAP: supported_modes.append(THERMOSTAT_MODE_MAP[mode]) - if self.supported_features & ClimateEntityFeature.FAN_MODE: - supported_modes.append(HVACMode.FAN_ONLY) return supported_modes @property @@ -252,7 +249,10 @@ class ThermostatEntity(ClimateEntity): @property def fan_mode(self) -> str: """Return the current fan mode.""" - if FanTrait.NAME in self._device.traits: + if ( + self.supported_features & ClimateEntityFeature.FAN_MODE + and FanTrait.NAME in self._device.traits + ): trait = self._device.traits[FanTrait.NAME] return FAN_MODE_MAP.get(trait.timer_mode, FAN_OFF) return FAN_OFF @@ -260,15 +260,12 @@ class ThermostatEntity(ClimateEntity): @property def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - modes = [] - if FanTrait.NAME in self._device.traits: - modes = list(FAN_INV_MODE_MAP) - return modes - - @property - def supported_features(self) -> int: - """Bitmap of supported features.""" - return self._supported_features + if ( + self.supported_features & ClimateEntityFeature.FAN_MODE + and FanTrait.NAME in self._device.traits + ): + return FAN_INV_MODES + return [] def _get_supported_features(self) -> int: """Compute the bitmap of supported features from the current state.""" @@ -290,10 +287,6 @@ class ThermostatEntity(ClimateEntity): """Set new target hvac mode.""" if hvac_mode not in self.hvac_modes: raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'") - if hvac_mode == HVACMode.FAN_ONLY: - # Turn the fan on but also turn off the hvac if it is on - await self.async_set_fan_mode(FAN_ON) - hvac_mode = HVACMode.OFF api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] trait = self._device.traits[ThermostatModeTrait.NAME] try: @@ -338,6 +331,10 @@ class ThermostatEntity(ClimateEntity): """Set new target fan mode.""" if fan_mode not in self.fan_modes: raise ValueError(f"Unsupported fan_mode '{fan_mode}'") + if fan_mode == FAN_ON and self.hvac_mode == HVACMode.OFF: + raise ValueError( + "Cannot turn on fan, please set an HVAC mode (e.g. heat/cool) first" + ) trait = self._device.traits[FanTrait.NAME] duration = None if fan_mode != FAN_OFF: diff --git a/tests/components/nest/test_climate_sdm.py b/tests/components/nest/test_climate_sdm.py index 123742607ad..c271687a348 100644 --- a/tests/components/nest/test_climate_sdm.py +++ b/tests/components/nest/test_climate_sdm.py @@ -33,15 +33,15 @@ from homeassistant.components.climate.const import ( FAN_ON, HVAC_MODE_COOL, HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_ECO, PRESET_NONE, PRESET_SLEEP, + ClimateEntityFeature, ) -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -794,7 +794,7 @@ async def test_thermostat_fan_off( "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, @@ -806,18 +806,22 @@ async def test_thermostat_fan_off( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_OFF - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.state == HVAC_MODE_COOL + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) async def test_thermostat_fan_on( @@ -837,7 +841,7 @@ async def test_thermostat_fan_on( }, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, @@ -849,18 +853,22 @@ async def test_thermostat_fan_on( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_FAN_ONLY + assert thermostat.state == HVAC_MODE_COOL assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) async def test_thermostat_cool_with_fan( @@ -895,11 +903,15 @@ async def test_thermostat_cool_with_fan( HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) async def test_thermostat_set_fan( @@ -907,6 +919,68 @@ async def test_thermostat_set_fan( setup_platform: PlatformSetup, auth: FakeAuth, create_device: CreateDevice, +) -> None: + """Test a thermostat enabling the fan.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "ON", + "timerTimeout": "2019-05-10T03:22:54Z", + }, + "sdm.devices.traits.ThermostatHvac": { + "status": "OFF", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEAT", + }, + } + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT + assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON + assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) + + # Turn off fan mode + await common.async_set_fan_mode(hass, FAN_OFF) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": {"timerMode": "OFF"}, + } + + # Turn on fan mode + await common.async_set_fan_mode(hass, FAN_ON) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": { + "duration": "43200s", + "timerMode": "ON", + }, + } + + +async def test_thermostat_set_fan_when_off( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, ) -> None: """Test a thermostat enabling the fan.""" create_device.create( @@ -929,34 +1003,18 @@ async def test_thermostat_set_fan( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_FAN_ONLY + assert thermostat.state == HVAC_MODE_OFF assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) - # Turn off fan mode - await common.async_set_fan_mode(hass, FAN_OFF) - await hass.async_block_till_done() - - assert auth.method == "post" - assert auth.url == DEVICE_COMMAND - assert auth.json == { - "command": "sdm.devices.commands.Fan.SetTimer", - "params": {"timerMode": "OFF"}, - } - - # Turn on fan mode - await common.async_set_fan_mode(hass, FAN_ON) - await hass.async_block_till_done() - - assert auth.method == "post" - assert auth.url == DEVICE_COMMAND - assert auth.json == { - "command": "sdm.devices.commands.Fan.SetTimer", - "params": { - "duration": "43200s", - "timerMode": "ON", - }, - } + # Fan cannot be turned on when HVAC is off + with pytest.raises(ValueError): + await common.async_set_fan_mode(hass, FAN_ON, entity_id="climate.my_thermostat") async def test_thermostat_fan_empty( @@ -994,6 +1052,10 @@ async def test_thermostat_fan_empty( } assert ATTR_FAN_MODE not in thermostat.attributes assert ATTR_FAN_MODES not in thermostat.attributes + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) # Ignores set_fan_mode since it is lacking SUPPORT_FAN_MODE await common.async_set_fan_mode(hass, FAN_ON) @@ -1018,7 +1080,7 @@ async def test_thermostat_invalid_fan_mode( "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, @@ -1030,14 +1092,13 @@ async def test_thermostat_invalid_fan_mode( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_FAN_ONLY + assert thermostat.state == HVAC_MODE_COOL assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON @@ -1048,58 +1109,6 @@ async def test_thermostat_invalid_fan_mode( await hass.async_block_till_done() -async def test_thermostat_set_hvac_fan_only( - hass: HomeAssistant, - setup_platform: PlatformSetup, - auth: FakeAuth, - create_device: CreateDevice, -) -> None: - """Test a thermostat enabling the fan via hvac_mode.""" - create_device.create( - { - "sdm.devices.traits.Fan": { - "timerMode": "OFF", - "timerTimeout": "2019-05-10T03:22:54Z", - }, - "sdm.devices.traits.ThermostatHvac": { - "status": "OFF", - }, - "sdm.devices.traits.ThermostatMode": { - "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", - }, - } - ) - await setup_platform() - - assert len(hass.states.async_all()) == 1 - thermostat = hass.states.get("climate.my_thermostat") - assert thermostat is not None - assert thermostat.state == HVAC_MODE_OFF - assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF - assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] - - await common.async_set_hvac_mode(hass, HVAC_MODE_FAN_ONLY) - await hass.async_block_till_done() - - assert len(auth.captured_requests) == 2 - - (method, url, json, headers) = auth.captured_requests.pop(0) - assert method == "post" - assert url == DEVICE_COMMAND - assert json == { - "command": "sdm.devices.commands.Fan.SetTimer", - "params": {"duration": "43200s", "timerMode": "ON"}, - } - (method, url, json, headers) = auth.captured_requests.pop(0) - assert method == "post" - assert url == DEVICE_COMMAND - assert json == { - "command": "sdm.devices.commands.ThermostatMode.SetMode", - "params": {"mode": "OFF"}, - } - - async def test_thermostat_target_temp( hass: HomeAssistant, setup_platform: PlatformSetup, @@ -1397,7 +1406,7 @@ async def test_thermostat_hvac_mode_failure( "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Fan": { "timerMode": "OFF", @@ -1416,8 +1425,8 @@ async def test_thermostat_hvac_mode_failure( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_OFF - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.state == HVAC_MODE_COOL + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] with pytest.raises(HomeAssistantError): From d8f2afb7727ffa66e3a5c10ceca18201f36620d5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Jun 2022 10:55:58 -0700 Subject: [PATCH 09/12] Guard withings accessing hass.data without it being set (#73454) Co-authored-by: Martin Hjelmare --- homeassistant/components/withings/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 47702090cc0..6e8dee9a774 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -72,11 +72,12 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Withings component.""" - conf = config.get(DOMAIN, {}) - if not (conf := config.get(DOMAIN, {})): + if not (conf := config.get(DOMAIN)): + # Apply the defaults. + conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + hass.data[DOMAIN] = {const.CONFIG: conf} return True - # Make the config available to the oauth2 config flow. hass.data[DOMAIN] = {const.CONFIG: conf} # Setup the oauth2 config flow. From 0b22e47c53b63bd071deaf3a3d24d97ebc92f1a0 Mon Sep 17 00:00:00 2001 From: Thibaut Date: Tue, 14 Jun 2022 15:17:40 +0200 Subject: [PATCH 10/12] =?UTF-8?q?Fix=20max=5Fvalue=20access=20for=20number?= =?UTF-8?q?=20platform=20in=E2=80=AFOverkiz=20(#73479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix wrong property name --- homeassistant/components/overkiz/number.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 741c666a42a..167065e9015 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -134,7 +134,7 @@ class OverkizNumber(OverkizDescriptiveEntity, NumberEntity): """Return the entity value to represent the entity state.""" if state := self.device.states.get(self.entity_description.key): if self.entity_description.inverted: - return self._attr_max_value - cast(float, state.value) + return self.max_value - cast(float, state.value) return cast(float, state.value) @@ -143,7 +143,7 @@ class OverkizNumber(OverkizDescriptiveEntity, NumberEntity): async def async_set_value(self, value: float) -> None: """Set new value.""" if self.entity_description.inverted: - value = self._attr_max_value - value + value = self.max_value - value await self.executor.async_execute_command( self.entity_description.command, value From a4a511b6db6f706ce9d42b951da2fd76975b04ec Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Jun 2022 12:31:51 -0700 Subject: [PATCH 11/12] Bumped version to 2022.6.6 --- homeassistant/const.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index eeba0f6698c..8f8a8008457 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 6 -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, 9, 0) diff --git a/setup.cfg b/setup.cfg index fd13e6f7d09..17e4901b2ff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.5 +version = 2022.6.6 url = https://www.home-assistant.io/ [options] From 063e680589ca956b9534c63841fb7fea943aa28f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Jun 2022 13:23:27 -0700 Subject: [PATCH 12/12] Fix unifiprotect import --- homeassistant/components/unifiprotect/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 02cfa6c16ff..b902409595e 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval -from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES +from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES, DOMAIN _LOGGER = logging.getLogger(__name__)