From fea4af69d7a034afb8696b682e3288b647864777 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 12 Aug 2023 23:08:33 -0700 Subject: [PATCH 001/180] Add missing logging for opower library (#98325) --- homeassistant/components/opower/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 73942231b40..97a605676e1 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,6 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", + "loggers": ["opower"], "requirements": ["opower==0.0.26"] } From 38cea8f31c81c4a9f5d065567290dc7abe615062 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 13 Aug 2023 12:39:46 +0200 Subject: [PATCH 002/180] Plugwise climate: add HVAC_Mode handling to set_temperature() (#98273) * Add HVAC_Mode handling to set_temperature() * Move added code down, as suggested * Implement walrus as suggested Co-authored-by: G Johansson --------- Co-authored-by: G Johansson --- homeassistant/components/plugwise/climate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index e83b76a76da..610ffa34d7c 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ClimateEntity, @@ -161,6 +162,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): ): raise ValueError("Invalid temperature change requested") + if mode := kwargs.get(ATTR_HVAC_MODE): + await self.async_set_hvac_mode(mode) + await self.coordinator.api.set_temperature(self.device["location"], data) @plugwise_command From b41d3b465c9bc8563acd69fb149637f144e84739 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 13 Aug 2023 12:57:34 +0200 Subject: [PATCH 003/180] Add domestic_hot_water_setpoint number to Plugwise (#98092) * Add max_dhw_temperature number * Update strings.json * Add related tests * Correct test * Black-fix --- homeassistant/components/plugwise/number.py | 9 +++++- .../components/plugwise/strings.json | 3 ++ tests/components/plugwise/test_number.py | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 102d94f91b7..5979480d90f 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -48,6 +48,14 @@ NUMBER_TYPES = ( entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), + PlugwiseNumberEntityDescription( + key="max_dhw_temperature", + translation_key="max_dhw_temperature", + command=lambda api, number, value: api.set_number_setpoint(number, value), + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), ) @@ -89,7 +97,6 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" self._attr_mode = NumberMode.BOX - self._attr_native_max_value = self.device[description.key]["upper_bound"] self._attr_native_min_value = self.device[description.key]["lower_bound"] self._attr_native_step = max(self.device[description.key]["resolution"], 0.5) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index e1b5b5c4053..5210f8a6dc0 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -76,6 +76,9 @@ "number": { "maximum_boiler_temperature": { "name": "Maximum boiler temperature setpoint" + }, + "max_dhw_temperature": { + "name": "Domestic hot water setpoint" } }, "select": { diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index da31b8038c8..9ca64e104d3 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -40,3 +40,32 @@ async def test_anna_max_boiler_temp_change( mock_smile_anna.set_number_setpoint.assert_called_with( "maximum_boiler_temperature", 65.0 ) + + +async def test_adam_number_entities( + hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test creation of a number.""" + state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") + assert state + assert float(state.state) == 60.0 + + +async def test_adam_dhw_setpoint_change( + hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of number entities.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.opentherm_domestic_hot_water_setpoint", + ATTR_VALUE: 55, + }, + blocking=True, + ) + + assert mock_smile_adam_2.set_number_setpoint.call_count == 1 + mock_smile_adam_2.set_number_setpoint.assert_called_with( + "max_dhw_temperature", 55.0 + ) From 00c60151d420d52042b2531c1482aae623a849e5 Mon Sep 17 00:00:00 2001 From: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Date: Sun, 13 Aug 2023 13:10:53 +0200 Subject: [PATCH 004/180] Add Ezviz siren entity (#93612) * Initial commit * Add siren entity * Update coveragerc * Cleanup unused entity description. * Add restore and fix entity property to standards. * Schedule turn off to match camera firmware * Only add siren for devices that support capability * Removed unused attribute and import. * Add translation * Update camera.py * Update strings.json * Update camera.py * Cleanup * Update homeassistant/components/ezviz/siren.py Co-authored-by: G Johansson * use description * Apply suggestions from code review Co-authored-by: G Johansson * Update strings.json * Dont inherit coordinator class. * Add assumed state * Update homeassistant/components/ezviz/siren.py Co-authored-by: G Johansson * Reset delay listener if trigered --------- Co-authored-by: G Johansson --- .coveragerc | 1 + homeassistant/components/ezviz/__init__.py | 1 + homeassistant/components/ezviz/camera.py | 11 ++ homeassistant/components/ezviz/entity.py | 2 + homeassistant/components/ezviz/siren.py | 133 ++++++++++++++++++++ homeassistant/components/ezviz/strings.json | 16 +++ 6 files changed, 164 insertions(+) create mode 100644 homeassistant/components/ezviz/siren.py diff --git a/.coveragerc b/.coveragerc index d36153ccca7..e64058d93d0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -341,6 +341,7 @@ omit = homeassistant/components/ezviz/entity.py homeassistant/components/ezviz/select.py homeassistant/components/ezviz/sensor.py + homeassistant/components/ezviz/siren.py homeassistant/components/ezviz/switch.py homeassistant/components/ezviz/update.py homeassistant/components/faa_delays/__init__.py diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index c007de78130..12754af25e8 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -42,6 +42,7 @@ PLATFORMS_BY_TYPE: dict[str, list] = { Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, Platform.UPDATE, ], diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 083e433952f..85b1f316a7b 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -288,6 +288,17 @@ class EzvizCamera(EzvizEntity, Camera): def perform_sound_alarm(self, enable: int) -> None: """Sound the alarm on a camera.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "service_depreciation_sound_alarm", + breaks_in_ha_version="2024.3.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_depreciation_sound_alarm", + ) + try: self.coordinator.ezviz_client.sound_alarm(self._serial, enable) except HTTPError as err: diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 6fad2b57142..c8ce3daf074 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -45,6 +45,8 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): class EzvizBaseEntity(Entity): """Generic entity for EZVIZ individual poll entities.""" + _attr_has_entity_name = True + def __init__( self, coordinator: EzvizDataUpdateCoordinator, diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py new file mode 100644 index 00000000000..1f08b389236 --- /dev/null +++ b/homeassistant/components/ezviz/siren.py @@ -0,0 +1,133 @@ +"""Support for EZVIZ sirens.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import Any + +from pyezviz import HTTPError, PyEzvizError, SupportExt + +from homeassistant.components.siren import ( + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.event as evt +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizBaseEntity + +PARALLEL_UPDATES = 1 +OFF_DELAY = timedelta(seconds=60) # Camera firmware has hard coded turn off. + +SIREN_ENTITY_TYPE = SirenEntityDescription( + key="siren", + translation_key="siren", +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ sensors based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + EzvizSirenEntity(coordinator, camera, SIREN_ENTITY_TYPE) + for camera in coordinator.data + for capability, value in coordinator.data[camera]["supportExt"].items() + if capability == str(SupportExt.SupportActiveDefense.value) + if value != "0" + ) + + +class EzvizSirenEntity(EzvizBaseEntity, SirenEntity, RestoreEntity): + """Representation of a EZVIZ Siren entity.""" + + _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF + _attr_should_poll = False + _attr_assumed_state = True + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + description: SirenEntityDescription, + ) -> None: + """Initialize the Siren.""" + super().__init__(coordinator, serial) + self._attr_unique_id = f"{serial}_{description.key}" + self.entity_description = description + self._attr_is_on = False + self._delay_listener: Callable | None = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + if not (last_state := await self.async_get_last_state()): + return + self._attr_is_on = last_state.state == STATE_ON + + if self._attr_is_on: + evt.async_call_later(self.hass, OFF_DELAY, self.off_delay_listener) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off camera siren.""" + try: + result = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.sound_alarm, self._serial, 1 + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to turn siren off for {self.name}" + ) from err + + if result: + if self._delay_listener is not None: + self._delay_listener() + self._delay_listener = None + + self._attr_is_on = False + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on camera siren.""" + try: + result = self.hass.async_add_executor_job( + self.coordinator.ezviz_client.sound_alarm, self._serial, 2 + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to turn siren on for {self.name}" + ) from err + + if result: + if self._delay_listener is not None: + self._delay_listener() + self._delay_listener = None + + self._attr_is_on = True + self._delay_listener = evt.async_call_later( + self.hass, OFF_DELAY, self.off_delay_listener + ) + self.async_write_ha_state() + + @callback + def off_delay_listener(self, now: datetime) -> None: + """Switch device off after a delay. + + Camera firmware has hard coded turn off after 60 seconds. + """ + self._attr_is_on = False + self._delay_listener = None + self.async_write_ha_state() diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 3e8797e7c02..373f9af22fc 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -92,6 +92,17 @@ } } } + }, + "service_depreciation_sound_alarm": { + "title": "Ezviz Sound alarm service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::ezviz::issues::service_depreciation_sound_alarm::title%]", + "description": "Ezviz Sound alarm service is deprecated and will be removed.\nTo sound the alarm, you can instead use the `siren.toggle` service targeting the Siren entity.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to fix this issue." + } + } + } } }, "entity": { @@ -216,6 +227,11 @@ "firmware": { "name": "[%key:component::update::entity_component::firmware::name%]" } + }, + "siren": { + "siren": { + "name": "[%key:component::siren::title%]" + } } }, "services": { From a74d83de6601694786229572abbbecbcdc278aaf Mon Sep 17 00:00:00 2001 From: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Date: Sun, 13 Aug 2023 13:41:37 +0200 Subject: [PATCH 005/180] Cleanup EZVIZ number entity (#98333) * EZVIZ - Cleanup number entity * NL * Fix naming --- homeassistant/components/ezviz/number.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index e4d39894d85..ea7a4812b32 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -66,7 +66,7 @@ async def async_setup_entry( ] async_add_entities( - EzvizSensor(coordinator, camera, value, entry.entry_id) + EzvizNumber(coordinator, camera, value, entry.entry_id) for camera in coordinator.data for capibility, value in coordinator.data[camera]["supportExt"].items() if capibility == NUMBER_TYPE.supported_ext @@ -74,11 +74,9 @@ async def async_setup_entry( ) -class EzvizSensor(EzvizBaseEntity, NumberEntity): +class EzvizNumber(EzvizBaseEntity, NumberEntity): """Representation of a EZVIZ number entity.""" - _attr_has_entity_name = True - def __init__( self, coordinator: EzvizDataUpdateCoordinator, @@ -86,7 +84,7 @@ class EzvizSensor(EzvizBaseEntity, NumberEntity): value: str, config_entry_id: str, ) -> None: - """Initialize the sensor.""" + """Initialize the entity.""" super().__init__(coordinator, serial) self.sensitivity_type = 3 if value == "3" else 0 self._attr_native_max_value = 100 if value == "3" else 6 From b36681b318f869c3bc961e2b7593337a0f7d8c7d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 13 Aug 2023 15:33:36 +0200 Subject: [PATCH 006/180] Update homekit entity feature constants (#98337) --- .../homekit/test_get_accessories.py | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 08a7f8a2206..b57dd2da10f 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -3,8 +3,8 @@ from unittest.mock import Mock, patch import pytest -import homeassistant.components.climate as climate -import homeassistant.components.cover as cover +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.homekit.accessories import TYPES, get_accessory from homeassistant.components.homekit.const import ( ATTR_INTEGRATION, @@ -17,9 +17,9 @@ from homeassistant.components.homekit.const import ( TYPE_SWITCH, TYPE_VALVE, ) -import homeassistant.components.media_player.const as media_player_c +from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.sensor import SensorDeviceClass -import homeassistant.components.vacuum as vacuum +from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_CLASS, @@ -90,7 +90,7 @@ def test_customize_options(config, name) -> None: "Thermostat", "climate.test", "auto", - {ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_RANGE}, + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE}, {}, ), ("HumidifierDehumidifier", "humidifier.test", "auto", {}, {}), @@ -118,7 +118,8 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: "open", { ATTR_DEVICE_CLASS: "garage", - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, }, ), ( @@ -127,26 +128,20 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: "open", { ATTR_DEVICE_CLASS: "window", - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, }, ), ( "WindowCovering", "cover.set_position", "open", - {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION}, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION}, ), ( "WindowCovering", "cover.tilt", "open", - {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_TILT_POSITION}, - ), - ( - "WindowCoveringBasic", - "cover.open_window", - "open", - {ATTR_SUPPORTED_FEATURES: (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE)}, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION}, ), ( "WindowCoveringBasic", @@ -154,9 +149,19 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: "open", { ATTR_SUPPORTED_FEATURES: ( - cover.SUPPORT_OPEN - | cover.SUPPORT_CLOSE - | cover.SUPPORT_SET_TILT_POSITION + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + }, + ), + ( + "WindowCoveringBasic", + "cover.open_window", + "open", + { + ATTR_SUPPORTED_FEATURES: ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_TILT_POSITION ) }, ), @@ -166,7 +171,7 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: "open", { ATTR_DEVICE_CLASS: "door", - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, }, ), ], @@ -188,8 +193,8 @@ def test_type_covers(type_name, entity_id, state, attrs) -> None: "media_player.test", "on", { - ATTR_SUPPORTED_FEATURES: media_player_c.MediaPlayerEntityFeature.TURN_ON - | media_player_c.MediaPlayerEntityFeature.TURN_OFF + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF }, {CONF_FEATURE_LIST: {FEATURE_ON_OFF: None}}, ), @@ -334,8 +339,8 @@ def test_type_switches(type_name, entity_id, state, attrs, config) -> None: "vacuum.dock_vacuum", "docked", { - ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_START - | vacuum.SUPPORT_RETURN_HOME + ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME }, ), ("Vacuum", "vacuum.basic_vacuum", "off", {}), From fa6ffd994a3f60bb8b7489073c40177ab731ab88 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 13 Aug 2023 15:35:00 +0200 Subject: [PATCH 007/180] Update vacuum entity constants for Alexa tests (#98336) * Update vacuum entity constants for Alexa tests * Import VacuumEntityFeature --- tests/components/alexa/test_smart_home.py | 65 ++++++++++++----------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 708b06bab2b..c42ea0a0f6a 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -8,7 +8,7 @@ from homeassistant.components.alexa import smart_home, state_report import homeassistant.components.camera as camera from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.media_player import MediaPlayerEntityFeature -import homeassistant.components.vacuum as vacuum +from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.config import async_process_ha_core_config from homeassistant.const import STATE_UNKNOWN, UnitOfTemperature from homeassistant.core import Context, Event, HomeAssistant @@ -3872,12 +3872,12 @@ async def test_vacuum_discovery(hass: HomeAssistant) -> None: "docked", { "friendly_name": "Test vacuum 1", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_STOP - | vacuum.SUPPORT_RETURN_HOME - | vacuum.SUPPORT_PAUSE, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.PAUSE, }, ) appliance = await discovery_test(device, hass) @@ -3913,12 +3913,12 @@ async def test_vacuum_fan_speed(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 2", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_STOP - | vacuum.SUPPORT_PAUSE - | vacuum.SUPPORT_FAN_SPEED, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.FAN_SPEED, "fan_speed_list": ["off", "low", "medium", "high", "turbo", "super_sucker"], "fan_speed": "medium", }, @@ -4042,12 +4042,12 @@ async def test_vacuum_pause(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 3", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_STOP - | vacuum.SUPPORT_PAUSE - | vacuum.SUPPORT_FAN_SPEED, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.FAN_SPEED, "fan_speed_list": ["off", "low", "medium", "high", "turbo", "super_sucker"], "fan_speed": "medium", }, @@ -4080,12 +4080,12 @@ async def test_vacuum_resume(hass: HomeAssistant) -> None: "docked", { "friendly_name": "Test vacuum 4", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_STOP - | vacuum.SUPPORT_PAUSE - | vacuum.SUPPORT_FAN_SPEED, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.FAN_SPEED, "fan_speed_list": ["off", "low", "medium", "high", "turbo", "super_sucker"], "fan_speed": "medium", }, @@ -4108,9 +4108,9 @@ async def test_vacuum_discovery_no_turn_on(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 5", - "supported_features": vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_RETURN_HOME, + "supported_features": VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME, }, ) appliance = await discovery_test(device, hass) @@ -4138,9 +4138,9 @@ async def test_vacuum_discovery_no_turn_off(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 6", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_START - | vacuum.SUPPORT_RETURN_HOME, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME, }, ) appliance = await discovery_test(device, hass) @@ -4169,7 +4169,8 @@ async def test_vacuum_discovery_no_turn_on_or_off(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 7", - "supported_features": vacuum.SUPPORT_START | vacuum.SUPPORT_RETURN_HOME, + "supported_features": VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME, }, ) appliance = await discovery_test(device, hass) From e5f7d83912f6a7c0e1ff632bac27a04adee279d5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 13 Aug 2023 17:17:47 +0200 Subject: [PATCH 008/180] Update entity feature constants google_assistant (#98335) * Update entity feature constants google_assistant * Update tests * Direct import * Some missed constants * Add fan and cover feature imports --- .../components/google_assistant/trait.py | 91 +++++----- .../google_assistant/test_smart_home.py | 6 +- .../components/google_assistant/test_trait.py | 166 +++++++++++------- 3 files changed, 158 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 36660820efb..425a394b522 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -28,8 +28,16 @@ from homeassistant.components import ( switch, vacuum, ) +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.camera import CameraEntityFeature +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.fan import FanEntityFeature +from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING -from homeassistant.components.media_player import MediaType +from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType +from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_BATTERY_LEVEL, @@ -302,7 +310,7 @@ class CameraStreamTrait(_Trait): def supported(domain, features, device_class, _): """Test if state is supported.""" if domain == camera.DOMAIN: - return features & camera.SUPPORT_STREAM + return features & CameraEntityFeature.STREAM return False @@ -612,7 +620,7 @@ class LocatorTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_LOCATE + return domain == vacuum.DOMAIN and features & VacuumEntityFeature.LOCATE def sync_attributes(self): """Return locator attributes for a sync request.""" @@ -652,7 +660,7 @@ class EnergyStorageTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_BATTERY + return domain == vacuum.DOMAIN and features & VacuumEntityFeature.BATTERY def sync_attributes(self): """Return EnergyStorage attributes for a sync request.""" @@ -710,7 +718,7 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: return True - if domain == cover.DOMAIN and features & cover.SUPPORT_STOP: + if domain == cover.DOMAIN and features & CoverEntityFeature.STOP: return True return False @@ -721,7 +729,7 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: return { "pausable": self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & vacuum.SUPPORT_PAUSE + & VacuumEntityFeature.PAUSE != 0 } if domain == cover.DOMAIN: @@ -991,7 +999,7 @@ class TemperatureSettingTrait(_Trait): response["thermostatHumidityAmbient"] = current_humidity if operation in (climate.HVACMode.AUTO, climate.HVACMode.HEAT_COOL): - if supported & climate.SUPPORT_TARGET_TEMPERATURE_RANGE: + if supported & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: response["thermostatTemperatureSetpointHigh"] = round( TemperatureConverter.convert( attrs[climate.ATTR_TARGET_TEMP_HIGH], @@ -1093,7 +1101,7 @@ class TemperatureSettingTrait(_Trait): supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) svc_data = {ATTR_ENTITY_ID: self.state.entity_id} - if supported & climate.SUPPORT_TARGET_TEMPERATURE_RANGE: + if supported & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: svc_data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high svc_data[climate.ATTR_TARGET_TEMP_LOW] = temp_low else: @@ -1311,11 +1319,11 @@ class ArmDisArmTrait(_Trait): } state_to_support = { - STATE_ALARM_ARMED_HOME: alarm_control_panel.const.SUPPORT_ALARM_ARM_HOME, - STATE_ALARM_ARMED_AWAY: alarm_control_panel.const.SUPPORT_ALARM_ARM_AWAY, - STATE_ALARM_ARMED_NIGHT: alarm_control_panel.const.SUPPORT_ALARM_ARM_NIGHT, - STATE_ALARM_ARMED_CUSTOM_BYPASS: alarm_control_panel.const.SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - STATE_ALARM_TRIGGERED: alarm_control_panel.const.SUPPORT_ALARM_TRIGGER, + STATE_ALARM_ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME, + STATE_ALARM_ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY, + STATE_ALARM_ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT, + STATE_ALARM_ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + STATE_ALARM_TRIGGERED: AlarmControlPanelEntityFeature.TRIGGER, } @staticmethod @@ -1454,9 +1462,9 @@ class FanSpeedTrait(_Trait): def supported(domain, features, device_class, _): """Test if state is supported.""" if domain == fan.DOMAIN: - return features & fan.SUPPORT_SET_SPEED + return features & FanEntityFeature.SET_SPEED if domain == climate.DOMAIN: - return features & climate.SUPPORT_FAN_MODE + return features & ClimateEntityFeature.FAN_MODE return False def sync_attributes(self): @@ -1468,7 +1476,7 @@ class FanSpeedTrait(_Trait): if domain == fan.DOMAIN: reversible = bool( self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & fan.SUPPORT_DIRECTION + & FanEntityFeature.DIRECTION ) result.update( @@ -1604,7 +1612,7 @@ class ModesTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - if domain == fan.DOMAIN and features & fan.SUPPORT_PRESET_MODE: + if domain == fan.DOMAIN and features & FanEntityFeature.PRESET_MODE: return True if domain == input_select.DOMAIN: @@ -1613,16 +1621,16 @@ class ModesTrait(_Trait): if domain == select.DOMAIN: return True - if domain == humidifier.DOMAIN and features & humidifier.SUPPORT_MODES: + if domain == humidifier.DOMAIN and features & HumidifierEntityFeature.MODES: return True - if domain == light.DOMAIN and features & light.SUPPORT_EFFECT: + if domain == light.DOMAIN and features & LightEntityFeature.EFFECT: return True if domain != media_player.DOMAIN: return False - return features & media_player.SUPPORT_SELECT_SOUND_MODE + return features & MediaPlayerEntityFeature.SELECT_SOUND_MODE def _generate(self, name, settings): """Generate a list of modes.""" @@ -1812,7 +1820,7 @@ class InputSelectorTrait(_Trait): def supported(domain, features, device_class, _): """Test if state is supported.""" if domain == media_player.DOMAIN and ( - features & media_player.SUPPORT_SELECT_SOURCE + features & MediaPlayerEntityFeature.SELECT_SOURCE ): return True @@ -1910,13 +1918,13 @@ class OpenCloseTrait(_Trait): response["discreteOnlyOpenClose"] = True elif ( self.state.domain == cover.DOMAIN - and features & cover.SUPPORT_SET_POSITION == 0 + and features & CoverEntityFeature.SET_POSITION == 0 ): response["discreteOnlyOpenClose"] = True if ( - features & cover.SUPPORT_OPEN == 0 - and features & cover.SUPPORT_CLOSE == 0 + features & CoverEntityFeature.OPEN == 0 + and features & CoverEntityFeature.CLOSE == 0 ): response["queryOnlyOpenClose"] = True @@ -1985,7 +1993,7 @@ class OpenCloseTrait(_Trait): elif position == 100: service = cover.SERVICE_OPEN_COVER should_verify = True - elif features & cover.SUPPORT_SET_POSITION: + elif features & CoverEntityFeature.SET_POSITION: service = cover.SERVICE_SET_COVER_POSITION if position > 0: should_verify = True @@ -2026,7 +2034,8 @@ class VolumeTrait(_Trait): """Test if trait is supported.""" if domain == media_player.DOMAIN: return features & ( - media_player.SUPPORT_VOLUME_SET | media_player.SUPPORT_VOLUME_STEP + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP ) return False @@ -2035,7 +2044,9 @@ class VolumeTrait(_Trait): """Return volume attributes for a sync request.""" features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) return { - "volumeCanMuteAndUnmute": bool(features & media_player.SUPPORT_VOLUME_MUTE), + "volumeCanMuteAndUnmute": bool( + features & MediaPlayerEntityFeature.VOLUME_MUTE + ), "commandOnlyVolume": self.state.attributes.get(ATTR_ASSUMED_STATE, False), # Volume amounts in SET_VOLUME and VOLUME_RELATIVE are on a scale # from 0 to this value. @@ -2078,7 +2089,7 @@ class VolumeTrait(_Trait): if not ( self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & media_player.SUPPORT_VOLUME_SET + & MediaPlayerEntityFeature.VOLUME_SET ): raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported") @@ -2088,13 +2099,13 @@ class VolumeTrait(_Trait): relative = params["relativeSteps"] features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if features & media_player.SUPPORT_VOLUME_SET: + if features & MediaPlayerEntityFeature.VOLUME_SET: current = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) target = max(0.0, min(1.0, current + relative / 100)) await self._set_volume_absolute(data, target) - elif features & media_player.SUPPORT_VOLUME_STEP: + elif features & MediaPlayerEntityFeature.VOLUME_STEP: svc = media_player.SERVICE_VOLUME_UP if relative < 0: svc = media_player.SERVICE_VOLUME_DOWN @@ -2116,7 +2127,7 @@ class VolumeTrait(_Trait): if not ( self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & media_player.SUPPORT_VOLUME_MUTE + & MediaPlayerEntityFeature.VOLUME_MUTE ): raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported") @@ -2158,14 +2169,14 @@ def _verify_pin_challenge(data, state, challenge): MEDIA_COMMAND_SUPPORT_MAPPING = { - COMMAND_MEDIA_NEXT: media_player.SUPPORT_NEXT_TRACK, - COMMAND_MEDIA_PAUSE: media_player.SUPPORT_PAUSE, - COMMAND_MEDIA_PREVIOUS: media_player.SUPPORT_PREVIOUS_TRACK, - COMMAND_MEDIA_RESUME: media_player.SUPPORT_PLAY, - COMMAND_MEDIA_SEEK_RELATIVE: media_player.SUPPORT_SEEK, - COMMAND_MEDIA_SEEK_TO_POSITION: media_player.SUPPORT_SEEK, - COMMAND_MEDIA_SHUFFLE: media_player.SUPPORT_SHUFFLE_SET, - COMMAND_MEDIA_STOP: media_player.SUPPORT_STOP, + COMMAND_MEDIA_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, + COMMAND_MEDIA_PAUSE: MediaPlayerEntityFeature.PAUSE, + COMMAND_MEDIA_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, + COMMAND_MEDIA_RESUME: MediaPlayerEntityFeature.PLAY, + COMMAND_MEDIA_SEEK_RELATIVE: MediaPlayerEntityFeature.SEEK, + COMMAND_MEDIA_SEEK_TO_POSITION: MediaPlayerEntityFeature.SEEK, + COMMAND_MEDIA_SHUFFLE: MediaPlayerEntityFeature.SHUFFLE_SET, + COMMAND_MEDIA_STOP: MediaPlayerEntityFeature.STOP, } MEDIA_COMMAND_ATTRIBUTES = { @@ -2350,7 +2361,7 @@ class ChannelTrait(_Trait): """Test if state is supported.""" if ( domain == media_player.DOMAIN - and (features & media_player.SUPPORT_PLAY_MEDIA) + and (features & MediaPlayerEntityFeature.PLAY_MEDIA) and device_class == media_player.MediaPlayerDeviceClass.TV ): return True diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 6cfa7965074..bf48564c251 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -6,7 +6,7 @@ from unittest.mock import ANY, patch import pytest from pytest_unordered import unordered -from homeassistant.components import camera +from homeassistant.components.camera import CameraEntityFeature from homeassistant.components.climate import ATTR_MAX_TEMP, ATTR_MIN_TEMP, HVACMode from homeassistant.components.demo.binary_sensor import DemoBinarySensor from homeassistant.components.demo.cover import DemoCover @@ -1186,7 +1186,9 @@ async def test_trait_execute_adding_query_data(hass: HomeAssistant) -> None: {"external_url": "https://example.com"}, ) hass.states.async_set( - "camera.office", "idle", {"supported_features": camera.SUPPORT_STREAM} + "camera.office", + "idle", + {"supported_features": CameraEntityFeature.STREAM}, ) with patch( diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index fd6b3a6790b..fcbf16c21c7 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -27,9 +27,22 @@ from homeassistant.components import ( switch, vacuum, ) +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.camera import CameraEntityFeature +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.fan import FanEntityFeature from homeassistant.components.google_assistant import const, error, helpers, trait from homeassistant.components.google_assistant.error import SmartHomeError -from homeassistant.components.media_player import SERVICE_PLAY_MEDIA, MediaType +from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.light import LightEntityFeature +from homeassistant.components.lock import LockEntityFeature +from homeassistant.components.media_player import ( + SERVICE_PLAY_MEDIA, + MediaPlayerEntityFeature, + MediaType, +) +from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -126,7 +139,7 @@ async def test_camera_stream(hass: HomeAssistant) -> None: ) assert helpers.get_google_type(camera.DOMAIN, None) is not None assert trait.CameraStreamTrait.supported( - camera.DOMAIN, camera.SUPPORT_STREAM, None, None + camera.DOMAIN, CameraEntityFeature.STREAM, None, None ) trt = trait.CameraStreamTrait( @@ -364,7 +377,7 @@ async def test_locate_vacuum(hass: HomeAssistant) -> None: """Test locate trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None assert trait.LocatorTrait.supported( - vacuum.DOMAIN, vacuum.SUPPORT_LOCATE, None, None + vacuum.DOMAIN, VacuumEntityFeature.LOCATE, None, None ) trt = trait.LocatorTrait( @@ -372,7 +385,7 @@ async def test_locate_vacuum(hass: HomeAssistant) -> None: State( "vacuum.bla", vacuum.STATE_IDLE, - {ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_LOCATE}, + {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.LOCATE}, ), BASIC_CONFIG, ) @@ -395,7 +408,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: """Test EnergyStorage trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None assert trait.EnergyStorageTrait.supported( - vacuum.DOMAIN, vacuum.SUPPORT_BATTERY, None, None + vacuum.DOMAIN, VacuumEntityFeature.BATTERY, None, None ) trt = trait.EnergyStorageTrait( @@ -404,7 +417,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: "vacuum.bla", vacuum.STATE_DOCKED, { - ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_BATTERY, + ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY, ATTR_BATTERY_LEVEL: 100, }, ), @@ -430,7 +443,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: "vacuum.bla", vacuum.STATE_CLEANING, { - ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_BATTERY, + ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY, ATTR_BATTERY_LEVEL: 20, }, ), @@ -469,7 +482,7 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: State( "vacuum.bla", vacuum.STATE_PAUSED, - {ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_PAUSE}, + {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.PAUSE}, ), BASIC_CONFIG, ) @@ -502,12 +515,14 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: async def test_startstop_cover(hass: HomeAssistant) -> None: """Test startStop trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.StartStopTrait.supported(cover.DOMAIN, cover.SUPPORT_STOP, None, None) + assert trait.StartStopTrait.supported( + cover.DOMAIN, CoverEntityFeature.STOP, None, None + ) state = State( "cover.bla", cover.STATE_CLOSED, - {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_STOP}, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP}, ) trt = trait.StartStopTrait( @@ -551,7 +566,10 @@ async def test_startstop_cover_assumed(hass: HomeAssistant) -> None: State( "cover.bla", cover.STATE_CLOSED, - {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_STOP, ATTR_ASSUMED_STATE: True}, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP, + ATTR_ASSUMED_STATE: True, + }, ), BASIC_CONFIG, ) @@ -707,7 +725,9 @@ async def test_color_light_temperature_light_bad_temp(hass: HomeAssistant) -> No async def test_light_modes(hass: HomeAssistant) -> None: """Test Light Mode trait.""" assert helpers.get_google_type(light.DOMAIN, None) is not None - assert trait.ModesTrait.supported(light.DOMAIN, light.SUPPORT_EFFECT, None, None) + assert trait.ModesTrait.supported( + light.DOMAIN, LightEntityFeature.EFFECT, None, None + ) trt = trait.ModesTrait( hass, @@ -847,7 +867,7 @@ async def test_temperature_setting_climate_onoff(hass: HomeAssistant) -> None: "climate.bla", climate.HVACMode.AUTO, { - ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, climate.ATTR_HVAC_MODES: [ climate.HVACMode.OFF, climate.HVACMode.COOL, @@ -928,7 +948,7 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: { climate.ATTR_CURRENT_TEMPERATURE: 70, climate.ATTR_CURRENT_HUMIDITY: 25, - ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, climate.ATTR_HVAC_MODES: [ STATE_OFF, climate.HVACMode.COOL, @@ -1040,7 +1060,7 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None "climate.bla", climate.HVACMode.COOL, { - ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, climate.ATTR_HVAC_MODES: [STATE_OFF, climate.HVACMode.COOL], climate.ATTR_MIN_TEMP: 10, climate.ATTR_MAX_TEMP: 30, @@ -1230,8 +1250,10 @@ async def test_humidity_setting_humidifier_setpoint(hass: HomeAssistant) -> None async def test_lock_unlock_lock(hass: HomeAssistant) -> None: """Test LockUnlock trait locking support for lock domain.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None - assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) - assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + assert trait.LockUnlockTrait.supported( + lock.DOMAIN, LockEntityFeature.OPEN, None, None + ) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, LockEntityFeature.OPEN, None) trt = trait.LockUnlockTrait( hass, State("lock.front_door", lock.STATE_LOCKED), PIN_CONFIG @@ -1254,8 +1276,10 @@ async def test_lock_unlock_lock(hass: HomeAssistant) -> None: async def test_lock_unlock_unlocking(hass: HomeAssistant) -> None: """Test LockUnlock trait locking support for lock domain.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None - assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) - assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + assert trait.LockUnlockTrait.supported( + lock.DOMAIN, LockEntityFeature.OPEN, None, None + ) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, LockEntityFeature.OPEN, None) trt = trait.LockUnlockTrait( hass, State("lock.front_door", lock.STATE_UNLOCKING), PIN_CONFIG @@ -1269,8 +1293,10 @@ async def test_lock_unlock_unlocking(hass: HomeAssistant) -> None: async def test_lock_unlock_lock_jammed(hass: HomeAssistant) -> None: """Test LockUnlock trait locking support for lock domain that jams.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None - assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) - assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + assert trait.LockUnlockTrait.supported( + lock.DOMAIN, LockEntityFeature.OPEN, None, None + ) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, LockEntityFeature.OPEN, None) trt = trait.LockUnlockTrait( hass, State("lock.front_door", lock.STATE_JAMMED), PIN_CONFIG @@ -1293,7 +1319,9 @@ async def test_lock_unlock_lock_jammed(hass: HomeAssistant) -> None: async def test_lock_unlock_unlock(hass: HomeAssistant) -> None: """Test LockUnlock trait unlocking support for lock domain.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None - assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) + assert trait.LockUnlockTrait.supported( + lock.DOMAIN, LockEntityFeature.OPEN, None, None + ) trt = trait.LockUnlockTrait( hass, State("lock.front_door", lock.STATE_LOCKED), PIN_CONFIG @@ -1363,8 +1391,8 @@ async def test_arm_disarm_arm_away(hass: HomeAssistant) -> None: STATE_ALARM_ARMED_AWAY, { alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True, - ATTR_SUPPORTED_FEATURES: alarm_control_panel.const.SUPPORT_ALARM_ARM_HOME - | alarm_control_panel.const.SUPPORT_ALARM_ARM_AWAY, + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY, }, ), PIN_CONFIG, @@ -1526,8 +1554,8 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: STATE_ALARM_DISARMED, { alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True, - ATTR_SUPPORTED_FEATURES: alarm_control_panel.const.SUPPORT_ALARM_TRIGGER - | alarm_control_panel.const.SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.TRIGGER + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, }, ), PIN_CONFIG, @@ -1662,7 +1690,9 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: async def test_fan_speed(hass: HomeAssistant) -> None: """Test FanSpeed trait speed control support for fan domain.""" assert helpers.get_google_type(fan.DOMAIN, None) is not None - assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED, None, None) + assert trait.FanSpeedTrait.supported( + fan.DOMAIN, FanEntityFeature.SET_SPEED, None, None + ) trt = trait.FanSpeedTrait( hass, @@ -1700,7 +1730,9 @@ async def test_fan_speed(hass: HomeAssistant) -> None: async def test_fan_speed_without_percentage_step(hass: HomeAssistant) -> None: """Test FanSpeed trait speed control percentage step for fan domain.""" assert helpers.get_google_type(fan.DOMAIN, None) is not None - assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED, None, None) + assert trait.FanSpeedTrait.supported( + fan.DOMAIN, FanEntityFeature.SET_SPEED, None, None + ) trt = trait.FanSpeedTrait( hass, @@ -1787,7 +1819,9 @@ async def test_fan_speed_ordered( ): """Test FanSpeed trait speed control support for fan domain.""" assert helpers.get_google_type(fan.DOMAIN, None) is not None - assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED, None, None) + assert trait.FanSpeedTrait.supported( + fan.DOMAIN, FanEntityFeature.SET_SPEED, None, None + ) trt = trait.FanSpeedTrait( hass, @@ -1858,7 +1892,7 @@ async def test_fan_reverse( "percentage": 33, "percentage_step": 1.0, "direction": direction_state, - "supported_features": fan.SUPPORT_DIRECTION, + "supported_features": FanEntityFeature.DIRECTION, }, ), BASIC_CONFIG, @@ -1889,7 +1923,7 @@ async def test_climate_fan_speed(hass: HomeAssistant) -> None: """Test FanSpeed trait speed control support for climate domain.""" assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.FanSpeedTrait.supported( - climate.DOMAIN, climate.SUPPORT_FAN_MODE, None, None + climate.DOMAIN, ClimateEntityFeature.FAN_MODE, None, None ) trt = trait.FanSpeedTrait( @@ -1951,7 +1985,7 @@ async def test_inputselector(hass: HomeAssistant) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.InputSelectorTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.SELECT_SOURCE, + MediaPlayerEntityFeature.SELECT_SOURCE, None, None, ) @@ -2265,7 +2299,7 @@ async def test_modes_humidifier(hass: HomeAssistant) -> None: """Test Humidifier Mode trait.""" assert helpers.get_google_type(humidifier.DOMAIN, None) is not None assert trait.ModesTrait.supported( - humidifier.DOMAIN, humidifier.SUPPORT_MODES, None, None + humidifier.DOMAIN, HumidifierEntityFeature.MODES, None, None ) trt = trait.ModesTrait( @@ -2279,7 +2313,7 @@ async def test_modes_humidifier(hass: HomeAssistant) -> None: humidifier.MODE_AUTO, humidifier.MODE_AWAY, ], - ATTR_SUPPORTED_FEATURES: humidifier.SUPPORT_MODES, + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES, humidifier.ATTR_MIN_HUMIDITY: 30, humidifier.ATTR_MAX_HUMIDITY: 99, humidifier.ATTR_HUMIDITY: 50, @@ -2345,7 +2379,7 @@ async def test_sound_modes(hass: HomeAssistant) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.ModesTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.SELECT_SOUND_MODE, + MediaPlayerEntityFeature.SELECT_SOUND_MODE, None, None, ) @@ -2420,7 +2454,9 @@ async def test_sound_modes(hass: HomeAssistant) -> None: async def test_preset_modes(hass: HomeAssistant) -> None: """Test Mode trait for fan preset modes.""" assert helpers.get_google_type(fan.DOMAIN, None) is not None - assert trait.ModesTrait.supported(fan.DOMAIN, fan.SUPPORT_PRESET_MODE, None, None) + assert trait.ModesTrait.supported( + fan.DOMAIN, FanEntityFeature.PRESET_MODE, None, None + ) trt = trait.ModesTrait( hass, @@ -2430,7 +2466,7 @@ async def test_preset_modes(hass: HomeAssistant) -> None: attributes={ fan.ATTR_PRESET_MODES: ["auto", "whoosh"], fan.ATTR_PRESET_MODE: "auto", - ATTR_SUPPORTED_FEATURES: fan.SUPPORT_PRESET_MODE, + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE, }, ), BASIC_CONFIG, @@ -2514,7 +2550,7 @@ async def test_openclose_cover(hass: HomeAssistant) -> None: """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, None, None + cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None ) trt = trait.OpenCloseTrait( @@ -2524,7 +2560,7 @@ async def test_openclose_cover(hass: HomeAssistant) -> None: cover.STATE_OPEN, { cover.ATTR_CURRENT_POSITION: 75, - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, }, ), BASIC_CONFIG, @@ -2551,14 +2587,16 @@ async def test_openclose_cover_unknown_state(hass: HomeAssistant) -> None: """Test OpenClose trait support for cover domain with unknown state.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, None, None + cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None ) # No state trt = trait.OpenCloseTrait( hass, State( - "cover.bla", STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN} + "cover.bla", + STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN}, ), BASIC_CONFIG, ) @@ -2581,7 +2619,7 @@ async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, None, None + cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None ) trt = trait.OpenCloseTrait( @@ -2591,7 +2629,7 @@ async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: cover.STATE_OPEN, { ATTR_ASSUMED_STATE: True, - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, }, ), BASIC_CONFIG, @@ -2634,14 +2672,17 @@ async def test_openclose_cover_no_position(hass: HomeAssistant) -> None: """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE, None, None + cover.DOMAIN, + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + None, + None, ) state = State( "cover.bla", cover.STATE_OPEN, { - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, }, ) @@ -2695,10 +2736,10 @@ async def test_openclose_cover_secure(hass: HomeAssistant, device_class) -> None """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, device_class) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class, None + cover.DOMAIN, CoverEntityFeature.SET_POSITION, device_class, None ) assert trait.OpenCloseTrait.might_2fa( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class + cover.DOMAIN, CoverEntityFeature.SET_POSITION, device_class ) trt = trait.OpenCloseTrait( @@ -2708,7 +2749,7 @@ async def test_openclose_cover_secure(hass: HomeAssistant, device_class) -> None cover.STATE_OPEN, { ATTR_DEVICE_CLASS: device_class, - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, cover.ATTR_CURRENT_POSITION: 75, }, ), @@ -2796,7 +2837,7 @@ async def test_volume_media_player(hass: HomeAssistant) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.VolumeTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.VOLUME_SET, + MediaPlayerEntityFeature.VOLUME_SET, None, None, ) @@ -2807,7 +2848,7 @@ async def test_volume_media_player(hass: HomeAssistant) -> None: "media_player.bla", media_player.STATE_PLAYING, { - ATTR_SUPPORTED_FEATURES: media_player.MediaPlayerEntityFeature.VOLUME_SET, + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.3, }, ), @@ -2850,7 +2891,7 @@ async def test_volume_media_player_relative(hass: HomeAssistant) -> None: """Test volume trait support for relative-volume-only media players.""" assert trait.VolumeTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.VOLUME_STEP, + MediaPlayerEntityFeature.VOLUME_STEP, None, None, ) @@ -2861,7 +2902,7 @@ async def test_volume_media_player_relative(hass: HomeAssistant) -> None: media_player.STATE_PLAYING, { ATTR_ASSUMED_STATE: True, - ATTR_SUPPORTED_FEATURES: media_player.MediaPlayerEntityFeature.VOLUME_STEP, + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_STEP, }, ), BASIC_CONFIG, @@ -2918,8 +2959,7 @@ async def test_media_player_mute(hass: HomeAssistant) -> None: """Test volume trait support for muting.""" assert trait.VolumeTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.VOLUME_STEP - | media_player.MediaPlayerEntityFeature.VOLUME_MUTE, + MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE, None, None, ) @@ -2930,8 +2970,8 @@ async def test_media_player_mute(hass: HomeAssistant) -> None: media_player.STATE_PLAYING, { ATTR_SUPPORTED_FEATURES: ( - media_player.MediaPlayerEntityFeature.VOLUME_STEP - | media_player.MediaPlayerEntityFeature.VOLUME_MUTE + MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE ), media_player.ATTR_MEDIA_VOLUME_MUTED: False, }, @@ -3095,8 +3135,8 @@ async def test_transport_control(hass: HomeAssistant) -> None: media_player.ATTR_MEDIA_POSITION_UPDATED_AT: now - timedelta(seconds=10), media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.5, - ATTR_SUPPORTED_FEATURES: media_player.MediaPlayerEntityFeature.PLAY - | media_player.MediaPlayerEntityFeature.STOP, + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP, }, ), BASIC_CONFIG, @@ -3210,7 +3250,7 @@ async def test_media_state(hass: HomeAssistant, state) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.TransportControlTrait.supported( - media_player.DOMAIN, media_player.MediaPlayerEntityFeature.PLAY, None, None + media_player.DOMAIN, MediaPlayerEntityFeature.PLAY, None, None ) trt = trait.MediaStateTrait( @@ -3222,8 +3262,8 @@ async def test_media_state(hass: HomeAssistant, state) -> None: media_player.ATTR_MEDIA_POSITION: 100, media_player.ATTR_MEDIA_DURATION: 200, media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.5, - ATTR_SUPPORTED_FEATURES: media_player.MediaPlayerEntityFeature.PLAY - | media_player.MediaPlayerEntityFeature.STOP, + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP, }, ), BASIC_CONFIG, @@ -3244,14 +3284,14 @@ async def test_channel(hass: HomeAssistant) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.ChannelTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.PLAY_MEDIA, + MediaPlayerEntityFeature.PLAY_MEDIA, media_player.MediaPlayerDeviceClass.TV, None, ) assert ( trait.ChannelTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.PLAY_MEDIA, + MediaPlayerEntityFeature.PLAY_MEDIA, None, None, ) From 54cbc85c13587bedd45c30fd15516b4c355bc47f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Aug 2023 18:57:46 +0200 Subject: [PATCH 009/180] Add types-Pillow dependency (#98266) --- homeassistant/components/generic/config_flow.py | 4 ++-- homeassistant/util/pil.py | 2 +- requirements_test.txt | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index ec94d4c227c..eb2d109caeb 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -12,7 +12,7 @@ from typing import Any from aiohttp import web from async_timeout import timeout from httpx import HTTPStatusError, RequestError, TimeoutException -import PIL +import PIL.Image import voluptuous as vol import yarl @@ -137,7 +137,7 @@ def get_image_type(image: bytes) -> str | None: imagefile = io.BytesIO(image) with contextlib.suppress(PIL.UnidentifiedImageError): img = PIL.Image.open(imagefile) - fmt = img.format.lower() + fmt = img.format.lower() if img.format else None if fmt is None: # if PIL can't figure it out, could be svg. diff --git a/homeassistant/util/pil.py b/homeassistant/util/pil.py index 068b807cbe5..58dd48dec5e 100644 --- a/homeassistant/util/pil.py +++ b/homeassistant/util/pil.py @@ -4,7 +4,7 @@ Can only be used by integrations that have pillow in their requirements. """ from __future__ import annotations -from PIL import ImageDraw +from PIL.ImageDraw import ImageDraw def draw_box( diff --git a/requirements_test.txt b/requirements_test.txt index 76a94c758b9..c1c7fdbdcc3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -41,6 +41,7 @@ types-decorator==5.1.8.3 types-enum34==1.1.8 types-ipaddress==1.0.8 types-paho-mqtt==1.6.0.6 +types-Pillow==10.0.0.2 types-pkg-resources==0.1.3 types-psutil==5.9.5 types-python-dateutil==2.8.19.13 From ee3af297010ff17c937c76f470c0dd3974fba498 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Aug 2023 18:58:34 +0200 Subject: [PATCH 010/180] Update coverage to 7.3.0 (#98327) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index c1c7fdbdcc3..e043497c4a8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==2.15.4 -coverage==7.2.7 +coverage==7.3.0 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.0 From e25fdebda1b7500a79ad6c51725e4f9e5209719d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Aug 2023 18:58:55 +0200 Subject: [PATCH 011/180] Add types-caldav dependency (#98265) --- homeassistant/components/caldav/calendar.py | 1 + requirements_test.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 712873e51ce..57bf8e81e03 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -87,6 +87,7 @@ def setup_platform( calendars = client.principal().calendars() calendar_devices = [] + device_id: str | None for calendar in list(calendars): # If a calendar name was given in the configuration, # ignore all the others diff --git a/requirements_test.txt b/requirements_test.txt index e043497c4a8..9a7dd9b23da 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -36,6 +36,7 @@ tqdm==4.65.0 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 +types-caldav==1.2.0.2 types-chardet==0.1.5 types-decorator==5.1.8.3 types-enum34==1.1.8 From ef6e75657af067b7720adfa8127a7ea8075a4a0f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Aug 2023 19:05:15 +0200 Subject: [PATCH 012/180] Update attrs to 23.1.0 (#97095) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 282ff1ddb44..938f7a359d4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ astral==2.2 async-timeout==4.0.2 async-upnp-client==0.34.1 atomicwrites-homeassistant==1.4.1 -attrs==22.2.0 +attrs==23.1.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.1.1 diff --git a/pyproject.toml b/pyproject.toml index 02dbc87fb72..f91776289af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.8.5", "astral==2.2", "async-timeout==4.0.2", - "attrs==22.2.0", + "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==22.9.0", "bcrypt==4.0.1", diff --git a/requirements.txt b/requirements.txt index f3cd10a3577..5e20ed3b5b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ aiohttp==3.8.5 astral==2.2 async-timeout==4.0.2 -attrs==22.2.0 +attrs==23.1.0 atomicwrites-homeassistant==1.4.1 awesomeversion==22.9.0 bcrypt==4.0.1 From 5b6a7edd8da94a6bc20399ff428051b2c87c494b Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 13 Aug 2023 11:06:12 -0700 Subject: [PATCH 013/180] Add Unifi outlet switches for PDU devices (#98320) Updates the Unifi outlet switching feature to support PDU devices --- homeassistant/components/unifi/switch.py | 11 +- tests/components/unifi/test_sensor.py | 2 +- tests/components/unifi/test_switch.py | 223 +++++++++++++++++++++-- 3 files changed, 214 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index ae339eb8d22..a82b9e35d45 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -106,6 +106,15 @@ async def async_dpi_group_control_fn( ) +@callback +def async_outlet_supports_switching_fn( + controller: UniFiController, obj_id: str +) -> bool: + """Determine if an outlet supports switching.""" + outlet = controller.api.outlets[obj_id] + return outlet.has_relay or outlet.caps in (1, 3) + + async def async_outlet_control_fn( api: aiounifi.Controller, obj_id: str, target: bool ) -> None: @@ -210,7 +219,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( name_fn=lambda outlet: outlet.name, object_fn=lambda api, obj_id: api.outlets[obj_id], should_poll=False, - supported_fn=lambda c, obj_id: c.api.outlets[obj_id].has_relay, + supported_fn=async_outlet_supports_switching_fn, unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-outlet-{obj_id.split('_', 1)[1]}", ), UnifiSwitchEntityDescription[Ports, Port]( diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 359825514d7..cf6b74b9765 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -785,7 +785,7 @@ async def test_outlet_power_readings( """Test the outlet power reporting on PDU devices.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) - assert len(hass.states.async_all()) == 7 + assert len(hass.states.async_all()) == 9 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 ent_reg = er.async_get(hass) diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index ad5131614af..5344ac901b7 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -4,6 +4,7 @@ from datetime import timedelta from aiounifi.models.message import MessageKey from aiounifi.websocket import WebsocketState +import pytest from homeassistant import config_entries from homeassistant.components.switch import ( @@ -384,7 +385,7 @@ OUTLET_UP1 = { "x_vwirekey": "2dabb7e23b048c88b60123456789", "vwire_table": [], "dot1x_portctrl_enabled": False, - "outlet_overrides": [], + "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}], "outlet_enabled": True, "license_state": "registered", "x_aes_gcm": True, @@ -580,6 +581,152 @@ OUTLET_UP1 = { } +PDU_DEVICE_1 = { + "_id": "123456654321abcdef012345", + "required_version": "5.28.0", + "port_table": [], + "license_state": "registered", + "lcm_brightness_override": False, + "type": "usw", + "board_rev": 4, + "hw_caps": 136, + "reboot_duration": 70, + "snmp_contact": "", + "config_network": {"type": "dhcp", "bonding_enabled": False}, + "outlet_table": [ + { + "index": 1, + "relay_state": True, + "cycle_enabled": False, + "name": "USB Outlet 1", + "outlet_caps": 1, + }, + { + "index": 2, + "relay_state": True, + "cycle_enabled": False, + "name": "Outlet 2", + "outlet_caps": 3, + "outlet_voltage": "119.644", + "outlet_current": "0.935", + "outlet_power": "73.827", + "outlet_power_factor": "0.659", + }, + ], + "model": "USPPDUP", + "manufacturer_id": 4, + "ip": "192.168.1.76", + "fw2_caps": 0, + "jumboframe_enabled": False, + "version": "6.5.59.14777", + "unsupported_reason": 0, + "adoption_completed": True, + "outlet_enabled": True, + "stp_version": "rstp", + "name": "Dummy USP-PDU-Pro", + "fw_caps": 1732968229, + "lcm_brightness": 80, + "internet": True, + "mgmt_network_id": "123456654321abcdef012347", + "gateway_mac": "01:02:03:04:05:06", + "stp_priority": "32768", + "lcm_night_mode_begins": "22:00", + "two_phase_adopt": False, + "connected_at": 1690626493, + "inform_ip": "192.168.1.1", + "cfgversion": "ba8f30a5a17aad64", + "mac": "01:02:03:04:05:ff", + "provisioned_at": 1690989511, + "inform_url": "http://192.168.1.1:8080/inform", + "upgrade_duration": 100, + "ethernet_table": [{"num_port": 1, "name": "eth0", "mac": "01:02:03:04:05:a1"}], + "flowctrl_enabled": False, + "unsupported": False, + "ble_caps": 0, + "sys_error_caps": 0, + "dot1x_portctrl_enabled": False, + "last_uplink": {}, + "disconnected_at": 1690626452, + "architecture": "mips", + "x_aes_gcm": True, + "has_fan": False, + "outlet_overrides": [ + { + "cycle_enabled": False, + "name": "USB Outlet 1", + "relay_state": True, + "index": 1, + }, + {"cycle_enabled": False, "name": "Outlet 2", "relay_state": True, "index": 2}, + ], + "model_incompatible": False, + "satisfaction": 100, + "model_in_eol": False, + "anomalies": -1, + "has_temperature": False, + "switch_caps": {}, + "adopted_by_client": "web", + "snmp_location": "", + "model_in_lts": False, + "kernel_version": "4.14.115", + "serial": "abc123", + "power_source_ctrl_enabled": False, + "lcm_night_mode_ends": "08:00", + "adopted": True, + "hash_id": "abcdef123456", + "device_id": "mock-pdu", + "uplink": {}, + "state": 1, + "start_disconnected_millis": 1690626383386, + "credential_caps": 0, + "default": False, + "discovered_via": "l2", + "adopt_ip": "10.0.10.4", + "adopt_url": "http://192.168.1.1:8080/inform", + "last_seen": 1691518814, + "min_inform_interval_seconds": 10, + "upgradable": False, + "adoptable_when_upgraded": False, + "rollupgrade": False, + "known_cfgversion": "abcfde03929", + "uptime": 1193042, + "_uptime": 1193042, + "locating": False, + "start_connected_millis": 1690626493324, + "prev_non_busy_state": 5, + "next_interval": 47, + "sys_stats": {}, + "system-stats": {"cpu": "1.4", "mem": "28.9", "uptime": "1193042"}, + "ssh_session_table": [], + "lldp_table": [], + "displayable_version": "6.5.59", + "connection_network_id": "123456654321abcdef012349", + "connection_network_name": "Default", + "startup_timestamp": 1690325774, + "is_access_point": False, + "safe_for_autoupgrade": True, + "overheating": False, + "power_source": "0", + "total_max_power": 0, + "outlet_ac_power_budget": "1875.000", + "outlet_ac_power_consumption": "201.683", + "downlink_table": [], + "uplink_depth": 1, + "downlink_lldp_macs": [], + "dhcp_server_table": [], + "connect_request_ip": "10.0.10.4", + "connect_request_port": "57951", + "ipv4_lease_expiration_timestamp_seconds": 1691576686, + "stat": {}, + "tx_bytes": 1426780, + "rx_bytes": 1435064, + "bytes": 2861844, + "num_sta": 0, + "user-num_sta": 0, + "guest-num_sta": 0, + "x_has_ssh_hostkey": True, +} + WLAN = { "_id": "012345678910111213141516", "bc_filter_enabled": False, @@ -960,56 +1107,92 @@ async def test_dpi_switches_add_second_app( assert hass.states.get("switch.block_media_streaming").state == STATE_ON +@pytest.mark.parametrize( + ("entity_id", "test_data", "outlet_index", "expected_switches"), + [ + ( + "plug_outlet_1", + OUTLET_UP1, + 1, + 1, + ), + ( + "dummy_usp_pdu_pro_usb_outlet_1", + PDU_DEVICE_1, + 1, + 2, + ), + ( + "dummy_usp_pdu_pro_outlet_2", + PDU_DEVICE_1, + 2, + 2, + ), + ], +) async def test_outlet_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + entity_id: str, + test_data: any, + outlet_index: int, + expected_switches: int, ) -> None: """Test the outlet entities.""" config_entry = await setup_unifi_integration( - hass, aioclient_mock, devices_response=[OUTLET_UP1] + hass, aioclient_mock, devices_response=[test_data] ) controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == expected_switches # Validate state object - switch_1 = hass.states.get("switch.plug_outlet_1") + switch_1 = hass.states.get(f"switch.{entity_id}") assert switch_1 is not None assert switch_1.state == STATE_ON assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET # Update state object - device_1 = deepcopy(OUTLET_UP1) - device_1["outlet_table"][0]["relay_state"] = False + device_1 = deepcopy(test_data) + device_1["outlet_table"][outlet_index - 1]["relay_state"] = False mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Turn off outlet + device_id = test_data["device_id"] aioclient_mock.clear_requests() aioclient_mock.put( - f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/600c8356942a6ade50707b56", + f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/{device_id}", ) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, + {ATTR_ENTITY_ID: f"switch.{entity_id}"}, blocking=True, ) + + expected_off_overrides = deepcopy(device_1["outlet_overrides"]) + expected_off_overrides[outlet_index - 1]["relay_state"] = False + assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[0][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": False}] + "outlet_overrides": expected_off_overrides } # Turn on outlet await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, + {ATTR_ENTITY_ID: f"switch.{entity_id}"}, blocking=True, ) + + expected_on_overrides = deepcopy(device_1["outlet_overrides"]) + expected_on_overrides[outlet_index - 1]["relay_state"] = True assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[1][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}] + "outlet_overrides": expected_on_overrides } # Availability signalling @@ -1017,33 +1200,33 @@ async def test_outlet_switches( # Controller disconnects mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Controller reconnects mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Device gets disabled device_1["disabled"] = True mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Device gets re-enabled device_1["disabled"] = False mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Unload config entry await hass.config_entries.async_unload(config_entry.entry_id) - assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Remove config entry await hass.config_entries.async_remove(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1") is None + assert hass.states.get(f"switch.{entity_id}") is None async def test_new_client_discovered_on_block_control( From 66b01bee490f0f7a31769bb956d01728a2f8fa7b Mon Sep 17 00:00:00 2001 From: Mr-Ker <58399419+Mr-Ker@users.noreply.github.com> Date: Sun, 13 Aug 2023 21:05:57 +0200 Subject: [PATCH 014/180] Add support for Bosch 2nd Gen Shutter Contact (#98331) Add support for Bosch 2nd Gen SHCShutterContact2 We only need to check for the shutter contact 2 types as both devices provide the same properties that are used by the bosch_shc component. Resolves: #86295 --- homeassistant/components/bosch_shc/binary_sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index 25ab320a4c4..348bfe80701 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -25,7 +25,9 @@ async def async_setup_entry( entities: list[BinarySensorEntity] = [] session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] - for binary_sensor in session.device_helper.shutter_contacts: + for binary_sensor in ( + session.device_helper.shutter_contacts + session.device_helper.shutter_contacts2 + ): entities.append( ShutterContactSensor( device=binary_sensor, @@ -37,6 +39,7 @@ async def async_setup_entry( for binary_sensor in ( session.device_helper.motion_detectors + session.device_helper.shutter_contacts + + session.device_helper.shutter_contacts2 + session.device_helper.smoke_detectors + session.device_helper.thermostats + session.device_helper.twinguards From 429f939fee001ff7f5679da61fb838d8a5532be6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Aug 2023 17:23:03 -0500 Subject: [PATCH 015/180] Bump zeroconf to 0.75.0 (#98360) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index cd7b9e95e75..a6b840691bb 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.74.0"] + "requirements": ["zeroconf==0.75.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 938f7a359d4..a29fa2af946 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.74.0 +zeroconf==0.75.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 15c40edc32d..f771426929a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2752,7 +2752,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.74.0 +zeroconf==0.75.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e5e84347c2..bd6f9c70f4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2025,7 +2025,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.74.0 +zeroconf==0.75.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 07e20e1eabf5b591b4f2ca045b0c22b2b499b5b2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Aug 2023 00:23:38 +0200 Subject: [PATCH 016/180] Downgrade todoist-api-python to 2.0.2 and attrs to 22.2.0 (#98354) --- homeassistant/components/todoist/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index 22d3b19b6c9..ac7e899d8a1 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], - "requirements": ["todoist-api-python==2.1.1"] + "requirements": ["todoist-api-python==2.0.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a29fa2af946..78d8a7d4ac9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ astral==2.2 async-timeout==4.0.2 async-upnp-client==0.34.1 atomicwrites-homeassistant==1.4.1 -attrs==23.1.0 +attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.1.1 diff --git a/pyproject.toml b/pyproject.toml index f91776289af..02dbc87fb72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.8.5", "astral==2.2", "async-timeout==4.0.2", - "attrs==23.1.0", + "attrs==22.2.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==22.9.0", "bcrypt==4.0.1", diff --git a/requirements.txt b/requirements.txt index 5e20ed3b5b2..f3cd10a3577 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ aiohttp==3.8.5 astral==2.2 async-timeout==4.0.2 -attrs==23.1.0 +attrs==22.2.0 atomicwrites-homeassistant==1.4.1 awesomeversion==22.9.0 bcrypt==4.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index f771426929a..2844ad47e98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2554,7 +2554,7 @@ tilt-ble==0.2.3 tmb==0.0.4 # homeassistant.components.todoist -todoist-api-python==2.1.1 +todoist-api-python==2.0.2 # homeassistant.components.tolo tololib==0.1.0b4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd6f9c70f4e..c42ad02da8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1860,7 +1860,7 @@ thermopro-ble==0.4.5 tilt-ble==0.2.3 # homeassistant.components.todoist -todoist-api-python==2.1.1 +todoist-api-python==2.0.2 # homeassistant.components.tolo tololib==0.1.0b4 From 790c1bc2519703b547ce6f16daa7a9f58857c00a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Aug 2023 19:37:45 -0500 Subject: [PATCH 017/180] Decrease event loop latency by binding time.monotonic to loop.time directly (#98288) * Decrease event loop latency by binding time.monotonic to loop.time directly This is a small improvment to decrease event loop latency. While the goal is is to reduce Bluetooth connection time latency, everything using asyncio is a bit more responsive as a result. * relo per comments * fix too fast by adding resolution, ensure monotonic time is patchable by freezegun * fix test that freezes time too late and has a race loop --- homeassistant/runner.py | 5 +++++ tests/common.py | 5 ++++- tests/components/statistics/test_sensor.py | 7 +++++-- tests/patch_time.py | 9 ++++++++- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 4bbf1a7dada..ed49db37f97 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -8,6 +8,7 @@ import logging import os import subprocess import threading +from time import monotonic import traceback from typing import Any @@ -114,6 +115,10 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): loop.set_default_executor = warn_use( # type: ignore[method-assign] loop.set_default_executor, "sets default executor on the event loop" ) + # bind the built-in time.monotonic directly as loop.time to avoid the + # overhead of the additional method call since its the most called loop + # method and its roughly 10%+ of all the call time in base_events.py + loop.time = monotonic # type: ignore[method-assign] return loop diff --git a/tests/common.py b/tests/common.py index eb8c8417f16..0431743cccf 100644 --- a/tests/common.py +++ b/tests/common.py @@ -420,6 +420,9 @@ def async_fire_time_changed( _async_fire_time_changed(hass, utc_datetime, fire_all) +_MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution + + @callback def _async_fire_time_changed( hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool @@ -432,7 +435,7 @@ def _async_fire_time_changed( continue mock_seconds_into_future = timestamp - time.time() - future_seconds = task.when() - hass.loop.time() + future_seconds = task.when() - (hass.loop.time() + _MONOTONIC_RESOLUTION) if fire_all or mock_seconds_into_future >= future_seconds: with patch( diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 4b77e2d0725..780e550f224 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -8,6 +8,7 @@ from typing import Any from unittest.mock import patch from freezegun import freeze_time +import pytest from homeassistant import config as hass_config from homeassistant.components.recorder import Recorder @@ -1286,12 +1287,14 @@ async def test_initialize_from_database( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS +@pytest.mark.freeze_time( + datetime(dt_util.utcnow().year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) +) async def test_initialize_from_database_with_maxage( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test initializing the statistics from the database.""" - now = dt_util.utcnow() - current_time = datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) + current_time = dt_util.utcnow() # Testing correct retrieval from recorder, thus we do not # want purging to occur within the class itself. diff --git a/tests/patch_time.py b/tests/patch_time.py index 2a453053170..5f5dc467c9d 100644 --- a/tests/patch_time.py +++ b/tests/patch_time.py @@ -2,8 +2,9 @@ from __future__ import annotations import datetime +import time -from homeassistant import util +from homeassistant import runner, util from homeassistant.util import dt as dt_util @@ -12,5 +13,11 @@ def _utcnow() -> datetime.datetime: return datetime.datetime.now(datetime.UTC) +def _monotonic() -> float: + """Make monotonic patchable by freezegun.""" + return time.monotonic() + + dt_util.utcnow = _utcnow # type: ignore[assignment] util.utcnow = _utcnow # type: ignore[assignment] +runner.monotonic = _monotonic # type: ignore[assignment] From 96f9b852a210254e8c03245ee2a543d6ad608e1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Aug 2023 22:47:29 -0500 Subject: [PATCH 018/180] Bump zeroconf to 0.76.0 (#98366) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index a6b840691bb..da8cfd26b1f 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.75.0"] + "requirements": ["zeroconf==0.76.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 78d8a7d4ac9..65e5dd33b8d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.75.0 +zeroconf==0.76.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 2844ad47e98..7e21d0778fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2752,7 +2752,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.75.0 +zeroconf==0.76.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c42ad02da8d..99cd624b45b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2025,7 +2025,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.75.0 +zeroconf==0.76.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 066db11620bfae5848d5120a82212a8a1dd7b252 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Mon, 14 Aug 2023 10:02:30 +0200 Subject: [PATCH 019/180] Exchange WazeRouteCalculator with pywaze in waze_travel_time (#98169) * exchange WazeRouteCalculator with pywaze * directly use async is_valid_config_entry * store pywaze client as property * fix tests * Remove obsolete error logs * Reuse existing httpx client * Remove redundant typing * Do not clcose common httpx client --- .../waze_travel_time/config_flow.py | 3 +- .../components/waze_travel_time/helpers.py | 16 ++++++--- .../components/waze_travel_time/manifest.json | 4 +-- .../components/waze_travel_time/sensor.py | 33 +++++++++-------- requirements_all.txt | 6 ++-- requirements_test_all.txt | 6 ++-- tests/components/waze_travel_time/conftest.py | 35 +++++++------------ .../waze_travel_time/test_sensor.py | 14 ++++---- 8 files changed, 58 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index a743844659c..60134452025 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -129,8 +129,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: user_input[CONF_REGION] = user_input[CONF_REGION].upper() - if await self.hass.async_add_executor_job( - is_valid_config_entry, + if await is_valid_config_entry( self.hass, user_input[CONF_ORIGIN], user_input[CONF_DESTINATION], diff --git a/homeassistant/components/waze_travel_time/helpers.py b/homeassistant/components/waze_travel_time/helpers.py index 8468bb8ea9a..0659424429f 100644 --- a/homeassistant/components/waze_travel_time/helpers.py +++ b/homeassistant/components/waze_travel_time/helpers.py @@ -1,19 +1,25 @@ """Helpers for Waze Travel Time integration.""" import logging -from WazeRouteCalculator import WazeRouteCalculator, WRCError +from pywaze.route_calculator import WazeRouteCalculator, WRCError +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates _LOGGER = logging.getLogger(__name__) -def is_valid_config_entry(hass, origin, destination, region): +async def is_valid_config_entry( + hass: HomeAssistant, origin: str, destination: str, region: str +) -> bool: """Return whether the config entry data is valid.""" - origin = find_coordinates(hass, origin) - destination = find_coordinates(hass, destination) + resolved_origin = find_coordinates(hass, origin) + resolved_destination = find_coordinates(hass, destination) + httpx_client = get_async_client(hass) + client = WazeRouteCalculator(region=region, client=httpx_client) try: - WazeRouteCalculator(origin, destination, region).calc_all_routes_info() + await client.calc_all_routes_info(resolved_origin, resolved_destination) except WRCError as error: _LOGGER.error("Error trying to validate entry: %s", error) return False diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 5e19ee6949c..3f1f8c6d67b 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", "iot_class": "cloud_polling", - "loggers": ["WazeRouteCalculator", "homeassistant.helpers.location"], - "requirements": ["WazeRouteCalculator==0.14"] + "loggers": ["pywaze", "homeassistant.helpers.location"], + "requirements": ["pywaze==0.3.0"] } diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 2a620e48937..2b3010a39cb 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -5,7 +5,8 @@ from datetime import timedelta import logging from typing import Any -from WazeRouteCalculator import WazeRouteCalculator, WRCError +import httpx +from pywaze.route_calculator import WazeRouteCalculator, WRCError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -23,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates from homeassistant.util.unit_conversion import DistanceConverter @@ -60,6 +62,7 @@ async def async_setup_entry( data = WazeTravelTimeData( region, + get_async_client(hass), config_entry, ) @@ -132,31 +135,33 @@ class WazeTravelTime(SensorEntity): async def first_update(self, _=None) -> None: """Run first update and write state.""" - await self.hass.async_add_executor_job(self.update) + await self.async_update() self.async_write_ha_state() - def update(self) -> None: + async def async_update(self) -> None: """Fetch new state data for the sensor.""" _LOGGER.debug("Fetching Route for %s", self._attr_name) self._waze_data.origin = find_coordinates(self.hass, self._origin) self._waze_data.destination = find_coordinates(self.hass, self._destination) - self._waze_data.update() + await self._waze_data.async_update() class WazeTravelTimeData: """WazeTravelTime Data object.""" - def __init__(self, region: str, config_entry: ConfigEntry) -> None: + def __init__( + self, region: str, client: httpx.AsyncClient, config_entry: ConfigEntry + ) -> None: """Set up WazeRouteCalculator.""" - self.region = region self.config_entry = config_entry + self.client = WazeRouteCalculator(region=region, client=client) self.origin: str | None = None self.destination: str | None = None self.duration = None self.distance = None self.route = None - def update(self): + async def async_update(self): """Update WazeRouteCalculator Sensor.""" _LOGGER.debug( "Getting update for origin: %s destination: %s", @@ -177,17 +182,17 @@ class WazeTravelTimeData: avoid_ferries = self.config_entry.options[CONF_AVOID_FERRIES] units = self.config_entry.options[CONF_UNITS] + routes = {} try: - params = WazeRouteCalculator( + routes = await self.client.calc_all_routes_info( self.origin, self.destination, - self.region, - vehicle_type, - avoid_toll_roads, - avoid_subscription_roads, - avoid_ferries, + vehicle_type=vehicle_type, + avoid_toll_roads=avoid_toll_roads, + avoid_subscription_roads=avoid_subscription_roads, + avoid_ferries=avoid_ferries, + real_time=realtime, ) - routes = params.calc_all_routes_info(real_time=realtime) if incl_filter not in {None, ""}: routes = { diff --git a/requirements_all.txt b/requirements_all.txt index 7e21d0778fa..39ac9a5a19a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -139,9 +139,6 @@ TwitterAPI==2.7.12 # homeassistant.components.onvif WSDiscovery==2.0.0 -# homeassistant.components.waze_travel_time -WazeRouteCalculator==0.14 - # homeassistant.components.accuweather accuweather==1.0.0 @@ -2226,6 +2223,9 @@ pyvlx==0.2.20 # homeassistant.components.volumio pyvolumio==0.1.5 +# homeassistant.components.waze_travel_time +pywaze==0.3.0 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99cd624b45b..99970f83219 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -120,9 +120,6 @@ SQLAlchemy==2.0.15 # homeassistant.components.onvif WSDiscovery==2.0.0 -# homeassistant.components.waze_travel_time -WazeRouteCalculator==0.14 - # homeassistant.components.accuweather accuweather==1.0.0 @@ -1634,6 +1631,9 @@ pyvizio==0.1.61 # homeassistant.components.volumio pyvolumio==0.1.5 +# homeassistant.components.waze_travel_time +pywaze==0.3.0 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/waze_travel_time/conftest.py b/tests/components/waze_travel_time/conftest.py index 65c2616d1dc..64c05a5dcc1 100644 --- a/tests/components/waze_travel_time/conftest.py +++ b/tests/components/waze_travel_time/conftest.py @@ -2,42 +2,31 @@ from unittest.mock import patch import pytest -from WazeRouteCalculator import WRCError - - -@pytest.fixture(name="mock_wrc", autouse=True) -def mock_wrc_fixture(): - """Mock out WazeRouteCalculator.""" - with patch( - "homeassistant.components.waze_travel_time.sensor.WazeRouteCalculator" - ) as mock_wrc: - yield mock_wrc +from pywaze.route_calculator import WRCError @pytest.fixture(name="mock_update") -def mock_update_fixture(mock_wrc): +def mock_update_fixture(): """Mock an update to the sensor.""" - obj = mock_wrc.return_value - obj.calc_all_routes_info.return_value = {"My route": (150, 300)} + with patch( + "pywaze.route_calculator.WazeRouteCalculator.calc_all_routes_info", + return_value={"My route": (150, 300)}, + ) as mock_wrc: + yield mock_wrc @pytest.fixture(name="validate_config_entry") -def validate_config_entry_fixture(): +def validate_config_entry_fixture(mock_update): """Return valid config entry.""" - with patch( - "homeassistant.components.waze_travel_time.helpers.WazeRouteCalculator" - ) as mock_wrc: - obj = mock_wrc.return_value - obj.calc_all_routes_info.return_value = None - yield mock_wrc + mock_update.return_value = None + return mock_update @pytest.fixture(name="invalidate_config_entry") def invalidate_config_entry_fixture(validate_config_entry): """Return invalid config entry.""" - obj = validate_config_entry.return_value - obj.calc_all_routes_info.return_value = {} - obj.calc_all_routes_info.side_effect = WRCError("test") + validate_config_entry.side_effect = WRCError("test") + return validate_config_entry @pytest.fixture(name="bypass_platform_setup") diff --git a/tests/components/waze_travel_time/test_sensor.py b/tests/components/waze_travel_time/test_sensor.py index a3367a48d2a..adcc334889d 100644 --- a/tests/components/waze_travel_time/test_sensor.py +++ b/tests/components/waze_travel_time/test_sensor.py @@ -1,6 +1,6 @@ """Test Waze Travel Time sensors.""" import pytest -from WazeRouteCalculator import WRCError +from pywaze.route_calculator import WRCError from homeassistant.components.waze_travel_time.const import ( CONF_AVOID_FERRIES, @@ -35,17 +35,17 @@ async def mock_config_fixture(hass, data, options): @pytest.fixture(name="mock_update_wrcerror") -def mock_update_wrcerror_fixture(mock_wrc): +def mock_update_wrcerror_fixture(mock_update): """Mock an update to the sensor failed with WRCError.""" - obj = mock_wrc.return_value - obj.calc_all_routes_info.side_effect = WRCError("test") + mock_update.side_effect = WRCError("test") + return mock_update @pytest.fixture(name="mock_update_keyerror") -def mock_update_keyerror_fixture(mock_wrc): +def mock_update_keyerror_fixture(mock_update): """Mock an update to the sensor failed with KeyError.""" - obj = mock_wrc.return_value - obj.calc_all_routes_info.side_effect = KeyError("test") + mock_update.side_effect = KeyError("test") + return mock_update @pytest.mark.parametrize( From 21acb5527f7907b4f16e5c94813203043d1df502 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Aug 2023 10:22:53 +0200 Subject: [PATCH 020/180] Update beautifulsoup to 4.12.2 (#98372) --- homeassistant/components/scrape/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 23845cc2eac..26603603198 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.11.1", "lxml==4.9.3"] + "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 39ac9a5a19a..e38135267e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -494,7 +494,7 @@ batinfo==0.4.2 # beacontools[scan]==2.1.0 # homeassistant.components.scrape -beautifulsoup4==4.11.1 +beautifulsoup4==4.12.2 # homeassistant.components.beewi_smartclim # beewi-smartclim==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99970f83219..ec44bde36af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -418,7 +418,7 @@ azure-eventhub==5.11.1 base36==0.1.1 # homeassistant.components.scrape -beautifulsoup4==4.11.1 +beautifulsoup4==4.12.2 # homeassistant.components.zha bellows==0.35.9 From e36a8f6e8bdaaa9c65c827ac3a098b198686ab99 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Aug 2023 10:23:23 +0200 Subject: [PATCH 021/180] Update async-timeout to 4.0.3 (#98370) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 65e5dd33b8d..78973f15520 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodiscover==1.4.16 aiohttp-cors==0.7.0 aiohttp==3.8.5 astral==2.2 -async-timeout==4.0.2 +async-timeout==4.0.3 async-upnp-client==0.34.1 atomicwrites-homeassistant==1.4.1 attrs==22.2.0 diff --git a/pyproject.toml b/pyproject.toml index 02dbc87fb72..af386239ac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ requires-python = ">=3.11.0" dependencies = [ "aiohttp==3.8.5", "astral==2.2", - "async-timeout==4.0.2", + "async-timeout==4.0.3", "attrs==22.2.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==22.9.0", diff --git a/requirements.txt b/requirements.txt index f3cd10a3577..0c55b1f9a9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ # Home Assistant Core aiohttp==3.8.5 astral==2.2 -async-timeout==4.0.2 +async-timeout==4.0.3 attrs==22.2.0 atomicwrites-homeassistant==1.4.1 awesomeversion==22.9.0 From f7d95e00f663270fd0eb99e5be00faf616de28b3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Aug 2023 10:29:26 +0200 Subject: [PATCH 022/180] Update tqdm to 4.66.1 (#98328) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9a7dd9b23da..6df5c7a9b9a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -32,7 +32,7 @@ pytest==7.3.1 requests_mock==1.11.0 respx==0.20.2 syrupy==4.0.8 -tqdm==4.65.0 +tqdm==4.66.1 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 From 7cf1ff887d983f012de68a9d94355a98fcc867b9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Aug 2023 10:31:24 +0200 Subject: [PATCH 023/180] Update caldav to 1.3.6 (#98371) --- homeassistant/components/caldav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 16624f2af56..92e2f7e67d8 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.2.0"] + "requirements": ["caldav==1.3.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index e38135267e9..de2a2302312 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -580,7 +580,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.2.0 +caldav==1.3.6 # homeassistant.components.circuit circuit-webhook==1.0.1 diff --git a/requirements_test.txt b/requirements_test.txt index 6df5c7a9b9a..3ba9ed8abf8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -36,7 +36,7 @@ tqdm==4.66.1 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 -types-caldav==1.2.0.2 +types-caldav==1.3.0.0 types-chardet==0.1.5 types-decorator==5.1.8.3 types-enum34==1.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec44bde36af..5618c9bf7ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -478,7 +478,7 @@ bthome-ble==3.0.0 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.2.0 +caldav==1.3.6 # homeassistant.components.coinbase coinbase==2.1.0 From e0d6210bd0f52a76fb567d095aab1ae61b5e827b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Aug 2023 10:38:53 +0200 Subject: [PATCH 024/180] Create pytest output artifact [ci] (#98106) --- .github/workflows/ci.yaml | 41 +++++++++++++++++++++++++++++++++++---- .gitignore | 1 + 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 31a158c1ffd..6d41c4e1e7f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -734,9 +734,12 @@ jobs: - name: Run pytest (fully) if: needs.info.outputs.test_full_suite == 'true' timeout-minutes: 60 + id: pytest-full run: | . venv/bin/activate python --version + set -o pipefail + python3 -X dev -m pytest \ -qq \ --timeout=9 \ @@ -749,14 +752,17 @@ jobs: --cov-report=xml \ -o console_output_style=count \ -p no:sugar \ - tests + tests \ + 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Run pytest (partially) if: needs.info.outputs.test_full_suite == 'false' timeout-minutes: 10 + id: pytest-partial shell: bash run: | . venv/bin/activate python --version + set -o pipefail if [[ ! -f "tests/components/${{ matrix.group }}/__init__.py" ]]; then echo "::error:: missing file tests/components/${{ matrix.group }}/__init__.py" @@ -774,7 +780,14 @@ jobs: --durations=0 \ --durations-min=1 \ -p no:sugar \ - tests/components/${{ matrix.group }} + tests/components/${{ matrix.group }} \ + 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt + - name: Upload pytest output + if: success() || failure() && (steps.pytest-full.conclusion == 'failure' || steps.pytest-partial.conclusion == 'failure') + uses: actions/upload-artifact@v3.1.2 + with: + name: pytest-${{ github.run_number }} + path: pytest-*.txt - name: Upload coverage artifact uses: actions/upload-artifact@v3.1.2 with: @@ -862,10 +875,13 @@ jobs: python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 + id: pytest-partial shell: bash run: | . venv/bin/activate python --version + set -o pipefail + mariadb=$(echo "${{ matrix.mariadb-group }}" | sed "s/:/-/g") python3 -X dev -m pytest \ -qq \ @@ -881,7 +897,14 @@ jobs: tests/components/history \ tests/components/logbook \ tests/components/recorder \ - tests/components/sensor + tests/components/sensor \ + 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt + - name: Upload pytest output + if: success() || failure() && steps.pytest-partial.conclusion == 'failure' + uses: actions/upload-artifact@v3.1.2 + with: + name: pytest-${{ github.run_number }} + path: pytest-*.txt - name: Upload coverage artifact uses: actions/upload-artifact@v3.1.2 with: @@ -969,10 +992,13 @@ jobs: python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 + id: pytest-partial shell: bash run: | . venv/bin/activate python --version + set -o pipefail + postgresql=$(echo "${{ matrix.postgresql-group }}" | sed "s/:/-/g") python3 -X dev -m pytest \ -qq \ @@ -989,7 +1015,14 @@ jobs: tests/components/history \ tests/components/logbook \ tests/components/recorder \ - tests/components/sensor + tests/components/sensor \ + 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt + - name: Upload pytest output + if: success() || failure() && steps.pytest-partial.conclusion == 'failure' + uses: actions/upload-artifact@v3.1.2 + with: + name: pytest-${{ github.run_number }} + path: pytest-*.txt - name: Upload coverage artifact uses: actions/upload-artifact@v3.1.0 with: diff --git a/.gitignore b/.gitignore index 2f3c3e10301..ff20c088eb2 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ htmlcov/ test-reports/ test-results.xml test-output.xml +pytest-*.txt # Translations *.mo From 533a8beac23e529beaed206b47e85c4b8192658a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 14 Aug 2023 10:42:20 +0200 Subject: [PATCH 025/180] Raise ConfigEntryNotReady when unable to connect during setup of AVM Fritz!Smarthome (#97985) --- homeassistant/components/fritzbox/__init__.py | 5 +++- .../components/fritzbox/coordinator.py | 6 ++--- .../components/fritzbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/fritzbox/test_init.py | 23 ++++++++++++++++++- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 54e09f90df7..d199d2c5a2c 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome.devicetypes.fritzhomeentitybase import FritzhomeEntityBase +from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -16,7 +17,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries @@ -36,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await hass.async_add_executor_job(fritz.login) + except RequestConnectionError as err: + raise ConfigEntryNotReady from err except LoginError as err: raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 80087adf9ac..194825e602f 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome.devicetypes import FritzhomeTemplate -import requests +from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -51,9 +51,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.fritz.update_devices() if self.has_templates: self.fritz.update_templates() - except requests.exceptions.ConnectionError as ex: + except RequestConnectionError as ex: raise UpdateFailed from ex - except requests.exceptions.HTTPError: + except HTTPError: # If the device rebooted, login again try: self.fritz.login() diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 29df2f51a34..35b78e91f81 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.8"], + "requirements": ["pyfritzhome==0.6.9"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index de2a2302312..7c1d4ea5bb8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1701,7 +1701,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.8 +pyfritzhome==0.6.9 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5618c9bf7ea..e143e1d9156 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1256,7 +1256,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.8 +pyfritzhome==0.6.9 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 28476d88273..dd5a8127185 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -205,7 +205,7 @@ async def test_coordinator_update_when_unreachable( unique_id="any", ) entry.add_to_hass(hass) - fritz().get_devices.side_effect = [ConnectionError(), ""] + fritz().update_devices.side_effect = [ConnectionError(), ""] assert not await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -258,6 +258,27 @@ async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> unique_id="any", ) entry.add_to_hass(hass) + with patch( + "homeassistant.components.fritzbox.Fritzhome.login", + side_effect=ConnectionError(), + ) as mock_login: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + mock_login.assert_called_once() + + entries = hass.config_entries.async_entries() + config_entry = entries[0] + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_raise_config_entry_error_when_login_fail(hass: HomeAssistant) -> None: + """Config entry state is SETUP_ERROR when login to fritzbox fail.""" + entry = MockConfigEntry( + domain=FB_DOMAIN, + data={CONF_HOST: "any", **MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0]}, + unique_id="any", + ) + entry.add_to_hass(hass) with patch( "homeassistant.components.fritzbox.Fritzhome.login", side_effect=LoginError("user"), From e0ee713bb27b5bec2df68077fe88e35cb46c40ce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Aug 2023 11:32:55 +0200 Subject: [PATCH 026/180] Store preferred border agent ID in thread dataset store (#98375) --- homeassistant/components/thread/__init__.py | 4 ++ .../components/thread/dataset_store.py | 37 ++++++++++++++++++- .../components/thread/websocket_api.py | 34 +++++++++++++++++ tests/components/thread/test_dataset_store.py | 36 ++++++++++++++++++ tests/components/thread/test_websocket_api.py | 29 +++++++++++++++ 5 files changed, 139 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index dd2527763ad..679127e5202 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -11,7 +11,9 @@ from .dataset_store import ( DatasetEntry, async_add_dataset, async_get_dataset, + async_get_preferred_border_agent_id, async_get_preferred_dataset, + async_set_preferred_border_agent_id, ) from .websocket_api import async_setup as async_setup_ws_api @@ -19,8 +21,10 @@ __all__ = [ "DOMAIN", "DatasetEntry", "async_add_dataset", + "async_get_preferred_border_agent_id", "async_get_dataset", "async_get_preferred_dataset", + "async_set_preferred_border_agent_id", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 55623f7e3a4..96a9cf8e59e 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -19,7 +19,7 @@ from homeassistant.util import dt as dt_util, ulid as ulid_util DATA_STORE = "thread.datasets" STORAGE_KEY = "thread.datasets" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) @@ -86,7 +86,9 @@ class DatasetStoreStore(Store): ) -> dict[str, Any]: """Migrate to the new version.""" if old_major_version == 1: + data = old_data if old_minor_version < 2: + # Deduplicate datasets datasets: dict[str, DatasetEntry] = {} preferred_dataset = old_data["preferred_dataset"] @@ -156,6 +158,9 @@ class DatasetStoreStore(Store): "preferred_dataset": preferred_dataset, "datasets": [dataset.to_json() for dataset in datasets.values()], } + if old_minor_version < 3: + # Add border agent ID + data.setdefault("preferred_border_agent_id", None) return data @@ -167,6 +172,7 @@ class DatasetStore: """Initialize the dataset store.""" self.hass = hass self.datasets: dict[str, DatasetEntry] = {} + self._preferred_border_agent_id: str | None = None self._preferred_dataset: str | None = None self._store: Store[dict[str, Any]] = DatasetStoreStore( hass, @@ -259,6 +265,17 @@ class DatasetStore: """Get dataset by id.""" return self.datasets.get(dataset_id) + @callback + def async_get_preferred_border_agent_id(self) -> str | None: + """Get preferred border agent id.""" + return self._preferred_border_agent_id + + @callback + def async_set_preferred_border_agent_id(self, border_agent_id: str) -> None: + """Set preferred border agent id.""" + self._preferred_border_agent_id = border_agent_id + self.async_schedule_save() + @property @callback def preferred_dataset(self) -> str | None: @@ -279,6 +296,7 @@ class DatasetStore: data = await self._store.async_load() datasets: dict[str, DatasetEntry] = {} + preferred_border_agent_id: str | None = None preferred_dataset: str | None = None if data is not None: @@ -290,9 +308,11 @@ class DatasetStore: source=dataset["source"], tlv=dataset["tlv"], ) + preferred_border_agent_id = data["preferred_border_agent_id"] preferred_dataset = data["preferred_dataset"] self.datasets = datasets + self._preferred_border_agent_id = preferred_border_agent_id self._preferred_dataset = preferred_dataset @callback @@ -305,6 +325,7 @@ class DatasetStore: """Return data of datasets to store in a file.""" data: dict[str, Any] = {} data["datasets"] = [dataset.to_json() for dataset in self.datasets.values()] + data["preferred_border_agent_id"] = self._preferred_border_agent_id data["preferred_dataset"] = self._preferred_dataset return data @@ -331,6 +352,20 @@ async def async_get_dataset(hass: HomeAssistant, dataset_id: str) -> str | None: return entry.tlv +async def async_get_preferred_border_agent_id(hass: HomeAssistant) -> str | None: + """Get the preferred border agent ID.""" + store = await async_get_store(hass) + return store.async_get_preferred_border_agent_id() + + +async def async_set_preferred_border_agent_id( + hass: HomeAssistant, border_agent_id: str +) -> None: + """Get the preferred border agent ID.""" + store = await async_get_store(hass) + store.async_set_preferred_border_agent_id(border_agent_id) + + async def async_get_preferred_dataset(hass: HomeAssistant) -> str | None: """Get the preferred dataset.""" store = await async_get_store(hass) diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 60941426b7e..853d8c3c893 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -20,6 +20,8 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_discover_routers) websocket_api.async_register_command(hass, ws_get_dataset) websocket_api.async_register_command(hass, ws_list_datasets) + websocket_api.async_register_command(hass, ws_get_preferred_border_agent_id) + websocket_api.async_register_command(hass, ws_set_preferred_border_agent_id) websocket_api.async_register_command(hass, ws_set_preferred_dataset) @@ -50,6 +52,38 @@ async def ws_add_dataset( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "thread/get_preferred_border_agent_id", + } +) +@websocket_api.async_response +async def ws_get_preferred_border_agent_id( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Get the preferred border agent ID.""" + border_agent_id = await dataset_store.async_get_preferred_border_agent_id(hass) + connection.send_result(msg["id"], {"border_agent_id": border_agent_id}) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "thread/set_preferred_border_agent_id", + vol.Required("border_agent_id"): str, + } +) +@websocket_api.async_response +async def ws_set_preferred_border_agent_id( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Set the preferred border agent ID.""" + border_agent_id = msg["border_agent_id"] + await dataset_store.async_set_preferred_border_agent_id(hass, border_agent_id) + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 1ed754dbdcd..1171c597e99 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -319,12 +319,17 @@ async def test_loading_datasets_from_storage( "tlv": DATASET_3, }, ], + "preferred_border_agent_id": "230C6A1AC57F6F4BE262ACF32E5EF52C", "preferred_dataset": "id1", }, } store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 3 + assert ( + store.async_get_preferred_border_agent_id() + == "230C6A1AC57F6F4BE262ACF32E5EF52C" + ) assert store.preferred_dataset == "id1" @@ -512,3 +517,34 @@ async def test_migrate_drop_duplicate_datasets_preferred( f"Dropped duplicated Thread dataset '{DATASET_1_LARGER_TIMESTAMP}' " f"(duplicate of preferred dataset '{DATASET_1}')" ) in caplog.text + + +async def test_migrate_set_default_border_agent_id( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store adds default border agent.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": 1, + "minor_version": 2, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + ], + "preferred_dataset": "id1", + }, + } + + store = await dataset_store.async_get_store(hass) + assert store.async_get_preferred_border_agent_id() is None + + +async def test_preferred_border_agent_id(hass: HomeAssistant) -> None: + """Test get and set the preferred border agent ID.""" + assert await dataset_store.async_get_preferred_border_agent_id(hass) is None + await dataset_store.async_set_preferred_border_agent_id(hass, "blah") + assert await dataset_store.async_get_preferred_border_agent_id(hass) == "blah" diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 0db16318db1..82450474e92 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -200,6 +200,35 @@ async def test_list_get_dataset( assert msg["error"] == {"code": "not_found", "message": "unknown dataset"} +async def test_preferred_border_agent_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test setting and getting the preferred border agent ID.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "thread/get_preferred_border_agent_id"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"border_agent_id": None} + + await client.send_json_auto_id( + {"type": "thread/set_preferred_border_agent_id", "border_agent_id": "blah"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + await client.send_json_auto_id({"type": "thread/get_preferred_border_agent_id"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"border_agent_id": "blah"} + + assert await dataset_store.async_get_preferred_border_agent_id(hass) == "blah" + + async def test_set_preferred_dataset( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: From 4dd102f818ca37c1faafca2022b61f1aa4359ec4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Aug 2023 11:33:07 +0200 Subject: [PATCH 027/180] Bump python-otbr-api to 2.4.0 (#98376) --- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index a8a5ae062f7..e62a2d42b1e 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.3.0"] + "requirements": ["python-otbr-api==2.4.0"] } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 71dbb786eb5..29b7e61d407 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.3.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.4.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c1d4ea5bb8..0bd5e55c1a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2141,7 +2141,7 @@ python-opensky==0.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.3.0 +python-otbr-api==2.4.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e143e1d9156..31e4aa2d284 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1570,7 +1570,7 @@ python-opensky==0.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.3.0 +python-otbr-api==2.4.0 # homeassistant.components.picnic python-picnic-api==1.1.0 From 180ff2449282602f1ef2bf9646460cf0f08a64bf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Aug 2023 11:50:14 +0200 Subject: [PATCH 028/180] Add types-beautifulsoup4 dependency (#98377) --- homeassistant/components/scrape/sensor.py | 1 + requirements_test.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 197f2e003d8..7cd7e2197ab 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -178,6 +178,7 @@ class ScrapeSensor( def _extract_value(self) -> Any: """Parse the html extraction in the executor.""" raw_data = self.coordinator.data + value: str | list[str] | None try: if self._attr is not None: value = raw_data.select(self._select)[self._index][self._attr] diff --git a/requirements_test.txt b/requirements_test.txt index 3ba9ed8abf8..acb70d5fb8c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -36,6 +36,7 @@ tqdm==4.66.1 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 +types-beautifulsoup4==4.12.0.6 types-caldav==1.3.0.0 types-chardet==0.1.5 types-decorator==5.1.8.3 From 9ce033daebd7dde8ed71b85ff0d8e27d0e0b9929 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Aug 2023 11:51:08 +0200 Subject: [PATCH 029/180] Use default translations by removing names from tplink descriptions (#98338) --- homeassistant/components/tplink/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index ba4949434f7..46909f39dfe 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -50,7 +50,6 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - name="Current Consumption", emeter_attr="power", precision=1, ), @@ -60,7 +59,6 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - name="Total Consumption", emeter_attr="total", precision=3, ), @@ -70,7 +68,6 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - name="Today's Consumption", precision=3, ), TPLinkSensorEntityDescription( From 11b1a42a1c65a689af264471560259767cba2a54 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Aug 2023 12:52:27 +0200 Subject: [PATCH 030/180] Add entity translations to Aseko (#98117) --- .../aseko_pool_live/binary_sensor.py | 5 ++--- .../components/aseko_pool_live/entity.py | 2 ++ .../components/aseko_pool_live/sensor.py | 15 +++++++------ .../components/aseko_pool_live/strings.json | 21 +++++++++++++++++++ 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index 8178e243279..3e0e57fffac 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -37,19 +37,18 @@ class AsekoBinarySensorEntityDescription( UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( AsekoBinarySensorEntityDescription( key="water_flow", - name="Water Flow", + translation_key="water_flow", icon="mdi:waves-arrow-right", value_fn=lambda unit: unit.water_flow, ), AsekoBinarySensorEntityDescription( key="has_alarm", - name="Alarm", + translation_key="alarm", value_fn=lambda unit: unit.has_alarm, device_class=BinarySensorDeviceClass.SAFETY, ), AsekoBinarySensorEntityDescription( key="has_error", - name="Error", value_fn=lambda unit: unit.has_error, device_class=BinarySensorDeviceClass.PROBLEM, ), diff --git a/homeassistant/components/aseko_pool_live/entity.py b/homeassistant/components/aseko_pool_live/entity.py index 54afc80d451..1defbe18345 100644 --- a/homeassistant/components/aseko_pool_live/entity.py +++ b/homeassistant/components/aseko_pool_live/entity.py @@ -11,6 +11,8 @@ from .coordinator import AsekoDataUpdateCoordinator class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]): """Representation of an aseko entity.""" + _attr_has_entity_name = True + def __init__(self, unit: Unit, coordinator: AsekoDataUpdateCoordinator) -> None: """Initialize the aseko entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index 09c4af31428..d7e5e330705 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -45,13 +45,16 @@ class VariableSensorEntity(AsekoEntity, SensorEntity): super().__init__(unit, coordinator) self._variable = variable - variable_name = { - "Air temp.": "Air Temperature", - "Cl free": "Free Chlorine", - "Water temp.": "Water Temperature", - }.get(self._variable.name, self._variable.name) + translation_key = { + "Air temp.": "air_temperature", + "Cl free": "free_chlorine", + "Water temp.": "water_temperature", + }.get(self._variable.name) + if translation_key is not None: + self._attr_translation_key = translation_key + else: + self._attr_name = self._variable.name - self._attr_name = f"{self._device_name} {variable_name}" self._attr_unique_id = f"{self._unit.serial_number}{self._variable.type}" self._attr_native_unit_of_measurement = self._variable.unit diff --git a/homeassistant/components/aseko_pool_live/strings.json b/homeassistant/components/aseko_pool_live/strings.json index 7a91b2c9f8b..2a6df30b148 100644 --- a/homeassistant/components/aseko_pool_live/strings.json +++ b/homeassistant/components/aseko_pool_live/strings.json @@ -16,5 +16,26 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } + }, + "entity": { + "binary_sensor": { + "water_flow": { + "name": "Water flow" + }, + "alarm": { + "name": "Alarm" + } + }, + "sensor": { + "air_temperature": { + "name": "Air temperature" + }, + "free_chlorine": { + "name": "Free chlorine" + }, + "water_temperature": { + "name": "Water temperature" + } + } } } From 398a789ba2cc9267752e14b5201130f0539dec21 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Aug 2023 13:14:49 +0200 Subject: [PATCH 031/180] Add entity translations to justnimbus (#98235) --- homeassistant/components/justnimbus/entity.py | 2 + homeassistant/components/justnimbus/sensor.py | 24 +++++------ .../components/justnimbus/strings.json | 40 +++++++++++++++++++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/justnimbus/entity.py b/homeassistant/components/justnimbus/entity.py index 968e9581a67..7303d4ec2c7 100644 --- a/homeassistant/components/justnimbus/entity.py +++ b/homeassistant/components/justnimbus/entity.py @@ -13,6 +13,8 @@ class JustNimbusEntity( ): """Defines a base JustNimbus entity.""" + _attr_has_entity_name = True + def __init__( self, *, diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index e3d6562c088..156fa37e982 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -46,7 +46,7 @@ class JustNimbusEntityDescription( SENSOR_TYPES = ( JustNimbusEntityDescription( key="pump_flow", - name="Pump flow", + translation_key="pump_flow", icon="mdi:pump", native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, @@ -55,7 +55,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="drink_flow", - name="Drink flow", + translation_key="drink_flow", icon="mdi:water-pump", native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, @@ -64,7 +64,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="pump_pressure", - name="Pump pressure", + translation_key="pump_pressure", native_unit_of_measurement=UnitOfPressure.BAR, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, @@ -73,7 +73,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="pump_starts", - name="Pump starts", + translation_key="pump_starts", icon="mdi:restart", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -81,7 +81,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="pump_hours", - name="Pump hours", + translation_key="pump_hours", icon="mdi:clock", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, @@ -91,7 +91,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="reservoir_temp", - name="Reservoir Temperature", + translation_key="reservoir_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -100,7 +100,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="reservoir_content", - name="Reservoir content", + translation_key="reservoir_content", icon="mdi:car-coolant-level", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, @@ -110,7 +110,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="total_saved", - name="Total saved", + translation_key="total_saved", icon="mdi:water-opacity", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, @@ -120,7 +120,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="total_replenished", - name="Total replenished", + translation_key="total_replenished", icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, @@ -130,7 +130,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="error_code", - name="Error code", + translation_key="error_code", icon="mdi:bug", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -138,7 +138,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="totver", - name="Total use", + translation_key="total_use", icon="mdi:chart-donut", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, @@ -148,7 +148,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="reservoir_content_max", - name="Max reservoir content", + translation_key="reservoir_content_max", icon="mdi:waves", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, diff --git a/homeassistant/components/justnimbus/strings.json b/homeassistant/components/justnimbus/strings.json index 609b1425e93..92ebf19714a 100644 --- a/homeassistant/components/justnimbus/strings.json +++ b/homeassistant/components/justnimbus/strings.json @@ -15,5 +15,45 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "pump_flow": { + "name": "Pump flow" + }, + "drink_flow": { + "name": "Drink flow" + }, + "pump_pressure": { + "name": "Pump pressure" + }, + "pump_starts": { + "name": "Pump starts" + }, + "pump_hours": { + "name": "Pump hours" + }, + "reservoir_temperature": { + "name": "Reservoir temperature" + }, + "reservoir_content": { + "name": "Reservoir content" + }, + "total_saved": { + "name": "Total saved" + }, + "total_replenished": { + "name": "Total replenished" + }, + "error_code": { + "name": "Error code" + }, + "total_use": { + "name": "Total use" + }, + "reservoir_content_max": { + "name": "Maximum reservoir content" + } + } } } From 57cacbc2a7d37a6230b187dfacd95860f7ebb8ac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Aug 2023 13:16:02 +0200 Subject: [PATCH 032/180] Add entity translations to Aurora (#98079) --- homeassistant/components/aurora/binary_sensor.py | 7 +++++-- homeassistant/components/aurora/entity.py | 4 ++-- homeassistant/components/aurora/sensor.py | 2 +- homeassistant/components/aurora/strings.json | 12 ++++++++++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index a0e09685a0f..d817ea51988 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -13,9 +13,12 @@ async def async_setup_entry( ) -> None: """Set up the binary_sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - name = f"{coordinator.name} Aurora Visibility Alert" - entity = AuroraSensor(coordinator=coordinator, name=name, icon="mdi:hazard-lights") + entity = AuroraSensor( + coordinator=coordinator, + translation_key="visibility_alert", + icon="mdi:hazard-lights", + ) async_add_entries([entity]) diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index 49afe9fb8c8..a52f523f667 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -19,14 +19,14 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): def __init__( self, coordinator: AuroraDataUpdateCoordinator, - name: str, + translation_key: str, icon: str, ) -> None: """Initialize the Aurora Entity.""" super().__init__(coordinator=coordinator) - self._attr_name = name + self._attr_translation_key = translation_key self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" self._attr_icon = icon diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index a5436e1e219..f44cc23f832 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -17,7 +17,7 @@ async def async_setup_entry( entity = AuroraSensor( coordinator=coordinator, - name=f"{coordinator.name} Aurora Visibility %", + translation_key="visibility", icon="mdi:gauge", ) diff --git a/homeassistant/components/aurora/strings.json b/homeassistant/components/aurora/strings.json index 9beb9c7906d..09ec86bdf4d 100644 --- a/homeassistant/components/aurora/strings.json +++ b/homeassistant/components/aurora/strings.json @@ -25,5 +25,17 @@ } } } + }, + "entity": { + "binary_sensor": { + "visibility_alert": { + "name": "Visibility alert" + } + }, + "sensor": { + "visibility": { + "name": "Visibility" + } + } } } From 6f97270cd2ac61d6f5494aad961dda3fdd608014 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 14 Aug 2023 13:30:25 +0200 Subject: [PATCH 033/180] Fix tts notify config validation (#98381) * Add test * Require either entity_id or tts_service --- homeassistant/components/tts/notify.py | 23 +++++++++++++---------- tests/components/tts/test_notify.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py index 92244fc41f9..c2576e12bb5 100644 --- a/homeassistant/components/tts/notify.py +++ b/homeassistant/components/tts/notify.py @@ -20,16 +20,19 @@ ENTITY_LEGACY_PROVIDER_GROUP = "entity_or_legacy_provider" _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.string, - vol.Exclusive(CONF_TTS_SERVICE, ENTITY_LEGACY_PROVIDER_GROUP): cv.entity_id, - vol.Exclusive(CONF_ENTITY_ID, ENTITY_LEGACY_PROVIDER_GROUP): cv.entities_domain( - DOMAIN - ), - vol.Required(CONF_MEDIA_PLAYER): cv.entity_id, - vol.Optional(ATTR_LANGUAGE): cv.string, - } +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_TTS_SERVICE, CONF_ENTITY_ID), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NAME): cv.string, + vol.Exclusive(CONF_TTS_SERVICE, ENTITY_LEGACY_PROVIDER_GROUP): cv.entity_id, + vol.Exclusive( + CONF_ENTITY_ID, ENTITY_LEGACY_PROVIDER_GROUP + ): cv.entities_domain(DOMAIN), + vol.Required(CONF_MEDIA_PLAYER): cv.entity_id, + vol.Optional(ATTR_LANGUAGE): cv.string, + } + ), ) diff --git a/tests/components/tts/test_notify.py b/tests/components/tts/test_notify.py index 22ab151b864..1a776140457 100644 --- a/tests/components/tts/test_notify.py +++ b/tests/components/tts/test_notify.py @@ -68,6 +68,21 @@ async def test_setup_platform(hass: HomeAssistant) -> None: assert hass.services.has_service(notify.DOMAIN, "tts_test") +async def test_setup_platform_missing_key(hass: HomeAssistant) -> None: + """Test platform without required tts_service or entity_id key.""" + config = { + notify.DOMAIN: { + "platform": "tts", + "name": "tts_test", + "media_player": "media_player.demo", + } + } + with assert_setup_component(0, notify.DOMAIN): + assert await async_setup_component(hass, notify.DOMAIN, config) + + assert not hass.services.has_service(notify.DOMAIN, "tts_test") + + async def test_setup_legacy_service(hass: HomeAssistant) -> None: """Set up the demo platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) From 9ddf11f6cde0e3f5a13eb0e0f1d74a8997432f33 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 14 Aug 2023 04:32:08 -0700 Subject: [PATCH 034/180] Improve rainbird error handling (#98239) --- .../components/rainbird/coordinator.py | 10 +++- homeassistant/components/rainbird/number.py | 12 ++++- homeassistant/components/rainbird/switch.py | 27 ++++++++-- tests/components/rainbird/conftest.py | 12 +++-- tests/components/rainbird/test_init.py | 50 +++++++++++++++++-- tests/components/rainbird/test_number.py | 40 +++++++++++++++ tests/components/rainbird/test_switch.py | 36 +++++++++++++ 7 files changed, 170 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 6e462603dbb..91319b25e59 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -8,7 +8,11 @@ import logging from typing import TypeVar import async_timeout -from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException +from pyrainbird.async_client import ( + AsyncRainbirdController, + RainbirdApiException, + RainbirdDeviceBusyException, +) from pyrainbird.data import ModelAndVersion from homeassistant.core import HomeAssistant @@ -84,8 +88,10 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): try: async with async_timeout.timeout(TIMEOUT_SECONDS): return await self._fetch_data() + except RainbirdDeviceBusyException as err: + raise UpdateFailed("Rain Bird device is busy") from err except RainbirdApiException as err: - raise UpdateFailed(f"Error communicating with Device: {err}") from err + raise UpdateFailed("Rain Bird device failure") from err async def _fetch_data(self) -> RainbirdDeviceState: """Fetch data from the Rain Bird device. diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index febb960d652..de049f921dd 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -3,10 +3,13 @@ from __future__ import annotations import logging +from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException + from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -58,4 +61,11 @@ class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self.coordinator.controller.set_rain_delay(value) + try: + await self.coordinator.controller.set_rain_delay(value) + except RainbirdDeviceBusyException as err: + raise HomeAssistantError( + "Rain Bird device is busy; Wait and try again" + ) from err + except RainbirdApiException as err: + raise HomeAssistantError("Rain Bird device failure") from err diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 3b945b31db5..ac42e00c676 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -3,11 +3,13 @@ from __future__ import annotations import logging +from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -86,15 +88,30 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) async def async_turn_on(self, **kwargs): """Turn the switch on.""" - await self.coordinator.controller.irrigate_zone( - int(self._zone), - int(kwargs.get(ATTR_DURATION, self._duration_minutes)), - ) + try: + await self.coordinator.controller.irrigate_zone( + int(self._zone), + int(kwargs.get(ATTR_DURATION, self._duration_minutes)), + ) + except RainbirdDeviceBusyException as err: + raise HomeAssistantError( + "Rain Bird device is busy; Wait and try again" + ) from err + except RainbirdApiException as err: + raise HomeAssistantError("Rain Bird device failure") from err + await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs): """Turn the switch off.""" - await self.coordinator.controller.stop_irrigation() + try: + await self.coordinator.controller.stop_irrigation() + except RainbirdDeviceBusyException as err: + raise HomeAssistantError( + "Rain Bird device is busy; Wait and try again" + ) from err + except RainbirdApiException as err: + raise HomeAssistantError("Rain Bird device failure") from err await self.coordinator.async_request_refresh() @property diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 21ad5230581..9e4e4e546cb 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -72,11 +72,6 @@ CONFIG_ENTRY_DATA = { } -UNAVAILABLE_RESPONSE = AiohttpClientMockResponse( - "POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE -) - - @pytest.fixture def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" @@ -150,6 +145,13 @@ def mock_response(data: str) -> AiohttpClientMockResponse: return AiohttpClientMockResponse("POST", URL, response=rainbird_response(data)) +def mock_response_error( + status: HTTPStatus = HTTPStatus.SERVICE_UNAVAILABLE, +) -> AiohttpClientMockResponse: + """Create a fake AiohttpClientMockResponse.""" + return AiohttpClientMockResponse("POST", URL, status=status) + + @pytest.fixture(name="stations_response") def mock_station_response() -> str: """Mock response to return available stations.""" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 1330f1cb4b2..f548d3aacda 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -2,13 +2,21 @@ from __future__ import annotations +from http import HTTPStatus + import pytest from homeassistant.components.rainbird import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import CONFIG_ENTRY_DATA, UNAVAILABLE_RESPONSE, ComponentSetup +from .conftest import ( + CONFIG_ENTRY_DATA, + MODEL_AND_VERSION_RESPONSE, + ComponentSetup, + mock_response, + mock_response_error, +) from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -44,16 +52,50 @@ async def test_init_success( @pytest.mark.parametrize( ("yaml_config", "config_entry_data", "responses", "config_entry_states"), [ - ({}, CONFIG_ENTRY_DATA, [UNAVAILABLE_RESPONSE], [ConfigEntryState.SETUP_RETRY]), + ( + {}, + CONFIG_ENTRY_DATA, + [mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)], + [ConfigEntryState.SETUP_RETRY], + ), + ( + {}, + CONFIG_ENTRY_DATA, + [mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR)], + [ConfigEntryState.SETUP_RETRY], + ), + ( + {}, + CONFIG_ENTRY_DATA, + [ + mock_response(MODEL_AND_VERSION_RESPONSE), + mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE), + ], + [ConfigEntryState.SETUP_RETRY], + ), + ( + {}, + CONFIG_ENTRY_DATA, + [ + mock_response(MODEL_AND_VERSION_RESPONSE), + mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR), + ], + [ConfigEntryState.SETUP_RETRY], + ), + ], + ids=[ + "unavailable", + "server-error", + "coordinator-unavailable", + "coordinator-server-error", ], - ids=["config_entry_failure"], ) async def test_communication_failure( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry_states: list[ConfigEntryState], ) -> None: - """Test unable to talk to server on startup, which permanently fails setup.""" + """Test unable to talk to device on startup, which fails setup.""" assert await setup_integration() diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 1335a1595d3..2c837a75c66 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -1,5 +1,6 @@ """Tests for rainbird number platform.""" +from http import HTTPStatus import pytest @@ -8,6 +9,7 @@ from homeassistant.components.rainbird import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from .conftest import ( @@ -17,6 +19,7 @@ from .conftest import ( SERIAL_NUMBER, ComponentSetup, mock_response, + mock_response_error, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -87,3 +90,40 @@ async def test_set_value( ) assert len(aioclient_mock.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("status", "expected_msg"), + [ + (HTTPStatus.SERVICE_UNAVAILABLE, "Rain Bird device is busy"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "Rain Bird device failure"), + ], +) +async def test_set_value_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[str], + config_entry: ConfigEntry, + status: HTTPStatus, + expected_msg: str, +) -> None: + """Test an error while talking to the device.""" + + assert await setup_integration() + + aioclient_mock.mock_calls.clear() + responses.append(mock_response_error(status=status)) + + with pytest.raises(HomeAssistantError, match=expected_msg): + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.rain_bird_controller_rain_delay", + number.ATTR_VALUE: 3, + }, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 684287a5d1a..9127a0b0c61 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -1,11 +1,13 @@ """Tests for rainbird sensor platform.""" +from http import HTTPStatus import pytest from homeassistant.components.rainbird import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import ( ACK_ECHO, @@ -19,6 +21,7 @@ from .conftest import ( ZONE_OFF_RESPONSE, ComponentSetup, mock_response, + mock_response_error, ) from tests.components.switch import common as switch_common @@ -240,3 +243,36 @@ async def test_yaml_imported_config( assert hass.states.get("switch.back_yard") assert not hass.states.get("switch.rain_bird_sprinkler_2") assert hass.states.get("switch.rain_bird_sprinkler_3") + + +@pytest.mark.parametrize( + ("status", "expected_msg"), + [ + (HTTPStatus.SERVICE_UNAVAILABLE, "Rain Bird device is busy"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "Rain Bird device failure"), + ], +) +async def test_switch_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + status: HTTPStatus, + expected_msg: str, +) -> None: + """Test an error talking to the device.""" + + assert await setup_integration() + + aioclient_mock.mock_calls.clear() + responses.append(mock_response_error(status=status)) + + with pytest.raises(HomeAssistantError, match=expected_msg): + await switch_common.async_turn_on(hass, "switch.rain_bird_sprinkler_3") + await hass.async_block_till_done() + + responses.append(mock_response_error(status=status)) + + with pytest.raises(HomeAssistantError, match=expected_msg): + await switch_common.async_turn_off(hass, "switch.rain_bird_sprinkler_3") + await hass.async_block_till_done() From 318b8adbed54bfa591337af80042c4cb3f3feb2f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Aug 2023 13:40:32 +0200 Subject: [PATCH 035/180] Set preferred router when importing OTBR dataset (#98379) --- homeassistant/components/otbr/__init__.py | 21 ++++++++++++++++++++- homeassistant/components/otbr/util.py | 5 +++++ tests/components/otbr/__init__.py | 2 ++ tests/components/otbr/conftest.py | 11 ++++++++++- tests/components/otbr/test_init.py | 15 +++++++++++---- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 8685282acec..09a4499b60f 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -2,11 +2,17 @@ from __future__ import annotations import asyncio +import contextlib import aiohttp import python_otbr_api -from homeassistant.components.thread import async_add_dataset +from homeassistant.components.thread import ( + async_add_dataset, + async_get_preferred_border_agent_id, + async_get_preferred_dataset, + async_set_preferred_border_agent_id, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError @@ -46,6 +52,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if dataset_tlvs: await update_issues(hass, otbrdata, dataset_tlvs) await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + # If this OTBR's dataset is the preferred one, and there is no preferred router, + # make this the preferred router + border_agent_id: bytes | None = None + with contextlib.suppress( + HomeAssistantError, aiohttp.ClientError, asyncio.TimeoutError + ): + border_agent_id = await otbrdata.get_border_agent_id() + if ( + await async_get_preferred_dataset(hass) == dataset_tlvs.hex() + and await async_get_preferred_border_agent_id(hass) is None + and border_agent_id + ): + await async_set_preferred_border_agent_id(hass, border_agent_id.hex()) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 4d6efb9a9f0..67f36c09246 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -82,6 +82,11 @@ class OTBRData: ) await self.delete_active_dataset() + @_handle_otbr_error + async def get_border_agent_id(self) -> bytes: + """Get the border agent ID.""" + return await self.api.get_border_agent_id() + @_handle_otbr_error async def set_enabled(self, enabled: bool) -> None: """Enable or disable the router.""" diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index 9f2fd4a4355..a30275d3569 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -26,3 +26,5 @@ DATASET_INSECURE_PASSPHRASE = bytes.fromhex( "0A336069051000112233445566778899AABBCCDDEEFA030E4F70656E54687265616444656D6F01" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) + +TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index e7d5ac8980e..75922e99aa0 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -6,7 +6,12 @@ import pytest from homeassistant.components import otbr from homeassistant.core import HomeAssistant -from . import CONFIG_ENTRY_DATA_MULTIPAN, CONFIG_ENTRY_DATA_THREAD, DATASET_CH16 +from . import ( + CONFIG_ENTRY_DATA_MULTIPAN, + CONFIG_ENTRY_DATA_THREAD, + DATASET_CH16, + TEST_BORDER_AGENT_ID, +) from tests.common import MockConfigEntry @@ -23,6 +28,8 @@ async def otbr_config_entry_multipan_fixture(hass): config_entry.add_to_hass(hass) with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 + ), patch( + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID ), patch( "homeassistant.components.otbr.util.compute_pskc" ): # Patch to speed up tests @@ -41,6 +48,8 @@ async def otbr_config_entry_thread_fixture(hass): config_entry.add_to_hass(hass) with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 + ), patch( + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID ), patch( "homeassistant.components.otbr.util.compute_pskc" ): # Patch to speed up tests diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 49694cf5585..63229f4b2e7 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -7,7 +7,7 @@ import aiohttp import pytest import python_otbr_api -from homeassistant.components import otbr +from homeassistant.components import otbr, thread from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -21,6 +21,7 @@ from . import ( DATASET_CH16, DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE, + TEST_BORDER_AGENT_ID, ) from tests.common import MockConfigEntry @@ -36,6 +37,8 @@ DATASET_NO_CHANNEL = bytes.fromhex( async def test_import_dataset(hass: HomeAssistant) -> None: """Test the active dataset is imported at setup.""" issue_registry = ir.async_get(hass) + assert await thread.async_get_preferred_border_agent_id(hass) is None + assert await thread.async_get_preferred_dataset(hass) is None config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA_MULTIPAN, @@ -47,11 +50,15 @@ async def test_import_dataset(hass: HomeAssistant) -> None: with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( - "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add: + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ): assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) + assert ( + await thread.async_get_preferred_border_agent_id(hass) + == TEST_BORDER_AGENT_ID.hex() + ) + assert await thread.async_get_preferred_dataset(hass) == DATASET_CH16.hex() assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" ) From a093c383c3cb92154dabb9c0fcd6c03da96ee397 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 14 Aug 2023 13:43:08 +0200 Subject: [PATCH 036/180] Remove Verisure default lock code (#94676) --- homeassistant/components/verisure/__init__.py | 32 ++++++- .../components/verisure/config_flow.py | 25 ++---- homeassistant/components/verisure/lock.py | 23 ++--- .../components/verisure/strings.json | 6 +- tests/components/verisure/conftest.py | 1 + tests/components/verisure/test_config_flow.py | 83 +------------------ 6 files changed, 51 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 94e8d667d75..302bd23b66f 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -5,14 +5,16 @@ from contextlib import suppress import os from pathlib import Path +from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR -from .const import DOMAIN +from .const import CONF_LOCK_DEFAULT_CODE, DOMAIN, LOGGER from .coordinator import VerisureDataUpdateCoordinator PLATFORMS = [ @@ -41,6 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator + # Migrate lock default code from config entry to lock entity + # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -72,3 +76,29 @@ def migrate_cookie_files(hass: HomeAssistant, entry: ConfigEntry) -> None: cookie_file.rename( hass.config.path(STORAGE_DIR, f"verisure_{entry.data[CONF_EMAIL]}") ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + config_entry_default_code = entry.options.get(CONF_LOCK_DEFAULT_CODE) + entity_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) + for entity in entries: + if entity.entity_id.startswith("lock"): + entity_reg.async_update_entity_options( + entity.entity_id, + LOCK_DOMAIN, + {CONF_DEFAULT_CODE: config_entry_default_code}, + ) + new_options = entry.options.copy() + del new_options[CONF_LOCK_DEFAULT_CODE] + + entry.version = 2 + hass.config_entries.async_update_entry(entry, options=new_options) + + LOGGER.info("Migration to version %s successful", entry.version) + + return True diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index 1fcf0eb9de2..d945463fa5e 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -21,7 +21,6 @@ from homeassistant.helpers.storage import STORAGE_DIR from .const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, - CONF_LOCK_DEFAULT_CODE, DEFAULT_LOCK_CODE_DIGITS, DOMAIN, LOGGER, @@ -31,7 +30,7 @@ from .const import ( class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Verisure.""" - VERSION = 1 + VERSION = 2 email: str entry: ConfigEntry @@ -306,16 +305,10 @@ class VerisureOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage Verisure options.""" - errors = {} + errors: dict[str, Any] = {} if user_input is not None: - if len(user_input[CONF_LOCK_DEFAULT_CODE]) not in [ - 0, - user_input[CONF_LOCK_CODE_DIGITS], - ]: - errors["base"] = "code_format_mismatch" - else: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(data=user_input) return self.async_show_form( step_id="init", @@ -323,14 +316,12 @@ class VerisureOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_LOCK_CODE_DIGITS, - default=self.entry.options.get( - CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS - ), + description={ + "suggested_value": self.entry.options.get( + CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS + ) + }, ): int, - vol.Optional( - CONF_LOCK_DEFAULT_CODE, - default=self.entry.options.get(CONF_LOCK_DEFAULT_CODE, ""), - ): str, } ), errors=errors, diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 94a27784e78..ad9590d2524 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -20,7 +20,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, - CONF_LOCK_DEFAULT_CODE, DEFAULT_LOCK_CODE_DIGITS, DOMAIN, LOGGER, @@ -129,25 +128,15 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" - code = kwargs.get( - ATTR_CODE, self.coordinator.entry.options.get(CONF_LOCK_DEFAULT_CODE) - ) - if code is None: - LOGGER.error("Code required but none provided") - return - - await self.async_set_lock_state(code, STATE_UNLOCKED) + code = kwargs.get(ATTR_CODE) + if code: + await self.async_set_lock_state(code, STATE_UNLOCKED) async def async_lock(self, **kwargs: Any) -> None: """Send lock command.""" - code = kwargs.get( - ATTR_CODE, self.coordinator.entry.options.get(CONF_LOCK_DEFAULT_CODE) - ) - if code is None: - LOGGER.error("Code required but none provided") - return - - await self.async_set_lock_state(code, STATE_LOCKED) + code = kwargs.get(ATTR_CODE) + if code: + await self.async_set_lock_state(code, STATE_LOCKED) async def async_set_lock_state(self, code: str, state: str) -> None: """Send set lock state command.""" diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index f715529b36b..051f17262a0 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -48,13 +48,9 @@ "step": { "init": { "data": { - "lock_code_digits": "Number of digits in PIN code for locks", - "lock_default_code": "Default PIN code for locks, used if none is given" + "lock_code_digits": "Number of digits in PIN code for locks" } } - }, - "error": { - "code_format_mismatch": "The default PIN code does not match the required number of digits" } }, "entity": { diff --git a/tests/components/verisure/conftest.py b/tests/components/verisure/conftest.py index 8ddc3a99815..8e1da712a5c 100644 --- a/tests/components/verisure/conftest.py +++ b/tests/components/verisure/conftest.py @@ -23,6 +23,7 @@ def mock_config_entry() -> MockConfigEntry: CONF_GIID: "12345", CONF_PASSWORD: "SuperS3cr3t!", }, + version=2, ) diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index af102cced98..94a0963fdf6 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -11,7 +11,6 @@ from homeassistant.components import dhcp from homeassistant.components.verisure.const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, - CONF_LOCK_DEFAULT_CODE, DEFAULT_LOCK_CODE_DIGITS, DOMAIN, ) @@ -561,48 +560,9 @@ async def test_reauth_flow_errors( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("input", "output"), - [ - ( - { - CONF_LOCK_CODE_DIGITS: 5, - CONF_LOCK_DEFAULT_CODE: "12345", - }, - { - CONF_LOCK_CODE_DIGITS: 5, - CONF_LOCK_DEFAULT_CODE: "12345", - }, - ), - ( - { - CONF_LOCK_DEFAULT_CODE: "", - }, - { - CONF_LOCK_DEFAULT_CODE: "", - CONF_LOCK_CODE_DIGITS: DEFAULT_LOCK_CODE_DIGITS, - }, - ), - ( - { - CONF_LOCK_CODE_DIGITS: 5, - }, - { - CONF_LOCK_CODE_DIGITS: 5, - CONF_LOCK_DEFAULT_CODE: "", - }, - ), - ], -) -async def test_options_flow( - hass: HomeAssistant, input: dict[str, int | str], output: dict[str, int | str] -) -> None: +async def test_options_flow(hass: HomeAssistant) -> None: """Test options config flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="12345", - data={}, - ) + entry = MockConfigEntry(domain=DOMAIN, unique_id="12345", data={}, version=2) entry.add_to_hass(hass) with patch( @@ -619,43 +579,8 @@ async def test_options_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input=input, + user_input={CONF_LOCK_CODE_DIGITS: 4}, ) assert result.get("type") == FlowResultType.CREATE_ENTRY - assert result.get("data") == output - - -async def test_options_flow_code_format_mismatch(hass: HomeAssistant) -> None: - """Test options config flow with a code format mismatch.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="12345", - data={}, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.verisure.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == "init" - assert result.get("errors") == {} - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_LOCK_CODE_DIGITS: 5, - CONF_LOCK_DEFAULT_CODE: "123", - }, - ) - - assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == "init" - assert result.get("errors") == {"base": "code_format_mismatch"} + assert result.get("data") == {CONF_LOCK_CODE_DIGITS: DEFAULT_LOCK_CODE_DIGITS} From 525f39fe28611fc084e9001a2b809a5bc4951c28 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Aug 2023 14:10:45 +0200 Subject: [PATCH 037/180] Update todoist-api-python to 2.1.2 (#98383) --- homeassistant/components/todoist/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index ac7e899d8a1..a83cdbe1b09 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], - "requirements": ["todoist-api-python==2.0.2"] + "requirements": ["todoist-api-python==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0bd5e55c1a4..114a1e6b5e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2554,7 +2554,7 @@ tilt-ble==0.2.3 tmb==0.0.4 # homeassistant.components.todoist -todoist-api-python==2.0.2 +todoist-api-python==2.1.2 # homeassistant.components.tolo tololib==0.1.0b4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 31e4aa2d284..ec9f4da9090 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1860,7 +1860,7 @@ thermopro-ble==0.4.5 tilt-ble==0.2.3 # homeassistant.components.todoist -todoist-api-python==2.0.2 +todoist-api-python==2.1.2 # homeassistant.components.tolo tololib==0.1.0b4 From b0f68f1ef30cb350b6011460ff955c3032599db7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 14 Aug 2023 15:07:20 +0200 Subject: [PATCH 038/180] Use @require_admin decorator (#98061) Co-authored-by: Robert Resch Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/api/__init__.py | 14 ++-- .../components/config/config_entries.py | 46 ++++++------- homeassistant/components/http/decorators.py | 66 +++++++++++++++---- .../components/media_source/local_source.py | 6 +- .../components/repairs/websocket_api.py | 17 ++--- homeassistant/components/zwave_js/api.py | 5 +- homeassistant/helpers/data_entry_flow.py | 2 +- .../components/config/test_config_entries.py | 46 +++++++++++++ 8 files changed, 136 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index b465a6b7037..f264806ad47 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.bootstrap import DATA_LOGGING -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, MATCH_ALL, @@ -110,10 +110,9 @@ class APIEventStream(HomeAssistantView): url = URL_API_STREAM name = "api:stream" + @require_admin async def get(self, request): """Provide a streaming interface for the event bus.""" - if not request["hass_user"].is_admin: - raise Unauthorized() hass = request.app["hass"] stop_obj = object() to_write = asyncio.Queue() @@ -278,10 +277,9 @@ class APIEventView(HomeAssistantView): url = "/api/events/{event_type}" name = "api:event" + @require_admin async def post(self, request, event_type): """Fire events.""" - if not request["hass_user"].is_admin: - raise Unauthorized() body = await request.text() try: event_data = json_loads(body) if body else None @@ -385,10 +383,9 @@ class APITemplateView(HomeAssistantView): url = URL_API_TEMPLATE name = "api:template" + @require_admin async def post(self, request): """Render a template.""" - if not request["hass_user"].is_admin: - raise Unauthorized() try: data = await request.json() tpl = _cached_template(data["template"], request.app["hass"]) @@ -405,10 +402,9 @@ class APIErrorLog(HomeAssistantView): url = URL_API_ERROR_LOG name = "api:error_log" + @require_admin async def get(self, request): """Retrieve API error log.""" - if not request["hass_user"].is_admin: - raise Unauthorized() return web.FileResponse(request.app["hass"].data[DATA_LOGGING]) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index d58616ff38f..9691994512c 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT from homeassistant.components import websocket_api -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import DependencyError, Unauthorized import homeassistant.helpers.config_validation as cv @@ -138,12 +138,11 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): """Not implemented.""" raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) - # pylint: disable=arguments-differ + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") + ) async def post(self, request): """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - # pylint: disable=no-value-for-parameter try: return await super().post(request) @@ -164,19 +163,18 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView): url = "/api/config/config_entries/flow/{flow_id}" name = "api:config:config_entries:flow:resource" - async def get(self, request, flow_id): + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") + ) + async def get(self, request, /, flow_id): """Get the current state of a data_entry_flow.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - return await super().get(request, flow_id) - # pylint: disable=arguments-differ + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") + ) async def post(self, request, flow_id): """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) @@ -206,15 +204,14 @@ class OptionManagerFlowIndexView(FlowManagerIndexView): url = "/api/config/config_entries/options/flow" name = "api:config:config_entries:option:flow" - # pylint: disable=arguments-differ + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) async def post(self, request): """Handle a POST request. handler in request is entry_id. """ - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - # pylint: disable=no-value-for-parameter return await super().post(request) @@ -225,19 +222,18 @@ class OptionManagerFlowResourceView(FlowManagerResourceView): url = "/api/config/config_entries/options/flow/{flow_id}" name = "api:config:config_entries:options:flow:resource" - async def get(self, request, flow_id): + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) + async def get(self, request, /, flow_id): """Get the current state of a data_entry_flow.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - return await super().get(request, flow_id) - # pylint: disable=arguments-differ + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) async def post(self, request, flow_id): """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index 45bd34fa49f..ce5b1b18c06 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -1,8 +1,9 @@ """Decorators for the Home Assistant API.""" from __future__ import annotations -from collections.abc import Awaitable, Callable -from typing import Concatenate, ParamSpec, TypeVar +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate, ParamSpec, TypeVar, overload from aiohttp.web import Request, Response @@ -12,20 +13,61 @@ from .view import HomeAssistantView _HomeAssistantViewT = TypeVar("_HomeAssistantViewT", bound=HomeAssistantView) _P = ParamSpec("_P") +_FuncType = Callable[ + Concatenate[_HomeAssistantViewT, Request, _P], Coroutine[Any, Any, Response] +] + + +@overload +def require_admin( + _func: None = None, + *, + error: Unauthorized | None = None, +) -> Callable[[_FuncType[_HomeAssistantViewT, _P]], _FuncType[_HomeAssistantViewT, _P]]: + ... + + +@overload +def require_admin( + _func: _FuncType[_HomeAssistantViewT, _P], +) -> _FuncType[_HomeAssistantViewT, _P]: + ... def require_admin( - func: Callable[Concatenate[_HomeAssistantViewT, Request, _P], Awaitable[Response]] -) -> Callable[Concatenate[_HomeAssistantViewT, Request, _P], Awaitable[Response]]: + _func: _FuncType[_HomeAssistantViewT, _P] | None = None, + *, + error: Unauthorized | None = None, +) -> ( + Callable[[_FuncType[_HomeAssistantViewT, _P]], _FuncType[_HomeAssistantViewT, _P]] + | _FuncType[_HomeAssistantViewT, _P] +): """Home Assistant API decorator to require user to be an admin.""" - async def with_admin( - self: _HomeAssistantViewT, request: Request, *args: _P.args, **kwargs: _P.kwargs - ) -> Response: - """Check admin and call function.""" - if not request["hass_user"].is_admin: - raise Unauthorized() + def decorator_require_admin( + func: _FuncType[_HomeAssistantViewT, _P] + ) -> _FuncType[_HomeAssistantViewT, _P]: + """Wrap the provided with_admin function.""" - return await func(self, request, *args, **kwargs) + @wraps(func) + async def with_admin( + self: _HomeAssistantViewT, + request: Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> Response: + """Check admin and call function.""" + if not request["hass_user"].is_admin: + raise error or Unauthorized() - return with_admin + return await func(self, request, *args, **kwargs) + + return with_admin + + # See if we're being called as @require_admin or @require_admin(). + if _func is None: + # We're called with brackets. + return decorator_require_admin + + # We're called as @require_admin without brackets. + return decorator_require_admin(_func) diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 89437a6b2e0..ac6623a3af8 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -12,9 +12,9 @@ from aiohttp.web_request import FileField import voluptuous as vol from homeassistant.components import http, websocket_api +from homeassistant.components.http import require_admin from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import Unauthorized from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES @@ -254,11 +254,9 @@ class UploadMediaView(http.HomeAssistantView): } ) + @require_admin async def post(self, request: web.Request) -> web.Response: """Handle upload.""" - if not request["hass_user"].is_admin: - raise Unauthorized() - # Increase max payload request._client_max_size = MAX_UPLOAD_SIZE # pylint: disable=protected-access diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index c5408054318..0c6230e4c35 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -12,6 +12,7 @@ from homeassistant import data_entry_flow from homeassistant.auth.permissions.const import POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.http.decorators import require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized from homeassistant.helpers.data_entry_flow import ( @@ -88,6 +89,7 @@ class RepairsFlowIndexView(FlowManagerIndexView): url = "/api/repairs/issues/fix" name = "api:repairs:issues:fix" + @require_admin(error=Unauthorized(permission=POLICY_EDIT)) @RequestDataValidator( vol.Schema( { @@ -99,9 +101,6 @@ class RepairsFlowIndexView(FlowManagerIndexView): ) async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(permission=POLICY_EDIT) - try: result = await self._flow_mgr.async_init( data["handler"], @@ -125,18 +124,12 @@ class RepairsFlowResourceView(FlowManagerResourceView): url = "/api/repairs/issues/fix/{flow_id}" name = "api:repairs:issues:fix:resource" - async def get(self, request: web.Request, flow_id: str) -> web.Response: + @require_admin(error=Unauthorized(permission=POLICY_EDIT)) + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" - if not request["hass_user"].is_admin: - raise Unauthorized(permission=POLICY_EDIT) - return await super().get(request, flow_id) - # pylint: disable=arguments-differ + @require_admin(error=Unauthorized(permission=POLICY_EDIT)) async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(permission=POLICY_EDIT) - - # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 6d2461df3e4..6781ccacdc7 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -55,6 +55,7 @@ from zwave_js_server.model.utils import ( from zwave_js_server.util.node import async_set_config_parameter from homeassistant.components import websocket_api +from homeassistant.components.http import require_admin from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.websocket_api import ( ERR_INVALID_FORMAT, @@ -65,7 +66,6 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import Unauthorized from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.device_registry as dr @@ -2149,10 +2149,9 @@ class FirmwareUploadView(HomeAssistantView): super().__init__() self._dev_reg = dev_reg + @require_admin async def post(self, request: web.Request, device_id: str) -> web.Response: """Handle upload.""" - if not request["hass_user"].is_admin: - raise Unauthorized() hass = request.app["hass"] try: diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index e3e4b4f0de8..aa4ef36b251 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -90,7 +90,7 @@ class FlowManagerIndexView(_BaseFlowManagerView): class FlowManagerResourceView(_BaseFlowManagerView): """View to interact with the flow manager.""" - async def get(self, request: web.Request, flow_id: str) -> web.Response: + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" try: result = await self._flow_mgr.async_configure(flow_id) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index bf94e36a9b4..4684b4148b1 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -825,6 +825,52 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: } +@pytest.mark.parametrize( + ("endpoint", "method"), + [ + ("/api/config/config_entries/options/flow", "post"), + ("/api/config/config_entries/options/flow/1", "get"), + ("/api/config/config_entries/options/flow/1", "post"), + ], +) +async def test_options_flow_unauth( + hass: HomeAssistant, client, hass_admin_user: MockUser, endpoint: str, method: str +) -> None: + """Test unauthorized on options flow.""" + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_options_flow(config_entry): + class OptionsFlowHandler(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required("enabled")] = bool + return self.async_show_form( + step_id="user", + data_schema=schema, + description_placeholders={"enabled": "Set to true to be true"}, + ) + + return OptionsFlowHandler() + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + hass_admin_user.groups = [] + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.UNAUTHORIZED + + async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: """Test we can finish a two step options flow.""" mock_integration( From e0fd83daab61ce90e40d3460ee495bf3a2c88438 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Aug 2023 15:47:18 +0200 Subject: [PATCH 039/180] Store preferred border agent ID for each thread dataset (#98384) --- homeassistant/components/otbr/__init__.py | 30 ++++------ homeassistant/components/thread/__init__.py | 4 -- .../components/thread/dataset_store.py | 59 +++++++++---------- .../components/thread/websocket_api.py | 22 ++----- tests/components/otbr/test_init.py | 10 ++-- tests/components/otbr/test_websocket_api.py | 2 +- tests/components/thread/test_dataset_store.py | 28 +++++---- tests/components/thread/test_websocket_api.py | 37 ++++++++---- 8 files changed, 92 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 09a4499b60f..ac59bacbd97 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -7,12 +7,7 @@ import contextlib import aiohttp import python_otbr_api -from homeassistant.components.thread import ( - async_add_dataset, - async_get_preferred_border_agent_id, - async_get_preferred_dataset, - async_set_preferred_border_agent_id, -) +from homeassistant.components.thread import async_add_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError @@ -50,21 +45,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) as err: raise ConfigEntryNotReady("Unable to connect") from err if dataset_tlvs: - await update_issues(hass, otbrdata, dataset_tlvs) - await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) - # If this OTBR's dataset is the preferred one, and there is no preferred router, - # make this the preferred router - border_agent_id: bytes | None = None + border_agent_id: str | None = None with contextlib.suppress( HomeAssistantError, aiohttp.ClientError, asyncio.TimeoutError ): - border_agent_id = await otbrdata.get_border_agent_id() - if ( - await async_get_preferred_dataset(hass) == dataset_tlvs.hex() - and await async_get_preferred_border_agent_id(hass) is None - and border_agent_id - ): - await async_set_preferred_border_agent_id(hass, border_agent_id.hex()) + border_agent_bytes = await otbrdata.get_border_agent_id() + if border_agent_bytes: + border_agent_id = border_agent_bytes.hex() + await update_issues(hass, otbrdata, dataset_tlvs) + await async_add_dataset( + hass, + DOMAIN, + dataset_tlvs.hex(), + preferred_border_agent_id=border_agent_id, + ) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index 679127e5202..dd2527763ad 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -11,9 +11,7 @@ from .dataset_store import ( DatasetEntry, async_add_dataset, async_get_dataset, - async_get_preferred_border_agent_id, async_get_preferred_dataset, - async_set_preferred_border_agent_id, ) from .websocket_api import async_setup as async_setup_ws_api @@ -21,10 +19,8 @@ __all__ = [ "DOMAIN", "DatasetEntry", "async_add_dataset", - "async_get_preferred_border_agent_id", "async_get_dataset", "async_get_preferred_dataset", - "async_set_preferred_border_agent_id", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 96a9cf8e59e..22e2c1822c1 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -33,6 +33,7 @@ class DatasetPreferredError(HomeAssistantError): class DatasetEntry: """Dataset store entry.""" + preferred_border_agent_id: str | None source: str tlv: str @@ -73,6 +74,7 @@ class DatasetEntry: return { "created": self.created.isoformat(), "id": self.id, + "preferred_border_agent_id": self.preferred_border_agent_id, "source": self.source, "tlv": self.tlv, } @@ -97,6 +99,7 @@ class DatasetStoreStore(Store): entry = DatasetEntry( created=created, id=dataset["id"], + preferred_border_agent_id=None, source=dataset["source"], tlv=dataset["tlv"], ) @@ -160,7 +163,8 @@ class DatasetStoreStore(Store): } if old_minor_version < 3: # Add border agent ID - data.setdefault("preferred_border_agent_id", None) + for dataset in data["datasets"]: + dataset.setdefault("preferred_border_agent_id", None) return data @@ -172,7 +176,6 @@ class DatasetStore: """Initialize the dataset store.""" self.hass = hass self.datasets: dict[str, DatasetEntry] = {} - self._preferred_border_agent_id: str | None = None self._preferred_dataset: str | None = None self._store: Store[dict[str, Any]] = DatasetStoreStore( hass, @@ -183,7 +186,9 @@ class DatasetStore: ) @callback - def async_add(self, source: str, tlv: str) -> None: + def async_add( + self, source: str, tlv: str, preferred_border_agent_id: str | None + ) -> None: """Add dataset, does nothing if it already exists.""" # Make sure the tlv is valid dataset = tlv_parser.parse_tlv(tlv) @@ -245,7 +250,9 @@ class DatasetStore: self.async_schedule_save() return - entry = DatasetEntry(source=source, tlv=tlv) + entry = DatasetEntry( + preferred_border_agent_id=preferred_border_agent_id, source=source, tlv=tlv + ) self.datasets[entry.id] = entry # Set to preferred if there is no preferred dataset if self._preferred_dataset is None: @@ -266,14 +273,13 @@ class DatasetStore: return self.datasets.get(dataset_id) @callback - def async_get_preferred_border_agent_id(self) -> str | None: - """Get preferred border agent id.""" - return self._preferred_border_agent_id - - @callback - def async_set_preferred_border_agent_id(self, border_agent_id: str) -> None: - """Set preferred border agent id.""" - self._preferred_border_agent_id = border_agent_id + def async_set_preferred_border_agent_id( + self, dataset_id: str, border_agent_id: str + ) -> None: + """Set preferred border agent id of a dataset.""" + self.datasets[dataset_id] = dataclasses.replace( + self.datasets[dataset_id], preferred_border_agent_id=border_agent_id + ) self.async_schedule_save() @property @@ -296,7 +302,6 @@ class DatasetStore: data = await self._store.async_load() datasets: dict[str, DatasetEntry] = {} - preferred_border_agent_id: str | None = None preferred_dataset: str | None = None if data is not None: @@ -305,14 +310,13 @@ class DatasetStore: datasets[dataset["id"]] = DatasetEntry( created=created, id=dataset["id"], + preferred_border_agent_id=dataset["preferred_border_agent_id"], source=dataset["source"], tlv=dataset["tlv"], ) - preferred_border_agent_id = data["preferred_border_agent_id"] preferred_dataset = data["preferred_dataset"] self.datasets = datasets - self._preferred_border_agent_id = preferred_border_agent_id self._preferred_dataset = preferred_dataset @callback @@ -325,7 +329,6 @@ class DatasetStore: """Return data of datasets to store in a file.""" data: dict[str, Any] = {} data["datasets"] = [dataset.to_json() for dataset in self.datasets.values()] - data["preferred_border_agent_id"] = self._preferred_border_agent_id data["preferred_dataset"] = self._preferred_dataset return data @@ -338,10 +341,16 @@ async def async_get_store(hass: HomeAssistant) -> DatasetStore: return store -async def async_add_dataset(hass: HomeAssistant, source: str, tlv: str) -> None: +async def async_add_dataset( + hass: HomeAssistant, + source: str, + tlv: str, + *, + preferred_border_agent_id: str | None = None, +) -> None: """Add a dataset.""" store = await async_get_store(hass) - store.async_add(source, tlv) + store.async_add(source, tlv, preferred_border_agent_id) async def async_get_dataset(hass: HomeAssistant, dataset_id: str) -> str | None: @@ -352,20 +361,6 @@ async def async_get_dataset(hass: HomeAssistant, dataset_id: str) -> str | None: return entry.tlv -async def async_get_preferred_border_agent_id(hass: HomeAssistant) -> str | None: - """Get the preferred border agent ID.""" - store = await async_get_store(hass) - return store.async_get_preferred_border_agent_id() - - -async def async_set_preferred_border_agent_id( - hass: HomeAssistant, border_agent_id: str -) -> None: - """Get the preferred border agent ID.""" - store = await async_get_store(hass) - store.async_set_preferred_border_agent_id(border_agent_id) - - async def async_get_preferred_dataset(hass: HomeAssistant) -> str | None: """Get the preferred dataset.""" store = await async_get_store(hass) diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 853d8c3c893..5b289cf1694 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -20,7 +20,6 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_discover_routers) websocket_api.async_register_command(hass, ws_get_dataset) websocket_api.async_register_command(hass, ws_list_datasets) - websocket_api.async_register_command(hass, ws_get_preferred_border_agent_id) websocket_api.async_register_command(hass, ws_set_preferred_border_agent_id) websocket_api.async_register_command(hass, ws_set_preferred_dataset) @@ -52,25 +51,11 @@ async def ws_add_dataset( connection.send_result(msg["id"]) -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "thread/get_preferred_border_agent_id", - } -) -@websocket_api.async_response -async def ws_get_preferred_border_agent_id( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] -) -> None: - """Get the preferred border agent ID.""" - border_agent_id = await dataset_store.async_get_preferred_border_agent_id(hass) - connection.send_result(msg["id"], {"border_agent_id": border_agent_id}) - - @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "thread/set_preferred_border_agent_id", + vol.Required("dataset_id"): str, vol.Required("border_agent_id"): str, } ) @@ -79,8 +64,10 @@ async def ws_set_preferred_border_agent_id( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Set the preferred border agent ID.""" + dataset_id = msg["dataset_id"] border_agent_id = msg["border_agent_id"] - await dataset_store.async_set_preferred_border_agent_id(hass, border_agent_id) + store = await dataset_store.async_get_store(hass) + store.async_set_preferred_border_agent_id(dataset_id, border_agent_id) connection.send_result(msg["id"]) @@ -186,6 +173,7 @@ async def ws_list_datasets( "network_name": dataset.network_name, "pan_id": dataset.pan_id, "preferred": dataset.id == preferred_dataset, + "preferred_border_agent_id": dataset.preferred_border_agent_id, "source": dataset.source, } ) diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 63229f4b2e7..18a60cfa196 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -37,7 +37,6 @@ DATASET_NO_CHANNEL = bytes.fromhex( async def test_import_dataset(hass: HomeAssistant) -> None: """Test the active dataset is imported at setup.""" issue_registry = ir.async_get(hass) - assert await thread.async_get_preferred_border_agent_id(hass) is None assert await thread.async_get_preferred_dataset(hass) is None config_entry = MockConfigEntry( @@ -54,8 +53,9 @@ async def test_import_dataset(hass: HomeAssistant) -> None: ): assert await hass.config_entries.async_setup(config_entry.entry_id) + dataset_store = await thread.dataset_store.async_get_store(hass) assert ( - await thread.async_get_preferred_border_agent_id(hass) + list(dataset_store.datasets.values())[0].preferred_border_agent_id == TEST_BORDER_AGENT_ID.hex() ) assert await thread.async_get_preferred_dataset(hass) == DATASET_CH16.hex() @@ -94,7 +94,7 @@ async def test_import_share_radio_channel_collision( ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex(), None) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"otbr_zha_channel_collision_{config_entry.entry_id}", @@ -127,7 +127,7 @@ async def test_import_share_radio_no_channel_collision( ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex(), None) assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"otbr_zha_channel_collision_{config_entry.entry_id}", @@ -158,7 +158,7 @@ async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> N ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex(), None) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" ) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index d62213ce78b..f149e89cc45 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -109,7 +109,7 @@ async def test_create_network( assert set_enabled_mock.mock_calls[0][1][0] is False assert set_enabled_mock.mock_calls[1][1][0] is True get_active_dataset_tlvs_mock.assert_called_once() - mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex(), None) async def test_create_network_no_entry( diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 1171c597e99..77102f92019 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -254,7 +254,7 @@ async def test_load_datasets(hass: HomeAssistant) -> None: store1 = await dataset_store.async_get_store(hass) for dataset in datasets: - store1.async_add(dataset["source"], dataset["tlv"]) + store1.async_add(dataset["source"], dataset["tlv"], None) assert len(store1.datasets) == 3 for dataset in store1.datasets.values(): @@ -303,33 +303,31 @@ async def test_loading_datasets_from_storage( { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id1", + "preferred_border_agent_id": "230C6A1AC57F6F4BE262ACF32E5EF52C", "source": "source_1", "tlv": DATASET_1, }, { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id2", + "preferred_border_agent_id": None, "source": "source_2", "tlv": DATASET_2, }, { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id3", + "preferred_border_agent_id": None, "source": "source_3", "tlv": DATASET_3, }, ], - "preferred_border_agent_id": "230C6A1AC57F6F4BE262ACF32E5EF52C", "preferred_dataset": "id1", }, } store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 3 - assert ( - store.async_get_preferred_border_agent_id() - == "230C6A1AC57F6F4BE262ACF32E5EF52C" - ) assert store.preferred_dataset == "id1" @@ -540,11 +538,17 @@ async def test_migrate_set_default_border_agent_id( } store = await dataset_store.async_get_store(hass) - assert store.async_get_preferred_border_agent_id() is None + assert store.datasets[store._preferred_dataset].preferred_border_agent_id is None -async def test_preferred_border_agent_id(hass: HomeAssistant) -> None: - """Test get and set the preferred border agent ID.""" - assert await dataset_store.async_get_preferred_border_agent_id(hass) is None - await dataset_store.async_set_preferred_border_agent_id(hass, "blah") - assert await dataset_store.async_get_preferred_border_agent_id(hass) == "blah" +async def test_set_preferred_border_agent_id(hass: HomeAssistant) -> None: + """Test set the preferred border agent ID of a dataset.""" + assert await dataset_store.async_get_preferred_dataset(hass) is None + + await dataset_store.async_add_dataset( + hass, "source", DATASET_1, preferred_border_agent_id="blah" + ) + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].preferred_border_agent_id == "blah" diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 82450474e92..bfe71b8b21c 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -160,6 +160,7 @@ async def test_list_get_dataset( "network_name": "OpenThreadDemo", "pan_id": "1234", "preferred": True, + "preferred_border_agent_id": None, "source": "Google", }, { @@ -170,6 +171,7 @@ async def test_list_get_dataset( "network_name": "HomeAssistant!", "pan_id": "1234", "preferred": False, + "preferred_border_agent_id": None, "source": "Multipan", }, { @@ -180,6 +182,7 @@ async def test_list_get_dataset( "network_name": "~🐣🐥🐤~", "pan_id": "1234", "preferred": False, + "preferred_border_agent_id": None, "source": "🎅", }, ] @@ -200,33 +203,45 @@ async def test_list_get_dataset( assert msg["error"] == {"code": "not_found", "message": "unknown dataset"} -async def test_preferred_border_agent_id( +async def test_set_preferred_border_agent_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test setting and getting the preferred border agent ID.""" + """Test setting the preferred border agent ID.""" assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "thread/get_preferred_border_agent_id"}) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"border_agent_id": None} - await client.send_json_auto_id( - {"type": "thread/set_preferred_border_agent_id", "border_agent_id": "blah"} + {"type": "thread/add_dataset_tlv", "source": "test", "tlv": DATASET_1} ) msg = await client.receive_json() assert msg["success"] assert msg["result"] is None - await client.send_json_auto_id({"type": "thread/get_preferred_border_agent_id"}) + await client.send_json_auto_id({"type": "thread/list_datasets"}) msg = await client.receive_json() assert msg["success"] - assert msg["result"] == {"border_agent_id": "blah"} + datasets = msg["result"]["datasets"] + dataset_id = datasets[0]["dataset_id"] + assert datasets[0]["preferred_border_agent_id"] is None - assert await dataset_store.async_get_preferred_border_agent_id(hass) == "blah" + await client.send_json_auto_id( + { + "type": "thread/set_preferred_border_agent_id", + "dataset_id": dataset_id, + "border_agent_id": "blah", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + await client.send_json_auto_id({"type": "thread/list_datasets"}) + msg = await client.receive_json() + assert msg["success"] + datasets = msg["result"]["datasets"] + assert datasets[0]["preferred_border_agent_id"] == "blah" async def test_set_preferred_dataset( From 1869177f0836899664ac402737149a04c9fcc2df Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Aug 2023 15:47:55 +0200 Subject: [PATCH 040/180] Rename some incorrectly named assist_pipeline tests (#98389) --- tests/components/assist_pipeline/test_pipeline.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index f6a62a630d2..32468e3af91 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -31,7 +31,7 @@ async def load_homeassistant(hass) -> None: assert await async_setup_component(hass, "homeassistant", {}) -async def test_load_datasets(hass: HomeAssistant, init_components) -> None: +async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: """Make sure that we can load/save data correctly.""" pipelines = [ @@ -92,10 +92,10 @@ async def test_load_datasets(hass: HomeAssistant, init_components) -> None: assert store1.async_get_preferred_item() == store2.async_get_preferred_item() -async def test_loading_datasets_from_storage( +async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Test loading stored datasets on start.""" + """Test loading stored pipelines on start.""" hass_storage[STORAGE_KEY] = { "version": 1, "minor_version": 1, From d059c9924a5dcce58f2fb3c470f8ab63637ccd2a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Aug 2023 15:50:43 +0200 Subject: [PATCH 041/180] Update attrs to 23.1.0 (#98385) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 78973f15520..29c654cd05c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ astral==2.2 async-timeout==4.0.3 async-upnp-client==0.34.1 atomicwrites-homeassistant==1.4.1 -attrs==22.2.0 +attrs==23.1.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.1.1 diff --git a/pyproject.toml b/pyproject.toml index af386239ac5..3003e3a29ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.8.5", "astral==2.2", "async-timeout==4.0.3", - "attrs==22.2.0", + "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==22.9.0", "bcrypt==4.0.1", diff --git a/requirements.txt b/requirements.txt index 0c55b1f9a9e..72fa57f6b1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ aiohttp==3.8.5 astral==2.2 async-timeout==4.0.3 -attrs==22.2.0 +attrs==23.1.0 atomicwrites-homeassistant==1.4.1 awesomeversion==22.9.0 bcrypt==4.0.1 From 2272a9db0082db7398df023e60e9965e249eed90 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 14 Aug 2023 15:54:43 +0200 Subject: [PATCH 042/180] Improve picotts (#98391) --- homeassistant/components/picotts/tts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py index 23e94b5206d..4d9f1755145 100644 --- a/homeassistant/components/picotts/tts.py +++ b/homeassistant/components/picotts/tts.py @@ -51,7 +51,7 @@ class PicoProvider(Provider): with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf: fname = tmpf.name - cmd = ["pico2wave", "--wave", fname, "-l", language, message] + cmd = ["pico2wave", "--wave", fname, "-l", language, "--", message] subprocess.call(cmd) data = None try: From d4753ebd3b310a60c633aeedfa50336b415750b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Aug 2023 16:46:55 +0200 Subject: [PATCH 043/180] Include border agent ID in thread router discovery (#98378) --- homeassistant/components/thread/discovery.py | 9 +++++++-- tests/components/thread/__init__.py | 16 ++++++++++++++++ tests/components/thread/test_discovery.py | 4 ++++ tests/components/thread/test_websocket_api.py | 2 ++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index d07469f36fb..ce721a20e28 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -32,6 +32,7 @@ class ThreadRouterDiscoveryData: """Thread router discovery data.""" addresses: list[str] | None + border_agent_id: str | None brand: str | None extended_address: str | None extended_pan_id: str | None @@ -61,13 +62,16 @@ def async_discovery_data_from_service( # For legacy backwards compatibility zeroconf allows properties to be set # as strings but we never do that so we can safely cast here. service_properties = cast(dict[bytes, bytes | None], service.properties) + + border_agent_id = service_properties.get(b"id") ext_addr = service_properties.get(b"xa") ext_pan_id = service_properties.get(b"xp") - network_name = try_decode(service_properties.get(b"nn")) model_name = try_decode(service_properties.get(b"mn")) + network_name = try_decode(service_properties.get(b"nn")) server = service.server - vendor_name = try_decode(service_properties.get(b"vn")) thread_version = try_decode(service_properties.get(b"tv")) + vendor_name = try_decode(service_properties.get(b"vn")) + unconfigured = None brand = KNOWN_BRANDS.get(vendor_name) if brand == "homeassistant": @@ -84,6 +88,7 @@ def async_discovery_data_from_service( return ThreadRouterDiscoveryData( addresses=service.parsed_addresses(), + border_agent_id=border_agent_id.hex() if border_agent_id is not None else None, brand=brand, extended_address=ext_addr.hex() if ext_addr is not None else None, extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index e7435b8e94a..f9d527919b4 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -93,6 +93,7 @@ ROUTER_DISCOVERY_HASS = { "server": "core-silabs-multiprotocol.local.", "properties": { b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", b"vn": b"HomeAssistant", b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", @@ -105,6 +106,7 @@ ROUTER_DISCOVERY_HASS = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -119,6 +121,7 @@ ROUTER_DISCOVERY_HASS_BAD_DATA = { "server": "core-silabs-multiprotocol.local.", "properties": { b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", b"vn": b"HomeAssistant\xff", # Invalid UTF-8 b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", @@ -131,6 +134,7 @@ ROUTER_DISCOVERY_HASS_BAD_DATA = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -145,6 +149,7 @@ ROUTER_DISCOVERY_HASS_MISSING_DATA = { "server": "core-silabs-multiprotocol.local.", "properties": { b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5", @@ -156,6 +161,7 @@ ROUTER_DISCOVERY_HASS_MISSING_DATA = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -171,6 +177,7 @@ ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { "server": "core-silabs-multiprotocol.local.", "properties": { b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", b"vn": b"HomeAssistant", b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", @@ -182,6 +189,7 @@ ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -197,6 +205,7 @@ ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP = { "server": "core-silabs-multiprotocol.local.", "properties": { b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", b"vn": b"HomeAssistant", b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", @@ -208,6 +217,7 @@ ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -223,6 +233,7 @@ ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP = { "server": "core-silabs-multiprotocol.local.", "properties": { b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", b"vn": b"HomeAssistant", b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", @@ -234,6 +245,7 @@ ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -249,6 +261,7 @@ ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP = { "server": "core-silabs-multiprotocol.local.", "properties": { b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", b"vn": b"HomeAssistant", b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", @@ -261,6 +274,7 @@ ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -276,6 +290,7 @@ ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE = { "server": "core-silabs-multiprotocol.local.", "properties": { b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", b"vn": b"HomeAssistant", b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", @@ -288,6 +303,7 @@ ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index 84fe4c30974..4d43142b7b7 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -72,6 +72,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], + border_agent_id="230c6a1ac57f6f4be262acf32e5ef52c", brand="homeassistant", extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", @@ -98,6 +99,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) "f6a99b425a67abed", discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.124"], + border_agent_id="bc3740c3e963aa8735bebecd7cc503c7", brand="google", extended_address="f6a99b425a67abed", extended_pan_id="9e75e256f61409a3", @@ -176,6 +178,7 @@ async def test_discover_routers_unconfigured( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], + border_agent_id="230c6a1ac57f6f4be262acf32e5ef52c", brand="homeassistant", extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", @@ -221,6 +224,7 @@ async def test_discover_routers_bad_data( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], + border_agent_id="230c6a1ac57f6f4be262acf32e5ef52c", brand=None, extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index bfe71b8b21c..75e1b313132 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -332,6 +332,7 @@ async def test_discover_routers( "event": { "data": { "addresses": ["192.168.0.115"], + "border_agent_id": "230c6a1ac57f6f4be262acf32e5ef52c", "brand": "homeassistant", "extended_address": "aeeb2f594b570bbf", "extended_pan_id": "e60fc7c186212ce5", @@ -361,6 +362,7 @@ async def test_discover_routers( "event": { "data": { "addresses": ["192.168.0.124"], + "border_agent_id": "bc3740c3e963aa8735bebecd7cc503c7", "brand": "google", "extended_address": "f6a99b425a67abed", "extended_pan_id": "9e75e256f61409a3", From 77b421887befe1478fe40798e301d25721cd30c4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Aug 2023 16:58:57 +0200 Subject: [PATCH 044/180] Add entity translations for August (#98077) --- .../components/august/binary_sensor.py | 31 ++++-------- homeassistant/components/august/button.py | 3 +- homeassistant/components/august/camera.py | 7 +-- homeassistant/components/august/entity.py | 1 + homeassistant/components/august/lock.py | 3 +- homeassistant/components/august/sensor.py | 10 +--- homeassistant/components/august/strings.json | 22 +++++++++ tests/components/august/test_binary_sensor.py | 48 ++++++++++--------- tests/components/august/test_init.py | 4 +- 9 files changed, 69 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 2cbeeeee5aa..b19a9833a47 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -109,10 +109,6 @@ def _native_datetime() -> datetime: class AugustBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes August binary_sensor entity.""" - # AugustBinarySensor does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - @dataclass class AugustDoorbellRequiredKeysMixin: @@ -128,34 +124,28 @@ class AugustDoorbellBinarySensorEntityDescription( ): """Describes August binary_sensor entity.""" - # AugustDoorbellBinarySensor does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - SENSOR_TYPE_DOOR = AugustBinarySensorEntityDescription( - key="door_open", - name="Open", + key="open", + device_class=BinarySensorDeviceClass.DOOR, ) SENSOR_TYPES_VIDEO_DOORBELL = ( AugustDoorbellBinarySensorEntityDescription( - key="doorbell_motion", - name="Motion", + key="motion", device_class=BinarySensorDeviceClass.MOTION, value_fn=_retrieve_motion_state, is_time_based=True, ), AugustDoorbellBinarySensorEntityDescription( - key="doorbell_image_capture", - name="Image Capture", + key="image capture", + translation_key="image_capture", icon="mdi:file-image", value_fn=_retrieve_image_capture_state, is_time_based=True, ), AugustDoorbellBinarySensorEntityDescription( - key="doorbell_online", - name="Online", + key="online", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_online_state, @@ -166,8 +156,7 @@ SENSOR_TYPES_VIDEO_DOORBELL = ( SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = ( AugustDoorbellBinarySensorEntityDescription( - key="doorbell_ding", - name="Ding", + key="ding", device_class=BinarySensorDeviceClass.OCCUPANCY, value_fn=_retrieve_ding_state, is_time_based=True, @@ -236,8 +225,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): self.entity_description = description self._data = data self._device = device - self._attr_name = f"{device.device_name} {description.name}" - self._attr_unique_id = f"{self._device_id}_{description.name.lower()}" + self._attr_unique_id = f"{self._device_id}_{description.key}" @callback def _update_from_data(self): @@ -284,8 +272,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): self.entity_description = description self._check_for_off_update_listener = None self._data = data - self._attr_name = f"{device.device_name} {description.name}" - self._attr_unique_id = f"{self._device_id}_{description.name.lower()}" + self._attr_unique_id = f"{self._device_id}_{description.key}" @callback def _update_from_data(self): diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py index c96db61ca1a..b8f66aea02b 100644 --- a/homeassistant/components/august/button.py +++ b/homeassistant/components/august/button.py @@ -24,10 +24,11 @@ async def async_setup_entry( class AugustWakeLockButton(AugustEntityMixin, ButtonEntity): """Representation of an August lock wake button.""" + _attr_translation_key = "wake" + def __init__(self, data: AugustData, device: Lock) -> None: """Initialize the lock wake button.""" super().__init__(data, device) - self._attr_name = f"{device.device_name} Wake" self._attr_unique_id = f"{self._device_id}_wake" async def async_press(self) -> None: diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index a3cc18ab9c0..4c3c124953a 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -33,16 +33,17 @@ async def async_setup_entry( class AugustCamera(AugustEntityMixin, Camera): - """An implementation of a August security camera.""" + """An implementation of an August security camera.""" + + _attr_translation_key = "camera" def __init__(self, data, device, session, timeout): - """Initialize a August security camera.""" + """Initialize an August security camera.""" super().__init__(data, device) self._timeout = timeout self._session = session self._image_url = None self._image_content = None - self._attr_name = f"{device.device_name} Camera" self._attr_unique_id = f"{self._device_id:s}_camera" @property diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index bd81dc0c96f..47f3b8be74f 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -19,6 +19,7 @@ class AugustEntityMixin(Entity): """Base implementation for August device.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, data: AugustData, device: Doorbell | Lock) -> None: """Initialize an August device.""" diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 9e8b2470b4e..e082cd1cfab 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -37,11 +37,12 @@ async def async_setup_entry( class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): """Representation of an August lock.""" + _attr_name = None + def __init__(self, data, device): """Initialize the lock.""" super().__init__(data, device) self._lock_status = None - self._attr_name = device.device_name self._attr_unique_id = f"{self._device_id:s}_lock" self._update_from_data() diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 169a344e2bd..2c688ae7615 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -75,7 +75,6 @@ class AugustSensorEntityDescription( SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( key="device_battery", - name="Battery", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=_retrieve_device_battery_state, @@ -83,7 +82,6 @@ SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( SENSOR_TYPE_KEYPAD_BATTERY = AugustSensorEntityDescription[KeypadDetail]( key="linked_keypad_battery", - name="Battery", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=_retrieve_linked_keypad_battery_state, @@ -176,6 +174,8 @@ async def _async_migrate_old_unique_ids(hass, devices): class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): """Representation of an August lock operation sensor.""" + _attr_translation_key = "operator" + def __init__(self, data, device): """Initialize the sensor.""" super().__init__(data, device) @@ -188,11 +188,6 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._entity_picture = None self._update_from_data() - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._device.device_name} Operator" - @callback def _update_from_data(self): """Get the latest state of the sensor and update activity.""" @@ -278,7 +273,6 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[_T]): super().__init__(data, device) self.entity_description = description self._old_device = old_device - self._attr_name = f"{device.device_name} {description.name}" self._attr_unique_id = f"{self._device_id}_{description.key}" self._update_from_data() diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json index 88362c9fd66..7e33ec30881 100644 --- a/homeassistant/components/august/strings.json +++ b/homeassistant/components/august/strings.json @@ -37,5 +37,27 @@ "title": "Reauthenticate an August account" } } + }, + "entity": { + "binary_sensor": { + "image_capture": { + "name": "Image capture" + } + }, + "button": { + "wake": { + "name": "Wake" + } + }, + "camera": { + "camera": { + "name": "[%key:component::camera::title%]" + } + }, + "sensor": { + "operator": { + "name": "Operator" + } + } } } diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 2787cdbe23d..50cac4445ab 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -41,7 +41,7 @@ async def test_doorsense(hass: HomeAssistant) -> None: await _create_august_with_devices(hass, [lock_one]) binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -50,7 +50,7 @@ async def test_doorsense(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -58,7 +58,7 @@ async def test_doorsense(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_OFF @@ -74,7 +74,7 @@ async def test_lock_bridge_offline(hass: HomeAssistant) -> None: await _create_august_with_devices(hass, [lock_one], activities=activities) binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_UNAVAILABLE @@ -93,11 +93,11 @@ async def test_create_doorbell(hass: HomeAssistant) -> None: ) assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_OFF binary_sensor_k98gidt45gul_name_online = hass.states.get( - "binary_sensor.k98gidt45gul_name_online" + "binary_sensor.k98gidt45gul_name_connectivity" ) assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF binary_sensor_k98gidt45gul_name_motion = hass.states.get( @@ -120,10 +120,12 @@ async def test_create_doorbell_offline(hass: HomeAssistant) -> None: ) assert binary_sensor_tmt100_name_motion.state == STATE_UNAVAILABLE binary_sensor_tmt100_name_online = hass.states.get( - "binary_sensor.tmt100_name_online" + "binary_sensor.tmt100_name_connectivity" ) assert binary_sensor_tmt100_name_online.state == STATE_OFF - binary_sensor_tmt100_name_ding = hass.states.get("binary_sensor.tmt100_name_ding") + binary_sensor_tmt100_name_ding = hass.states.get( + "binary_sensor.tmt100_name_occupancy" + ) assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE @@ -140,11 +142,11 @@ async def test_create_doorbell_with_motion(hass: HomeAssistant) -> None: ) assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON binary_sensor_k98gidt45gul_name_online = hass.states.get( - "binary_sensor.k98gidt45gul_name_online" + "binary_sensor.k98gidt45gul_name_connectivity" ) assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) @@ -174,7 +176,7 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: ) assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF @@ -242,7 +244,7 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF @@ -273,7 +275,7 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_ON new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) @@ -286,7 +288,7 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF @@ -317,7 +319,7 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: ) binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -332,7 +334,7 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_OFF @@ -346,14 +348,14 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -361,7 +363,7 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -369,7 +371,7 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -383,14 +385,14 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -404,6 +406,6 @@ async def test_create_lock_with_doorbell(hass: HomeAssistant) -> None: await _create_august_with_devices(hass, [lock_one]) ding_sensor = hass.states.get( - "binary_sensor.a6697750d607098bae8d6baa11ef8063_name_ding" + "binary_sensor.a6697750d607098bae8d6baa11ef8063_name_occupancy" ) assert ding_sensor.state == STATE_OFF diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index fe297c97a57..36a7f73f8a8 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -186,11 +186,11 @@ async def test_lock_has_doorsense(hass: HomeAssistant) -> None: await _create_august_with_devices(hass, [doorsenselock, nodoorsenselock]) binary_sensor_online_with_doorsense_name_open = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name_open.state == STATE_ON binary_sensor_missing_doorsense_id_name_open = hass.states.get( - "binary_sensor.missing_doorsense_id_name_open" + "binary_sensor.missing_with_doorsense_name_door" ) assert binary_sensor_missing_doorsense_id_name_open is None From 54223fe06c5afa3d0c18007c1be41fec24f350ca Mon Sep 17 00:00:00 2001 From: Marco Ranieri Date: Mon, 14 Aug 2023 17:47:50 +0200 Subject: [PATCH 045/180] Enable Alexa Unlock directive (#97127) Co-authored-by: Jan Bouwhuis --- homeassistant/components/alexa/handlers.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 06ce4f88b56..3e995e9ffe2 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -474,7 +474,24 @@ async def async_api_unlock( context: ha.Context, ) -> AlexaResponse: """Process an unlock request.""" - if config.locale not in {"de-DE", "en-US", "ja-JP"}: + if config.locale not in { + "ar-SA", + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "es-US", + "fr-CA", + "fr-FR", + "hi-IN", + "it-IT", + "ja-JP", + "pt-BR", + }: msg = ( "The unlock directive is not supported for the following locales:" f" {config.locale}" From 85c2216cd7d2b7371df811a15e046cd6e8b1dd56 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 14 Aug 2023 17:48:11 +0200 Subject: [PATCH 046/180] Ensure headers middleware handles errors too (#98397) --- homeassistant/components/http/headers.py | 26 ++++++++++++++++-------- tests/components/http/test_headers.py | 24 +++++++++++++++++++++- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py index b53f354b144..20c0a58967b 100644 --- a/homeassistant/components/http/headers.py +++ b/homeassistant/components/http/headers.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from aiohttp.web import Application, Request, StreamResponse, middleware +from aiohttp.web_exceptions import HTTPException from homeassistant.core import callback @@ -12,20 +13,29 @@ from homeassistant.core import callback def setup_headers(app: Application, use_x_frame_options: bool) -> None: """Create headers middleware for the app.""" + added_headers = { + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "Server": "", # Empty server header, to prevent aiohttp of setting one. + } + + if use_x_frame_options: + added_headers["X-Frame-Options"] = "SAMEORIGIN" + @middleware async def headers_middleware( request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and add headers to the responses.""" - response = await handler(request) - response.headers["Referrer-Policy"] = "no-referrer" - response.headers["X-Content-Type-Options"] = "nosniff" + try: + response = await handler(request) + except HTTPException as err: + for key, value in added_headers.items(): + err.headers[key] = value + raise err - # Set an empty server header, to prevent aiohttp of setting one. - response.headers["Server"] = "" - - if use_x_frame_options: - response.headers["X-Frame-Options"] = "SAMEORIGIN" + for key, value in added_headers.items(): + response.headers[key] = value return response diff --git a/tests/components/http/test_headers.py b/tests/components/http/test_headers.py index 6d7dbad68f6..16b897b9f99 100644 --- a/tests/components/http/test_headers.py +++ b/tests/components/http/test_headers.py @@ -2,21 +2,28 @@ from http import HTTPStatus from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized from homeassistant.components.http.headers import setup_headers from tests.typing import ClientSessionGenerator -async def mock_handler(request): +async def mock_handler(_: web.Request) -> web.Response: """Return OK.""" return web.Response(text="OK") +async def mock_handler_error(_: web.Request) -> web.Response: + """Return Unauthorized.""" + raise HTTPUnauthorized(text="Ah ah ah, you didn't say the magic word") + + async def test_headers_added(aiohttp_client: ClientSessionGenerator) -> None: """Test that headers are being added on each request.""" app = web.Application() app.router.add_get("/", mock_handler) + app.router.add_get("/error", mock_handler_error) setup_headers(app, use_x_frame_options=True) @@ -29,11 +36,20 @@ async def test_headers_added(aiohttp_client: ClientSessionGenerator) -> None: assert resp.headers["X-Content-Type-Options"] == "nosniff" assert resp.headers["X-Frame-Options"] == "SAMEORIGIN" + resp = await mock_api_client.get("/error") + + assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.headers["Referrer-Policy"] == "no-referrer" + assert resp.headers["Server"] == "" + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-Frame-Options"] == "SAMEORIGIN" + async def test_allow_framing(aiohttp_client: ClientSessionGenerator) -> None: """Test that we allow framing when disabled.""" app = web.Application() app.router.add_get("/", mock_handler) + app.router.add_get("/error", mock_handler_error) setup_headers(app, use_x_frame_options=False) @@ -42,3 +58,9 @@ async def test_allow_framing(aiohttp_client: ClientSessionGenerator) -> None: assert resp.status == HTTPStatus.OK assert "X-Frame-Options" not in resp.headers + + mock_api_client = await aiohttp_client(app) + resp = await mock_api_client.get("/error") + + assert resp.status == HTTPStatus.UNAUTHORIZED + assert "X-Frame-Options" not in resp.headers From d6fcdeac06dcec668c6ae521bd7676c6d76c6bf3 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 14 Aug 2023 18:03:17 +0200 Subject: [PATCH 047/180] Avoid leaking backtrace on connection lost in arcam (#98277) * Avoid leaking backtrace on connection lost * Correct ruff error after rebase --- .../components/arcam_fmj/media_player.py | 45 +++++++++++-- tests/components/arcam_fmj/conftest.py | 3 + .../components/arcam_fmj/test_media_player.py | 63 +++++++++++++++++-- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 0173005eb2f..12114ec04b8 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -1,10 +1,11 @@ """Arcam media player.""" from __future__ import annotations +import functools import logging from typing import Any -from arcam.fmj import SourceCodes +from arcam.fmj import ConnectionFailed, SourceCodes from arcam.fmj.state import State from homeassistant.components.media_player import ( @@ -19,6 +20,7 @@ from homeassistant.components.media_player.errors import BrowseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -57,6 +59,21 @@ async def async_setup_entry( ) +def convert_exception(func): + """Return decorator to convert a connection error into a home assistant error.""" + + @functools.wraps(func) + async def _convert_exception(*args, **kwargs): + try: + return await func(*args, **kwargs) + except ConnectionFailed as exception: + raise HomeAssistantError( + f"Connection failed to device during {func}" + ) from exception + + return _convert_exception + + class ArcamFmj(MediaPlayerEntity): """Representation of a media device.""" @@ -105,7 +122,10 @@ class ArcamFmj(MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Once registered, add listener for events.""" await self._state.start() - await self._state.update() + try: + await self._state.update() + except ConnectionFailed as connection: + _LOGGER.debug("Connection lost during addition: %s", connection) @callback def _data(host: str) -> None: @@ -137,13 +157,18 @@ class ArcamFmj(MediaPlayerEntity): async def async_update(self) -> None: """Force update of state.""" _LOGGER.debug("Update state %s", self.name) - await self._state.update() + try: + await self._state.update() + except ConnectionFailed as connection: + _LOGGER.debug("Connection lost during update: %s", connection) + @convert_exception async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" await self._state.set_mute(mute) self.async_write_ha_state() + @convert_exception async def async_select_source(self, source: str) -> None: """Select a specific source.""" try: @@ -155,31 +180,37 @@ class ArcamFmj(MediaPlayerEntity): await self._state.set_source(value) self.async_write_ha_state() + @convert_exception async def async_select_sound_mode(self, sound_mode: str) -> None: """Select a specific source.""" try: await self._state.set_decode_mode(sound_mode) - except (KeyError, ValueError): - _LOGGER.error("Unsupported sound_mode %s", sound_mode) - return + except (KeyError, ValueError) as exception: + raise HomeAssistantError( + f"Unsupported sound_mode {sound_mode}" + ) from exception self.async_write_ha_state() + @convert_exception async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._state.set_volume(round(volume * 99.0)) self.async_write_ha_state() + @convert_exception async def async_volume_up(self) -> None: """Turn volume up for media player.""" await self._state.inc_volume() self.async_write_ha_state() + @convert_exception async def async_volume_down(self) -> None: """Turn volume up for media player.""" await self._state.dec_volume() self.async_write_ha_state() + @convert_exception async def async_turn_on(self) -> None: """Turn the media player on.""" if self._state.get_power() is not None: @@ -189,6 +220,7 @@ class ArcamFmj(MediaPlayerEntity): _LOGGER.debug("Firing event to turn on device") self.hass.bus.async_fire(EVENT_TURN_ON, {ATTR_ENTITY_ID: self.entity_id}) + @convert_exception async def async_turn_off(self) -> None: """Turn the media player off.""" await self._state.set_power(False) @@ -230,6 +262,7 @@ class ArcamFmj(MediaPlayerEntity): return root + @convert_exception async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: diff --git a/tests/components/arcam_fmj/conftest.py b/tests/components/arcam_fmj/conftest.py index 693cdc685c9..ba32951efe4 100644 --- a/tests/components/arcam_fmj/conftest.py +++ b/tests/components/arcam_fmj/conftest.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.arcam_fmj.const import DEFAULT_NAME from homeassistant.components.arcam_fmj.media_player import ArcamFmj from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockEntityPlatform @@ -94,6 +95,8 @@ async def player_setup_fixture(hass, state_1, state_2, client): if zone == 2: return state_2 + await async_setup_component(hass, "homeassistant", {}) + with patch("homeassistant.components.arcam_fmj.Client", return_value=client), patch( "homeassistant.components.arcam_fmj.media_player.State", side_effect=state_mock ), patch("homeassistant.components.arcam_fmj._run_client", return_value=None): diff --git a/tests/components/arcam_fmj/test_media_player.py b/tests/components/arcam_fmj/test_media_player.py index 2607ab817df..b9c86140cb9 100644 --- a/tests/components/arcam_fmj/test_media_player.py +++ b/tests/components/arcam_fmj/test_media_player.py @@ -2,14 +2,20 @@ from math import isclose from unittest.mock import ANY, PropertyMock, patch -from arcam.fmj import DecodeMode2CH, DecodeModeMCH, SourceCodes +from arcam.fmj import ConnectionFailed, DecodeMode2CH, DecodeModeMCH, SourceCodes import pytest +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_VOLUME_LEVEL, ATTR_SOUND_MODE, ATTR_SOUND_MODE_LIST, SERVICE_SELECT_SOURCE, + SERVICE_VOLUME_SET, MediaType, ) from homeassistant.const import ( @@ -20,6 +26,7 @@ from homeassistant.const import ( ATTR_NAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import MOCK_HOST, MOCK_UUID @@ -106,12 +113,33 @@ async def test_name(player) -> None: assert data.attributes["friendly_name"] == "Zone 1" -async def test_update(player, state) -> None: +async def test_update(hass: HomeAssistant, player_setup: str, state) -> None: """Test update.""" - await update(player, force_refresh=True) + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: player_setup}, + blocking=True, + ) state.update.assert_called_with() +async def test_update_lost( + hass: HomeAssistant, player_setup: str, state, caplog: pytest.LogCaptureFixture +) -> None: + """Test update, with connection loss is ignored.""" + state.update.side_effect = ConnectionFailed() + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: player_setup}, + blocking=True, + ) + state.update.assert_called_with() + assert "Connection lost during update" in caplog.text + + @pytest.mark.parametrize( ("source", "value"), [("PVR", SourceCodes.PVR), ("BD", SourceCodes.BD), ("INVALID", None)], @@ -220,12 +248,37 @@ async def test_volume_level(player, state) -> None: @pytest.mark.parametrize(("volume", "call"), [(0.0, 0), (0.5, 50), (1.0, 99)]) -async def test_set_volume_level(player, state, volume, call) -> None: +async def test_set_volume_level( + hass: HomeAssistant, player_setup: str, state, volume, call +) -> None: """Test setting volume.""" - await player.async_set_volume_level(volume) + + await hass.services.async_call( + "media_player", + SERVICE_VOLUME_SET, + service_data={ATTR_ENTITY_ID: player_setup, ATTR_MEDIA_VOLUME_LEVEL: volume}, + blocking=True, + ) + state.set_volume.assert_called_with(call) +async def test_set_volume_level_lost( + hass: HomeAssistant, player_setup: str, state +) -> None: + """Test setting volume, with a lost connection.""" + + state.set_volume.side_effect = ConnectionFailed() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "media_player", + SERVICE_VOLUME_SET, + service_data={ATTR_ENTITY_ID: player_setup, ATTR_MEDIA_VOLUME_LEVEL: 0.0}, + blocking=True, + ) + + @pytest.mark.parametrize( ("source", "media_content_type"), [ From c3c00e69849429ae9be24e2cc40432405a4e79c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 14 Aug 2023 18:21:12 +0200 Subject: [PATCH 048/180] Update aioairzone to v0.6.6 (#98399) --- .../components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone/test_climate.py | 7 ++++++ tests/components/airzone/test_config_flow.py | 25 ++++++++++++++++++- tests/components/airzone/test_coordinator.py | 10 +++++++- tests/components/airzone/test_init.py | 8 +++++- tests/components/airzone/test_sensor.py | 4 +++ tests/components/airzone/util.py | 21 ++++++++++++++++ 9 files changed, 75 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 39adf08236e..711da2ec993 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.5"] + "requirements": ["aioairzone==0.6.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 114a1e6b5e8..6f7cbba02ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -188,7 +188,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.5 +aioairzone==0.6.6 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec9f4da9090..de2b973d57c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,7 +169,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.5 +aioairzone==0.6.6 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index 3e68c056566..1f8667d0344 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -54,6 +54,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util.dt import utcnow from .util import ( + HVAC_DHW_MOCK, HVAC_MOCK, HVAC_SYSTEMS_MOCK, HVAC_WEBSERVER_MOCK, @@ -226,6 +227,9 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: HVAC_MOCK_CHANGED[API_SYSTEMS][0][API_DATA][0][API_MIN_TEMP] = 10 with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK_CHANGED, ), patch( @@ -437,6 +441,9 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: del HVAC_MOCK_NO_SET_POINT[API_SYSTEMS][0][API_DATA][0][API_SET_POINT] with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK_NO_SET_POINT, ), patch( diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index d703a232c7b..10aaf07885b 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import patch from aioairzone.const import API_MAC, API_SYSTEMS from aioairzone.exceptions import ( AirzoneError, + HotWaterNotAvailable, InvalidMethod, InvalidSystem, SystemOutOfRange, @@ -19,7 +20,14 @@ from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .util import CONFIG, CONFIG_ID1, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK +from .util import ( + CONFIG, + CONFIG_ID1, + HVAC_DHW_MOCK, + HVAC_MOCK, + HVAC_VERSION_MOCK, + HVAC_WEBSERVER_MOCK, +) from tests.common import MockConfigEntry @@ -41,6 +49,9 @@ async def test_form(hass: HomeAssistant) -> None: "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( @@ -87,6 +98,9 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", side_effect=InvalidSystem, ) as mock_hvac, patch( @@ -186,6 +200,9 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( @@ -264,6 +281,9 @@ async def test_dhcp_connection_error(hass: HomeAssistant) -> None: "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( @@ -317,6 +337,9 @@ async def test_dhcp_invalid_system_id(hass: HomeAssistant) -> None: "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", side_effect=InvalidSystem, ) as mock_hvac, patch( diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py index bcfdad8ead8..62f6a15fe35 100644 --- a/tests/components/airzone/test_coordinator.py +++ b/tests/components/airzone/test_coordinator.py @@ -2,7 +2,12 @@ from unittest.mock import patch -from aioairzone.exceptions import AirzoneError, InvalidMethod, SystemOutOfRange +from aioairzone.exceptions import ( + AirzoneError, + HotWaterNotAvailable, + InvalidMethod, + SystemOutOfRange, +) from homeassistant.components.airzone.const import DOMAIN from homeassistant.components.airzone.coordinator import SCAN_INTERVAL @@ -26,6 +31,9 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ) as mock_hvac, patch( diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index bb7cb06d1c2..2214e5d07ab 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioairzone.exceptions import InvalidMethod, SystemOutOfRange +from aioairzone.exceptions import HotWaterNotAvailable, InvalidMethod, SystemOutOfRange from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,6 +23,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( @@ -45,6 +48,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None: ) with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 1d778761ee1..cce8a452a15 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from .util import ( + HVAC_DHW_MOCK, HVAC_MOCK, HVAC_SYSTEMS_MOCK, HVAC_VERSION_MOCK, @@ -86,6 +87,9 @@ async def test_airzone_sensors_availability( del HVAC_MOCK_UNAVAILABLE_ZONE[API_SYSTEMS][0][API_DATA][1] with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK_UNAVAILABLE_ZONE, ), patch( diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 4afcaeac232..74cda7c8017 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -3,6 +3,12 @@ from unittest.mock import patch from aioairzone.const import ( + API_ACS_MAX_TEMP, + API_ACS_MIN_TEMP, + API_ACS_ON, + API_ACS_POWER_MODE, + API_ACS_SET_POINT, + API_ACS_TEMP, API_AIR_DEMAND, API_COLD_ANGLE, API_COLD_STAGE, @@ -266,6 +272,18 @@ HVAC_MOCK = { ] } +HVAC_DHW_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_TEMP: 43, + API_ACS_SET_POINT: 45, + API_ACS_MAX_TEMP: 75, + API_ACS_MIN_TEMP: 30, + API_ACS_ON: 1, + API_ACS_POWER_MODE: 0, + } +} + HVAC_SYSTEMS_MOCK = { API_SYSTEMS: [ { @@ -301,6 +319,9 @@ async def async_init_integration( config_entry.add_to_hass(hass) with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( From 318aa9b95ad064b15da6dfa2ed41c084535bfd08 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Aug 2023 19:35:31 +0200 Subject: [PATCH 049/180] Add entity translations to Goodwe (#98224) * Add entity translations to Goodwe * Add entity translations to Goodwe --- homeassistant/components/goodwe/button.py | 3 ++- homeassistant/components/goodwe/number.py | 9 ++++++--- homeassistant/components/goodwe/select.py | 2 +- homeassistant/components/goodwe/strings.json | 14 ++++++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/goodwe/button.py b/homeassistant/components/goodwe/button.py index 55ba33b63f6..12cad42547d 100644 --- a/homeassistant/components/goodwe/button.py +++ b/homeassistant/components/goodwe/button.py @@ -34,7 +34,7 @@ class GoodweButtonEntityDescription( SYNCHRONIZE_CLOCK = GoodweButtonEntityDescription( key="synchronize_clock", - name="Synchronize inverter clock", + translation_key="synchronize_clock", icon="mdi:clock-check-outline", entity_category=EntityCategory.CONFIG, action=lambda inv: inv.write_setting("time", datetime.now()), @@ -66,6 +66,7 @@ class GoodweButtonEntity(ButtonEntity): """Entity representing the inverter clock synchronization button.""" _attr_should_poll = False + _attr_has_entity_name = True entity_description: GoodweButtonEntityDescription def __init__( diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 7e31dd14037..a3e4190f309 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -45,10 +45,12 @@ def _get_setting_unit(inverter: Inverter, setting: str) -> str: NUMBERS = ( + # Only one of the export limits are added. + # Availability is checked in the filter method. # Export limit in W GoodweNumberEntityDescription( key="grid_export_limit", - name="Grid export limit", + translation_key="grid_export_limit", icon="mdi:transmission-tower", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.POWER, @@ -63,7 +65,7 @@ NUMBERS = ( # Export limit in % GoodweNumberEntityDescription( key="grid_export_limit", - name="Grid export limit", + translation_key="grid_export_limit", icon="mdi:transmission-tower", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -76,7 +78,7 @@ NUMBERS = ( ), GoodweNumberEntityDescription( key="battery_discharge_depth", - name="Depth of discharge (on-grid)", + translation_key="battery_discharge_depth", icon="mdi:battery-arrow-down", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -120,6 +122,7 @@ class InverterNumberEntity(NumberEntity): """Inverter numeric setting entity.""" _attr_should_poll = False + _attr_has_entity_name = True entity_description: GoodweNumberEntityDescription def __init__( diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index 012d73f792c..bc22376e4d9 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -31,7 +31,6 @@ _OPTION_TO_MODE: dict[str, OperationMode] = { OPERATION_MODE = SelectEntityDescription( key="operation_mode", - name="Inverter operation mode", icon="mdi:solar-power", entity_category=EntityCategory.CONFIG, translation_key="operation_mode", @@ -72,6 +71,7 @@ class InverterOperationModeEntity(SelectEntity): """Entity representing the inverter operation mode.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/goodwe/strings.json b/homeassistant/components/goodwe/strings.json index 28765c005af..ec4ea80e22a 100644 --- a/homeassistant/components/goodwe/strings.json +++ b/homeassistant/components/goodwe/strings.json @@ -18,8 +18,22 @@ } }, "entity": { + "button": { + "synchronize_clock": { + "name": "Synchronize inverter clock" + } + }, + "number": { + "grid_export_limit": { + "name": "Grid export limit" + }, + "battery_discharge_depth": { + "name": "Depth of discharge (on-grid)" + } + }, "select": { "operation_mode": { + "name": "Inverter operation mode", "state": { "general": "General mode", "off_grid": "Off grid mode", From 80fa0340483a90d46b2389414ea027e96d050d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 14 Aug 2023 18:36:58 +0100 Subject: [PATCH 050/180] ipma: remove abmantis from codeowners (#98304) * ipma: remove abmantis from codeowners I am not currently maintaining this integration. * Run hassfest * Try again --------- Co-authored-by: Franck Nijhof --- CODEOWNERS | 4 ++-- homeassistant/components/ipma/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 084d83b0da1..bd1b8ed49f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -606,8 +606,8 @@ build.json @home-assistant/supervisor /homeassistant/components/iotawatt/ @gtdiehl @jyavenard /tests/components/iotawatt/ @gtdiehl @jyavenard /homeassistant/components/iperf3/ @rohankapoorcom -/homeassistant/components/ipma/ @dgomes @abmantis -/tests/components/ipma/ @dgomes @abmantis +/homeassistant/components/ipma/ @dgomes +/tests/components/ipma/ @dgomes /homeassistant/components/ipp/ @ctalkington /tests/components/ipp/ @ctalkington /homeassistant/components/iqvia/ @bachya diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 4f86295db08..4fea047e834 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -1,7 +1,7 @@ { "domain": "ipma", "name": "Instituto Portugu\u00eas do Mar e Atmosfera (IPMA)", - "codeowners": ["@dgomes", "@abmantis"], + "codeowners": ["@dgomes"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ipma", "iot_class": "cloud_polling", From 6294014fcd3d89656a1aeca808f79e0a11634aa4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Aug 2023 20:09:50 +0200 Subject: [PATCH 051/180] Bump python-otbr-api to 2.5.0 (#98403) --- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index e62a2d42b1e..cf6aba33e80 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.4.0"] + "requirements": ["python-otbr-api==2.5.0"] } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 29b7e61d407..eeac24a626f 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.4.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.5.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6f7cbba02ca..4133e60181c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2141,7 +2141,7 @@ python-opensky==0.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.4.0 +python-otbr-api==2.5.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de2b973d57c..621ca8ba98b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1570,7 +1570,7 @@ python-opensky==0.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.4.0 +python-otbr-api==2.5.0 # homeassistant.components.picnic python-picnic-api==1.1.0 From 69b3ae4588778cc2b10b5352440c7891fd139964 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Aug 2023 14:07:17 -0500 Subject: [PATCH 052/180] Bump zeroconf to 0.78.0 (#98405) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index da8cfd26b1f..6f3020244fa 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.76.0"] + "requirements": ["zeroconf==0.78.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 29c654cd05c..37ec683aff0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.76.0 +zeroconf==0.78.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 4133e60181c..f4d8381f0c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2752,7 +2752,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.76.0 +zeroconf==0.78.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 621ca8ba98b..05f81b16e78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2025,7 +2025,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.76.0 +zeroconf==0.78.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 97134668174cd13711dc652a641902a104e5093b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 14 Aug 2023 21:42:47 +0200 Subject: [PATCH 053/180] Add sensor when meter last sent its data to Discovergy (#97223) * Add sensor for last reading by Discovergy * Rename sensor to make it clear what it actually is * Revert back to single sensor classe and extend entity_description with a value function --- homeassistant/components/discovergy/sensor.py | 36 ++++++++++++++----- .../components/discovergy/strings.json | 3 ++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index b243f9adc54..5b8fb864987 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -1,7 +1,9 @@ """Discovergy sensor entity.""" +from collections.abc import Callable from dataclasses import dataclass, field +from datetime import datetime -from pydiscovergy.models import Meter +from pydiscovergy.models import Meter, Reading from homeassistant.components.sensor import ( SensorDeviceClass, @@ -11,6 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + EntityCategory, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, @@ -19,7 +22,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DiscovergyData, DiscovergyUpdateCoordinator @@ -32,6 +34,9 @@ PARALLEL_UPDATES = 1 class DiscovergyMixin: """Mixin for alternative keys.""" + value_fn: Callable[[Reading, str, int], datetime | float | None] = field( + default=lambda reading, key, scale: float(reading.values[key] / scale) + ) alternative_keys: list[str] = field(default_factory=lambda: []) scale: int = field(default_factory=lambda: 1000) @@ -144,6 +149,17 @@ ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( ), ) +ADDITIONAL_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( + DiscovergySensorEntityDescription( + key="last_transmitted", + translation_key="last_transmitted", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda reading, key, scale: reading.time_with_timezone, + ), +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -160,18 +176,22 @@ async def async_setup_entry( elif meter.measurement_type == "GAS": sensors = GAS_SENSORS + coordinator: DiscovergyUpdateCoordinator = data.coordinators[meter.meter_id] + if sensors is not None: for description in sensors: # check if this meter has this data, then add this sensor for key in {description.key, *description.alternative_keys}: - coordinator: DiscovergyUpdateCoordinator = data.coordinators[ - meter.meter_id - ] if key in coordinator.data.values: entities.append( DiscovergySensor(key, description, meter, coordinator) ) + for description in ADDITIONAL_SENSORS: + entities.append( + DiscovergySensor(description.key, description, meter, coordinator) + ) + async_add_entities(entities, False) @@ -204,8 +224,8 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt ) @property - def native_value(self) -> StateType: + def native_value(self) -> datetime | float | None: """Return the sensor state.""" - return float( - self.coordinator.data.values[self.data_key] / self.entity_description.scale + return self.entity_description.value_fn( + self.coordinator.data, self.data_key, self.entity_description.scale ) diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index e8dbbab2021..5147440e1b7 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -60,6 +60,9 @@ }, "phase_3_power": { "name": "Phase 3 power" + }, + "last_transmitted": { + "name": "Last transmitted" } } } From 49a9d0e43996b4ab72d482805e1286cca003eba3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Aug 2023 22:26:20 +0200 Subject: [PATCH 054/180] Add entity translations to hunterdouglas powerview (#98232) --- .../hunterdouglas_powerview/button.py | 14 ++-- .../hunterdouglas_powerview/cover.py | 64 +++++++++---------- .../hunterdouglas_powerview/entity.py | 2 + .../hunterdouglas_powerview/select.py | 3 +- .../hunterdouglas_powerview/sensor.py | 4 +- .../hunterdouglas_powerview/strings.json | 37 +++++++++++ 6 files changed, 81 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index cb2da3ba8fa..2e0bc1c413a 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -7,7 +7,11 @@ from typing import Any, Final from aiopvapi.resources.shade import BaseShade, factory as PvShade -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -36,21 +40,20 @@ class PowerviewButtonDescription( BUTTONS: Final = [ PowerviewButtonDescription( key="calibrate", - name="Calibrate", + translation_key="calibrate", icon="mdi:swap-vertical-circle-outline", entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.calibrate(), ), PowerviewButtonDescription( key="identify", - name="Identify", - icon="mdi:crosshairs-question", + device_class=ButtonDeviceClass.IDENTIFY, entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.jog(), ), PowerviewButtonDescription( key="favorite", - name="Favorite", + translation_key="favorite", icon="mdi:heart", entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.favorite(), @@ -104,7 +107,6 @@ class PowerviewButton(ShadeEntity, ButtonEntity): """Initialize the button entity.""" super().__init__(coordinator, device_info, room_name, shade, name) self.entity_description: PowerviewButtonDescription = description - self._attr_name = f"{self._shade_name} {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" async def async_press(self) -> None: diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index dfb1a7ad967..5cb84658c50 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -118,7 +118,11 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): """Representation of a powerview shade.""" _attr_device_class = CoverDeviceClass.SHADE - _attr_supported_features = CoverEntityFeature(0) + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) def __init__( self, @@ -131,7 +135,6 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._shade: BaseShade = shade - self._attr_name = self._shade_name self._scheduled_transition_update: CALLBACK_TYPE | None = None if self._device_info.model != LEGACY_DEVICE_MODEL: self._attr_supported_features |= CoverEntityFeature.STOP @@ -346,26 +349,14 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): class PowerViewShade(PowerViewShadeBase): """Represent a standard shade.""" - def __init__( - self, - coordinator: PowerviewShadeUpdateCoordinator, - device_info: PowerviewDeviceInfo, - room_name: str, - shade: BaseShade, - name: str, - ) -> None: - """Initialize the shade.""" - super().__init__(coordinator, device_info, room_name, shade, name) - self._attr_supported_features |= ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) + _attr_name = None -class PowerViewShadeWithTiltBase(PowerViewShade): +class PowerViewShadeWithTiltBase(PowerViewShadeBase): """Representation for PowerView shades with tilt capabilities.""" + _attr_name = None + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -453,9 +444,11 @@ class PowerViewShadeWithTiltOnClosed(PowerViewShadeWithTiltBase): API Class: ShadeBottomUpTiltOnClosed + ShadeBottomUpTiltOnClosed90 Type 1 - Bottom Up w/ 90° Tilt - Shade 44 - a shade thought to have been a firmware issue (type 0 usually dont tilt) + Shade 44 - a shade thought to have been a firmware issue (type 0 usually don't tilt) """ + _attr_name = None + @property def open_position(self) -> PowerviewShadeMove: """Return the open position and required additional positions.""" @@ -570,7 +563,7 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): self._max_tilt = self._shade.shade_limits.tilt_max -class PowerViewShadeTopDown(PowerViewShade): +class PowerViewShadeTopDown(PowerViewShadeBase): """Representation of a shade that lowers from the roof to the floor. These shades are inverted where MAX_POSITION equates to closed and MIN_POSITION is open @@ -579,6 +572,8 @@ class PowerViewShadeTopDown(PowerViewShade): Type 6 - Top Down """ + _attr_name = None + @property def current_cover_position(self) -> int: """Return the current position of cover.""" @@ -594,7 +589,7 @@ class PowerViewShadeTopDown(PowerViewShade): await self._async_set_cover_position(100 - kwargs[ATTR_POSITION]) -class PowerViewShadeDualRailBase(PowerViewShade): +class PowerViewShadeDualRailBase(PowerViewShadeBase): """Representation of a shade with top/down bottom/up capabilities. Base methods shared between the two shades created @@ -613,11 +608,13 @@ class PowerViewShadeDualRailBase(PowerViewShade): class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): """Representation of the bottom PowerViewShadeDualRailBase shade. - These shades have top/down bottom up functionality and two entiites. + These shades have top/down bottom up functionality and two entities. Sibling Class: PowerViewShadeTDBUTop API Class: ShadeTopDownBottomUp """ + _attr_translation_key = "bottom" + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -629,7 +626,6 @@ class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_bottom" - self._attr_name = f"{self._shade_name} Bottom" @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: @@ -655,11 +651,13 @@ class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): """Representation of the top PowerViewShadeDualRailBase shade. - These shades have top/down bottom up functionality and two entiites. + These shades have top/down bottom up functionality and two entities. Sibling Class: PowerViewShadeTDBUBottom API Class: ShadeTopDownBottomUp """ + _attr_translation_key = "top" + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -671,7 +669,6 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_top" - self._attr_name = f"{self._shade_name} Top" @property def should_poll(self) -> bool: @@ -711,7 +708,7 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: - """Dont allow a cover to go into an impossbile position.""" + """Don't allow a cover to go into an impossbile position.""" cover_bottom = hd_position_to_hass(self.positions.primary, MAX_POSITION) return min(target_hass_position, (100 - cover_bottom)) @@ -730,7 +727,7 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): ) -class PowerViewShadeDualOverlappedBase(PowerViewShade): +class PowerViewShadeDualOverlappedBase(PowerViewShadeBase): """Represent a shade that has a front sheer and rear opaque panel. This equates to two shades being controlled by one motor @@ -783,6 +780,8 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): Type 8 - Duolite (front and rear shades) """ + _attr_translation_key = "combined" + # type def __init__( self, @@ -795,7 +794,6 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_combined" - self._attr_name = f"{self._shade_name} Combined" @property def is_closed(self) -> bool: @@ -842,7 +840,7 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): - """Represent the shade front panel - These have a opaque panel too. + """Represent the shade front panel - These have an opaque panel too. This equates to two shades being controlled by one motor. The front shade must be completely down before the rear shade will move. @@ -857,6 +855,8 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): Type 10 - Duolite with 180° Tilt """ + _attr_translation_key = "front" + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -868,7 +868,6 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_front" - self._attr_name = f"{self._shade_name} Front" @property def should_poll(self) -> bool: @@ -906,7 +905,7 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): - """Represent the shade front panel - These have a opaque panel too. + """Represent the shade front panel - These have an opaque panel too. This equates to two shades being controlled by one motor. The front shade must be completely down before the rear shade will move. @@ -921,6 +920,8 @@ class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): Type 10 - Duolite with 180° Tilt """ + _attr_translation_key = "rear" + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -932,7 +933,6 @@ class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_rear" - self._attr_name = f"{self._shade_name} Rear" @property def should_poll(self) -> bool: diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 08f3c749fc5..78f63e16879 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -25,6 +25,8 @@ from .shade_data import PowerviewShadeData, PowerviewShadePositions class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): """Base class for hunter douglas entities.""" + _attr_has_entity_name = True + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 7de7d3e8735..37d1193e0e5 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -47,7 +47,7 @@ class PowerviewSelectDescription( DROPDOWNS: Final = [ PowerviewSelectDescription( key="powersource", - name="Power Source", + translation_key="power_source", icon="mdi:power-plug-outline", current_fn=lambda shade: POWER_SUPPLY_TYPE_MAP.get( shade.raw_data.get(ATTR_BATTERY_KIND), None @@ -106,7 +106,6 @@ class PowerViewSelect(ShadeEntity, SelectEntity): """Initialize the select entity.""" super().__init__(coordinator, device_info, room_name, shade, name) self.entity_description: PowerviewSelectDescription = description - self._attr_name = f"{self._shade_name} {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" @property diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index b36457324e1..825ca140f14 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -55,7 +55,6 @@ class PowerviewSensorDescription( SENSORS: Final = [ PowerviewSensorDescription( key="charge", - name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, native_value_fn=lambda shade: round( @@ -69,7 +68,7 @@ SENSORS: Final = [ ), PowerviewSensorDescription( key="signal", - name="Signal", + translation_key="signal_strength", icon="mdi:signal", native_unit_of_measurement=PERCENTAGE, native_value_fn=lambda shade: round( @@ -129,7 +128,6 @@ class PowerViewSensor(ShadeEntity, SensorEntity): """Initialize the select entity.""" super().__init__(coordinator, device_info, room_name, shade, name) self.entity_description = description - self._attr_name = f"{self._shade_name} {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._attr_native_unit_of_measurement = description.native_unit_of_measurement diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json index ec26e423e06..7c17788be83 100644 --- a/homeassistant/components/hunterdouglas_powerview/strings.json +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -20,5 +20,42 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "button": { + "calibrate": { + "name": "Calibrate" + }, + "favorite": { + "name": "Favorite" + } + }, + "cover": { + "bottom": { + "name": "Bottom" + }, + "top": { + "name": "Top" + }, + "combined": { + "name": "Combined" + }, + "front": { + "name": "Front" + }, + "rear": { + "name": "Rear" + } + }, + "select": { + "power_source": { + "name": "Power source" + } + }, + "sensor": { + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + } + } } } From e3438baf49b447074193bf33a5505dad209873f9 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Mon, 14 Aug 2023 20:23:16 -0400 Subject: [PATCH 055/180] Add select platform to Enphase integration (#98368) * Add select platform to Enphase integration * Review comments pt1 * Review comments pt2 * Review comments * Additional tweaks from code review * .coveragerc --------- Co-authored-by: J. Nick Koston --- .coveragerc | 1 + .../components/enphase_envoy/const.py | 2 +- .../components/enphase_envoy/manifest.json | 2 +- .../components/enphase_envoy/select.py | 171 ++++++++++++++++++ .../components/enphase_envoy/strings.json | 36 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/enphase_envoy/select.py diff --git a/.coveragerc b/.coveragerc index e64058d93d0..014dc2f0f39 100644 --- a/.coveragerc +++ b/.coveragerc @@ -305,6 +305,7 @@ omit = homeassistant/components/enphase_envoy/binary_sensor.py homeassistant/components/enphase_envoy/coordinator.py homeassistant/components/enphase_envoy/entity.py + homeassistant/components/enphase_envoy/select.py homeassistant/components/enphase_envoy/sensor.py homeassistant/components/enphase_envoy/switch.py homeassistant/components/entur_public_transport/* diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 828abe8fe4c..d1c6618502e 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -5,6 +5,6 @@ from homeassistant.const import Platform DOMAIN = "enphase_envoy" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 6969dc3d6ab..62f7c73ef76 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.5.2"], + "requirements": ["pyenphase==1.6.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py new file mode 100644 index 00000000000..75c9ce0cf7c --- /dev/null +++ b/homeassistant/components/enphase_envoy/select.py @@ -0,0 +1,171 @@ +"""Select platform for Enphase Envoy solar energy monitor.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from pyenphase import EnvoyDryContactSettings +from pyenphase.models.dry_contacts import DryContactAction, DryContactMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EnphaseUpdateCoordinator +from .entity import EnvoyBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EnvoyRelayRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyDryContactSettings], str] + update_fn: Callable[[Any, Any, Any], Any] + + +@dataclass +class EnvoyRelaySelectEntityDescription( + SelectEntityDescription, EnvoyRelayRequiredKeysMixin +): + """Describes an Envoy Dry Contact Relay select entity.""" + + +RELAY_MODE_MAP = { + DryContactMode.MANUAL: "standard", + DryContactMode.STATE_OF_CHARGE: "battery", +} +REVERSE_RELAY_MODE_MAP = {v: k for k, v in RELAY_MODE_MAP.items()} +RELAY_ACTION_MAP = { + DryContactAction.APPLY: "powered", + DryContactAction.SHED: "not_powered", + DryContactAction.SCHEDULE: "schedule", + DryContactAction.NONE: "none", +} +REVERSE_RELAY_ACTION_MAP = {v: k for k, v in RELAY_ACTION_MAP.items()} +MODE_OPTIONS = list(REVERSE_RELAY_MODE_MAP) +ACTION_OPTIONS = list(REVERSE_RELAY_ACTION_MAP) + +RELAY_ENTITIES = ( + EnvoyRelaySelectEntityDescription( + key="mode", + translation_key="relay_mode", + options=MODE_OPTIONS, + value_fn=lambda relay: RELAY_MODE_MAP[relay.mode], + update_fn=lambda envoy, relay, value: envoy.update_dry_contact( + { + "id": relay.id, + "mode": REVERSE_RELAY_MODE_MAP[value], + } + ), + ), + EnvoyRelaySelectEntityDescription( + key="grid_action", + translation_key="relay_grid_action", + options=ACTION_OPTIONS, + value_fn=lambda relay: RELAY_ACTION_MAP[relay.grid_action], + update_fn=lambda envoy, relay, value: envoy.update_dry_contact( + { + "id": relay.id, + "grid_action": REVERSE_RELAY_ACTION_MAP[value], + } + ), + ), + EnvoyRelaySelectEntityDescription( + key="microgrid_action", + translation_key="relay_microgrid_action", + options=ACTION_OPTIONS, + value_fn=lambda relay: RELAY_ACTION_MAP[relay.micro_grid_action], + update_fn=lambda envoy, relay, value: envoy.update_dry_contact( + { + "id": relay.id, + "micro_grid_action": REVERSE_RELAY_ACTION_MAP[value], + } + ), + ), + EnvoyRelaySelectEntityDescription( + key="generator_action", + translation_key="relay_generator_action", + options=ACTION_OPTIONS, + value_fn=lambda relay: RELAY_ACTION_MAP[relay.generator_action], + update_fn=lambda envoy, relay, value: envoy.update_dry_contact( + { + "id": relay.id, + "generator_action": REVERSE_RELAY_ACTION_MAP[value], + } + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Enphase Envoy select platform.""" + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + envoy_data = coordinator.envoy.data + assert envoy_data is not None + envoy_serial_num = config_entry.unique_id + assert envoy_serial_num is not None + entities: list[SelectEntity] = [] + if envoy_data.dry_contact_settings: + entities.extend( + EnvoyRelaySelectEntity(coordinator, entity, relay) + for entity in RELAY_ENTITIES + for relay in envoy_data.dry_contact_settings + ) + async_add_entities(entities) + + +class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): + """Representation of an Enphase Enpower select entity.""" + + entity_description: EnvoyRelaySelectEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyRelaySelectEntityDescription, + relay: str, + ) -> None: + """Initialize the Enphase relay select entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + assert self.envoy is not None + assert self.data is not None + self.enpower = self.data.enpower + assert self.enpower is not None + self._serial_number = self.enpower.serial_number + self.relay = self.data.dry_contact_settings[relay] + self.relay_id = relay + self._attr_unique_id = ( + f"{self._serial_number}_relay_{relay}_{self.entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, relay)}, + manufacturer="Enphase", + model="Dry contact relay", + name=self.relay.load_name, + sw_version=str(self.enpower.firmware_version), + via_device=(DOMAIN, self._serial_number), + ) + + @property + def current_option(self) -> str: + """Return the state of the Enpower switch.""" + return self.entity_description.value_fn( + self.data.dry_contact_settings[self.relay_id] + ) + + async def async_select_option(self, option: str) -> None: + """Update the relay.""" + await self.entity_description.update_fn(self.envoy, self.relay, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 2afd19d87d1..bab16bc6c58 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -36,6 +36,42 @@ "name": "Grid status" } }, + "select": { + "relay_mode": { + "name": "Mode", + "state": { + "standard": "Standard", + "battery": "Battery level" + } + }, + "relay_grid_action": { + "name": "Grid action", + "state": { + "powered": "Powered", + "not_powered": "Not powered", + "schedule": "Follow schedule", + "none": "None" + } + }, + "relay_microgrid_action": { + "name": "Microgrid action", + "state": { + "powered": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::powered%]", + "not_powered": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::not_powered%]", + "schedule": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::schedule%]", + "none": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::none%]" + } + }, + "relay_generator_action": { + "name": "Generator action", + "state": { + "powered": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::powered%]", + "not_powered": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::not_powered%]", + "schedule": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::schedule%]", + "none": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::none%]" + } + } + }, "sensor": { "last_reported": { "name": "Last reported" diff --git a/requirements_all.txt b/requirements_all.txt index f4d8381f0c1..6f755d7c860 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1662,7 +1662,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.5.2 +pyenphase==1.6.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05f81b16e78..51c87f8176a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1226,7 +1226,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.5.2 +pyenphase==1.6.0 # homeassistant.components.everlights pyeverlights==0.1.0 From ced4af1e22f7cc5eb7b2a8dc385e97b5294a8079 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Aug 2023 22:39:05 -0400 Subject: [PATCH 056/180] Ignore smartthings storage on fresh install (#98418) * Ignore smartthings storage on fresh install * Also unload existing things when going for clean install * Rename param * Fix tests --- homeassistant/components/smartthings/__init__.py | 2 +- homeassistant/components/smartthings/config_flow.py | 7 ++++++- homeassistant/components/smartthings/smartapp.py | 11 ++++++++--- tests/components/smartthings/test_config_flow.py | 6 ++++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 4e694556598..22856bdb05b 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -58,7 +58,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the SmartThings platform.""" - await setup_smartapp_endpoint(hass) + await setup_smartapp_endpoint(hass, False) return True diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 0328c3a7f8e..5e3451dfbce 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -50,6 +50,7 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.installed_app_id = None self.refresh_token = None self.location_id = None + self.endpoints_initialized = False async def async_step_import(self, user_input=None): """Occurs when a previously entry setup fails and is re-initiated.""" @@ -57,7 +58,11 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Validate and confirm webhook setup.""" - await setup_smartapp_endpoint(self.hass) + if not self.endpoints_initialized: + self.endpoints_initialized = True + await setup_smartapp_endpoint( + self.hass, len(self._async_current_entries()) == 0 + ) webhook_url = get_webhook_url(self.hass) # Abort if the webhook is invalid diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 9b17034ab3b..78c0bfa86b1 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -197,7 +197,7 @@ def setup_smartapp(hass, app): return smartapp -async def setup_smartapp_endpoint(hass: HomeAssistant): +async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool): """Configure the SmartApp webhook in hass. SmartApps are an extension point within the SmartThings ecosystem and @@ -205,11 +205,16 @@ async def setup_smartapp_endpoint(hass: HomeAssistant): """ if hass.data.get(DOMAIN): # already setup - return + if not fresh_install: + return + + # We're doing a fresh install, clean up + await unload_smartapp_endpoint(hass) # Get/create config to store a unique id for this hass instance. store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - if not (config := await store.async_load()): + + if fresh_install or not (config := await store.async_load()): # Create config config = { CONF_INSTANCE_ID: str(uuid4()), diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 5c44a5af2e9..168756b0dfe 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -187,6 +187,7 @@ async def test_entry_created_existing_app_new_oauth_client( smartthings_mock.apps.return_value = [app] smartthings_mock.generate_app_oauth.return_value = app_oauth_client smartthings_mock.locations.return_value = [location] + smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) request = Mock() request.installed_app_id = installed_app_id request.auth_token = token @@ -366,7 +367,7 @@ async def test_entry_created_with_cloudhook( "async_create_cloudhook", AsyncMock(return_value="http://cloud.test"), ) as mock_create_cloudhook: - await smartapp.setup_smartapp_endpoint(hass) + await smartapp.setup_smartapp_endpoint(hass, True) # Webhook confirmation shown result = await hass.config_entries.flow.async_init( @@ -377,7 +378,8 @@ async def test_entry_created_with_cloudhook( assert result["description_placeholders"][ "webhook_url" ] == smartapp.get_webhook_url(hass) - assert mock_create_cloudhook.call_count == 1 + # One is done by app fixture, one done by new config entry + assert mock_create_cloudhook.call_count == 2 # Advance to PAT screen result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) From 6c7f50b5b27b4a43fe93cdcc32b7ee0b1cb89e2a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 08:27:50 +0200 Subject: [PATCH 057/180] Simplify error handling in otbr async_setup_entry (#98395) * Simplify error handling in otbr async_setup_entry * Create issue if the OTBR does not support border agent ID * Update test * Improve test coverage * Catch the right exception --- homeassistant/components/otbr/__init__.py | 24 ++++++++------ homeassistant/components/otbr/strings.json | 4 +++ homeassistant/components/otbr/util.py | 9 +++-- tests/components/otbr/test_init.py | 38 ++++++++++++++++++++-- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index ac59bacbd97..0f4374d95bd 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -import contextlib import aiohttp import python_otbr_api @@ -11,7 +10,7 @@ from homeassistant.components.thread import async_add_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -37,6 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: otbrdata = OTBRData(entry.data["url"], api, entry.entry_id) try: + border_agent_id = await otbrdata.get_border_agent_id() dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( HomeAssistantError, @@ -44,20 +44,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: asyncio.TimeoutError, ) as err: raise ConfigEntryNotReady("Unable to connect") from err + if border_agent_id is None: + ir.async_create_issue( + hass, + DOMAIN, + f"get_get_border_agent_id_unsupported_{otbrdata.entry_id}", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="get_get_border_agent_id_unsupported", + ) + return False if dataset_tlvs: - border_agent_id: str | None = None - with contextlib.suppress( - HomeAssistantError, aiohttp.ClientError, asyncio.TimeoutError - ): - border_agent_bytes = await otbrdata.get_border_agent_id() - if border_agent_bytes: - border_agent_id = border_agent_bytes.hex() await update_issues(hass, otbrdata, dataset_tlvs) await async_add_dataset( hass, DOMAIN, dataset_tlvs.hex(), - preferred_border_agent_id=border_agent_id, + preferred_border_agent_id=border_agent_id.hex(), ) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index 129cbec4468..838ebeb5b8c 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -16,6 +16,10 @@ } }, "issues": { + "get_get_border_agent_id_unsupported": { + "title": "The OTBR does not support border agent ID", + "description": "Your OTBR does not support border agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nTo update the OTBR, update the Open Thread Border Router or Silicon Labs Multiprotocol add-on if you use the OTBR from the add-on, otherwise update your self managed OTBR." + }, "insecure_thread_network": { "title": "Insecure Thread network settings detected", "description": "Your Thread network is using a default network key or pass phrase.\n\nThis is a security risk, please create a new Thread network." diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 67f36c09246..4cbf7ce6a08 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -83,9 +83,12 @@ class OTBRData: await self.delete_active_dataset() @_handle_otbr_error - async def get_border_agent_id(self) -> bytes: - """Get the border agent ID.""" - return await self.api.get_border_agent_id() + async def get_border_agent_id(self) -> bytes | None: + """Get the border agent ID or None if not supported by the router.""" + try: + return await self.api.get_border_agent_id() + except python_otbr_api.GetBorderAgentIdNotSupportedError: + return None @_handle_otbr_error async def set_enabled(self, enabled: bool) -> None: diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 18a60cfa196..1b5c1e8b60a 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -89,12 +89,16 @@ async def test_import_share_radio_channel_collision( config_entry.add_to_hass(hass) with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 + ), patch( + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex(), None) + mock_add.assert_called_once_with( + otbr.DOMAIN, DATASET_CH16.hex(), TEST_BORDER_AGENT_ID.hex() + ) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"otbr_zha_channel_collision_{config_entry.entry_id}", @@ -122,12 +126,16 @@ async def test_import_share_radio_no_channel_collision( config_entry.add_to_hass(hass) with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset + ), patch( + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex(), None) + mock_add.assert_called_once_with( + otbr.DOMAIN, dataset.hex(), TEST_BORDER_AGENT_ID.hex() + ) assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"otbr_zha_channel_collision_{config_entry.entry_id}", @@ -153,12 +161,16 @@ async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> N config_entry.add_to_hass(hass) with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset + ), patch( + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex(), None) + mock_add.assert_called_once_with( + otbr.DOMAIN, dataset.hex(), TEST_BORDER_AGENT_ID.hex() + ) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" ) @@ -186,6 +198,25 @@ async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: assert not await hass.config_entries.async_setup(config_entry.entry_id) +async def test_border_agent_id_not_supported(hass: HomeAssistant) -> None: + """Test border router does not support border agent ID.""" + + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA_MULTIPAN, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + ) + config_entry.add_to_hass(hass) + with patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 + ), patch( + "python_otbr_api.OTBR.get_border_agent_id", + side_effect=python_otbr_api.GetBorderAgentIdNotSupportedError, + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + + async def test_config_entry_update(hass: HomeAssistant) -> None: """Test update config entry settings.""" config_entry = MockConfigEntry( @@ -197,6 +228,7 @@ async def test_config_entry_update(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) mock_api = MagicMock() mock_api.get_active_dataset_tlvs = AsyncMock(return_value=None) + mock_api.get_border_agent_id = AsyncMock(return_value=TEST_BORDER_AGENT_ID) with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: assert await hass.config_entries.async_setup(config_entry.entry_id) From 71b92265af68f74f4128013321bd70c80fa8c754 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 09:17:47 +0200 Subject: [PATCH 058/180] Include border agent id in response to WS otbr/info (#98394) * Include border agent id in response to WS otbr/info * Assert border agent ID is not None --- homeassistant/components/otbr/websocket_api.py | 5 +++++ tests/components/otbr/test_websocket_api.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 9b57cd8ebd1..449f0ccb44d 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -47,17 +47,22 @@ async def websocket_info( data: OTBRData = hass.data[DOMAIN] try: + border_agent_id = await data.get_border_agent_id() dataset = await data.get_active_dataset() dataset_tlvs = await data.get_active_dataset_tlvs() except HomeAssistantError as exc: connection.send_error(msg["id"], "get_dataset_failed", str(exc)) return + # The border agent ID is checked when the OTBR config entry is setup, + # we can assert it's not None + assert border_agent_id is not None connection.send_result( msg["id"], { "url": data.url, "active_dataset_tlvs": dataset_tlvs.hex() if dataset_tlvs else None, + "border_agent_id": border_agent_id.hex(), "channel": dataset.channel if dataset else None, }, ) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index f149e89cc45..7877045c8a4 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -8,7 +8,7 @@ from homeassistant.components import otbr, thread from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import BASE_URL, DATASET_CH15, DATASET_CH16 +from . import BASE_URL, DATASET_CH15, DATASET_CH16, TEST_BORDER_AGENT_ID from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -31,7 +31,11 @@ async def test_get_info( with patch( "python_otbr_api.OTBR.get_active_dataset", return_value=python_otbr_api.ActiveDataSet(channel=16), - ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16): + ), patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 + ), patch( + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ): await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() @@ -40,6 +44,7 @@ async def test_get_info( "url": BASE_URL, "active_dataset_tlvs": DATASET_CH16.hex().lower(), "channel": 16, + "border_agent_id": TEST_BORDER_AGENT_ID.hex(), } @@ -68,6 +73,8 @@ async def test_get_info_fetch_fails( with patch( "python_otbr_api.OTBR.get_active_dataset", side_effect=python_otbr_api.OTBRError, + ), patch( + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID ): await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() From e6ea70fd00d1ebddb26b92a6c2d62f55bc94a4f7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 09:40:05 +0200 Subject: [PATCH 059/180] Adjust thread router discovery typing (#98439) * Adjust thread router discovery typing * Adjust debug logs --- homeassistant/components/thread/discovery.py | 50 ++++++++++------ tests/components/thread/__init__.py | 31 +++++++++- tests/components/thread/test_diagnostics.py | 60 ++++++++++++++++++++ tests/components/thread/test_discovery.py | 24 +++++--- 4 files changed, 139 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index ce721a20e28..3395353b7bf 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -31,11 +31,11 @@ TYPE_PTR = 12 class ThreadRouterDiscoveryData: """Thread router discovery data.""" - addresses: list[str] | None + addresses: list[str] border_agent_id: str | None brand: str | None - extended_address: str | None - extended_pan_id: str | None + extended_address: str + extended_pan_id: str model_name: str | None network_name: str | None server: str | None @@ -46,6 +46,8 @@ class ThreadRouterDiscoveryData: def async_discovery_data_from_service( service: AsyncServiceInfo, + ext_addr: bytes, + ext_pan_id: bytes, ) -> ThreadRouterDiscoveryData: """Get a ThreadRouterDiscoveryData from an AsyncServiceInfo.""" @@ -64,8 +66,6 @@ def async_discovery_data_from_service( service_properties = cast(dict[bytes, bytes | None], service.properties) border_agent_id = service_properties.get(b"id") - ext_addr = service_properties.get(b"xa") - ext_pan_id = service_properties.get(b"xp") model_name = try_decode(service_properties.get(b"mn")) network_name = try_decode(service_properties.get(b"nn")) server = service.server @@ -90,8 +90,8 @@ def async_discovery_data_from_service( addresses=service.parsed_addresses(), border_agent_id=border_agent_id.hex() if border_agent_id is not None else None, brand=brand, - extended_address=ext_addr.hex() if ext_addr is not None else None, - extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, + extended_address=ext_addr.hex(), + extended_pan_id=ext_pan_id.hex(), model_name=model_name, network_name=network_name, server=server, @@ -121,7 +121,19 @@ def async_read_zeroconf_cache(aiozc: AsyncZeroconf) -> list[ThreadRouterDiscover # data is not fully in the cache, so ignore for now continue - results.append(async_discovery_data_from_service(info)) + # Service properties are always bytes if they are set from the network. + # For legacy backwards compatibility zeroconf allows properties to be set + # as strings but we never do that so we can safely cast here. + service_properties = cast(dict[bytes, bytes | None], info.properties) + + if not (xa := service_properties.get(b"xa")): + _LOGGER.debug("Ignoring record without xa %s", info) + continue + if not (xp := service_properties.get(b"xp")): + _LOGGER.debug("Ignoring record without xp %s", info) + continue + + results.append(async_discovery_data_from_service(info, xa, xp)) return results @@ -182,18 +194,22 @@ class ThreadRouterDiscovery: # as strings but we never do that so we can safely cast here. service_properties = cast(dict[bytes, bytes | None], service.properties) + # We need xa and xp, bail out if either is missing if not (xa := service_properties.get(b"xa")): - _LOGGER.debug("_add_update_service failed to find xa in %s", service) + _LOGGER.info( + "Discovered unsupported Thread router without extended address: %s", + service, + ) + return + if not (xp := service_properties.get(b"xp")): + _LOGGER.info( + "Discovered unsupported Thread router without extended pan ID: %s", + service, + ) return - # We use the extended mac address as key, bail out if it's missing - try: - extended_mac_address = xa.hex() - except UnicodeDecodeError as err: - _LOGGER.debug("_add_update_service failed to parse service %s", err) - return - - data = async_discovery_data_from_service(service) + data = async_discovery_data_from_service(service, xa, xp) + extended_mac_address = xa.hex() if name in self._known_routers and self._known_routers[name] == ( extended_mac_address, data, diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index f9d527919b4..7ca6cbaf2ed 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -150,6 +150,7 @@ ROUTER_DISCOVERY_HASS_MISSING_DATA = { "properties": { b"rv": b"1", b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", + # vn is missing b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5", @@ -167,7 +168,7 @@ ROUTER_DISCOVERY_HASS_MISSING_DATA = { } -ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { +ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XA = { "type_": "_meshcop._udp.local.", "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", "addresses": [b"\xc0\xa8\x00s"], @@ -195,6 +196,34 @@ ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { } +ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XP = { + "type_": "_meshcop._udp.local.", + "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", + "addresses": [b"\xc0\xa8\x00s"], + "port": 49153, + "weight": 0, + "priority": 0, + "server": "core-silabs-multiprotocol.local.", + "properties": { + b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", + b"vn": b"HomeAssistant", + b"mn": b"OpenThreadBorderRouter", + b"nn": b"OpenThread HC", + b"tv": b"1.3.0", + b"xa": b"\xae\xeb/YKW\x0b\xbf", + b"sb": b"\x00\x00\x01\xb1", + b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00", + b"pt": b"\x8f\x06Q~", + b"sq": b"3", + b"bb": b"\xf0\xbf", + b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", + }, + "interface_index": None, +} + + ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP = { "type_": "_meshcop._udp.local.", "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", diff --git a/tests/components/thread/test_diagnostics.py b/tests/components/thread/test_diagnostics.py index a551315205b..94ca4373715 100644 --- a/tests/components/thread/test_diagnostics.py +++ b/tests/components/thread/test_diagnostics.py @@ -106,6 +106,48 @@ TEST_ZEROCONF_RECORD_4 = ServiceInfo( # Make sure this generates an invalid DNSPointer TEST_ZEROCONF_RECORD_4.name = "office._meshcop._udp.lo\x00cal." +# This has no XA +TEST_ZEROCONF_RECORD_5 = ServiceInfo( + type_="_meshcop._udp.local.", + name="bad_1._meshcop._udp.local.", + addresses=["127.0.0.1", "fe80::10ed:6406:4ee9:85e0"], + port=8080, + properties={ + "rv": "1", + "vn": "Apple", + "nn": "OpenThread HC", + "xp": "\xe6\x0f\xc7\xc1\x86!,\xe5", + "tv": "1.2.0", + "sb": "\x00\x00\x01\xb1", + "at": "\x00\x00\x00\x00\x00\x01\x00\x00", + "pt": "\x8f\x06Q~", + "sq": "3", + "bb": "\xf0\xbf", + "dn": "DefaultDomain", + }, +) + +# This has no XP +TEST_ZEROCONF_RECORD_6 = ServiceInfo( + type_="_meshcop._udp.local.", + name="bad_2._meshcop._udp.local.", + addresses=["127.0.0.1", "fe80::10ed:6406:4ee9:85e0"], + port=8080, + properties={ + "rv": "1", + "vn": "Apple", + "nn": "OpenThread HC", + "tv": "1.2.0", + "xa": "\xae\xeb/YKW\x0b\xbf", + "sb": "\x00\x00\x01\xb1", + "at": "\x00\x00\x00\x00\x00\x01\x00\x00", + "pt": "\x8f\x06Q~", + "sq": "3", + "bb": "\xf0\xbf", + "dn": "DefaultDomain", + }, +) + @dataclasses.dataclass class MockRoute: @@ -177,6 +219,24 @@ async def test_diagnostics( TEST_ZEROCONF_RECORD_4.dns_pointer(created=now), ] ) + # Test for record without xa + cache.async_add_records( + [ + *TEST_ZEROCONF_RECORD_5.dns_addresses(created=now), + TEST_ZEROCONF_RECORD_5.dns_service(created=now), + TEST_ZEROCONF_RECORD_5.dns_text(created=now), + TEST_ZEROCONF_RECORD_5.dns_pointer(created=now), + ] + ) + # Test for record without xp + cache.async_add_records( + [ + *TEST_ZEROCONF_RECORD_6.dns_addresses(created=now), + TEST_ZEROCONF_RECORD_6.dns_service(created=now), + TEST_ZEROCONF_RECORD_6.dns_text(created=now), + TEST_ZEROCONF_RECORD_6.dns_pointer(created=now), + ] + ) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index 4d43142b7b7..12eddb0b92a 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -16,7 +16,8 @@ from . import ( ROUTER_DISCOVERY_HASS_BAD_DATA, ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP, ROUTER_DISCOVERY_HASS_MISSING_DATA, - ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA, + ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XA, + ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XP, ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP, ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP, ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE, @@ -152,7 +153,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) async def test_discover_routers_unconfigured( hass: HomeAssistant, mock_async_zeroconf: None, data, unconfigured ) -> None: - """Test discovering thread routers with bad or missing vendor mDNS data.""" + """Test discovering thread routers and setting the unconfigured flag.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() mock_async_zeroconf.async_remove_service_listener = AsyncMock() mock_async_zeroconf.async_get_service_info = AsyncMock() @@ -195,7 +196,7 @@ async def test_discover_routers_unconfigured( @pytest.mark.parametrize( "data", (ROUTER_DISCOVERY_HASS_BAD_DATA, ROUTER_DISCOVERY_HASS_MISSING_DATA) ) -async def test_discover_routers_bad_data( +async def test_discover_routers_bad_or_missing_optional_data( hass: HomeAssistant, mock_async_zeroconf: None, data ) -> None: """Test discovering thread routers with bad or missing vendor mDNS data.""" @@ -238,8 +239,15 @@ async def test_discover_routers_bad_data( ) -async def test_discover_routers_missing_mandatory_data( - hass: HomeAssistant, mock_async_zeroconf: None +@pytest.mark.parametrize( + "service", + [ + ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XA, + ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XP, + ], +) +async def test_discover_routers_bad_or_missing_mandatory_data( + hass: HomeAssistant, mock_async_zeroconf: None, service ) -> None: """Test discovering thread routers with missing mandatory mDNS data.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() @@ -261,12 +269,12 @@ async def test_discover_routers_missing_mandatory_data( # Discover a service with missing mandatory data mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( - **ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA + **service ) listener.add_service( None, - ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA["type_"], - ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA["name"], + service["type_"], + service["name"], ) await hass.async_block_till_done() router_discovered_removed.assert_not_called() From 94ad4786c3bf11ec194c6c43cfd634577bd44d4a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 09:48:29 +0200 Subject: [PATCH 060/180] Include extended address in response to WS otbr/info (#98440) --- .../components/otbr/websocket_api.py | 33 ++--------- tests/components/otbr/test_websocket_api.py | 58 ++----------------- 2 files changed, 9 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 449f0ccb44d..0693bc3a325 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -24,7 +24,6 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" websocket_api.async_register_command(hass, websocket_info) websocket_api.async_register_command(hass, websocket_create_network) - websocket_api.async_register_command(hass, websocket_get_extended_address) websocket_api.async_register_command(hass, websocket_set_channel) websocket_api.async_register_command(hass, websocket_set_network) @@ -50,8 +49,9 @@ async def websocket_info( border_agent_id = await data.get_border_agent_id() dataset = await data.get_active_dataset() dataset_tlvs = await data.get_active_dataset_tlvs() + extended_address = await data.get_extended_address() except HomeAssistantError as exc: - connection.send_error(msg["id"], "get_dataset_failed", str(exc)) + connection.send_error(msg["id"], "otbr_info_failed", str(exc)) return # The border agent ID is checked when the OTBR config entry is setup, @@ -60,10 +60,11 @@ async def websocket_info( connection.send_result( msg["id"], { - "url": data.url, "active_dataset_tlvs": dataset_tlvs.hex() if dataset_tlvs else None, "border_agent_id": border_agent_id.hex(), "channel": dataset.channel if dataset else None, + "extended_address": extended_address.hex(), + "url": data.url, }, ) @@ -192,32 +193,6 @@ async def websocket_set_network( connection.send_result(msg["id"]) -@websocket_api.websocket_command( - { - "type": "otbr/get_extended_address", - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def websocket_get_extended_address( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict -) -> None: - """Get extended address (EUI-64).""" - if DOMAIN not in hass.data: - connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") - return - - data: OTBRData = hass.data[DOMAIN] - - try: - extended_address = await data.get_extended_address() - except HomeAssistantError as exc: - connection.send_error(msg["id"], "get_extended_address_failed", str(exc)) - return - - connection.send_result(msg["id"], {"extended_address": extended_address.hex()}) - - @websocket_api.websocket_command( { "type": "otbr/set_channel", diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 7877045c8a4..cba046a2a9d 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -35,6 +35,9 @@ async def test_get_info( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=bytes.fromhex("4EF6C4F3FF750626"), ): await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() @@ -45,6 +48,7 @@ async def test_get_info( "active_dataset_tlvs": DATASET_CH16.hex().lower(), "channel": 16, "border_agent_id": TEST_BORDER_AGENT_ID.hex(), + "extended_address": "4EF6C4F3FF750626".lower(), } @@ -80,7 +84,7 @@ async def test_get_info_fetch_fails( msg = await websocket_client.receive_json() assert not msg["success"] - assert msg["error"]["code"] == "get_dataset_failed" + assert msg["error"]["code"] == "otbr_info_failed" async def test_create_network( @@ -442,58 +446,6 @@ async def test_set_network_fails_3( assert msg["error"]["code"] == "set_enabled_failed" -async def test_get_extended_address( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan, - websocket_client, -) -> None: - """Test get extended address.""" - - with patch( - "python_otbr_api.OTBR.get_extended_address", - return_value=bytes.fromhex("4EF6C4F3FF750626"), - ): - await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) - msg = await websocket_client.receive_json() - - assert msg["success"] - assert msg["result"] == {"extended_address": "4EF6C4F3FF750626".lower()} - - -async def test_get_extended_address_no_entry( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test get extended address.""" - await async_setup_component(hass, "otbr", {}) - websocket_client = await hass_ws_client(hass) - await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) - - msg = await websocket_client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "not_loaded" - - -async def test_get_extended_address_fetch_fails( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan, - websocket_client, -) -> None: - """Test get extended address.""" - with patch( - "python_otbr_api.OTBR.get_extended_address", - side_effect=python_otbr_api.OTBRError, - ): - await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) - msg = await websocket_client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == "get_extended_address_failed" - - async def test_set_channel( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From 2da3b7177d57fa5d1c46c3bc2e9edfe14d95ea23 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Tue, 15 Aug 2023 02:57:10 -0500 Subject: [PATCH 061/180] Update pyipp to 0.14.3 (#98434) --- homeassistant/components/ipp/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 7cdf6767362..e8bd4425ef3 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.14.2"], + "requirements": ["pyipp==0.14.3"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6f755d7c860..4fd926a4855 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1746,7 +1746,7 @@ pyintesishome==1.8.0 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.14.2 +pyipp==0.14.3 # homeassistant.components.iqvia pyiqvia==2022.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51c87f8176a..bc2d9ef5e63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1289,7 +1289,7 @@ pyinsteon==1.4.3 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.14.2 +pyipp==0.14.3 # homeassistant.components.iqvia pyiqvia==2022.04.0 From eb4745012a30240e868e35dbff2747bc248953bd Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Tue, 15 Aug 2023 03:02:38 -0500 Subject: [PATCH 062/180] Update rokuecp to 0.18.1 (#98432) --- homeassistant/components/roku/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index f9b81dc8ddd..6fe70a3ab65 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.18.0"], + "requirements": ["rokuecp==0.18.1"], "ssdp": [ { "st": "roku:ecp", diff --git a/requirements_all.txt b/requirements_all.txt index 4fd926a4855..da09cbde9be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2305,7 +2305,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.18.0 +rokuecp==0.18.1 # homeassistant.components.roomba roombapy==1.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc2d9ef5e63..10fd8af6ce6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1686,7 +1686,7 @@ rflink==0.0.65 ring-doorbell==0.7.2 # homeassistant.components.roku -rokuecp==0.18.0 +rokuecp==0.18.1 # homeassistant.components.roomba roombapy==1.6.8 From 262483f3f6d5fc843af42905ee8176876f658e27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Aug 2023 03:29:28 -0500 Subject: [PATCH 063/180] Replace async_timeout with asyncio.timeout A-B (#98415) --- homeassistant/components/accuweather/__init__.py | 2 +- homeassistant/components/accuweather/config_flow.py | 2 +- homeassistant/components/acmeda/config_flow.py | 4 ++-- homeassistant/components/ads/__init__.py | 4 ++-- .../components/aemet/weather_update_coordinator.py | 4 ++-- homeassistant/components/airly/__init__.py | 4 ++-- homeassistant/components/airly/config_flow.py | 4 ++-- homeassistant/components/airzone/coordinator.py | 4 ++-- homeassistant/components/airzone_cloud/coordinator.py | 4 ++-- homeassistant/components/alexa/auth.py | 4 ++-- homeassistant/components/alexa/state_report.py | 6 +++--- homeassistant/components/analytics/analytics.py | 4 ++-- homeassistant/components/androidtv_remote/__init__.py | 4 ++-- homeassistant/components/anova/coordinator.py | 4 ++-- homeassistant/components/api/__init__.py | 4 ++-- homeassistant/components/arcam_fmj/__init__.py | 4 ++-- homeassistant/components/assist_pipeline/websocket_api.py | 3 +-- homeassistant/components/atag/__init__.py | 4 ++-- homeassistant/components/awair/__init__.py | 3 +-- homeassistant/components/axis/device.py | 4 ++-- homeassistant/components/baf/__init__.py | 4 ++-- homeassistant/components/baf/config_flow.py | 4 ++-- homeassistant/components/bluesound/media_player.py | 7 +++---- homeassistant/components/bluetooth/api.py | 4 ++-- homeassistant/components/brother/__init__.py | 4 ++-- homeassistant/components/brunt/__init__.py | 4 ++-- homeassistant/components/buienradar/util.py | 4 ++-- 27 files changed, 52 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index cdc23fe7e47..e98b19e8e82 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -1,6 +1,7 @@ """The AccuWeather component.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging from typing import Any @@ -8,7 +9,6 @@ from typing import Any from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index 1480f6c1352..b1d113dad73 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio +from asyncio import timeout from typing import Any from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout import voluptuous as vol from homeassistant import config_entries diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py index f1bd0613f1e..b0dd287f428 100644 --- a/homeassistant/components/acmeda/config_flow.py +++ b/homeassistant/components/acmeda/config_flow.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio +from asyncio import timeout from contextlib import suppress from typing import Any import aiopulse -import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -43,7 +43,7 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): hubs: list[aiopulse.Hub] = [] with suppress(asyncio.TimeoutError): - async with async_timeout.timeout(5): + async with timeout(5): async for hub in aiopulse.Hub.discover(): if hub.id not in already_configured: hubs.append(hub) diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 5d1e9f2b656..1f80553031b 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -1,12 +1,12 @@ """Support for Automation Device Specification (ADS).""" import asyncio +from asyncio import timeout from collections import namedtuple import ctypes import logging import struct import threading -import async_timeout import pyads import voluptuous as vol @@ -301,7 +301,7 @@ class AdsEntity(Entity): self._ads_hub.add_device_notification, ads_var, plctype, update ) try: - async with async_timeout.timeout(10): + async with timeout(10): await self._event.wait() except asyncio.TimeoutError: _LOGGER.debug("Variable %s: Timeout during first update", ads_var) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 5242540748f..5e9ce6af677 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -1,6 +1,7 @@ """Weather data coordinator for the AEMET OpenData service.""" from __future__ import annotations +from asyncio import timeout from dataclasses import dataclass, field from datetime import timedelta import logging @@ -41,7 +42,6 @@ from aemet_opendata.helpers import ( get_forecast_hour_value, get_forecast_interval_value, ) -import async_timeout from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -139,7 +139,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): data = {} - async with async_timeout.timeout(120): + async with timeout(120): weather_response = await self._get_aemet_weather() data = self._convert_weather_response(weather_response) return data diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index f52bdca4b86..982687c7723 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -1,6 +1,7 @@ """The Airly integration.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging from math import ceil @@ -9,7 +10,6 @@ from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from airly import Airly from airly.exceptions import AirlyError -import async_timeout from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -167,7 +167,7 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator): measurements = self.airly.create_measurements_session_point( self.latitude, self.longitude ) - async with async_timeout.timeout(20): + async with timeout(20): try: await measurements.update() except (AirlyError, ClientConnectorError) as error: diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index 5d41116eaa1..27c7b0f91e3 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -1,13 +1,13 @@ """Adds config flow for Airly.""" from __future__ import annotations +from asyncio import timeout from http import HTTPStatus from typing import Any from aiohttp import ClientSession from airly import Airly from airly.exceptions import AirlyError -import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -105,7 +105,7 @@ async def test_location( measurements = airly.create_measurements_session_point( latitude=latitude, longitude=longitude ) - async with async_timeout.timeout(10): + async with timeout(10): await measurements.update() current = measurements.current diff --git a/homeassistant/components/airzone/coordinator.py b/homeassistant/components/airzone/coordinator.py index ba0296557a1..6053c587550 100644 --- a/homeassistant/components/airzone/coordinator.py +++ b/homeassistant/components/airzone/coordinator.py @@ -1,13 +1,13 @@ """The Airzone integration.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging from typing import Any from aioairzone.exceptions import AirzoneError from aioairzone.localapi import AirzoneLocalApi -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -35,7 +35,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - async with async_timeout.timeout(AIOAIRZONE_DEVICE_TIMEOUT_SEC): + async with timeout(AIOAIRZONE_DEVICE_TIMEOUT_SEC): try: await self.airzone.update() except AirzoneError as error: diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py index edd99355092..37b31c68ee7 100644 --- a/homeassistant/components/airzone_cloud/coordinator.py +++ b/homeassistant/components/airzone_cloud/coordinator.py @@ -1,13 +1,13 @@ """The Airzone Cloud integration coordinator.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging from typing import Any from aioairzone_cloud.cloudapi import AirzoneCloudApi from aioairzone_cloud.exceptions import AirzoneCloudError -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -35,7 +35,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC): + async with timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC): try: await self.airzone.update() except AirzoneCloudError as error: diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 61a87d9ebab..58095340146 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -1,5 +1,6 @@ """Support for Alexa skill auth.""" import asyncio +from asyncio import timeout from datetime import datetime, timedelta from http import HTTPStatus import json @@ -7,7 +8,6 @@ import logging from typing import Any import aiohttp -import async_timeout from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant, callback @@ -113,7 +113,7 @@ class Auth: async def _async_request_new_token(self, lwa_params: dict[str, str]) -> str | None: try: session = aiohttp_client.async_get_clientsession(self.hass) - async with async_timeout.timeout(10): + async with timeout(10): response = await session.post( LWA_TOKEN_URI, headers=LWA_HEADERS, diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index bbaa8a240f7..786b2ee5227 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from asyncio import timeout from http import HTTPStatus import json import logging @@ -10,7 +11,6 @@ from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 import aiohttp -import async_timeout from homeassistant.components import event from homeassistant.const import MATCH_ALL, STATE_ON @@ -364,7 +364,7 @@ async def async_send_changereport_message( assert config.endpoint is not None try: - async with async_timeout.timeout(DEFAULT_TIMEOUT): + async with timeout(DEFAULT_TIMEOUT): response = await session.post( config.endpoint, headers=headers, @@ -517,7 +517,7 @@ async def async_send_doorbell_event_message( assert config.endpoint is not None try: - async with async_timeout.timeout(DEFAULT_TIMEOUT): + async with timeout(DEFAULT_TIMEOUT): response = await session.post( config.endpoint, headers=headers, diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index a106e3f0068..1c81eacd14a 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -2,13 +2,13 @@ from __future__ import annotations import asyncio +from asyncio import timeout from dataclasses import asdict as dataclass_asdict, dataclass from datetime import datetime from typing import Any import uuid import aiohttp -import async_timeout from homeassistant.components import hassio from homeassistant.components.api import ATTR_INSTALLATION_TYPE @@ -313,7 +313,7 @@ class Analytics: ) try: - async with async_timeout.timeout(30): + async with timeout(30): response = await self.session.post(self.endpoint, json=payload) if response.status == 200: LOGGER.info( diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 4c58f82b8e7..9471504808c 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from asyncio import timeout import logging from androidtvremote2 import ( @@ -10,7 +11,6 @@ from androidtvremote2 import ( ConnectionClosed, InvalidAuth, ) -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api.add_is_available_updated_callback(is_available_updated) try: - async with async_timeout.timeout(5.0): + async with timeout(5.0): await api.async_connect() except InvalidAuth as exc: # The Android TV is hard reset or the certificate and key files were deleted. diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py index 436a1e469ba..94bd9bec9aa 100644 --- a/homeassistant/components/anova/coordinator.py +++ b/homeassistant/components/anova/coordinator.py @@ -1,9 +1,9 @@ """Support for Anova Coordinators.""" +from asyncio import timeout from datetime import timedelta import logging from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate -import async_timeout from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -47,7 +47,7 @@ class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): async def _async_update_data(self) -> APCUpdate: try: - async with async_timeout.timeout(5): + async with timeout(5): return await self.anova_device.update() except AnovaOffline as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index f264806ad47..7b13833ccab 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,12 +1,12 @@ """Rest API for Home Assistant.""" import asyncio +from asyncio import timeout from functools import lru_cache from http import HTTPStatus import logging from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest -import async_timeout import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_READ @@ -148,7 +148,7 @@ class APIEventStream(HomeAssistantView): while True: try: - async with async_timeout.timeout(STREAM_PING_INTERVAL): + async with timeout(STREAM_PING_INTERVAL): payload = await to_write.get() if payload is stop_obj: diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index 9c77690ac22..d9ab17dba86 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -1,11 +1,11 @@ """Arcam component.""" import asyncio +from asyncio import timeout import logging from typing import Any from arcam.fmj import ConnectionFailed from arcam.fmj.client import Client -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform @@ -66,7 +66,7 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N while True: try: - async with async_timeout.timeout(interval): + async with timeout(interval): await client.start() _LOGGER.debug("Client connected %s", client.host) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index bf61b9776e9..57e2cc8b398 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -7,7 +7,6 @@ from collections.abc import AsyncGenerator, Callable import logging from typing import Any -import async_timeout import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api @@ -207,7 +206,7 @@ async def websocket_run( try: # Task contains a timeout - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await run_task except asyncio.TimeoutError: pipeline_input.run.process_event( diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py index 5f0552e9d77..2d04ca798e0 100644 --- a/homeassistant/components/atag/__init__.py +++ b/homeassistant/components/atag/__init__.py @@ -1,8 +1,8 @@ """The ATAG Integration.""" +from asyncio import timeout from datetime import timedelta import logging -import async_timeout from pyatag import AtagException, AtagOne from homeassistant.config_entries import ConfigEntry @@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_data(): """Update data via library.""" - async with async_timeout.timeout(20): + async with timeout(20): try: await atag.update() except AtagException as err: diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index bfd95fece2a..083c7d48b03 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -1,12 +1,11 @@ """The awair component.""" from __future__ import annotations -from asyncio import gather +from asyncio import gather, timeout from dataclasses import dataclass from datetime import timedelta from aiohttp import ClientSession -from async_timeout import timeout from python_awair import Awair, AwairLocal from python_awair.air_data import AirData from python_awair.devices import AwairBaseDevice, AwairLocalDevice diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 8f3c8b9a8b6..0c132814e39 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -1,10 +1,10 @@ """Axis network device abstraction.""" import asyncio +from asyncio import timeout from types import MappingProxyType from typing import Any -import async_timeout import axis from axis.configuration import Configuration from axis.errors import Unauthorized @@ -253,7 +253,7 @@ async def get_axis_device( ) try: - async with async_timeout.timeout(30): + async with timeout(30): await device.vapix.initialize() return device diff --git a/homeassistant/components/baf/__init__.py b/homeassistant/components/baf/__init__.py index c9e51c79b82..dd784b214f7 100644 --- a/homeassistant/components/baf/__init__.py +++ b/homeassistant/components/baf/__init__.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio +from asyncio import timeout from aiobafi6 import Device, Service from aiobafi6.discovery import PORT -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, Platform @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: run_future = device.async_run() try: - async with async_timeout.timeout(RUN_TIMEOUT): + async with timeout(RUN_TIMEOUT): await device.async_wait_available() except asyncio.TimeoutError as ex: run_future.cancel() diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index 3f37df1b70a..bbae3914533 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio +from asyncio import timeout import logging from typing import Any from aiobafi6 import Device, Service from aiobafi6.discovery import PORT -import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -27,7 +27,7 @@ async def async_try_connect(ip_address: str) -> Device: device = Device(Service(ip_addresses=[ip_address], port=PORT)) run_future = device.async_run() try: - async with async_timeout.timeout(RUN_TIMEOUT): + async with timeout(RUN_TIMEOUT): await device.async_wait_available() except asyncio.TimeoutError as ex: raise CannotConnect from ex diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 91984cf6247..eba03963ebc 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from asyncio import CancelledError +from asyncio import CancelledError, timeout from datetime import timedelta from http import HTTPStatus import logging @@ -12,7 +12,6 @@ from urllib import parse import aiohttp from aiohttp.client_exceptions import ClientError from aiohttp.hdrs import CONNECTION, KEEP_ALIVE -import async_timeout import voluptuous as vol import xmltodict @@ -355,7 +354,7 @@ class BluesoundPlayer(MediaPlayerEntity): try: websession = async_get_clientsession(self._hass) - async with async_timeout.timeout(10): + async with timeout(10): response = await websession.get(url) if response.status == HTTPStatus.OK: @@ -396,7 +395,7 @@ class BluesoundPlayer(MediaPlayerEntity): _LOGGER.debug("Calling URL: %s", url) try: - async with async_timeout.timeout(125): + async with timeout(125): response = await self._polling_session.get( url, headers={CONNECTION: KEEP_ALIVE} ) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 6c232e2a42c..be35a9d255d 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -4,11 +4,11 @@ These APIs are the only documented way to interact with the bluetooth integratio """ from __future__ import annotations +import asyncio from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast -import async_timeout from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback @@ -152,7 +152,7 @@ async def async_process_advertisements( ) try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): return await done finally: unload() diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 5f05caf0fc1..27ac97a27dc 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -1,10 +1,10 @@ """The Brother component.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging -import async_timeout from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError from homeassistant.config_entries import ConfigEntry @@ -79,7 +79,7 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): async def _async_update_data(self) -> BrotherSensors: """Update data via library.""" try: - async with async_timeout.timeout(20): + async with timeout(20): data = await self.brother.async_update() except (ConnectionError, SnmpError, UnsupportedModelError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/brunt/__init__.py b/homeassistant/components/brunt/__init__.py index 979b3f5b005..660c43f1004 100644 --- a/homeassistant/components/brunt/__init__.py +++ b/homeassistant/components/brunt/__init__.py @@ -1,10 +1,10 @@ """The brunt component.""" from __future__ import annotations +from asyncio import timeout import logging from aiohttp.client_exceptions import ClientResponseError, ServerDisconnectedError -import async_timeout from brunt import BruntClientAsync, Thing from homeassistant.config_entries import ConfigEntry @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: Error 401 is the API response for things that are not part of the account, could happen when a device is deleted from the account. """ try: - async with async_timeout.timeout(10): + async with timeout(10): things = await bapi.async_get_things(force=True) return {thing.serial: thing for thing in things} except ServerDisconnectedError as err: diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 54f3732afe4..8fce65c0600 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -1,11 +1,11 @@ """Shared utilities for different supported platforms.""" import asyncio +from asyncio import timeout from datetime import datetime, timedelta from http import HTTPStatus import logging import aiohttp -import async_timeout from buienradar.buienradar import parse_data from buienradar.constants import ( ATTRIBUTION, @@ -92,7 +92,7 @@ class BrData: resp = None try: websession = async_get_clientsession(self.hass) - async with async_timeout.timeout(10): + async with timeout(10): resp = await websession.get(url) result[STATUS_CODE] = resp.status From b1e5b3be34e10de3561ddfa733c95f660bbd96f4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 15 Aug 2023 10:43:19 +0200 Subject: [PATCH 064/180] Bump Reolink_aio to 0.7.7 (#98425) --- homeassistant/components/reolink/binary_sensor.py | 4 ++++ homeassistant/components/reolink/host.py | 2 +- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 850aa110171..996f2c6b3ab 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -5,6 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from reolink_aio.api import ( + DUAL_LENS_DUAL_MOTION_MODELS, FACE_DETECTION_TYPE, PERSON_DETECTION_TYPE, PET_DETECTION_TYPE, @@ -128,6 +129,9 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt super().__init__(reolink_data, channel) self.entity_description = entity_description + if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: + self._attr_name = f"{entity_description.name} lens {self._channel}" + self._attr_unique_id = ( f"{self._host.unique_id}_{self._channel}_{entity_description.key}" ) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index feeff9312c7..a679cb34f4b 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -163,7 +163,7 @@ class ReolinkHost: else: _LOGGER.debug( "Camera model %s most likely does not push its initial state" - "upon ONVIF subscription, do not check", + " upon ONVIF subscription, do not check", self._api.model, ) self._cancel_onvif_check = async_call_later( diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index fa61f873cca..f350bb4f948 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.7.6"] + "requirements": ["reolink-aio==0.7.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index da09cbde9be..ef6f4af565c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2284,7 +2284,7 @@ renault-api==0.1.13 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.6 +reolink-aio==0.7.7 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10fd8af6ce6..ca1951057fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1677,7 +1677,7 @@ renault-api==0.1.13 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.6 +reolink-aio==0.7.7 # homeassistant.components.rflink rflink==0.0.65 From 3b9d6f2ddebbffb02563a7fbbba67c6e8a126168 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 10:59:42 +0200 Subject: [PATCH 065/180] Add setup function to the component loader (#98148) * Add setup function to the component loader * Update test * Setup the loader in safe mode and in check_config script --- homeassistant/bootstrap.py | 3 ++ homeassistant/loader.py | 26 ++++++++-------- homeassistant/scripts/check_config.py | 3 +- tests/common.py | 22 ++++---------- .../components/device_automation/test_init.py | 30 +++++++++---------- .../components/websocket_api/test_commands.py | 2 +- 6 files changed, 38 insertions(+), 48 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 6a667884962..196a00dda7c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -134,6 +134,7 @@ async def async_setup_hass( _LOGGER.info("Config directory: %s", runtime_config.config_dir) + loader.async_setup(hass) config_dict = None basic_setup_success = False @@ -185,6 +186,8 @@ async def async_setup_hass( hass.config.internal_url = old_config.internal_url hass.config.external_url = old_config.external_url hass.config.config_dir = old_config.config_dir + # Setup loader cache after the config dir has been set + loader.async_setup(hass) if safe_mode: _LOGGER.info("Starting in safe mode") diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6c083b6a024..340888a2f7a 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -166,6 +166,13 @@ class Manifest(TypedDict, total=False): loggers: list[str] +def async_setup(hass: HomeAssistant) -> None: + """Set up the necessary data structures.""" + _async_mount_config_dir(hass) + hass.data[DATA_COMPONENTS] = {} + hass.data[DATA_INTEGRATIONS] = {} + + def manifest_from_legacy_module(domain: str, module: ModuleType) -> Manifest: """Generate a manifest from a legacy module.""" return { @@ -802,9 +809,7 @@ class Integration: def get_component(self) -> ComponentProtocol: """Return the component.""" - cache: dict[str, ComponentProtocol] = self.hass.data.setdefault( - DATA_COMPONENTS, {} - ) + cache: dict[str, ComponentProtocol] = self.hass.data[DATA_COMPONENTS] if self.domain in cache: return cache[self.domain] @@ -824,7 +829,7 @@ class Integration: def get_platform(self, platform_name: str) -> ModuleType: """Return a platform for an integration.""" - cache: dict[str, ModuleType] = self.hass.data.setdefault(DATA_COMPONENTS, {}) + cache: dict[str, ModuleType] = self.hass.data[DATA_COMPONENTS] full_name = f"{self.domain}.{platform_name}" if full_name in cache: return cache[full_name] @@ -883,11 +888,7 @@ async def async_get_integrations( hass: HomeAssistant, domains: Iterable[str] ) -> dict[str, Integration | Exception]: """Get integrations.""" - if (cache := hass.data.get(DATA_INTEGRATIONS)) is None: - if not _async_mount_config_dir(hass): - return {domain: IntegrationNotFound(domain) for domain in domains} - cache = hass.data[DATA_INTEGRATIONS] = {} - + cache = hass.data[DATA_INTEGRATIONS] results: dict[str, Integration | Exception] = {} needed: dict[str, asyncio.Future[None]] = {} in_progress: dict[str, asyncio.Future[None]] = {} @@ -993,10 +994,7 @@ def _load_file( comp_or_platform ] - if (cache := hass.data.get(DATA_COMPONENTS)) is None: - if not _async_mount_config_dir(hass): - return None - cache = hass.data[DATA_COMPONENTS] = {} + cache = hass.data[DATA_COMPONENTS] for path in (f"{base}.{comp_or_platform}" for base in base_paths): try: @@ -1066,7 +1064,7 @@ class Components: def __getattr__(self, comp_name: str) -> ModuleWrapper: """Fetch a component.""" # Test integration cache - integration = self._hass.data.get(DATA_INTEGRATIONS, {}).get(comp_name) + integration = self._hass.data[DATA_INTEGRATIONS].get(comp_name) if isinstance(integration, Integration): component: ComponentProtocol | None = integration.get_component() diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 5384b86cb98..7c4a200bbc5 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -11,7 +11,7 @@ import os from typing import Any from unittest.mock import patch -from homeassistant import core +from homeassistant import core, loader from homeassistant.config import get_default_config_dir from homeassistant.config_entries import ConfigEntries from homeassistant.exceptions import HomeAssistantError @@ -232,6 +232,7 @@ def check(config_dir, secrets=False): async def async_check_config(config_dir): """Check the HA config.""" hass = core.HomeAssistant() + loader.async_setup(hass) hass.config.config_dir = config_dir hass.config_entries = ConfigEntries(hass, {}) await ar.async_load(hass) diff --git a/tests/common.py b/tests/common.py index 0431743cccf..95947719ef4 100644 --- a/tests/common.py +++ b/tests/common.py @@ -256,6 +256,7 @@ async def async_test_home_assistant(event_loop, load_registries=True): # Load the registries entity.async_setup(hass) + loader.async_setup(hass) if load_registries: with patch( "homeassistant.helpers.storage.Store.async_load", return_value=None @@ -1339,16 +1340,10 @@ def mock_integration( integration._import_platform = mock_import_platform _LOGGER.info("Adding mock integration: %s", module.DOMAIN) - integration_cache = hass.data.get(loader.DATA_INTEGRATIONS) - if integration_cache is None: - integration_cache = hass.data[loader.DATA_INTEGRATIONS] = {} - loader._async_mount_config_dir(hass) + integration_cache = hass.data[loader.DATA_INTEGRATIONS] integration_cache[module.DOMAIN] = integration - module_cache = hass.data.get(loader.DATA_COMPONENTS) - if module_cache is None: - module_cache = hass.data[loader.DATA_COMPONENTS] = {} - loader._async_mount_config_dir(hass) + module_cache = hass.data[loader.DATA_COMPONENTS] module_cache[module.DOMAIN] = module return integration @@ -1374,15 +1369,8 @@ def mock_platform( platform_path is in form hue.config_flow. """ domain = platform_path.split(".")[0] - integration_cache = hass.data.get(loader.DATA_INTEGRATIONS) - if integration_cache is None: - integration_cache = hass.data[loader.DATA_INTEGRATIONS] = {} - loader._async_mount_config_dir(hass) - - module_cache = hass.data.get(loader.DATA_COMPONENTS) - if module_cache is None: - module_cache = hass.data[loader.DATA_COMPONENTS] = {} - loader._async_mount_config_dir(hass) + integration_cache = hass.data[loader.DATA_INTEGRATIONS] + module_cache = hass.data[loader.DATA_COMPONENTS] if domain not in integration_cache: mock_integration(hass, MockModule(domain)) diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 65fee1053ae..74150af67ae 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -304,7 +304,7 @@ async def test_websocket_get_action_capabilities( return {"extra_fields": vol.Schema({vol.Optional("code"): str})} return {} - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_get_action_capabilities = _async_get_action_capabilities @@ -406,7 +406,7 @@ async def test_websocket_get_action_capabilities_bad_action( await async_setup_component(hass, "device_automation", {}) expected_capabilities = {} - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_get_action_capabilities = Mock( side_effect=InvalidDeviceAutomationConfig @@ -459,7 +459,7 @@ async def test_websocket_get_condition_capabilities( """List condition capabilities.""" return await toggle_entity.async_get_condition_capabilities(hass, config) - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_condition"] module.async_get_condition_capabilities = _async_get_condition_capabilities @@ -569,7 +569,7 @@ async def test_websocket_get_condition_capabilities_bad_condition( await async_setup_component(hass, "device_automation", {}) expected_capabilities = {} - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_condition"] module.async_get_condition_capabilities = Mock( side_effect=InvalidDeviceAutomationConfig @@ -747,7 +747,7 @@ async def test_websocket_get_trigger_capabilities( """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_get_trigger_capabilities = _async_get_trigger_capabilities @@ -857,7 +857,7 @@ async def test_websocket_get_trigger_capabilities_bad_trigger( await async_setup_component(hass, "device_automation", {}) expected_capabilities = {} - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_get_trigger_capabilities = Mock( side_effect=InvalidDeviceAutomationConfig @@ -912,7 +912,7 @@ async def test_automation_with_device_action( ) -> None: """Test automation with a device action.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_call_action_from_config = AsyncMock() @@ -949,7 +949,7 @@ async def test_automation_with_dynamically_validated_action( ) -> None: """Test device automation with an action which is dynamically validated.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_validate_action_config = AsyncMock() @@ -1003,7 +1003,7 @@ async def test_automation_with_device_condition( ) -> None: """Test automation with a device condition.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_condition"] module.async_condition_from_config = Mock() @@ -1037,7 +1037,7 @@ async def test_automation_with_dynamically_validated_condition( ) -> None: """Test device automation with a condition which is dynamically validated.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_condition"] module.async_validate_condition_config = AsyncMock() @@ -1102,7 +1102,7 @@ async def test_automation_with_device_trigger( ) -> None: """Test automation with a device trigger.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_attach_trigger = AsyncMock() @@ -1136,7 +1136,7 @@ async def test_automation_with_dynamically_validated_trigger( ) -> None: """Test device automation with a trigger which is dynamically validated.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_attach_trigger = AsyncMock() module.async_validate_trigger_config = AsyncMock(wraps=lambda hass, config: config) @@ -1457,7 +1457,7 @@ async def test_automation_with_unknown_device( ) -> None: """Test device automation with a trigger with an unknown device.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_validate_trigger_config = AsyncMock() @@ -1492,7 +1492,7 @@ async def test_automation_with_device_wrong_domain( ) -> None: """Test device automation where the device doesn't have the right config entry.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_validate_trigger_config = AsyncMock() @@ -1534,7 +1534,7 @@ async def test_automation_with_device_component_not_loaded( ) -> None: """Test device automation where the device's config entry is not loaded.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_validate_trigger_config = AsyncMock() module.async_attach_trigger = AsyncMock() diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 3a68bbd88d3..85c0ac62b25 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1810,7 +1810,7 @@ async def test_execute_script_with_dynamically_validated_action( ws_client = await hass_ws_client(hass) - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_call_action_from_config = AsyncMock() module.async_validate_action_config = AsyncMock( From 87b7fc6c61329d33bdcbcfc9baf590387666c15b Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 15 Aug 2023 03:04:45 -0600 Subject: [PATCH 066/180] Bump pylitterbot to 2023.4.4 (#98414) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 2a4a3447eb6..81375dd3a6c 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.2"] + "requirements": ["pylitterbot==2023.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef6f4af565c..f86b6afc2e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1809,7 +1809,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.2 +pylitterbot==2023.4.4 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca1951057fb..9bc5a8b53ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1337,7 +1337,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.2 +pylitterbot==2023.4.4 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 From ed18c6a0137ea8ef9315a6fcf8968edbae7590af Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 15 Aug 2023 11:43:47 +0200 Subject: [PATCH 067/180] Refactor Rest Switch with ManualTriggerEntity (#97403) * Refactor Rest Switch with ManualTriggerEntity * Fix test * Fix 2 * review comments * remove async_added_to_hass * update on startup --- homeassistant/components/rest/switch.py | 52 ++++++++++++++++++------- tests/components/rest/test_switch.py | 10 ++--- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 827f4bad0b3..0a220204997 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -18,7 +18,9 @@ from homeassistant.components.switch import ( from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_HEADERS, + CONF_ICON, CONF_METHOD, + CONF_NAME, CONF_PARAMS, CONF_PASSWORD, CONF_RESOURCE, @@ -33,8 +35,10 @@ from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, + ManualTriggerEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -44,6 +48,14 @@ CONF_BODY_ON = "body_on" CONF_IS_ON_TEMPLATE = "is_on_template" CONF_STATE_RESOURCE = "state_resource" +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, +) + DEFAULT_METHOD = "post" DEFAULT_BODY_OFF = "OFF" DEFAULT_BODY_ON = "ON" @@ -71,6 +83,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, vol.Inclusive(CONF_PASSWORD, "authentication"): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_AVAILABILITY): cv.template, } ) @@ -83,10 +96,17 @@ async def async_setup_platform( ) -> None: """Set up the RESTful switch.""" resource: str = config[CONF_RESOURCE] - unique_id: str | None = config.get(CONF_UNIQUE_ID) + name = config.get(CONF_NAME) or template.Template(DEFAULT_NAME, hass) + + trigger_entity_config = {CONF_NAME: name} + + for key in TRIGGER_ENTITY_OPTIONS: + if key not in config: + continue + trigger_entity_config[key] = config[key] try: - switch = RestSwitch(hass, config, unique_id) + switch = RestSwitch(hass, config, trigger_entity_config) req = await switch.get_device_state(hass) if req.status_code >= HTTPStatus.BAD_REQUEST: @@ -102,23 +122,17 @@ async def async_setup_platform( raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc -class RestSwitch(TemplateEntity, SwitchEntity): +class RestSwitch(ManualTriggerEntity, SwitchEntity): """Representation of a switch that can be toggled using REST.""" def __init__( self, hass: HomeAssistant, config: ConfigType, - unique_id: str | None, + trigger_entity_config: ConfigType, ) -> None: """Initialize the REST switch.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=DEFAULT_NAME, - unique_id=unique_id, - ) + ManualTriggerEntity.__init__(self, hass, trigger_entity_config) auth: httpx.BasicAuth | None = None username: str | None = None @@ -138,8 +152,6 @@ class RestSwitch(TemplateEntity, SwitchEntity): self._timeout: int = config[CONF_TIMEOUT] self._verify_ssl: bool = config[CONF_VERIFY_SSL] - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._body_on.hass = hass self._body_off.hass = hass if (is_on_template := self._is_on_template) is not None: @@ -148,6 +160,11 @@ class RestSwitch(TemplateEntity, SwitchEntity): template.attach(hass, self._headers) template.attach(hass, self._params) + async def async_added_to_hass(self) -> None: + """Handle adding to Home Assistant.""" + await super().async_added_to_hass() + await self.async_update() + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" body_on_t = self._body_on.async_render(parse_result=False) @@ -198,13 +215,18 @@ class RestSwitch(TemplateEntity, SwitchEntity): async def async_update(self) -> None: """Get the current state, catching errors.""" + req = None try: - await self.get_device_state(self.hass) + req = await self.get_device_state(self.hass) except asyncio.TimeoutError: _LOGGER.exception("Timed out while fetching data") except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) + if req: + self._process_manual_data(req.text) + self.async_write_ha_state() + async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: """Get the latest data from REST API and update the state.""" websession = get_async_client(hass, self._verify_ssl) diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index a6895183d4e..8bd13550960 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -111,7 +111,7 @@ async def test_setup_minimum(hass: HomeAssistant) -> None: with assert_setup_component(1, SWITCH_DOMAIN): assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 @respx.mock @@ -129,7 +129,7 @@ async def test_setup_query_params(hass: HomeAssistant) -> None: assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 @respx.mock @@ -148,7 +148,7 @@ async def test_setup(hass: HomeAssistant) -> None: } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 assert_setup_component(1, SWITCH_DOMAIN) @@ -170,7 +170,7 @@ async def test_setup_with_state_resource(hass: HomeAssistant) -> None: } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 assert_setup_component(1, SWITCH_DOMAIN) @@ -195,7 +195,7 @@ async def test_setup_with_templated_headers_params(hass: HomeAssistant) -> None: } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 last_call = route.calls[-1] last_request: httpx.Request = last_call.request assert last_request.headers.get("Accept") == CONTENT_TYPE_JSON From a87878f72362529e8c28aa5eef60ff67fc5ec113 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 15 Aug 2023 12:26:37 +0200 Subject: [PATCH 068/180] Make image upload mimetype to match frontend (#98411) --- homeassistant/components/image_upload/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 569df9c65e4..6486d584b0e 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -78,8 +78,10 @@ class ImageStorageCollection(collection.DictStorageCollection): data = self.CREATE_SCHEMA(dict(data)) uploaded_file: FileField = data["file"] - if not uploaded_file.content_type.startswith("image/"): - raise vol.Invalid("Only images are allowed") + if not uploaded_file.content_type.startswith( + ("image/gif", "image/jpeg", "image/png") + ): + raise vol.Invalid("Only jpeg, png, and gif images are allowed") data[CONF_ID] = secrets.token_hex(16) data["filesize"] = await self.hass.async_add_executor_job(self._move_data, data) From 35b914af975a43bc6eda207f2f3b207aeb226153 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 13:28:43 +0200 Subject: [PATCH 069/180] Disable polling in buienradar weather entity (#98443) --- homeassistant/components/buienradar/sensor.py | 4 +- homeassistant/components/buienradar/util.py | 2 +- .../components/buienradar/weather.py | 92 ++++++++----------- 3 files changed, 41 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index e52000edf7f..00740eb4801 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -753,9 +753,9 @@ class BrSensor(SensorEntity): self._timeframe = None @callback - def data_updated(self, data): + def data_updated(self, data: BrData): """Update data.""" - if self.hass and self._load_data(data): + if self.hass and self._load_data(data.data): self.async_write_ha_state() @callback diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 8fce65c0600..9d0c2a575c9 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -75,7 +75,7 @@ class BrData: # Update all devices for dev in self.devices: - dev.data_updated(self.data) + dev.data_updated(self) async def schedule_update(self, minute=1): """Schedule an update after minute minutes.""" diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index c2a276eed1c..aedfcf82aea 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -48,7 +48,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback # Reuse data and API logic from the sensor implementation @@ -99,11 +99,13 @@ async def async_setup_entry( coordinates = {CONF_LATITUDE: float(latitude), CONF_LONGITUDE: float(longitude)} - # create weather data: - data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, None) - hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data - # create weather device: + # create weather entity: _LOGGER.debug("Initializing buienradar weather: coordinates %s", coordinates) + entities = [BrWeather(config, coordinates)] + + # create weather data: + data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, entities) + hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data # create condition helper if DATA_CONDITION not in hass.data[DOMAIN]: @@ -113,7 +115,7 @@ async def async_setup_entry( for condi in condlst: hass.data[DOMAIN][DATA_CONDITION][condi] = cond - async_add_entities([BrWeather(data, config, coordinates)]) + async_add_entities(entities) # schedule the first update in 1 minute from now: await data.schedule_update(1) @@ -127,75 +129,57 @@ class BrWeather(WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_visibility_unit = UnitOfLength.METERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_should_poll = False - def __init__(self, data, config, coordinates): + def __init__(self, config, coordinates): """Initialize the platform with a data instance and station name.""" self._stationname = config.get(CONF_NAME, "Buienradar") - self._attr_name = ( - self._stationname or f"BR {data.stationname or '(unknown station)'}" - ) - self._data = data + self._attr_name = self._stationname or f"BR {'(unknown station)'}" + self._attr_condition = None self._attr_unique_id = "{:2.6f}{:2.6f}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE] ) - @property - def attribution(self): - """Return the attribution.""" - return self._data.attribution + @callback + def data_updated(self, data: BrData) -> None: + """Update data.""" + if not self.hass: + return - @property - def condition(self): + self._attr_attribution = data.attribution + self._attr_condition = self._calc_condition(data) + self._attr_forecast = self._calc_forecast(data) + self._attr_humidity = data.humidity + self._attr_name = ( + self._stationname or f"BR {data.stationname or '(unknown station)'}" + ) + self._attr_native_pressure = data.pressure + self._attr_native_temperature = data.temperature + self._attr_native_visibility = data.visibility + self._attr_native_wind_speed = data.wind_speed + self._attr_wind_bearing = data.wind_bearing + self.async_write_ha_state() + + def _calc_condition(self, data: BrData): """Return the current condition.""" if ( - self._data - and self._data.condition - and (ccode := self._data.condition.get(CONDCODE)) + data.condition + and (ccode := data.condition.get(CONDCODE)) and (conditions := self.hass.data[DOMAIN].get(DATA_CONDITION)) ): return conditions.get(ccode) + return None - @property - def native_temperature(self): - """Return the current temperature.""" - return self._data.temperature - - @property - def native_pressure(self): - """Return the current pressure.""" - return self._data.pressure - - @property - def humidity(self): - """Return the name of the sensor.""" - return self._data.humidity - - @property - def native_visibility(self): - """Return the current visibility in m.""" - return self._data.visibility - - @property - def native_wind_speed(self): - """Return the current windspeed in m/s.""" - return self._data.wind_speed - - @property - def wind_bearing(self): - """Return the current wind bearing (degrees).""" - return self._data.wind_bearing - - @property - def forecast(self): + def _calc_forecast(self, data: BrData): """Return the forecast array.""" fcdata_out = [] cond = self.hass.data[DOMAIN][DATA_CONDITION] - if not self._data.forecast: + if not data.forecast: return None - for data_in in self._data.forecast: + for data_in in data.forecast: # remap keys from external library to # keys understood by the weather component: condcode = data_in.get(CONDITION, []).get(CONDCODE) From 71d985e4d664b2e3234344b29b2fb6a3f5f015de Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:32:15 +0200 Subject: [PATCH 070/180] Use asyncio.timeout [i-n] (#98450) --- homeassistant/components/ialarm/__init__.py | 5 ++--- homeassistant/components/iammeter/sensor.py | 5 ++--- homeassistant/components/image/__init__.py | 3 +-- homeassistant/components/imap/coordinator.py | 3 +-- homeassistant/components/intellifire/coordinator.py | 4 ++-- homeassistant/components/ipma/__init__.py | 3 +-- homeassistant/components/ipma/sensor.py | 4 ++-- homeassistant/components/ipma/weather.py | 5 ++--- homeassistant/components/isy994/__init__.py | 3 +-- homeassistant/components/isy994/config_flow.py | 4 ++-- homeassistant/components/izone/config_flow.py | 4 +--- homeassistant/components/kaiterra/api_data.py | 3 +-- homeassistant/components/kmtronic/__init__.py | 4 ++-- homeassistant/components/kraken/__init__.py | 3 +-- .../components/landisgyr_heat_meter/config_flow.py | 3 +-- .../components/landisgyr_heat_meter/coordinator.py | 4 ++-- homeassistant/components/laundrify/coordinator.py | 4 ++-- homeassistant/components/led_ble/__init__.py | 3 +-- homeassistant/components/lifx_cloud/scene.py | 5 ++--- homeassistant/components/logi_circle/__init__.py | 3 +-- homeassistant/components/logi_circle/config_flow.py | 3 +-- homeassistant/components/london_underground/sensor.py | 4 ++-- homeassistant/components/loqed/coordinator.py | 4 ++-- homeassistant/components/lutron_caseta/__init__.py | 3 +-- homeassistant/components/lutron_caseta/config_flow.py | 3 +-- homeassistant/components/lyric/__init__.py | 4 ++-- homeassistant/components/mailbox/__init__.py | 3 +-- homeassistant/components/matter/__init__.py | 5 ++--- homeassistant/components/mazda/__init__.py | 4 ++-- homeassistant/components/meater/__init__.py | 4 ++-- homeassistant/components/media_player/__init__.py | 3 +-- homeassistant/components/melcloud/__init__.py | 3 +-- homeassistant/components/melcloud/config_flow.py | 3 +-- homeassistant/components/microsoft_face/__init__.py | 3 +-- homeassistant/components/mjpeg/camera.py | 5 ++--- homeassistant/components/mobile_app/notify.py | 3 +-- homeassistant/components/mullvad/__init__.py | 4 ++-- homeassistant/components/mutesync/__init__.py | 4 ++-- homeassistant/components/mutesync/config_flow.py | 3 +-- homeassistant/components/mysensors/gateway.py | 5 ++--- homeassistant/components/nam/__init__.py | 3 +-- homeassistant/components/nam/config_flow.py | 5 ++--- homeassistant/components/nextdns/__init__.py | 5 ++--- homeassistant/components/nextdns/config_flow.py | 3 +-- homeassistant/components/nina/__init__.py | 4 ++-- homeassistant/components/no_ip/__init__.py | 3 +-- homeassistant/components/nuki/__init__.py | 10 +++++----- homeassistant/components/nut/__init__.py | 4 ++-- homeassistant/components/nzbget/coordinator.py | 4 ++-- 49 files changed, 78 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index b258c702725..b2c1800914e 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import logging -from async_timeout import timeout from pyialarm import IAlarm from homeassistant.components.alarm_control_panel import SCAN_INTERVAL @@ -27,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ialarm = IAlarm(host, port) try: - async with timeout(10): + async with asyncio.timeout(10): mac = await hass.async_add_executor_job(ialarm.get_mac) except (asyncio.TimeoutError, ConnectionError) as ex: raise ConfigEntryNotReady from ex @@ -81,7 +80,7 @@ class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from iAlarm.""" try: - async with timeout(10): + async with asyncio.timeout(10): await self.hass.async_add_executor_job(self._update_data) except ConnectionError as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index 206b5def832..ca468200370 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -5,7 +5,6 @@ import asyncio from datetime import timedelta import logging -import async_timeout from iammeter import real_time_api from iammeter.power_meter import IamMeterError import voluptuous as vol @@ -52,7 +51,7 @@ async def async_setup_platform( config_port = config[CONF_PORT] config_name = config[CONF_NAME] try: - async with async_timeout.timeout(PLATFORM_TIMEOUT): + async with asyncio.timeout(PLATFORM_TIMEOUT): api = await real_time_api(config_host, config_port) except (IamMeterError, asyncio.TimeoutError) as err: _LOGGER.error("Device is not ready") @@ -60,7 +59,7 @@ async def async_setup_platform( async def async_update_data(): try: - async with async_timeout.timeout(PLATFORM_TIMEOUT): + async with asyncio.timeout(PLATFORM_TIMEOUT): return await api.get_data() except (IamMeterError, asyncio.TimeoutError) as err: raise UpdateFailed from err diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index e4bc1664fd9..d1895053f02 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -11,7 +11,6 @@ from random import SystemRandom from typing import Final, final from aiohttp import hdrs, web -import async_timeout import httpx from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView @@ -72,7 +71,7 @@ def valid_image_content_type(content_type: str | None) -> str: async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: """Fetch image from an image entity.""" with suppress(asyncio.CancelledError, asyncio.TimeoutError, ImageContentTypeError): - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): if image_bytes := await image_entity.async_image(): content_type = valid_image_content_type(image_entity.content_type) image = Image(content_type, image_bytes) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index b644c300979..b9b541997a3 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -11,7 +11,6 @@ import logging from typing import Any from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -408,7 +407,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): idle: asyncio.Future = await self.imap_client.idle_start() await self.imap_client.wait_server_push() self.imap_client.idle_done() - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await idle # From python 3.11 asyncio.TimeoutError is an alias of TimeoutError diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index f9502f70ee7..4045c19217b 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -1,10 +1,10 @@ """The IntelliFire integration.""" from __future__ import annotations +import asyncio from datetime import timedelta from aiohttp import ClientConnectionError -from async_timeout import timeout from intellifire4py import IntellifirePollData from intellifire4py.intellifire import IntellifireAPILocal @@ -38,7 +38,7 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData await self._api.start_background_polling() # Don't return uninitialized poll data - async with timeout(15): + async with asyncio.timeout(15): try: await self._api.poll() except (ConnectionError, ClientConnectionError) as exception: diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index dd46593998e..5ff89fa8ed5 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -2,7 +2,6 @@ import asyncio import logging -import async_timeout from pyipma import IPMAException from pyipma.api import IPMA_API from pyipma.location import Location @@ -32,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b api = IPMA_API(async_get_clientsession(hass)) try: - async with async_timeout.timeout(30): + async with asyncio.timeout(30): location = await Location.get(api, float(latitude), float(longitude)) except (IPMAException, asyncio.TimeoutError) as err: raise ConfigEntryNotReady( diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index f02f8b7d9d0..1bd257a3994 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -1,11 +1,11 @@ """Support for IPMA sensors.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging -import async_timeout from pyipma.api import IPMA_API from pyipma.location import Location @@ -83,7 +83,7 @@ class IPMASensor(SensorEntity, IPMADevice): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Update Fire risk.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): self._attr_native_value = await self.entity_description.value_fn( self._location, self._api ) diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index b8e994a7500..d4d11aa26e8 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -6,7 +6,6 @@ import contextlib import logging from typing import Literal -import async_timeout from pyipma.api import IPMA_API from pyipma.forecast import Forecast as IPMAForecast from pyipma.location import Location @@ -91,7 +90,7 @@ class IPMAWeather(WeatherEntity, IPMADevice): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Update Condition and Forecast.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): new_observation = await self._location.observation(self._api) if new_observation: @@ -225,7 +224,7 @@ class IPMAWeather(WeatherEntity, IPMADevice): ) -> None: """Try to update weather forecast.""" with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await self._update_forecast(forecast_type, period, False) async def async_forecast_daily(self) -> list[Forecast]: diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index f19e21b4f6d..c611bf83050 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -5,7 +5,6 @@ import asyncio from urllib.parse import urlparse from aiohttp import CookieJar -import async_timeout from pyisy import ISY, ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError from pyisy.constants import CONFIG_NETWORKING, CONFIG_PORTAL import voluptuous as vol @@ -101,7 +100,7 @@ async def async_setup_entry( ) try: - async with async_timeout.timeout(60): + async with asyncio.timeout(60): await isy.initialize() except asyncio.TimeoutError as err: raise ConfigEntryNotReady( diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index d6bbf236c13..9f16b4a0d0c 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -1,13 +1,13 @@ """Config flow for Universal Devices ISY/IoX integration.""" from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse, urlunparse from aiohttp import CookieJar -import async_timeout from pyisy import ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError from pyisy.configuration import Configuration from pyisy.connection import Connection @@ -97,7 +97,7 @@ async def validate_input( ) try: - async with async_timeout.timeout(30): + async with asyncio.timeout(30): isy_conf_xml = await isy_conn.test_connection() except ISYInvalidAuthError as error: raise InvalidAuth from error diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index af5205feb07..8e6fe584456 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -4,8 +4,6 @@ import asyncio from contextlib import suppress import logging -from async_timeout import timeout - from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_entry_flow from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -28,7 +26,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: disco = await async_start_discovery_service(hass) with suppress(asyncio.TimeoutError): - async with timeout(TIMEOUT_DISCOVERY): + async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() if not disco.pi_disco.controllers: diff --git a/homeassistant/components/kaiterra/api_data.py b/homeassistant/components/kaiterra/api_data.py index 980c01d02a1..09d470af1de 100644 --- a/homeassistant/components/kaiterra/api_data.py +++ b/homeassistant/components/kaiterra/api_data.py @@ -3,7 +3,6 @@ import asyncio from logging import getLogger from aiohttp.client_exceptions import ClientConnectorError, ClientResponseError -import async_timeout from kaiterra_async_client import AQIStandard, KaiterraAPIClient, Units from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID, CONF_DEVICES, CONF_TYPE @@ -53,7 +52,7 @@ class KaiterraApiData: """Get the data from Kaiterra API.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): data = await self._api.get_latest_sensor_readings(self._devices) except (ClientResponseError, ClientConnectorError, asyncio.TimeoutError) as err: _LOGGER.debug("Couldn't fetch data from Kaiterra API: %s", err) diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py index ef4e8ebb303..638884dff26 100644 --- a/homeassistant/components/kmtronic/__init__.py +++ b/homeassistant/components/kmtronic/__init__.py @@ -1,9 +1,9 @@ """The kmtronic integration.""" +import asyncio from datetime import timedelta import logging import aiohttp -import async_timeout from pykmtronic.auth import Auth from pykmtronic.hub import KMTronicHubAPI @@ -33,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data(): try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hub.async_update_relays() except aiohttp.client_exceptions.ClientResponseError as err: raise UpdateFailed(f"Wrong credentials: {err}") from err diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 1cfade2a6b7..395de951bbd 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -5,7 +5,6 @@ import asyncio from datetime import timedelta import logging -import async_timeout import krakenex import pykrakenapi @@ -73,7 +72,7 @@ class KrakenData: once. """ try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self._hass.async_add_executor_job(self._get_kraken_data) except pykrakenapi.pykrakenapi.KrakenAPIError as error: if "Unknown asset pair" in str(error): diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 0353e5e63c7..4f7966ae90f 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -5,7 +5,6 @@ import asyncio import logging from typing import Any -import async_timeout import serial from serial.tools import list_ports import ultraheat_api @@ -105,7 +104,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): reader = ultraheat_api.UltraheatReader(port) heat_meter = ultraheat_api.HeatMeterService(reader) try: - async with async_timeout.timeout(ULTRAHEAT_TIMEOUT): + async with asyncio.timeout(ULTRAHEAT_TIMEOUT): # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index c85c661e79c..27231dc7b92 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -1,8 +1,8 @@ """Data update coordinator for the ultraheat api.""" +import asyncio import logging -import async_timeout import serial from ultraheat_api.response import HeatMeterResponse from ultraheat_api.service import HeatMeterService @@ -31,7 +31,7 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): async def _async_update_data(self) -> HeatMeterResponse: """Fetch data from API endpoint.""" try: - async with async_timeout.timeout(ULTRAHEAT_TIMEOUT): + async with asyncio.timeout(ULTRAHEAT_TIMEOUT): return await self.hass.async_add_executor_job(self.api.read) except (FileNotFoundError, serial.serialutil.SerialException) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 47728a38983..121d2cd913f 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -1,8 +1,8 @@ """Custom DataUpdateCoordinator for the laundrify integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from laundrify_aio import LaundrifyAPI from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException @@ -36,7 +36,7 @@ class LaundrifyUpdateCoordinator(DataUpdateCoordinator[dict[str, LaundrifyDevice try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): return {m["_id"]: m for m in await self.laundrify_api.get_machines()} except UnauthorizedException as err: # Raising ConfigEntryAuthFailed will cancel future updates diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 768300ff534..1bdb8bf8ec9 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -5,7 +5,6 @@ import asyncio from datetime import timedelta import logging -import async_timeout from led_ble import BLEAK_EXCEPTIONS, LEDBLE from homeassistant.components import bluetooth @@ -78,7 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise try: - async with async_timeout.timeout(DEVICE_TIMEOUT): + async with asyncio.timeout(DEVICE_TIMEOUT): await startup_event.wait() except asyncio.TimeoutError as ex: raise ConfigEntryNotReady( diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index ce03a595f64..bcf8ed1dc2c 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -8,7 +8,6 @@ from typing import Any import aiohttp from aiohttp.hdrs import AUTHORIZATION -import async_timeout import voluptuous as vol from homeassistant.components.scene import Scene @@ -48,7 +47,7 @@ async def async_setup_platform( try: httpsession = async_get_clientsession(hass) - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): scenes_resp = await httpsession.get(url, headers=headers) except (asyncio.TimeoutError, aiohttp.ClientError): @@ -90,7 +89,7 @@ class LifxCloudScene(Scene): try: httpsession = async_get_clientsession(self.hass) - async with async_timeout.timeout(self._timeout): + async with asyncio.timeout(self._timeout): await httpsession.put(url, headers=self._headers) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index 93e23be5d8d..a14cd60c993 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -2,7 +2,6 @@ import asyncio from aiohttp.client_exceptions import ClientResponseError -import async_timeout from logi_circle import LogiCircle from logi_circle.exception import AuthorizationFailed import voluptuous as vol @@ -154,7 +153,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False try: - async with async_timeout.timeout(_TIMEOUT): + async with asyncio.timeout(_TIMEOUT): # Ensure the cameras property returns the same Camera objects for # all devices. Performs implicit login and session validation. await logi_circle.synchronize_cameras() diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index ff7528ac9f6..9785940aca2 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -3,7 +3,6 @@ import asyncio from collections import OrderedDict from http import HTTPStatus -import async_timeout from logi_circle import LogiCircle from logi_circle.exception import AuthorizationFailed import voluptuous as vol @@ -158,7 +157,7 @@ class LogiCircleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) try: - async with async_timeout.timeout(_TIMEOUT): + async with asyncio.timeout(_TIMEOUT): await logi_session.authorize(code) except AuthorizationFailed: (self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS]) = "invalid_auth" diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 8217b3913a8..7e52186fa51 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -1,10 +1,10 @@ """Sensor for checking the status of London Underground tube lines.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging -import async_timeout from london_tube_status import TubeData import voluptuous as vol @@ -90,7 +90,7 @@ class LondonTubeCoordinator(DataUpdateCoordinator): self._data = data async def _async_update_data(self): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await self._data.update() return self._data.data diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py index 42e0d523aba..d33cd8772b2 100644 --- a/homeassistant/components/loqed/coordinator.py +++ b/homeassistant/components/loqed/coordinator.py @@ -1,9 +1,9 @@ """Provides the coordinator for a LOQED lock.""" +import asyncio import logging from typing import TypedDict from aiohttp.web import Request -import async_timeout from loqedAPI import loqed from homeassistant.components import cloud, webhook @@ -86,7 +86,7 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): async def _async_update_data(self) -> StatusMessage: """Fetch data from API endpoint.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self._api.async_get_lock_details() async def _handle_webhook( diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 0a6a2aa8211..41369046d51 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -8,7 +8,6 @@ import logging import ssl from typing import Any, cast -import async_timeout from pylutron_caseta import BUTTON_STATUS_PRESSED from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol @@ -173,7 +172,7 @@ async def async_setup_entry( timed_out = True with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(BRIDGE_TIMEOUT): + async with asyncio.timeout(BRIDGE_TIMEOUT): await bridge.connect() timed_out = False diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 74819e25e8e..9b243a3ec98 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -6,7 +6,6 @@ import logging import os import ssl -import async_timeout from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY, async_pair from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol @@ -226,7 +225,7 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return None try: - async with async_timeout.timeout(BRIDGE_TIMEOUT): + async with asyncio.timeout(BRIDGE_TIMEOUT): await bridge.connect() except asyncio.TimeoutError: _LOGGER.error( diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index c2c1c9ae77a..a407afaa207 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -1,6 +1,7 @@ """The Honeywell Lyric integration.""" from __future__ import annotations +import asyncio from datetime import timedelta from http import HTTPStatus import logging @@ -10,7 +11,6 @@ from aiolyric import Lyric from aiolyric.exceptions import LyricAuthenticationException, LyricException from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -74,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed(exception) from exception try: - async with async_timeout.timeout(60): + async with asyncio.timeout(60): await lyric.get_locations() return lyric except LyricAuthenticationException as exception: diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 75cea546b71..679abfd3164 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -10,7 +10,6 @@ from typing import Any, Final from aiohttp import web from aiohttp.web_exceptions import HTTPNotFound -import async_timeout from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView @@ -267,7 +266,7 @@ class MailboxMediaView(MailboxView): mailbox = self.get_mailbox(platform) with suppress(asyncio.CancelledError, asyncio.TimeoutError): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: stream = await mailbox.async_get_media(msgid) except StreamError as err: diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 59c5ec9efc8..a2aa2c5ceff 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from contextlib import suppress -import async_timeout from matter_server.client import MatterClient from matter_server.client.exceptions import CannotConnect, InvalidServerVersion from matter_server.common.errors import MatterError, NodeCommissionFailed, NodeNotExists @@ -42,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: matter_client = MatterClient(entry.data[CONF_URL], async_get_clientsession(hass)) try: - async with async_timeout.timeout(CONNECT_TIMEOUT): + async with asyncio.timeout(CONNECT_TIMEOUT): await matter_client.connect() except (CannotConnect, asyncio.TimeoutError) as err: raise ConfigEntryNotReady("Failed to connect to matter server") from err @@ -87,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - async with async_timeout.timeout(LISTEN_READY_TIMEOUT): + async with asyncio.timeout(LISTEN_READY_TIMEOUT): await init_ready.wait() except asyncio.TimeoutError as err: listen_task.cancel() diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index 1322a7db300..f375b8a75cd 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -1,11 +1,11 @@ """The Mazda Connected Services integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import TYPE_CHECKING -import async_timeout from pymazda import ( Client as MazdaAPI, MazdaAccountLockedException, @@ -53,7 +53,7 @@ PLATFORMS = [ async def with_timeout(task, timeout_seconds=30): """Run an async task with a timeout.""" - async with async_timeout.timeout(timeout_seconds): + async with asyncio.timeout(timeout_seconds): return await task diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 6db3093567d..12fdb7f3a06 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -1,8 +1,8 @@ """The Meater Temperature Probe integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from meater import ( AuthenticationError, MeaterApi, @@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(10): + async with asyncio.timeout(10): devices: list[MeaterProbe] = await meater_api.get_all_devices() except AuthenticationError as err: raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 501fa5c3fb8..2acb516fa95 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -19,7 +19,6 @@ from urllib.parse import quote, urlparse from aiohttp import web from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE from aiohttp.typedefs import LooseHeaders -import async_timeout import voluptuous as vol from yarl import URL @@ -1259,7 +1258,7 @@ async def async_fetch_image( content, content_type = (None, None) websession = async_get_clientsession(hass) with suppress(asyncio.TimeoutError): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await websession.get(url) if response.status == HTTPStatus.OK: content = await response.read() diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 2d7354f250f..68b40d8567f 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -7,7 +7,6 @@ import logging from typing import Any from aiohttp import ClientConnectionError -from async_timeout import timeout from pymelcloud import Device, get_devices import voluptuous as vol @@ -152,7 +151,7 @@ async def mel_devices_setup( """Query connected devices from MELCloud.""" session = async_get_clientsession(hass) try: - async with timeout(10): + async with asyncio.timeout(10): all_devices = await get_devices( token, session, diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 3d6d42c8b7a..0ff17ea751a 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -5,7 +5,6 @@ import asyncio from http import HTTPStatus from aiohttp import ClientError, ClientResponseError -from async_timeout import timeout import pymelcloud import voluptuous as vol @@ -78,7 +77,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ): """Create client.""" try: - async with timeout(10): + async with asyncio.timeout(10): if (acquired_token := token) is None: acquired_token = await pymelcloud.login( username, diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index f57a9146858..6e47ad79f5b 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -7,7 +7,6 @@ import logging import aiohttp from aiohttp.hdrs import CONTENT_TYPE -import async_timeout import voluptuous as vol from homeassistant.components import camera @@ -314,7 +313,7 @@ class MicrosoftFace: payload = None try: - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): response = await getattr(self.websession, method)( url, data=payload, headers=headers, params=params ) diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index dab5b477ede..a2b2de4eda8 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -7,7 +7,6 @@ from contextlib import suppress import aiohttp from aiohttp import web -import async_timeout import httpx from yarl import URL @@ -144,7 +143,7 @@ class MjpegCamera(Camera): websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): response = await websession.get(self._still_image_url, auth=self._auth) image = await response.read() @@ -206,7 +205,7 @@ class MjpegCamera(Camera): async for chunk in stream.aiter_bytes(BUFFER_SIZE): if not self.hass.is_running: break - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): await response.write(chunk) return response diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index dc9f8aaedcd..47b997e410c 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -7,7 +7,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout from homeassistant.components.notify import ( ATTR_DATA, @@ -166,7 +165,7 @@ class MobileAppNotificationService(BaseNotificationService): target_data["registration_info"] = reg_info try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await async_get_clientsession(self._hass).post( push_url, json=target_data ) diff --git a/homeassistant/components/mullvad/__init__.py b/homeassistant/components/mullvad/__init__.py index b8551682f1f..cd692f00537 100644 --- a/homeassistant/components/mullvad/__init__.py +++ b/homeassistant/components/mullvad/__init__.py @@ -1,8 +1,8 @@ """The Mullvad VPN integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from mullvad_api import MullvadAPI from homeassistant.config_entries import ConfigEntry @@ -19,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Mullvad VPN integration.""" async def async_get_mullvad_api_data(): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): api = await hass.async_add_executor_job(MullvadAPI) return api.data diff --git a/homeassistant/components/mutesync/__init__.py b/homeassistant/components/mutesync/__init__.py index aa5e0d70fe9..cbbbbaa6a11 100644 --- a/homeassistant/components/mutesync/__init__.py +++ b/homeassistant/components/mutesync/__init__.py @@ -1,9 +1,9 @@ """The mütesync integration.""" from __future__ import annotations +import asyncio import logging -import async_timeout import mutesync from homeassistant.config_entries import ConfigEntry @@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_data(): """Update the data.""" - async with async_timeout.timeout(2.5): + async with asyncio.timeout(2.5): state = await client.get_state() if state["muted"] is None or state["in_meeting"] is None: diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py index 7ebbc718a5b..e06c0b07c87 100644 --- a/homeassistant/components/mutesync/config_flow.py +++ b/homeassistant/components/mutesync/config_flow.py @@ -5,7 +5,6 @@ import asyncio from typing import Any import aiohttp -import async_timeout import mutesync import voluptuous as vol @@ -27,7 +26,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, """ session = async_get_clientsession(hass) try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): token = await mutesync.authenticate(session, data["host"]) except aiohttp.ClientResponseError as error: if error.status == 403: diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 1d016a791e3..ce602e6266d 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -9,7 +9,6 @@ import socket import sys from typing import Any -import async_timeout from mysensors import BaseAsyncGateway, Message, Sensor, mysensors import voluptuous as vol @@ -107,7 +106,7 @@ async def try_connect( connect_task = None try: connect_task = asyncio.create_task(gateway.start()) - async with async_timeout.timeout(GATEWAY_READY_TIMEOUT): + async with asyncio.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() return True except asyncio.TimeoutError: @@ -299,7 +298,7 @@ async def _gw_start( # Gatways connected via mqtt doesn't send gateway ready message. return try: - async with async_timeout.timeout(GATEWAY_READY_TIMEOUT): + async with asyncio.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() except asyncio.TimeoutError: _LOGGER.warning( diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 5004bafeb1b..d5881f52d8d 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -6,7 +6,6 @@ import logging from typing import cast from aiohttp.client_exceptions import ClientConnectorError, ClientError -import async_timeout from nettigo_air_monitor import ( ApiError, AuthFailedError, @@ -111,7 +110,7 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): async def _async_update_data(self) -> NAMSensors: """Update data via library.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): data = await self.nam.async_update() # We do not need to catch AuthFailed exception here because sensor data is # always available without authorization. diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index eef4c33e5f0..7eee84a66a4 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -8,7 +8,6 @@ import logging from typing import Any from aiohttp.client_exceptions import ClientConnectorError -import async_timeout from nettigo_air_monitor import ( ApiError, AuthFailedError, @@ -51,7 +50,7 @@ async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig: options = ConnectionOptions(host) nam = await NettigoAirMonitor.create(websession, options) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): mac = await nam.async_get_mac_address() return NamConfig(mac, nam.auth_enabled) @@ -67,7 +66,7 @@ async def async_check_credentials( nam = await NettigoAirMonitor.create(websession, options) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await nam.async_check_credentials() diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 3865136b2ac..011b487910f 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -7,7 +7,6 @@ import logging from typing import TypeVar from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from nextdns import ( AnalyticsDnssec, AnalyticsEncryption, @@ -75,7 +74,7 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): async def _async_update_data(self) -> CoordinatorDataT: """Update data via internal method.""" try: - async with timeout(10): + async with asyncio.timeout(10): return await self._async_update_data_internal() except (ApiError, ClientConnectorError, InvalidApiKeyError) as err: raise UpdateFailed(err) from err @@ -162,7 +161,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = async_get_clientsession(hass) try: - async with timeout(10): + async with asyncio.timeout(10): nextdns = await NextDns.create(websession, api_key) except (ApiError, ClientConnectorError, asyncio.TimeoutError) as err: raise ConfigEntryNotReady from err diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index 5c9bf04cfc1..3985644a478 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -5,7 +5,6 @@ import asyncio from typing import Any from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from nextdns import ApiError, InvalidApiKeyError, NextDns import voluptuous as vol @@ -38,7 +37,7 @@ class NextDnsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: self.api_key = user_input[CONF_API_KEY] try: - async with timeout(10): + async with asyncio.timeout(10): self.nextdns = await NextDns.create( websession, user_input[CONF_API_KEY] ) diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index fbb8e32bebe..dfb556deeb5 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -1,11 +1,11 @@ """The Nina integration.""" from __future__ import annotations +import asyncio from dataclasses import dataclass import re from typing import Any -from async_timeout import timeout from pynina import ApiError, Nina from homeassistant.config_entries import ConfigEntry @@ -103,7 +103,7 @@ class NINADataUpdateCoordinator( async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: """Update data.""" - async with timeout(10): + async with asyncio.timeout(10): try: await self._nina.update() except ApiError as err: diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index 6688888df01..e91b5cec92d 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -6,7 +6,6 @@ import logging import aiohttp from aiohttp.hdrs import AUTHORIZATION, USER_AGENT -import async_timeout import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME @@ -100,7 +99,7 @@ async def _update_no_ip( } try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): resp = await session.get(url, params=params, headers=headers) body = await resp.text() diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index f72abc410ef..3b846d73477 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1,6 +1,7 @@ """The nuki component.""" from __future__ import annotations +import asyncio from collections import defaultdict from datetime import timedelta from http import HTTPStatus @@ -8,7 +9,6 @@ import logging from typing import Generic, TypeVar from aiohttp import web -import async_timeout from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki.bridge import InvalidCredentialsException from pynuki.device import NukiDevice @@ -126,7 +126,7 @@ async def _create_webhook( ir.async_delete_issue(hass, DOMAIN, "https_webhook") try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hass.async_add_executor_job( _register_webhook, bridge, entry.entry_id, url ) @@ -216,7 +216,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Stop and remove the Nuki webhook.""" webhook.async_unregister(hass, entry.entry_id) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hass.async_add_executor_job( _remove_webhook, bridge, entry.entry_id ) @@ -252,7 +252,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Nuki entry.""" webhook.async_unregister(hass, entry.entry_id) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hass.async_add_executor_job( _remove_webhook, hass.data[DOMAIN][entry.entry_id][DATA_BRIDGE], @@ -301,7 +301,7 @@ class NukiCoordinator(DataUpdateCoordinator[None]): try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(10): + async with asyncio.timeout(10): events = await self.hass.async_add_executor_job( self.update_devices, self.locks + self.openers ) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 9ffe1016aec..8b0d8fe4640 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -1,12 +1,12 @@ """The nut component.""" from __future__ import annotations +import asyncio from dataclasses import dataclass from datetime import timedelta import logging from typing import cast -import async_timeout from pynut2.nut2 import PyNUTClient, PyNUTError from homeassistant.config_entries import ConfigEntry @@ -65,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data() -> dict[str, str]: """Fetch data from NUT.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hass.async_add_executor_job(data.update) if not data.status: raise UpdateFailed("Error fetching UPS state") diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index c037619d31b..7326fa50dd5 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -1,10 +1,10 @@ """Provides the NZBGet DataUpdateCoordinator.""" +import asyncio from collections.abc import Mapping from datetime import timedelta import logging from typing import Any -from async_timeout import timeout from pynzbgetapi import NZBGetAPI, NZBGetAPIException from homeassistant.const import ( @@ -96,7 +96,7 @@ class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): } try: - async with timeout(4): + async with asyncio.timeout(4): return await self.hass.async_add_executor_job(_update_data) except NZBGetAPIException as error: raise UpdateFailed(f"Invalid response from API: {error}") from error From 8b0fdd6fd21832ec4a2ef4a66f38676681c61f88 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:34:18 +0200 Subject: [PATCH 071/180] Use asyncio.timeout [s-z] (#98452) --- homeassistant/components/songpal/media_player.py | 3 +-- homeassistant/components/sonos/speaker.py | 3 +-- homeassistant/components/squeezebox/config_flow.py | 3 +-- homeassistant/components/srp_energy/sensor.py | 4 ++-- homeassistant/components/starlink/coordinator.py | 8 ++++---- homeassistant/components/startca/sensor.py | 4 ++-- homeassistant/components/supla/__init__.py | 4 ++-- homeassistant/components/switchbot/coordinator.py | 3 +-- homeassistant/components/syncthru/__init__.py | 4 ++-- homeassistant/components/system_bridge/__init__.py | 7 +++---- .../components/system_bridge/config_flow.py | 3 +-- .../components/system_bridge/coordinator.py | 3 +-- homeassistant/components/tado/device_tracker.py | 3 +-- homeassistant/components/tellduslive/config_flow.py | 3 +-- homeassistant/components/thethingsnetwork/sensor.py | 3 +-- .../components/tplink_omada/coordinator.py | 4 ++-- homeassistant/components/tradfri/config_flow.py | 3 +-- homeassistant/components/unifi/controller.py | 5 ++--- homeassistant/components/upb/config_flow.py | 3 +-- homeassistant/components/upnp/__init__.py | 3 +-- homeassistant/components/viaggiatreno/sensor.py | 3 +-- homeassistant/components/voicerss/tts.py | 3 +-- homeassistant/components/voip/voip.py | 13 ++++++------- homeassistant/components/volvooncall/__init__.py | 4 ++-- homeassistant/components/webostv/media_player.py | 3 +-- homeassistant/components/worxlandroid/sensor.py | 3 +-- homeassistant/components/wyoming/data.py | 5 +---- homeassistant/components/xiaomi_miio/__init__.py | 6 +++--- homeassistant/components/yandextts/tts.py | 3 +-- homeassistant/components/yeelight/scanner.py | 3 +-- homeassistant/components/yolink/__init__.py | 3 +-- homeassistant/components/yolink/coordinator.py | 4 ++-- homeassistant/components/zwave_js/__init__.py | 3 +-- homeassistant/components/zwave_js/config_flow.py | 3 +-- tests/components/sonos/test_init.py | 8 +------- tests/components/upb/test_config_flow.py | 2 +- tests/components/voip/test_voip.py | 9 ++++----- tests/components/wemo/test_wemo_device.py | 5 ++--- 38 files changed, 62 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index bc096d23437..2d2c5892636 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -5,7 +5,6 @@ import asyncio from collections import OrderedDict import logging -import async_timeout from songpal import ( ConnectChange, ContentChange, @@ -68,7 +67,7 @@ async def async_setup_entry( device = Device(endpoint) try: - async with async_timeout.timeout( + async with asyncio.timeout( 10 ): # set timeout to avoid blocking the setup process await device.get_supported_methods() diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index e576d3f7908..b73ca6a77e4 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -10,7 +10,6 @@ import logging import time from typing import Any, cast -import async_timeout import defusedxml.ElementTree as ET from soco.core import SoCo from soco.events_base import Event as SonosEvent, SubscriptionBase @@ -1122,7 +1121,7 @@ class SonosSpeaker: return True try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): while not _test_groups(groups): await hass.data[DATA_SONOS].topology_condition.wait() except asyncio.TimeoutError: diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index bb175ee00be..2c96046b97c 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -4,7 +4,6 @@ from http import HTTPStatus import logging from typing import TYPE_CHECKING -import async_timeout from pysqueezebox import Server, async_discover import voluptuous as vol @@ -131,7 +130,7 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # no host specified, see if we can discover an unconfigured LMS server try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): await self._discover() return await self.async_step_edit() except asyncio.TimeoutError: diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index cdfd53d40a0..946b2aedb13 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -1,9 +1,9 @@ """Support for SRP Energy Sensor.""" from __future__ import annotations +import asyncio from datetime import timedelta -import async_timeout from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout from homeassistant.components.sensor import ( @@ -52,7 +52,7 @@ async def async_setup_entry( end_date = dt_util.now(phx_time_zone) start_date = end_date - timedelta(days=1) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): hourly_usage = await hass.async_add_executor_job( api.usage, start_date, diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index f6f3623f8d4..3359706372e 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -1,11 +1,11 @@ """Contains the shared Coordinator for Starlink systems.""" from __future__ import annotations +import asyncio from dataclasses import dataclass from datetime import timedelta import logging -import async_timeout from starlink_grpc import ( AlertDict, ChannelContext, @@ -48,7 +48,7 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): ) async def _async_update_data(self) -> StarlinkData: - async with async_timeout.timeout(4): + async with asyncio.timeout(4): try: status = await self.hass.async_add_executor_job( status_data, self.channel_context @@ -59,7 +59,7 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): async def async_stow_starlink(self, stow: bool) -> None: """Set whether Starlink system tied to this coordinator should be stowed.""" - async with async_timeout.timeout(4): + async with asyncio.timeout(4): try: await self.hass.async_add_executor_job( set_stow_state, not stow, self.channel_context @@ -69,7 +69,7 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): async def async_reboot_starlink(self) -> None: """Reboot the Starlink system tied to this coordinator.""" - async with async_timeout.timeout(4): + async with asyncio.timeout(4): try: await self.hass.async_add_executor_job(reboot, self.channel_context) except GrpcError as exc: diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 3334afded00..50224944849 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -1,12 +1,12 @@ """Support for Start.ca Bandwidth Monitor.""" from __future__ import annotations +import asyncio from datetime import timedelta from http import HTTPStatus import logging from xml.parsers.expat import ExpatError -import async_timeout import voluptuous as vol import xmltodict @@ -213,7 +213,7 @@ class StartcaData: """Get the Start.ca bandwidth data from the web service.""" _LOGGER.debug("Updating Start.ca usage data") url = f"https://www.start.ca/support/usage/api?key={self.api_key}" - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): req = await self.websession.get(url) if req.status != HTTPStatus.OK: _LOGGER.error("Request failed with status: %u", req.status) diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 0d1308ca5a6..14d617ba88e 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -1,10 +1,10 @@ """Support for Supla devices.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging -import async_timeout from asyncpysupla import SuplaAPI import voluptuous as vol @@ -99,7 +99,7 @@ async def discover_devices(hass, hass_config): for server_name, server in hass.data[DOMAIN][SUPLA_SERVERS].items(): async def _fetch_channels(): - async with async_timeout.timeout(SCAN_INTERVAL.total_seconds()): + async with asyncio.timeout(SCAN_INTERVAL.total_seconds()): channels = { channel["id"]: channel # pylint: disable-next=cell-var-from-loop diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index c12e8122e52..39f2a4aa6da 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -6,7 +6,6 @@ import contextlib import logging from typing import TYPE_CHECKING -import async_timeout import switchbot from switchbot import SwitchbotModel @@ -117,7 +116,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) async def async_wait_ready(self) -> bool: """Wait for the device to be ready.""" with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(DEVICE_STARTUP_TIMEOUT): + async with asyncio.timeout(DEVICE_STARTUP_TIMEOUT): await self._ready_event.wait() return True return False diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index db546266328..8d17f038819 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -1,10 +1,10 @@ """The syncthru component.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging -import async_timeout from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported from homeassistant.config_entries import ConfigEntry @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data() -> SyncThru: """Fetch data from the printer.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await printer.update() except SyncThruAPINotSupported as api_error: # if an exception is thrown, printer does not support syncthru diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 29b127bf8db..058d03163ef 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import logging -import async_timeout from systembridgeconnector.exceptions import ( AuthenticationException, ConnectionClosedException, @@ -67,7 +66,7 @@ async def async_setup_entry( session=async_get_clientsession(hass), ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): if not await version.check_supported(): raise ConfigEntryNotReady( "You are not running a supported version of System Bridge. Please" @@ -91,7 +90,7 @@ async def async_setup_entry( entry=entry, ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await coordinator.async_get_data(MODULES) except AuthenticationException as exception: _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) @@ -109,7 +108,7 @@ async def async_setup_entry( try: # Wait for initial data - async with async_timeout.timeout(10): + async with asyncio.timeout(10): while not coordinator.is_ready: _LOGGER.debug( "Waiting for initial data from %s (%s)", diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index a73740e5dbd..a7dea5d6ab2 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -6,7 +6,6 @@ from collections.abc import Mapping import logging from typing import Any -import async_timeout from systembridgeconnector.exceptions import ( AuthenticationException, ConnectionClosedException, @@ -55,7 +54,7 @@ async def _validate_input( data[CONF_API_KEY], ) try: - async with async_timeout.timeout(15): + async with asyncio.timeout(15): await websocket_client.connect(session=async_get_clientsession(hass)) hass.async_create_task(websocket_client.listen()) response = await websocket_client.get_data(GetData(modules=["system"])) diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index adb88efd5ec..145e01ed29a 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -7,7 +7,6 @@ from datetime import timedelta import logging from typing import Any -import async_timeout from pydantic import BaseModel # pylint: disable=no-name-in-module from systembridgeconnector.exceptions import ( AuthenticationException, @@ -183,7 +182,7 @@ class SystemBridgeDataUpdateCoordinator( async def _setup_websocket(self) -> None: """Use WebSocket for updates.""" try: - async with async_timeout.timeout(20): + async with asyncio.timeout(20): await self.websocket_client.connect( session=async_get_clientsession(self.hass), ) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 4d50bc35c3b..1365c9f23a3 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -8,7 +8,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -109,7 +108,7 @@ class TadoDeviceScanner(DeviceScanner): last_results = [] try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): # Format the URL here, so we can log the template URL if # anything goes wrong without exposing username and password. url = self.tadoapiurl.format( diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index c87b3998a27..060b90a7d70 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -3,7 +3,6 @@ import asyncio import logging import os -import async_timeout from tellduslive import Session, supports_local_api import voluptuous as vol @@ -91,7 +90,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): auth_url = await self.hass.async_add_executor_job(self._get_auth_url) if not auth_url: return self.async_abort(reason="unknown_authorize_url_generation") diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index e14bd944d36..06005d7e4ed 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -7,7 +7,6 @@ import logging import aiohttp from aiohttp.hdrs import ACCEPT, AUTHORIZATION -import async_timeout import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -134,7 +133,7 @@ class TtnDataStorage: """Get the current state from The Things Network Data Storage.""" try: session = async_get_clientsession(self._hass) - async with async_timeout.timeout(DEFAULT_TIMEOUT): + async with asyncio.timeout(DEFAULT_TIMEOUT): response = await session.get(self._url, headers=self._headers) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index 3ff73501bdc..e9048a678ca 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,9 +1,9 @@ """Generic Omada API coordinator.""" +import asyncio from datetime import timedelta import logging from typing import Generic, TypeVar -import async_timeout from tplink_omada_client.exceptions import OmadaClientException from tplink_omada_client.omadaclient import OmadaSiteClient @@ -37,7 +37,7 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): async def _async_update_data(self) -> dict[str, T]: """Fetch data from API endpoint.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self.poll_update() except OmadaClientException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 1e9b63bb325..2a3052c1f7b 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -5,7 +5,6 @@ import asyncio from typing import Any from uuid import uuid4 -import async_timeout from pytradfri import Gateway, RequestError from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol @@ -141,7 +140,7 @@ async def authenticate( api_factory = await APIFactory.init(host, psk_id=identity) try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): key = await api_factory.generate_psk(security_code) except RequestError as err: raise AuthError("invalid_security_code") from err diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 6ac4e622736..649d7c30fdb 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -11,7 +11,6 @@ from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.websocket import WebsocketState -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -375,7 +374,7 @@ class UniFiController: async def async_reconnect(self) -> None: """Try to reconnect UniFi Network session.""" try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): await self.api.login() self.api.start_websocket() @@ -444,7 +443,7 @@ async def get_unifi_controller( ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await controller.check_unifi_os() await controller.login() return controller diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index 728d46acd76..318ba44f557 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -4,7 +4,6 @@ from contextlib import suppress import logging from urllib.parse import urlparse -import async_timeout import upb_lib import voluptuous as vol @@ -45,7 +44,7 @@ async def _validate_input(data): upb.connect(_connected_callback) with suppress(asyncio.TimeoutError): - async with async_timeout.timeout(VALIDATE_TIMEOUT): + async with asyncio.timeout(VALIDATE_TIMEOUT): await connected_event.wait() upb.disconnect() diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 5f77d58c5ea..bb505c08ad0 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from datetime import timedelta -import async_timeout from async_upnp_client.exceptions import UpnpConnectionError from homeassistant.components import ssdp @@ -71,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await device_discovered_event.wait() except asyncio.TimeoutError as err: raise ConfigEntryNotReady(f"Device not discovered: {usn}") from err diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 9326db64d0a..4043cc865c7 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -7,7 +7,6 @@ import logging import time import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -79,7 +78,7 @@ async def async_http_request(hass, uri): """Perform actual request.""" try: session = async_get_clientsession(hass) - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): req = await session.get(uri) if req.status != HTTPStatus.OK: return {"error": req.status} diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 072e0ee431d..5bdc8bee3ac 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -4,7 +4,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -196,7 +195,7 @@ class VoiceRSSProvider(Provider): form_data["hl"] = language try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): request = await websession.post(VOICERSS_API_URL, data=form_data) if request.status != HTTPStatus.OK: diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index ca78b604169..efa62e0e8f4 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -10,7 +10,6 @@ from pathlib import Path import time from typing import TYPE_CHECKING -import async_timeout from voip_utils import ( CallInfo, RtcpState, @@ -259,7 +258,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._clear_audio_queue() # Run pipeline with a timeout - async with async_timeout.timeout(self.pipeline_timeout): + async with asyncio.timeout(self.pipeline_timeout): await async_pipeline_from_audio_stream( self.hass, context=self._context, @@ -315,7 +314,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): """ # Timeout if no audio comes in for a while. # This means the caller hung up. - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() while chunk: @@ -326,7 +325,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): # Buffer until command starts return True - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() return False @@ -343,7 +342,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): # Timeout if no audio comes in for a while. # This means the caller hung up. - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() while chunk: @@ -353,7 +352,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): yield chunk - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() def _clear_audio_queue(self) -> None: @@ -395,7 +394,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): tts_samples = len(audio_bytes) / (WIDTH * CHANNELS) tts_seconds = tts_samples / RATE - async with async_timeout.timeout(tts_seconds + self.tts_extra_timeout): + async with asyncio.timeout(tts_seconds + self.tts_extra_timeout): # Assume TTS audio is 16Khz 16-bit mono await self._async_send_audio(audio_bytes) except asyncio.TimeoutError as err: diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 06f8d0ad5a2..4ec1bf4a4ba 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -1,9 +1,9 @@ """Support for Volvo On Call.""" +import asyncio import logging from aiohttp.client_exceptions import ClientResponseError -import async_timeout from volvooncall import Connection from volvooncall.dashboard import Instrument @@ -186,7 +186,7 @@ class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await self.volvo_data.update() diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 11903ebdd68..61bef8c693c 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -12,7 +12,6 @@ import ssl from typing import Any, Concatenate, ParamSpec, TypeVar, cast from aiowebostv import WebOsClient, WebOsTvPairError -import async_timeout from homeassistant import util from homeassistant.components.media_player import ( @@ -480,7 +479,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): websession = async_get_clientsession(self.hass) with suppress(asyncio.TimeoutError): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await websession.get(url, ssl=ssl_context) if response.status == HTTPStatus.OK: content = await response.read() diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 834a0b95f42..111acc5fff6 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -5,7 +5,6 @@ import asyncio import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -95,7 +94,7 @@ class WorxLandroidSensor(SensorEntity): try: session = async_get_clientsession(self.hass) - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): auth = aiohttp.helpers.BasicAuth("admin", self.pin) mower_response = await session.get(self.url, auth=auth) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index 1fe4d60b974..64b92eb8471 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import async_timeout from wyoming.client import AsyncTcpClient from wyoming.info import Describe, Info @@ -55,9 +54,7 @@ async def load_wyoming_info( for _ in range(retries + 1): try: - async with AsyncTcpClient(host, port) as client, async_timeout.timeout( - timeout - ): + async with AsyncTcpClient(host, port) as client, asyncio.timeout(timeout): # Describe -> Info await client.write_event(Describe().event()) while True: diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 541b077f6f0..0291ca2c8bd 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -1,13 +1,13 @@ """Support for Xiaomi Miio.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta import logging from typing import Any -import async_timeout from miio import ( AirFresh, AirFreshA1, @@ -176,7 +176,7 @@ def _async_update_data_default(hass, device): async def _async_fetch_data(): """Fetch data from the device.""" - async with async_timeout.timeout(POLLING_TIMEOUT_SEC): + async with asyncio.timeout(POLLING_TIMEOUT_SEC): state = await hass.async_add_executor_job(device.status) _LOGGER.debug("Got new state: %s", state) return state @@ -265,7 +265,7 @@ def _async_update_data_vacuum( """Fetch data from the device using async_add_executor_job.""" async def execute_update() -> VacuumCoordinatorData: - async with async_timeout.timeout(POLLING_TIMEOUT_SEC): + async with asyncio.timeout(POLLING_TIMEOUT_SEC): state = await hass.async_add_executor_job(update) _LOGGER.debug("Got new vacuum state: %s", state) return state diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 755207c272d..481678100de 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -4,7 +4,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -120,7 +119,7 @@ class YandexSpeechKitProvider(Provider): actual_language = language try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): url_param = { "text": message, "lang": actual_language, diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 7c6bbd2d2ee..43e976eeeac 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -10,7 +10,6 @@ import logging from typing import Self from urllib.parse import urlparse -import async_timeout from async_upnp_client.search import SsdpSearchListener from async_upnp_client.utils import CaseInsensitiveDict @@ -157,7 +156,7 @@ class YeelightScanner: listener.async_search((host, SSDP_TARGET[1])) with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(DISCOVERY_TIMEOUT): + async with asyncio.timeout(DISCOVERY_TIMEOUT): await host_event.wait() self._host_discovered_events[host].remove(host_event) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index c3633800685..20129b819ce 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -import async_timeout from yolink.const import ATTR_DEVICE_SMART_REMOTER from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError @@ -111,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) yolink_home = YoLinkHome() try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await yolink_home.async_setup( auth_mgr, YoLinkHomeMessageListener(hass, entry) ) diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index e322961d179..9055b2d044e 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -1,10 +1,10 @@ """YoLink DataUpdateCoordinator.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging -import async_timeout from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError @@ -41,7 +41,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): async def _async_update_data(self) -> dict: """Fetch device state.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): device_state_resp = await self.device.fetch_state() device_state = device_state_resp.data.get(ATTR_DEVICE_STATE) if self.paired_device is not None and device_state is not None: diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index d477964d229..2e6ff4f0b34 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -7,7 +7,6 @@ from collections.abc import Coroutine from contextlib import suppress from typing import Any -from async_timeout import timeout from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, RemoveNodeReason from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion @@ -146,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # connect and throw error if connection failed try: - async with timeout(CONNECT_TIMEOUT): + async with asyncio.timeout(CONNECT_TIMEOUT): await client.connect() except InvalidServerVersion as err: if use_addon: diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 071b562ceea..752e3545114 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -7,7 +7,6 @@ import logging from typing import Any import aiohttp -from async_timeout import timeout from serial.tools import list_ports import voluptuous as vol from zwave_js_server.version import VersionInfo, get_server_version @@ -115,7 +114,7 @@ async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo: async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> VersionInfo: """Return Z-Wave JS version info.""" try: - async with timeout(SERVER_VERSION_TIMEOUT): + async with asyncio.timeout(SERVER_VERSION_TIMEOUT): version_info: VersionInfo = await get_server_version( ws_address, async_get_clientsession(hass) ) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index d4072055407..a3f74127283 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,14 +1,8 @@ """Tests for the Sonos config flow.""" import asyncio import logging -import sys from unittest.mock import Mock, patch -if sys.version_info[:2] < (3, 11): - from async_timeout import timeout as asyncio_timeout -else: - from asyncio import timeout as asyncio_timeout - import pytest from homeassistant import config_entries, data_entry_flow @@ -377,7 +371,7 @@ async def test_async_poll_manual_hosts_6( caplog.clear() # The discovery events should not fire, wait with a timeout. with pytest.raises(asyncio.TimeoutError): - async with asyncio_timeout(1.0): + async with asyncio.timeout(1.0): await speaker_1_activity.event.wait() await hass.async_block_till_done() assert "Activity on Living Room" not in caplog.text diff --git a/tests/components/upb/test_config_flow.py b/tests/components/upb/test_config_flow.py index 40f2b5591f1..d2fbe27248d 100644 --- a/tests/components/upb/test_config_flow.py +++ b/tests/components/upb/test_config_flow.py @@ -82,7 +82,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: from asyncio import TimeoutError with patch( - "homeassistant.components.upb.config_flow.async_timeout.timeout", + "homeassistant.components.upb.config_flow.asyncio.timeout", side_effect=TimeoutError, ): result = await valid_tcp_flow(hass, sync_complete=False) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 9b3f5d963dc..361e4e7f0e2 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -3,7 +3,6 @@ import asyncio import time from unittest.mock import AsyncMock, Mock, patch -import async_timeout import pytest from homeassistant.components import assist_pipeline, voip @@ -118,7 +117,7 @@ async def test_pipeline( rtp_protocol.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await done.wait() @@ -159,7 +158,7 @@ async def test_pipeline_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> rtp_protocol.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to time out - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await done.wait() @@ -200,7 +199,7 @@ async def test_stt_stream_timeout(hass: HomeAssistant, voip_device: VoIPDevice) rtp_protocol.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to time out - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await done.wait() @@ -319,5 +318,5 @@ async def test_tts_timeout( rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) # Wait for mock pipeline to exhaust the audio stream - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await done.wait() diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index b715dd4ba72..5c8353fc8bc 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -4,7 +4,6 @@ from dataclasses import asdict from datetime import timedelta from unittest.mock import call, patch -import async_timeout import pytest from pywemo.exceptions import ActionException, PyWeMoException from pywemo.subscribe import EVENT_TYPE_LONG_PRESS @@ -77,7 +76,7 @@ async def test_long_press_event( "testing_params", ) - async with async_timeout.timeout(8): + async with asyncio.timeout(8): await got_event.wait() assert event_data == { @@ -108,7 +107,7 @@ async def test_subscription_callback( pywemo_registry.callbacks[device.wemo.name], device.wemo, "", "" ) - async with async_timeout.timeout(8): + async with asyncio.timeout(8): await got_callback.wait() assert device.last_update_success From 346a7292d73d27f43355b09a035ba84b8ebdaef3 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 15 Aug 2023 08:49:19 -0400 Subject: [PATCH 072/180] Update Enphase dry contact relay DeviceInfo and name (#98429) Switch relay binary_sensor to relay device --- .../components/enphase_envoy/binary_sensor.py | 14 ++++++++------ .../components/enphase_envoy/strings.json | 3 +++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 68368719fc4..0e70a9fe98b 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -60,7 +60,9 @@ ENCHARGE_SENSORS = ( ) RELAY_STATUS_SENSOR = BinarySensorEntityDescription( - key="relay_status", icon="mdi:power-plug", has_entity_name=True + key="relay_status", + translation_key="relay", + icon="mdi:power-plug", ) @@ -219,17 +221,17 @@ class EnvoyRelayBinarySensorEntity(EnvoyBaseBinarySensorEntity): enpower = self.data.enpower assert enpower is not None self.relay_id = relay_id + self.relay = self.data.dry_contact_settings[self.relay_id] self._serial_number = enpower.serial_number self._attr_unique_id = f"{self._serial_number}_relay_{relay_id}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, + identifiers={(DOMAIN, relay_id)}, manufacturer="Enphase", - model="Enpower", - name=f"Enpower {self._serial_number}", + model="Dry contact relay", + name=self.relay.load_name, sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device=(DOMAIN, enpower.serial_number), ) - self._attr_name = f"{self.data.dry_contact_settings[relay_id].load_name} Relay" @property def is_on(self) -> bool: diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index bab16bc6c58..0f292dfa8e3 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -34,6 +34,9 @@ }, "grid_status": { "name": "Grid status" + }, + "relay": { + "name": "Relay status" } }, "select": { From e2d2ec88178a5b1902dde15d41eddeb1d92ae64f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Aug 2023 15:30:20 +0200 Subject: [PATCH 073/180] Use asyncio.timeout [b-e] (#98448) --- homeassistant/components/blueprint/websocket_api.py | 4 ++-- homeassistant/components/canary/coordinator.py | 4 ++-- homeassistant/components/citybikes/sensor.py | 3 +-- .../components/color_extractor/__init__.py | 3 +-- .../components/comed_hourly_pricing/sensor.py | 3 +-- homeassistant/components/daikin/__init__.py | 3 +-- homeassistant/components/daikin/config_flow.py | 3 +-- homeassistant/components/deconz/config_flow.py | 7 +++---- homeassistant/components/deconz/gateway.py | 3 +-- .../components/devolo_home_network/__init__.py | 12 ++++++------ homeassistant/components/doorbird/camera.py | 3 +-- homeassistant/components/dsmr/config_flow.py | 3 +-- homeassistant/components/eafm/sensor.py | 4 ++-- .../components/electric_kiwi/coordinator.py | 4 ++-- homeassistant/components/elkm1/__init__.py | 3 +-- homeassistant/components/elmax/common.py | 4 ++-- homeassistant/components/emulated_hue/hue_api.py | 3 +-- homeassistant/components/escea/config_flow.py | 4 +--- .../components/esphome/bluetooth/client.py | 3 +-- homeassistant/components/esphome/voice_assistant.py | 13 ++++++------- .../components/evil_genius_labs/__init__.py | 8 ++++---- .../components/evil_genius_labs/config_flow.py | 3 +-- homeassistant/components/evil_genius_labs/light.py | 13 ++++++------- homeassistant/components/ezviz/coordinator.py | 4 ++-- tests/components/esphome/conftest.py | 4 ++-- tests/components/esphome/test_voice_assistant.py | 3 +-- 26 files changed, 53 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index a9bcf5ded1c..1732320c1e9 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -1,9 +1,9 @@ """Websocket API for blueprint.""" from __future__ import annotations +import asyncio from typing import Any, cast -import async_timeout import voluptuous as vol from homeassistant.components import websocket_api @@ -72,7 +72,7 @@ async def ws_import_blueprint( msg: dict[str, Any], ) -> None: """Import a blueprint.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): imported_blueprint = await importer.fetch_blueprint_from_url(hass, msg["url"]) if imported_blueprint is None: diff --git a/homeassistant/components/canary/coordinator.py b/homeassistant/components/canary/coordinator.py index d81589020e3..1b47d6d70b7 100644 --- a/homeassistant/components/canary/coordinator.py +++ b/homeassistant/components/canary/coordinator.py @@ -1,11 +1,11 @@ """Provides the Canary DataUpdateCoordinator.""" from __future__ import annotations +import asyncio from collections.abc import ValuesView from datetime import timedelta import logging -from async_timeout import timeout from canary.api import Api from canary.model import Location, Reading from requests.exceptions import ConnectTimeout, HTTPError @@ -58,7 +58,7 @@ class CanaryDataUpdateCoordinator(DataUpdateCoordinator[CanaryData]): """Fetch data from Canary.""" try: - async with timeout(15): + async with asyncio.timeout(15): return await self.hass.async_add_executor_job(self._update_data) except (ConnectTimeout, HTTPError) as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index c87427e0e7e..fcd780dba7d 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -6,7 +6,6 @@ from datetime import timedelta import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.sensor import ( @@ -140,7 +139,7 @@ async def async_citybikes_request(hass, uri, schema): try: session = async_get_clientsession(hass) - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): req = await session.get(DEFAULT_ENDPOINT.format(uri=uri)) json_response = await req.json() diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index d0a6b53964b..0e27f396c6d 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -4,7 +4,6 @@ import io import logging import aiohttp -import async_timeout from colorthief import ColorThief from PIL import UnidentifiedImageError import voluptuous as vol @@ -120,7 +119,7 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: try: session = aiohttp_client.async_get_clientsession(hass) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await session.get(url) except (asyncio.TimeoutError, aiohttp.ClientError) as err: diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 3336f5b79f8..ef974b8f3ed 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -7,7 +7,6 @@ import json import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.sensor import ( @@ -112,7 +111,7 @@ class ComedHourlyPricingSensor(SensorEntity): else: url_string += "?type=currenthouraverage" - async with async_timeout.timeout(60): + async with asyncio.timeout(60): response = await self.websession.get(url_string) # The API responds with MIME type 'text/html' text = await response.text() diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index dcab26211c9..f6fd399f855 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -4,7 +4,6 @@ from datetime import timedelta import logging from aiohttp import ClientConnectionError -from async_timeout import timeout from pydaikin.daikin_base import Appliance from homeassistant.config_entries import ConfigEntry @@ -74,7 +73,7 @@ async def daikin_api_setup(hass: HomeAssistant, host, key, uuid, password): session = async_get_clientsession(hass) try: - async with timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): device = await Appliance.factory( host, session, key=key, uuid=uuid, password=password ) diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index a64f2059972..2d5d1e12dfd 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -4,7 +4,6 @@ import logging from uuid import uuid4 from aiohttp import ClientError, web_exceptions -from async_timeout import timeout from pydaikin.daikin_base import Appliance, DaikinException from pydaikin.discovery import Discovery import voluptuous as vol @@ -70,7 +69,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): password = None try: - async with timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): device = await Appliance.factory( host, async_get_clientsession(self.hass), diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 8eda93c2d46..c0361aa2bca 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -9,7 +9,6 @@ from pprint import pformat from typing import Any, cast from urllib.parse import urlparse -import async_timeout from pydeconz.errors import LinkButtonNotPressed, RequestError, ResponseError from pydeconz.gateway import DeconzSession from pydeconz.utils import ( @@ -101,7 +100,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): session = aiohttp_client.async_get_clientsession(self.hass) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): self.bridges = await deconz_discovery(session) except (asyncio.TimeoutError, ResponseError): @@ -159,7 +158,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): deconz_session = DeconzSession(session, self.host, self.port) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): api_key = await deconz_session.get_api_key() except LinkButtonNotPressed: @@ -180,7 +179,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): session = aiohttp_client.async_get_clientsession(self.hass) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): self.bridge_id = await deconz_get_bridge_id( session, self.host, self.port, self.api_key ) diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index f4af7337427..156309c0903 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -7,7 +7,6 @@ from collections.abc import Callable from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast -import async_timeout from pydeconz import DeconzSession, errors from pydeconz.interfaces import sensors from pydeconz.interfaces.api_handlers import APIHandler, GroupedAPIHandler @@ -353,7 +352,7 @@ async def get_deconz_session( config[CONF_API_KEY], ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await deconz_session.refresh_state() return deconz_session diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index e70b28f9c3c..ed070abf0c8 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -1,10 +1,10 @@ """The devolo Home Network integration.""" from __future__ import annotations +import asyncio import logging from typing import Any -import async_timeout from devolo_plc_api import Device from devolo_plc_api.device_api import ( ConnectedStationInfo, @@ -70,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert device.plcnet try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await device.plcnet.async_get_network_overview() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -79,7 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await device.device.async_get_wifi_guest_access() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -90,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await device.device.async_get_led_setting() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -99,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await device.device.async_get_wifi_connected_station() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -108,7 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with async_timeout.timeout(30): + async with asyncio.timeout(30): return await device.device.async_get_wifi_neighbor_access_points() except DeviceUnavailable as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index c1c8a622af8..63eb646972d 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -6,7 +6,6 @@ import datetime import logging import aiohttp -import async_timeout from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry @@ -118,7 +117,7 @@ class DoorBirdCamera(DoorBirdEntity, Camera): try: websession = async_get_clientsession(self.hass) - async with async_timeout.timeout(_TIMEOUT): + async with asyncio.timeout(_TIMEOUT): response = await websession.get(self._url) self._last_image = await response.read() diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 6152a3756e3..c7b9ab4e380 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -6,7 +6,6 @@ from functools import partial import os from typing import Any -from async_timeout import timeout from dsmr_parser import obis_references as obis_ref from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader from dsmr_parser.clients.rfxtrx_protocol import ( @@ -121,7 +120,7 @@ class DSMRConnection: if transport: try: - async with timeout(30): + async with asyncio.timeout(30): await protocol.wait_closed() except asyncio.TimeoutError: # Timeout (no data received), close transport and return True (if telegram is empty, will result in CannotCommunicate error) diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index 8358887f7a2..2c7f8456a72 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -1,9 +1,9 @@ """Support for gauges from flood monitoring API.""" +import asyncio from datetime import timedelta import logging from aioeafm import get_station -import async_timeout from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry @@ -48,7 +48,7 @@ async def async_setup_entry( async def async_update_data(): # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts - async with async_timeout.timeout(30): + async with asyncio.timeout(30): data = await get_station(session, station_key) measures = get_measures(data) diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 3e0ba997cd4..49611f9febd 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -1,9 +1,9 @@ """Electric Kiwi coordinators.""" +import asyncio from collections import OrderedDict from datetime import timedelta import logging -import async_timeout from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException from electrickiwi_api.model import Hop, HopIntervals @@ -61,7 +61,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): filters the intervals to remove ones that are not active """ try: - async with async_timeout.timeout(60): + async with asyncio.timeout(60): if self.hop_intervals is None: hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals() hop_intervals.intervals = OrderedDict( diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 49e35a127fe..352c8419106 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -8,7 +8,6 @@ import re from types import MappingProxyType from typing import Any, cast -import async_timeout from elkm1_lib.elements import Element from elkm1_lib.elk import Elk from elkm1_lib.util import parse_url @@ -382,7 +381,7 @@ async def async_wait_for_elk_to_sync( ): _LOGGER.debug("Waiting for %s event for %s seconds", name, timeout) try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await event.wait() except asyncio.TimeoutError: _LOGGER.debug("Timed out waiting for %s event", name) diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index fc08895ba4d..b593ae399f4 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -1,11 +1,11 @@ """Elmax integration common classes and utilities.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from logging import Logger -import async_timeout from elmax_api.exceptions import ( ElmaxApiError, ElmaxBadLoginError, @@ -94,7 +94,7 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): async def _async_update_data(self): try: - async with async_timeout.timeout(DEFAULT_TIMEOUT): + async with asyncio.timeout(DEFAULT_TIMEOUT): # Retrieve the panel online status first panels = await self._client.list_control_panels() panel = next( diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index f0a54ba0ea9..566779671e8 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -11,7 +11,6 @@ import time from typing import Any from aiohttp import web -import async_timeout from homeassistant import core from homeassistant.components import ( @@ -898,7 +897,7 @@ async def wait_for_state_change_or_timeout( unsub = async_track_state_change_event(hass, [entity_id], _async_event_changed) try: - async with async_timeout.timeout(STATE_CHANGE_WAIT_TIMEOUT): + async with asyncio.timeout(STATE_CHANGE_WAIT_TIMEOUT): await ev.wait() except asyncio.TimeoutError: pass diff --git a/homeassistant/components/escea/config_flow.py b/homeassistant/components/escea/config_flow.py index 2a6e19343d9..8766c30c04a 100644 --- a/homeassistant/components/escea/config_flow.py +++ b/homeassistant/components/escea/config_flow.py @@ -3,8 +3,6 @@ import asyncio from contextlib import suppress import logging -from async_timeout import timeout - from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_entry_flow from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -34,7 +32,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: discovery_service = await async_start_discovery_service(hass) with suppress(asyncio.TimeoutError): - async with timeout(TIMEOUT_DISCOVERY): + async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() remove_handler() diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 748035bedac..4ce8909587e 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -22,7 +22,6 @@ from aioesphomeapi import ( from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError from aioesphomeapi.core import BluetoothGATTAPIError from async_interrupt import interrupt -import async_timeout from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.client import BaseBleakClient, NotifyCallback from bleak.backends.device import BLEDevice @@ -402,7 +401,7 @@ class ESPHomeClient(BaseBleakClient): self._ble_device.name, self._ble_device.address, ) - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await bluetooth_device.wait_for_ble_connections_free() @property diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 6b49549d812..f870f9e42f7 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -9,7 +9,6 @@ import socket from typing import cast from aioesphomeapi import VoiceAssistantEventType -import async_timeout from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import ( @@ -210,7 +209,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): Returns False if the connection was stopped gracefully (b"" put onto the queue). """ # Timeout if no audio comes in for a while. - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self.queue.get() while chunk: @@ -220,7 +219,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): if segmenter.in_command: return True - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self.queue.get() # If chunk is falsey, `stop()` was called @@ -240,7 +239,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): yield buffered_chunk # Timeout if no audio comes in for a while. - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self.queue.get() while chunk: @@ -250,7 +249,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): yield chunk - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self.queue.get() async def _iterate_packets_with_vad( @@ -259,7 +258,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): segmenter = VoiceCommandSegmenter(silence_seconds=silence_seconds) chunk_buffer: deque[bytes] = deque(maxlen=100) try: - async with async_timeout.timeout(pipeline_timeout): + async with asyncio.timeout(pipeline_timeout): speech_detected = await self._wait_for_speech(segmenter, chunk_buffer) if not speech_detected: _LOGGER.debug( @@ -326,7 +325,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): _LOGGER.debug("Starting pipeline") try: - async with async_timeout.timeout(pipeline_timeout): + async with asyncio.timeout(pipeline_timeout): await async_pipeline_from_audio_stream( self.hass, context=self.context, diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index 81a29b1432e..3d65a5516c7 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -1,12 +1,12 @@ """The Evil Genius Labs integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import cast from aiohttp import ContentTypeError -from async_timeout import timeout import pyevilgenius from homeassistant.config_entries import ConfigEntry @@ -85,18 +85,18 @@ class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): async def _async_update_data(self) -> dict: """Update Evil Genius data.""" if not hasattr(self, "info"): - async with timeout(5): + async with asyncio.timeout(5): self.info = await self.client.get_info() if not hasattr(self, "product"): - async with timeout(5): + async with asyncio.timeout(5): try: self.product = await self.client.get_product() except ContentTypeError: # Older versions of the API don't support this self.product = None - async with timeout(5): + async with asyncio.timeout(5): return cast(dict, await self.client.get_all()) diff --git a/homeassistant/components/evil_genius_labs/config_flow.py b/homeassistant/components/evil_genius_labs/config_flow.py index 53303d738a5..beb16115bd7 100644 --- a/homeassistant/components/evil_genius_labs/config_flow.py +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -6,7 +6,6 @@ import logging from typing import Any import aiohttp -import async_timeout import pyevilgenius import voluptuous as vol @@ -31,7 +30,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): data = await hub.get_all() info = await hub.get_info() except aiohttp.ClientError as err: diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index a915619b1b8..5612d0e8522 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -1,10 +1,9 @@ """Light platform for Evil Genius Light.""" from __future__ import annotations +import asyncio from typing import Any, cast -from async_timeout import timeout - from homeassistant.components import light from homeassistant.components.light import ColorMode, LightEntity, LightEntityFeature from homeassistant.config_entries import ConfigEntry @@ -89,27 +88,27 @@ class EvilGeniusLight(EvilGeniusEntity, LightEntity): ) -> None: """Turn light on.""" if (brightness := kwargs.get(light.ATTR_BRIGHTNESS)) is not None: - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_path_value("brightness", brightness) # Setting a color will change the effect to "Solid Color" so skip setting effect if (rgb_color := kwargs.get(light.ATTR_RGB_COLOR)) is not None: - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_rgb_color(*rgb_color) elif (effect := kwargs.get(light.ATTR_EFFECT)) is not None: if effect == HA_NO_EFFECT: effect = FIB_NO_EFFECT - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_path_value( "pattern", self.coordinator.data["pattern"]["options"].index(effect) ) - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_path_value("power", 1) @update_when_done async def async_turn_off(self, **kwargs: Any) -> None: """Turn light off.""" - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_path_value("power", 0) diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index ba8ed336a51..427e52f7dd0 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -1,8 +1,8 @@ """Provides the ezviz DataUpdateCoordinator.""" +import asyncio from datetime import timedelta import logging -from async_timeout import timeout from pyezviz.client import EzvizClient from pyezviz.exceptions import ( EzvizAuthTokenExpired, @@ -37,7 +37,7 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" try: - async with timeout(self._api_timeout): + async with asyncio.timeout(self._api_timeout): return await self.hass.async_add_executor_job( self.ezviz_client.load_cameras ) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 4deae7f13fa..f0fe2d9ccb0 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -1,6 +1,7 @@ """esphome session fixtures.""" from __future__ import annotations +import asyncio from asyncio import Event from collections.abc import Awaitable, Callable from typing import Any @@ -15,7 +16,6 @@ from aioesphomeapi import ( ReconnectLogic, UserService, ) -import async_timeout import pytest from zeroconf import Zeroconf @@ -252,7 +252,7 @@ async def _mock_generic_device_entry( "homeassistant.components.esphome.manager.ReconnectLogic", MockReconnectLogic ): assert await hass.config_entries.async_setup(entry.entry_id) - async with async_timeout.timeout(2): + async with asyncio.timeout(2): await try_connect_done.wait() await hass.async_block_till_done() diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index 4188e375907..d6562651f0b 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -5,7 +5,6 @@ import socket from unittest.mock import Mock, patch from aioesphomeapi import VoiceAssistantEventType -import async_timeout import pytest from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType @@ -148,7 +147,7 @@ async def test_udp_server( sock.sendto(b"test", ("127.0.0.1", port)) # Give the socket some time to send/receive the data - async with async_timeout.timeout(1): + async with asyncio.timeout(1): while voice_assistant_udp_server_v1.queue.qsize() == 0: await asyncio.sleep(0.1) From a9ade1f84df04c272545e6f3014ab9f43aaae346 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Aug 2023 15:36:05 +0200 Subject: [PATCH 074/180] Use asyncio.timeout [core] (#98447) --- homeassistant/components/bluetooth/scanner.py | 3 +-- homeassistant/components/camera/__init__.py | 5 ++--- .../components/cloud/alexa_config.py | 3 +-- homeassistant/components/cloud/http_api.py | 15 +++++++------ .../components/cloud/subscription.py | 5 ++--- homeassistant/components/mqtt/client.py | 3 +-- homeassistant/components/mqtt/util.py | 3 +-- homeassistant/components/recorder/core.py | 3 +-- homeassistant/components/stream/core.py | 3 +-- .../components/system_health/__init__.py | 3 +-- .../components/websocket_api/http.py | 5 ++--- homeassistant/core.py | 3 +-- homeassistant/helpers/aiohttp_client.py | 5 ++--- .../helpers/config_entry_oauth2_flow.py | 5 ++--- homeassistant/helpers/script.py | 17 ++++++++------- homeassistant/helpers/template.py | 3 +-- tests/components/group/test_cover.py | 4 ++-- tests/components/group/test_fan.py | 4 ++-- tests/components/group/test_light.py | 4 ++-- tests/components/group/test_media_player.py | 4 ++-- tests/components/group/test_switch.py | 5 ++--- .../components/history/test_websocket_api.py | 21 +++++++++---------- .../components/websocket_api/test_commands.py | 8 +++---- .../helpers/test_config_entry_oauth2_flow.py | 4 ++-- tests/helpers/test_event.py | 9 ++++---- tests/helpers/test_script.py | 5 ++--- tests/test_core.py | 21 +++++++++---------- 27 files changed, 77 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 35efbdf3cbe..f0b7df528e1 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -8,7 +8,6 @@ import logging import platform from typing import Any -import async_timeout import bleak from bleak import BleakError from bleak.assigned_numbers import AdvertisementDataType @@ -220,7 +219,7 @@ class HaScanner(BaseHaScanner): START_ATTEMPTS, ) try: - async with async_timeout.timeout(START_TIMEOUT): + async with asyncio.timeout(START_TIMEOUT): await self.scanner.start() # type: ignore[no-untyped-call] except InvalidMessageError as ex: _LOGGER.debug( diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 277aa10075e..486c964bb45 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -15,7 +15,6 @@ from random import SystemRandom from typing import Any, Final, cast, final from aiohttp import hdrs, web -import async_timeout import attr import voluptuous as vol @@ -168,7 +167,7 @@ async def _async_get_image( are handled. """ with suppress(asyncio.CancelledError, asyncio.TimeoutError): - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): if image_bytes := await camera.async_camera_image( width=width, height=height ): @@ -525,7 +524,7 @@ class Camera(Entity): self._create_stream_lock = asyncio.Lock() async with self._create_stream_lock: if not self.stream: - async with async_timeout.timeout(CAMERA_STREAM_SOURCE_TIMEOUT): + async with asyncio.timeout(CAMERA_STREAM_SOURCE_TIMEOUT): source = await self.stream_source() if not source: return None diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 3ceb02972d1..e85c6dd277a 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -10,7 +10,6 @@ import logging from typing import TYPE_CHECKING, Any import aiohttp -import async_timeout from hass_nabucasa import Cloud, cloud_api from yarl import URL @@ -501,7 +500,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) return True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 00ef4455f3b..e3b1b39f687 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -10,7 +10,6 @@ from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp from aiohttp import web -import async_timeout import attr from hass_nabucasa import Cloud, auth, thingtalk from hass_nabucasa.const import STATE_DISCONNECTED @@ -252,7 +251,7 @@ class CloudLogoutView(HomeAssistantView): hass = request.app["hass"] cloud = hass.data[DOMAIN] - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.logout() return self.json_message("ok") @@ -292,7 +291,7 @@ class CloudRegisterView(HomeAssistantView): if location_info.zip_code is not None: client_metadata["NC_ZIP_CODE"] = location_info.zip_code - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_register( data["email"], data["password"], @@ -316,7 +315,7 @@ class CloudResendConfirmView(HomeAssistantView): hass = request.app["hass"] cloud = hass.data[DOMAIN] - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_resend_email_confirm(data["email"]) return self.json_message("ok") @@ -336,7 +335,7 @@ class CloudForgotPasswordView(HomeAssistantView): hass = request.app["hass"] cloud = hass.data[DOMAIN] - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_forgot_password(data["email"]) return self.json_message("ok") @@ -439,7 +438,7 @@ async def websocket_update_prefs( if changes.get(PREF_ALEXA_REPORT_STATE): alexa_config = await cloud.client.get_alexa_config() try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await alexa_config.async_get_access_token() except asyncio.TimeoutError: connection.send_error( @@ -779,7 +778,7 @@ async def alexa_sync( cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: success = await alexa_config.async_sync_entities() except alexa_errors.NoTokenAvailable: @@ -808,7 +807,7 @@ async def thingtalk_convert( """Convert a query.""" cloud = hass.data[DOMAIN] - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: connection.send_result( msg["id"], await thingtalk.async_convert(cloud, msg["query"]) diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index 633f0c95e1b..9a62f2d115c 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -6,7 +6,6 @@ import logging from typing import Any from aiohttp.client_exceptions import ClientError -import async_timeout from hass_nabucasa import Cloud, cloud_api from .client import CloudClient @@ -18,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None: """Fetch the subscription info.""" try: - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): return await cloud_api.async_subscription_info(cloud) except asyncio.TimeoutError: _LOGGER.error( @@ -39,7 +38,7 @@ async def async_migrate_paypal_agreement( ) -> dict[str, Any] | None: """Migrate a paypal agreement from legacy.""" try: - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): return await cloud_api.async_migrate_paypal_agreement(cloud) except asyncio.TimeoutError: _LOGGER.error( diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 07fbc0ca8c5..0c351e69bcf 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -12,7 +12,6 @@ import time from typing import TYPE_CHECKING, Any import uuid -import async_timeout import attr import certifi @@ -918,7 +917,7 @@ class MQTT: # may be executed first. await self._register_mid(mid) try: - async with async_timeout.timeout(TIMEOUT_ACK): + async with asyncio.timeout(TIMEOUT_ACK): await self._pending_operations[mid].wait() except asyncio.TimeoutError: _LOGGER.warning( diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 896ba21f802..02d9964bcd1 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -8,7 +8,6 @@ from pathlib import Path import tempfile from typing import Any -import async_timeout import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -71,7 +70,7 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: return state_reached_future.result() try: - async with async_timeout.timeout(AVAILABILITY_TIMEOUT): + async with asyncio.timeout(AVAILABILITY_TIMEOUT): # Await the client setup or an error state was received return await state_reached_future except asyncio.TimeoutError: diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index d4a026cfefc..ffdc3807039 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -13,7 +13,6 @@ import threading import time from typing import Any, TypeVar, cast -import async_timeout import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select from sqlalchemy.engine import Engine @@ -1306,7 +1305,7 @@ class Recorder(threading.Thread): task = DatabaseLockTask(database_locked, threading.Event(), False) self.queue_task(task) try: - async with async_timeout.timeout(DB_LOCK_TIMEOUT): + async with asyncio.timeout(DB_LOCK_TIMEOUT): await database_locked.wait() except asyncio.TimeoutError as err: task.database_unlock.set() diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index cc3c0abb96c..f3591e7e5d7 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -10,7 +10,6 @@ import logging from typing import TYPE_CHECKING, Any from aiohttp import web -import async_timeout import attr import numpy as np @@ -332,7 +331,7 @@ class StreamOutput: async def part_recv(self, timeout: float | None = None) -> bool: """Wait for an event signalling the latest part segment.""" try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await self._part_event.wait() except asyncio.TimeoutError: return False diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 9a222d7096c..32970bc4fe5 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -9,7 +9,6 @@ import logging from typing import Any import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components import websocket_api @@ -73,7 +72,7 @@ async def get_integration_info( """Get integration system health.""" try: assert registration.info_callback - async with async_timeout.timeout(INFO_CALLBACK_TIMEOUT): + async with asyncio.timeout(INFO_CALLBACK_TIMEOUT): data = await registration.info_callback(hass) except asyncio.TimeoutError: data = {"error": {"type": "failed", "error": "timeout"}} diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index fcaa13ff8de..238cd6d7465 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -9,7 +9,6 @@ import logging from typing import TYPE_CHECKING, Any, Final from aiohttp import WSMsgType, web -import async_timeout from homeassistant.components.http import HomeAssistantView from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -273,7 +272,7 @@ class WebSocketHandler: logging_debug = logging.DEBUG try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await wsock.prepare(request) except asyncio.TimeoutError: self._logger.warning("Timeout preparing request from %s", request.remote) @@ -302,7 +301,7 @@ class WebSocketHandler: # Auth Phase try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): msg = await wsock.receive() except asyncio.TimeoutError as err: disconnect_warn = "Did not receive auth message within 10 seconds" diff --git a/homeassistant/core.py b/homeassistant/core.py index 3b54358dc3d..a025eacd4bc 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -29,7 +29,6 @@ from time import monotonic from typing import TYPE_CHECKING, Any, Generic, ParamSpec, Self, TypeVar, cast, overload from urllib.parse import urlparse -import async_timeout import voluptuous as vol import yarl @@ -806,7 +805,7 @@ class HomeAssistant: ) task.cancel("Home Assistant stage 2 shutdown") try: - async with async_timeout.timeout(0.1): + async with asyncio.timeout(0.1): await task except asyncio.CancelledError: pass diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 8208c774887..ac253d49254 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -13,7 +13,6 @@ import aiohttp from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout -import async_timeout from homeassistant import config_entries from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ @@ -170,7 +169,7 @@ async def async_aiohttp_proxy_web( ) -> web.StreamResponse | None: """Stream websession request to aiohttp web response.""" try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): req = await web_coro except asyncio.CancelledError: @@ -211,7 +210,7 @@ async def async_aiohttp_proxy_stream( # Suppressing something went wrong fetching data, closed connection with suppress(asyncio.TimeoutError, aiohttp.ClientError): while hass.is_running: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): data = await stream.read(buffer_size) if not data: diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index fe4e5473092..4fd8948843e 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -16,7 +16,6 @@ import time from typing import Any, cast from aiohttp import client, web -import async_timeout import jwt import voluptuous as vol from yarl import URL @@ -287,7 +286,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): return self.async_external_step_done(next_step_id=next_step) try: - async with async_timeout.timeout(OAUTH_AUTHORIZE_URL_TIMEOUT_SEC): + async with asyncio.timeout(OAUTH_AUTHORIZE_URL_TIMEOUT_SEC): url = await self.async_generate_authorize_url() except asyncio.TimeoutError as err: _LOGGER.error("Timeout generating authorize url: %s", err) @@ -311,7 +310,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): _LOGGER.debug("Creating config entry from external data") try: - async with async_timeout.timeout(OAUTH_TOKEN_TIMEOUT_SEC): + async with asyncio.timeout(OAUTH_TOKEN_TIMEOUT_SEC): token = await self.flow_impl.async_resolve_external_data( self.external_data ) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 0dacb90e318..4035d55b325 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -13,7 +13,6 @@ import logging from types import MappingProxyType from typing import Any, TypedDict, TypeVar, cast -import async_timeout import voluptuous as vol from homeassistant import exceptions @@ -574,7 +573,7 @@ class _ScriptRun: self._changed() trace_set_result(delay=delay, done=False) try: - async with async_timeout.timeout(delay): + async with asyncio.timeout(delay): await self._stop.wait() except asyncio.TimeoutError: trace_set_result(delay=delay, done=True) @@ -602,9 +601,10 @@ class _ScriptRun: @callback def async_script_wait(entity_id, from_s, to_s): """Handle script after template condition is true.""" + # pylint: disable=protected-access wait_var = self._variables["wait"] - if to_context and to_context.deadline: - wait_var["remaining"] = to_context.deadline - self._hass.loop.time() + if to_context and to_context._when: + wait_var["remaining"] = to_context._when - self._hass.loop.time() else: wait_var["remaining"] = timeout wait_var["completed"] = True @@ -621,7 +621,7 @@ class _ScriptRun: self._hass.async_create_task(flag.wait()) for flag in (self._stop, done) ] try: - async with async_timeout.timeout(timeout) as to_context: + async with asyncio.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) except asyncio.TimeoutError as ex: self._variables["wait"]["remaining"] = 0.0 @@ -971,9 +971,10 @@ class _ScriptRun: done = asyncio.Event() async def async_done(variables, context=None): + # pylint: disable=protected-access wait_var = self._variables["wait"] - if to_context and to_context.deadline: - wait_var["remaining"] = to_context.deadline - self._hass.loop.time() + if to_context and to_context._when: + wait_var["remaining"] = to_context._when - self._hass.loop.time() else: wait_var["remaining"] = timeout wait_var["trigger"] = variables["trigger"] @@ -1000,7 +1001,7 @@ class _ScriptRun: self._hass.async_create_task(flag.wait()) for flag in (self._stop, done) ] try: - async with async_timeout.timeout(timeout) as to_context: + async with asyncio.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) except asyncio.TimeoutError as ex: self._variables["wait"]["remaining"] = 0.0 diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 67c1a3ed52f..40d64ba37ae 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -34,7 +34,6 @@ from typing import ( from urllib.parse import urlencode as urllib_urlencode import weakref -import async_timeout from awesomeversion import AwesomeVersion import jinja2 from jinja2 import pass_context, pass_environment, pass_eval_context @@ -651,7 +650,7 @@ class Template: try: template_render_thread = ThreadWithException(target=_render_template) template_render_thread.start() - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await finish_event.wait() if self._exc_info: raise TemplateError(self._exc_info[1].with_traceback(self._exc_info[2])) diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 863747369e1..84ccba2ff66 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -1,7 +1,7 @@ """The tests for the group cover platform.""" +import asyncio from datetime import timedelta -import async_timeout import pytest from homeassistant.components.cover import ( @@ -828,7 +828,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["cover.bedroom_group"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER, diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index cb980841266..6269df3fed7 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -1,7 +1,7 @@ """The tests for the group fan platform.""" +import asyncio from unittest.mock import patch -import async_timeout import pytest from homeassistant import config as hass_config @@ -576,7 +576,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["fan.bedroom_group"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 539a8c61414..062cf161bb9 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -1,7 +1,7 @@ """The tests for the Group Light platform.""" +import asyncio from unittest.mock import MagicMock, patch -import async_timeout import pytest from homeassistant import config as hass_config @@ -1643,7 +1643,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["light.bedroom_group"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TOGGLE, diff --git a/tests/components/group/test_media_player.py b/tests/components/group/test_media_player.py index 2a1a2a05e4e..e1f269a947d 100644 --- a/tests/components/group/test_media_player.py +++ b/tests/components/group/test_media_player.py @@ -1,7 +1,7 @@ """The tests for the Media group platform.""" +import asyncio from unittest.mock import Mock, patch -import async_timeout import pytest from homeassistant.components.group import DOMAIN @@ -583,7 +583,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["media_player.group_1"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( MEDIA_DOMAIN, SERVICE_TURN_OFF, diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index 29cd389c233..bc9a05f4754 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -1,8 +1,7 @@ """The tests for the Group Switch platform.""" +import asyncio from unittest.mock import patch -import async_timeout - from homeassistant import config as hass_config from homeassistant.components.group import DOMAIN, SERVICE_RELOAD from homeassistant.components.switch import ( @@ -445,7 +444,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["switch.some_group"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TOGGLE, diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index 4f00e50def1..87489486614 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -4,7 +4,6 @@ import asyncio from datetime import timedelta from unittest.mock import patch -import async_timeout from freezegun import freeze_time import pytest @@ -560,12 +559,12 @@ async def test_history_stream_significant_domain_historical_only( "no_attributes": True, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 1 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response == { "event": { @@ -591,13 +590,13 @@ async def test_history_stream_significant_domain_historical_only( "minimal_response": True, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 2 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() sensor_test_history = response["event"]["states"]["climate.test"] assert len(sensor_test_history) == 5 @@ -626,13 +625,13 @@ async def test_history_stream_significant_domain_historical_only( "no_attributes": False, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 3 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() sensor_test_history = response["event"]["states"]["climate.test"] @@ -663,13 +662,13 @@ async def test_history_stream_significant_domain_historical_only( "no_attributes": False, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 4 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() sensor_test_history = response["event"]["states"]["climate.test"] @@ -708,13 +707,13 @@ async def test_history_stream_significant_domain_historical_only( "no_attributes": False, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 5 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() sensor_test_history = response["event"]["states"]["climate.test"] diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 85c0ac62b25..73baa968ab6 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1,9 +1,9 @@ """Tests for WebSocket API commands.""" +import asyncio from copy import deepcopy import datetime from unittest.mock import ANY, AsyncMock, Mock, patch -from async_timeout import timeout import pytest import voluptuous as vol @@ -497,7 +497,7 @@ async def test_subscribe_unsubscribe_events( hass.bus.async_fire("test_event", {"hello": "world"}) hass.bus.async_fire("ignore_event") - async with timeout(3): + async with asyncio.timeout(3): msg = await websocket_client.receive_json() assert msg["id"] == 5 @@ -712,7 +712,7 @@ async def test_subscribe_unsubscribe_events_whitelist( hass.bus.async_fire("themes_updated") - async with timeout(3): + async with asyncio.timeout(3): msg = await websocket_client.receive_json() assert msg["id"] == 6 @@ -1611,7 +1611,7 @@ async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: hass.bus.async_fire("test_event", {"hello": "world"}, context=context) hass.bus.async_fire("ignore_event") - async with timeout(3): + async with asyncio.timeout(3): msg = await websocket_client.receive_json() assert msg["id"] == 5 diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 3baaf7e7333..94cdf34cba3 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -140,7 +140,7 @@ async def test_abort_if_authorization_timeout( flow.hass = hass with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout", + "homeassistant.helpers.config_entry_oauth2_flow.asyncio.timeout", side_effect=asyncio.TimeoutError, ): result = await flow.async_step_user() @@ -331,7 +331,7 @@ async def test_abort_on_oauth_timeout_error( assert resp.headers["content-type"] == "text/html; charset=utf-8" with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout", + "homeassistant.helpers.config_entry_oauth2_flow.asyncio.timeout", side_effect=asyncio.TimeoutError, ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index b88f716a8ec..572a0d22e92 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -8,7 +8,6 @@ from unittest.mock import patch from astral import LocationInfo import astral.sun -import async_timeout from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import jinja2 @@ -4361,7 +4360,7 @@ async def test_call_later(hass: HomeAssistant) -> None: async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=delay)) - async with async_timeout.timeout(delay + delay_tolerance): + async with asyncio.timeout(delay + delay_tolerance): assert await future, "callback was called but the delay was wrong" @@ -4381,7 +4380,7 @@ async def test_async_call_later(hass: HomeAssistant) -> None: async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=delay)) - async with async_timeout.timeout(delay + delay_tolerance): + async with asyncio.timeout(delay + delay_tolerance): assert await future, "callback was called but the delay was wrong" assert isinstance(remove, Callable) remove() @@ -4403,7 +4402,7 @@ async def test_async_call_later_timedelta(hass: HomeAssistant) -> None: async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=delay)) - async with async_timeout.timeout(delay + delay_tolerance): + async with asyncio.timeout(delay + delay_tolerance): assert await future, "callback was called but the delay was wrong" assert isinstance(remove, Callable) remove() @@ -4430,7 +4429,7 @@ async def test_async_call_later_cancel(hass: HomeAssistant) -> None: async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=delay)) with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(delay + delay_tolerance): + async with asyncio.timeout(delay + delay_tolerance): assert await future, "callback not canceled" diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 7f66ec25977..5163dd0ca6d 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -9,7 +9,6 @@ from types import MappingProxyType from unittest import mock from unittest.mock import AsyncMock, MagicMock, patch -from async_timeout import timeout import pytest import voluptuous as vol @@ -1000,7 +999,7 @@ async def test_wait_basic_times_out(hass: HomeAssistant, action_type) -> None: assert script_obj.last_action == wait_alias hass.states.async_set("switch.test", "not_on") - async with timeout(0.1): + async with asyncio.timeout(0.1): await hass.async_block_till_done() except asyncio.TimeoutError: timed_out = True @@ -1386,7 +1385,7 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: ): async_fire_time_changed(hass, second_non_matching_time) - async with timeout(0.1): + async with asyncio.timeout(0.1): await hass.async_block_till_done() except asyncio.TimeoutError: timed_out = True diff --git a/tests/test_core.py b/tests/test_core.py index 488975ef02f..9f6e5aeb2dd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,7 +14,6 @@ import time from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch -import async_timeout import pytest import voluptuous as vol @@ -235,7 +234,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: assert can_call_async_get_hass() hass.async_create_task(_async_create_task(), "create_task") - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -246,7 +245,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: task_finished.set() hass.async_add_job(_add_job) - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -263,7 +262,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.async_add_job(_callback) _schedule_callback_from_callback() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -279,7 +278,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.async_add_job(_coroutine()) _schedule_coroutine_from_callback() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -295,7 +294,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.async_add_job(_callback) await _schedule_callback_from_coroutine() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -310,7 +309,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: await hass.async_create_task(_coroutine()) await _schedule_callback_from_coroutine() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -326,7 +325,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.add_job(_async_add_job) await hass.async_add_executor_job(_async_add_executor_job_add_job) - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -341,7 +340,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.create_task(_async_create_task()) await hass.async_add_executor_job(_async_add_executor_job_create_task) - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -359,7 +358,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: my_job_add_job = MyJobAddJob() my_job_add_job.start() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() my_job_add_job.join() @@ -377,7 +376,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: my_job_create_task = MyJobCreateTask() my_job_create_task.start() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() my_job_create_task.join() From 5dd3f05db89d0171ad31605e9273aae9c709d0a6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Aug 2023 15:37:06 +0200 Subject: [PATCH 075/180] Use asyncio.timeout [f-h] (#98449) --- homeassistant/components/faa_delays/__init__.py | 4 ++-- homeassistant/components/flick_electric/config_flow.py | 3 +-- homeassistant/components/flick_electric/sensor.py | 4 ++-- homeassistant/components/flo/device.py | 4 ++-- homeassistant/components/flock/notify.py | 3 +-- homeassistant/components/forked_daapd/media_player.py | 5 ++--- homeassistant/components/freedns/__init__.py | 3 +-- homeassistant/components/fully_kiosk/config_flow.py | 3 +-- homeassistant/components/fully_kiosk/coordinator.py | 3 +-- homeassistant/components/garages_amsterdam/__init__.py | 4 ++-- homeassistant/components/generic/config_flow.py | 4 ++-- homeassistant/components/gios/__init__.py | 4 ++-- homeassistant/components/gios/config_flow.py | 3 +-- homeassistant/components/google_cloud/tts.py | 3 +-- homeassistant/components/google_domains/__init__.py | 3 +-- homeassistant/components/hlk_sw16/config_flow.py | 3 +-- homeassistant/components/home_plus_control/__init__.py | 4 ++-- .../components/homeassistant_yellow/config_flow.py | 6 +++--- homeassistant/components/hue/bridge.py | 3 +-- homeassistant/components/hue/config_flow.py | 3 +-- homeassistant/components/hue/v1/light.py | 4 ++-- homeassistant/components/hue/v1/sensor_base.py | 4 ++-- homeassistant/components/huisbaasje/__init__.py | 4 ++-- .../components/hunterdouglas_powerview/__init__.py | 10 +++++----- .../components/hunterdouglas_powerview/config_flow.py | 4 ++-- .../components/hunterdouglas_powerview/coordinator.py | 4 ++-- .../components/hunterdouglas_powerview/cover.py | 3 +-- .../components/hvv_departures/binary_sensor.py | 4 ++-- 28 files changed, 48 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py index 10ddb13c228..b165492d076 100644 --- a/homeassistant/components/faa_delays/__init__.py +++ b/homeassistant/components/faa_delays/__init__.py @@ -1,9 +1,9 @@ """The FAA Delays integration.""" +import asyncio from datetime import timedelta import logging from aiohttp import ClientConnectionError -from async_timeout import timeout from faadelays import Airport from homeassistant.config_entries import ConfigEntry @@ -56,7 +56,7 @@ class FAADataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): try: - async with timeout(10): + async with asyncio.timeout(10): await self.data.update() except ClientConnectionError as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index 5fac5cdb83a..557d0492320 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -2,7 +2,6 @@ import asyncio import logging -import async_timeout from pyflick.authentication import AuthException, SimpleFlickAuth from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET import voluptuous as vol @@ -45,7 +44,7 @@ class FlickConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - async with async_timeout.timeout(60): + async with asyncio.timeout(60): token = await auth.async_get_access_token() except asyncio.TimeoutError as err: raise CannotConnect() from err diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index a0844fe6cdb..8280e7b2fe0 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -1,9 +1,9 @@ """Support for Flick Electric Pricing data.""" +import asyncio from datetime import timedelta import logging from typing import Any -import async_timeout from pyflick import FlickAPI, FlickPrice from homeassistant.components.sensor import SensorEntity @@ -58,7 +58,7 @@ class FlickPricingSensor(SensorEntity): if self._price and self._price.end_at >= utcnow(): return # Power price data is still valid - async with async_timeout.timeout(60): + async with asyncio.timeout(60): self._price = await self._api.getPricing() _LOGGER.debug("Pricing data: %s", self._price) diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index 1b28a2552a2..99e86d4b6b5 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -1,12 +1,12 @@ """Flo device object.""" from __future__ import annotations +import asyncio from datetime import datetime, timedelta from typing import Any from aioflo.api import API from aioflo.errors import RequestError -from async_timeout import timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -39,7 +39,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update data via library.""" try: - async with timeout(20): + async with asyncio.timeout(20): await self.send_presence_ping() await self._update_device() await self._update_consumption_data() diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 5ac340400af..3fdd54dd40d 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -5,7 +5,6 @@ import asyncio from http import HTTPStatus import logging -import async_timeout import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService @@ -49,7 +48,7 @@ class FlockNotificationService(BaseNotificationService): _LOGGER.debug("Attempting to call Flock at %s", self._url) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await self._session.post(self._url, json=payload) result = await response.json() diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 868ec8e1f9e..48c2be07c76 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -6,7 +6,6 @@ from collections import defaultdict import logging from typing import Any -import async_timeout from pyforked_daapd import ForkedDaapdAPI from pylibrespot_java import LibrespotJavaAPI @@ -667,7 +666,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): self._pause_requested = True await self.async_media_pause() try: - async with async_timeout.timeout(CALLBACK_TIMEOUT): + async with asyncio.timeout(CALLBACK_TIMEOUT): await self._paused_event.wait() # wait for paused except asyncio.TimeoutError: self._pause_requested = False @@ -762,7 +761,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): await sleep_future await self.api.add_to_queue(uris=media_id, playback="start", clear=True) try: - async with async_timeout.timeout(TTS_TIMEOUT): + async with asyncio.timeout(TTS_TIMEOUT): await self._tts_playing_event.wait() # we have started TTS, now wait for completion except asyncio.TimeoutError: diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index e6ac11889bc..e65856e03f4 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL, CONF_URL @@ -76,7 +75,7 @@ async def _update_freedns(hass, session, url, auth_token): params[auth_token] = "" try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index cdd7c7b276b..7d744214d93 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -6,7 +6,6 @@ import json from typing import Any from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from fullykiosk import FullyKiosk from fullykiosk.exceptions import FullyKioskError import voluptuous as vol @@ -42,7 +41,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - async with timeout(15): + async with asyncio.timeout(15): device_info = await fully.getDeviceInfo() except ( ClientConnectorError, diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 4e35d614587..0cfc15268b4 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -2,7 +2,6 @@ import asyncio from typing import Any, cast -from async_timeout import timeout from fullykiosk import FullyKiosk from fullykiosk.exceptions import FullyKioskError @@ -36,7 +35,7 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" try: - async with timeout(15): + async with asyncio.timeout(15): # Get device info and settings in parallel result = await asyncio.gather( self.fully.getDeviceInfo(), self.fully.getSettings() diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index 63a17dbf285..2af4227391b 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -1,8 +1,8 @@ """The Garages Amsterdam integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from odp_amsterdam import ODPAmsterdam from homeassistant.config_entries import ConfigEntry @@ -40,7 +40,7 @@ async def get_coordinator( return hass.data[DOMAIN] async def async_get_garages(): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return { garage.garage_name: garage for garage in await ODPAmsterdam( diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index eb2d109caeb..67ff5a84ed9 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -1,6 +1,7 @@ """Config flow for generic (IP Camera).""" from __future__ import annotations +import asyncio from collections.abc import Mapping import contextlib from datetime import datetime @@ -10,7 +11,6 @@ import logging from typing import Any from aiohttp import web -from async_timeout import timeout from httpx import HTTPStatusError, RequestError, TimeoutException import PIL.Image import voluptuous as vol @@ -171,7 +171,7 @@ async def async_test_still( auth = generate_auth(info) try: async_client = get_async_client(hass, verify_ssl=verify_ssl) - async with timeout(GET_IMAGE_TIMEOUT): + async with asyncio.timeout(GET_IMAGE_TIMEOUT): response = await async_client.get(url, auth=auth, timeout=GET_IMAGE_TIMEOUT) response.raise_for_status() image = response.content diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 2b56a9f6cbb..3cdf48944fd 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -1,11 +1,11 @@ """The GIOS component.""" from __future__ import annotations +import asyncio import logging from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from gios import Gios from gios.exceptions import GiosError from gios.model import GiosSensors @@ -88,7 +88,7 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): async def _async_update_data(self) -> GiosSensors: """Update data via library.""" try: - async with timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): return await self.gios.async_update() except (GiosError, ClientConnectorError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index a1b4abd2dc7..ffc34bd2b78 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -5,7 +5,6 @@ import asyncio from typing import Any from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from gios import ApiError, Gios, InvalidSensorsDataError, NoStationError import voluptuous as vol @@ -37,7 +36,7 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): websession = async_get_clientsession(self.hass) - async with timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): gios = Gios(user_input[CONF_STATION_ID], websession) await gios.async_update() diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index c8f6869f6e4..720c7d9aa2b 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -3,7 +3,6 @@ import asyncio import logging import os -import async_timeout from google.cloud import texttospeech import voluptuous as vol @@ -286,7 +285,7 @@ class GoogleCloudTTSProvider(Provider): "input": synthesis_input, } - async with async_timeout.timeout(10): + async with asyncio.timeout(10): assert self.hass response = await self.hass.async_add_executor_job( self._client.synthesize_speech, request diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py index c7f7e632bd6..52dcdb61e8f 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -4,7 +4,6 @@ from datetime import timedelta import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME @@ -69,7 +68,7 @@ async def _update_google_domains(hass, session, domain, user, password, timeout) params = {"hostname": domain} try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py index 4920e1542d5..01f695ad1a6 100644 --- a/homeassistant/components/hlk_sw16/config_flow.py +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -1,7 +1,6 @@ """Config flow for HLK-SW16.""" import asyncio -import async_timeout from hlk_sw16 import create_hlk_sw16_connection import voluptuous as vol @@ -36,7 +35,7 @@ async def connect_client(hass, user_input): reconnect_interval=DEFAULT_RECONNECT_INTERVAL, keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, ) - async with async_timeout.timeout(CONNECTION_TIMEOUT): + async with asyncio.timeout(CONNECTION_TIMEOUT): return await client_aw diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index 007f8895bf0..b6a1fc68a17 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -1,8 +1,8 @@ """The Legrand Home+ Control integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from homepluscontrol.homeplusapi import HomePlusControlApiError import voluptuous as vol @@ -100,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await api.async_get_modules() except HomePlusControlApiError as err: raise UpdateFailed( diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 3da67023abd..8be7b8a4ff7 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -1,11 +1,11 @@ """Config flow for the Home Assistant Yellow integration.""" from __future__ import annotations +import asyncio import logging from typing import Any import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.hassio import ( @@ -80,7 +80,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl if self._hw_settings == user_input: return self.async_create_entry(data={}) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await async_set_yellow_settings(self.hass, user_input) except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: _LOGGER.warning("Failed to write hardware settings", exc_info=err) @@ -88,7 +88,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl return await self.async_step_confirm_reboot() try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): self._hw_settings: dict[str, bool] = await async_get_yellow_settings( self.hass ) diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 0e1688221b3..04bd63e5b1f 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -10,7 +10,6 @@ import aiohttp from aiohttp import client_exceptions from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized from aiohue.errors import AiohueException, BridgeBusy -import async_timeout from homeassistant import core from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -73,7 +72,7 @@ class HueBridge: async def async_initialize_bridge(self) -> bool: """Initialize Connection with the Hue API.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await self.api.initialize() except (LinkButtonNotPressed, Unauthorized): diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 2b0ebdebcaa..9c8dda94c94 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -9,7 +9,6 @@ import aiohttp from aiohue import LinkButtonNotPressed, create_app_key from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp from aiohue.util import normalize_bridge_id -import async_timeout import slugify as unicode_slug import voluptuous as vol @@ -110,7 +109,7 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Find / discover bridges try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): bridges = await discover_nupnp( websession=aiohttp_client.async_get_clientsession(self.hass) ) diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index 8821c66a2cf..8ae09ef9d47 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -1,13 +1,13 @@ """Support for the Philips Hue lights.""" from __future__ import annotations +import asyncio from datetime import timedelta from functools import partial import logging import random import aiohue -import async_timeout from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -262,7 +262,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_safe_fetch(bridge, fetch_method): """Safely fetch data.""" try: - async with async_timeout.timeout(4): + async with asyncio.timeout(4): return await bridge.async_request_call(fetch_method) except aiohue.Unauthorized as err: await bridge.handle_unauthorized_error() diff --git a/homeassistant/components/hue/v1/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py index 84921707f2a..723ecfff451 100644 --- a/homeassistant/components/hue/v1/sensor_base.py +++ b/homeassistant/components/hue/v1/sensor_base.py @@ -1,13 +1,13 @@ """Support for the Philips Hue sensors as a platform.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any from aiohue import AiohueException, Unauthorized from aiohue.v1.sensors import TYPE_ZLL_PRESENCE -import async_timeout from homeassistant.components.sensor import SensorStateClass from homeassistant.core import callback @@ -61,7 +61,7 @@ class SensorManager: async def async_update_data(self): """Update sensor data.""" try: - async with async_timeout.timeout(4): + async with asyncio.timeout(4): return await self.bridge.async_request_call( self.bridge.api.sensors.update ) diff --git a/homeassistant/components/huisbaasje/__init__.py b/homeassistant/components/huisbaasje/__init__.py index 8559156379b..b1c2d865e0c 100644 --- a/homeassistant/components/huisbaasje/__init__.py +++ b/homeassistant/components/huisbaasje/__init__.py @@ -1,9 +1,9 @@ """The Huisbaasje integration.""" +import asyncio from datetime import timedelta import logging from typing import Any -import async_timeout from energyflip import EnergyFlip, EnergyFlipException from homeassistant.config_entries import ConfigEntry @@ -86,7 +86,7 @@ async def async_update_huisbaasje(energyflip: EnergyFlip) -> dict[str, dict[str, try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(FETCH_TIMEOUT): + async with asyncio.timeout(FETCH_TIMEOUT): if not energyflip.is_authenticated(): _LOGGER.warning("Huisbaasje is unauthenticated. Reauthenticating") await energyflip.authenticate() diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 4b0d666d2ae..56ebbe6fb26 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -1,4 +1,5 @@ """The Hunter Douglas PowerView integration.""" +import asyncio import logging from aiopvapi.helpers.aiorequest import AioRequest @@ -8,7 +9,6 @@ from aiopvapi.rooms import Rooms from aiopvapi.scenes import Scenes from aiopvapi.shades import Shades from aiopvapi.userdata import UserData -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform @@ -63,20 +63,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): device_info = await async_get_device_info(pv_request, hub_address) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): rooms = Rooms(pv_request) room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): scenes = Scenes(pv_request) scene_data = async_map_data_by_id( (await scenes.get_resources())[SCENE_DATA] ) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): shades = Shades(pv_request) shade_entries = await shades.get_resources() shade_data = async_map_data_by_id(shade_entries[SHADE_DATA]) diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index 7c9bdfcf244..8c6d0fc4dd3 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -1,10 +1,10 @@ """Config flow for Hunter Douglas PowerView integration.""" from __future__ import annotations +import asyncio import logging from aiopvapi.helpers.aiorequest import AioRequest -import async_timeout import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -34,7 +34,7 @@ async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): device_info = await async_get_device_info(pv_request, hub_address) except HUB_EXCEPTIONS as err: raise CannotConnect from err diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index 203aea6c49f..4643536d56d 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -1,11 +1,11 @@ """Coordinate data for powerview devices.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from aiopvapi.shades import Shades -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -37,7 +37,7 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]) async def _async_update_data(self) -> PowerviewShadeData: """Fetch data from shade endpoint.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): shade_entries = await self.shades.get_resources() if isinstance(shade_entries, bool): diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 5cb84658c50..833c1812ddb 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -19,7 +19,6 @@ from aiopvapi.helpers.constants import ( MIN_POSITION, ) from aiopvapi.resources.shade import BaseShade, factory as PvShade -import async_timeout from homeassistant.components.cover import ( ATTR_POSITION, @@ -84,7 +83,7 @@ async def async_setup_entry( shade: BaseShade = PvShade(raw_shade, pv_entry.api) name_before_refresh = shade.name with suppress(asyncio.TimeoutError): - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await shade.refresh() if ATTR_POSITION_DATA not in shade.raw_data: diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index ac965285977..513c8dbd8b0 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -1,12 +1,12 @@ """Binary sensor platform for hvv_departures.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any from aiohttp import ClientConnectorError -import async_timeout from pygti.exceptions import InvalidAuth from homeassistant.components.binary_sensor import ( @@ -90,7 +90,7 @@ async def async_setup_entry( payload = {"station": {"id": station["id"], "type": station["type"]}} try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return get_elevator_entities_from_station_information( station_name, await hub.gti.stationInformation(payload) ) From 3de402bd150472b66094b0940adac2b7700efdf4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 16:22:42 +0200 Subject: [PATCH 076/180] Fix AiohttpClientMockResponse.release (#98458) --- tests/test_util/aiohttp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 356240dc37a..5e7284eb9c2 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -255,7 +255,7 @@ class AiohttpClientMockResponse: """Return mock response as a json.""" return loads(self.response.decode(encoding)) - def release(self): + async def release(self): """Mock release.""" def raise_for_status(self): From e209f3723e61231867b3020774a8cb6b24591b8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Aug 2023 09:29:25 -0500 Subject: [PATCH 077/180] Restore sensorpush state when device becomes available (#98420) --- homeassistant/components/sensorpush/sensor.py | 4 +- tests/components/sensorpush/__init__.py | 11 ++++ tests/components/sensorpush/test_sensor.py | 60 ++++++++++++++++--- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py index 479acd8ac1e..e12bf0e48c6 100644 --- a/homeassistant/components/sensorpush/sensor.py +++ b/homeassistant/components/sensorpush/sensor.py @@ -110,7 +110,9 @@ async def async_setup_entry( SensorPushBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class SensorPushBluetoothSensorEntity( diff --git a/tests/components/sensorpush/__init__.py b/tests/components/sensorpush/__init__.py index 0fe9ced64df..c281d4dc086 100644 --- a/tests/components/sensorpush/__init__.py +++ b/tests/components/sensorpush/__init__.py @@ -32,3 +32,14 @@ HTPWX_SERVICE_INFO = BluetoothServiceInfo( service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], source="local", ) + + +HTPWX_EMPTY_SERVICE_INFO = BluetoothServiceInfo( + name="SensorPush HTP.xw F4D", + address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", + rssi=-56, + manufacturer_data={}, + service_data={}, + service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], + source="local", +) diff --git a/tests/components/sensorpush/test_sensor.py b/tests/components/sensorpush/test_sensor.py index f2d6cf6d1ac..e00b626b20b 100644 --- a/tests/components/sensorpush/test_sensor.py +++ b/tests/components/sensorpush/test_sensor.py @@ -1,17 +1,33 @@ """Test the SensorPush sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.sensorpush.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import HTPWX_SERVICE_INFO +from . import HTPWX_EMPTY_SERVICE_INFO, HTPWX_SERVICE_INFO -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" + start_monotonic = time.monotonic() entry = MockConfigEntry( domain=DOMAIN, unique_id="4125DDBA-2774-4851-9889-6AADDD4CAC3D", @@ -27,11 +43,39 @@ async def test_sensors(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 3 temp_sensor = hass.states.get("sensor.htp_xw_f4d_temperature") - temp_sensor_attribtes = temp_sensor.attributes + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "20.11" + assert temp_sensor_attributes[ATTR_FRIENDLY_NAME] == "HTP.xw F4D Temperature" + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.htp_xw_f4d_temperature") + assert temp_sensor.state == STATE_UNAVAILABLE + inject_bluetooth_service_info(hass, HTPWX_EMPTY_SERVICE_INFO) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.htp_xw_f4d_temperature") assert temp_sensor.state == "20.11" - assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "HTP.xw F4D Temperature" - assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C" - assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() From 92cf6ed2a0e4117819b59137031bf1c1ab26beaa Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 15 Aug 2023 16:50:17 +0200 Subject: [PATCH 078/180] Reolink 100% test coverage (#94763) --- tests/components/reolink/conftest.py | 12 ++--- tests/components/reolink/test_config_flow.py | 4 +- tests/components/reolink/test_init.py | 50 ++++++++++++++------ 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 1e6f9aa4902..25719c4cff7 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -58,18 +58,16 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None host_mock.is_admin = True host_mock.user_level = "admin" host_mock.sw_version_update_required = False + host_mock.hardware_version = "IPC_00000" + host_mock.sw_version = "v1.0.0.0.0.0000" + host_mock.manufacturer = "Reolink" + host_mock.model = "RLC-123" + host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 yield host_mock -@pytest.fixture -def reolink_ONVIF_wait() -> Generator[None, None, None]: - """Mock reolink connection.""" - with patch("homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock()): - yield - - @pytest.fixture def reolink_platforms(mock_get_source_ip: None) -> Generator[None, None, None]: """Mock reolink entry setup.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index b6e48cab7b2..048b48d9576 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -29,9 +29,7 @@ from .conftest import ( from tests.common import MockConfigEntry -pytestmark = pytest.mark.usefixtures( - "mock_setup_entry", "reolink_connect", "reolink_ONVIF_wait" -) +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") async def test_config_flow_manual_success(hass: HomeAssistant) -> None: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 8558ff0e8a2..d649baeb937 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,18 +1,23 @@ """Test the Reolink init.""" +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from reolink_aio.exceptions import ReolinkError -from homeassistant.components.reolink import const +from homeassistant.components.reolink import FIRMWARE_UPDATE_INTERVAL, const from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from .conftest import TEST_NVR_NAME + +from tests.common import MockConfigEntry, async_fire_time_changed pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") @@ -45,17 +50,11 @@ pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") Mock(return_value=False), ConfigEntryState.LOADED, ), - ( - "check_new_firmware", - AsyncMock(side_effect=ReolinkError("Test error")), - ConfigEntryState.LOADED, - ), ], ) async def test_failures_parametrized( hass: HomeAssistant, reolink_connect: MagicMock, - reolink_ONVIF_wait: MagicMock, config_entry: MockConfigEntry, attr: str, value: Any, @@ -71,11 +70,36 @@ async def test_failures_parametrized( assert config_entry.state == expected +async def test_firmware_error_twice( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test when the firmware update fails 2 times.""" + reolink_connect.check_new_firmware = AsyncMock( + side_effect=ReolinkError("Test error") + ) + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + + entity_id = f"{Platform.UPDATE}.{TEST_NVR_NAME}_update" + assert hass.states.is_state(entity_id, STATE_OFF) + + async_fire_time_changed( + hass, utcnow() + FIRMWARE_UPDATE_INTERVAL + timedelta(minutes=1) + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + async def test_entry_reloading( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, - reolink_ONVIF_wait: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -92,7 +116,7 @@ async def test_entry_reloading( async def test_no_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test no repairs issue is raised when http local url is used.""" await async_process_ha_core_config( @@ -111,7 +135,7 @@ async def test_no_repair_issue( async def test_https_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test repairs issue is raised when https local url is used.""" await async_process_ha_core_config( @@ -133,7 +157,7 @@ async def test_https_repair_issue( async def test_ssl_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test repairs issue is raised when global ssl certificate is used.""" assert await async_setup_component(hass, "webhook", {}) @@ -162,7 +186,6 @@ async def test_port_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, - reolink_ONVIF_wait: MagicMock, protocol: str, ) -> None: """Test repairs issue is raised when auto enable of ports fails.""" @@ -200,7 +223,6 @@ async def test_firmware_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, - reolink_ONVIF_wait: MagicMock, ) -> None: """Test firmware issue is raised when too old firmware is used.""" reolink_connect.sw_version_update_required = True From 496a975c582552e4b6dd048a02a10891aa3e6c7b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 17:17:35 +0200 Subject: [PATCH 079/180] Set _attr_condition in WeatherEntity (#98459) --- homeassistant/components/buienradar/weather.py | 1 - homeassistant/components/smhi/weather.py | 1 - homeassistant/components/weather/__init__.py | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index aedfcf82aea..cdb8adf1dac 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -136,7 +136,6 @@ class BrWeather(WeatherEntity): self._stationname = config.get(CONF_NAME, "Buienradar") self._attr_name = self._stationname or f"BR {'(unknown station)'}" - self._attr_condition = None self._attr_unique_id = "{:2.6f}{:2.6f}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE] ) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index e62d236c819..db5d7287ccd 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -149,7 +149,6 @@ class SmhiWeather(WeatherEntity): name=name, configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) - self._attr_condition = None self._attr_native_temperature = None @property diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 635c4948285..74671f0c1df 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -227,7 +227,7 @@ class WeatherEntity(Entity, PostInit): """ABC for weather data.""" entity_description: WeatherEntityDescription - _attr_condition: str | None + _attr_condition: str | None = None # _attr_forecast is deprecated, implement async_forecast_daily, # async_forecast_hourly or async_forecast_twice daily instead _attr_forecast: list[Forecast] | None = None From 063ce9159df64bbc9e7149ac00fcab3a39e088c1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Aug 2023 17:21:49 +0200 Subject: [PATCH 080/180] Use asyncio.timeout [o-s] (#98451) --- .../components/openalpr_cloud/image_processing.py | 3 +-- homeassistant/components/openexchangerates/config_flow.py | 5 ++--- homeassistant/components/openexchangerates/coordinator.py | 4 ++-- homeassistant/components/opentherm_gw/__init__.py | 3 +-- homeassistant/components/opentherm_gw/config_flow.py | 3 +-- .../openweathermap/weather_update_coordinator.py | 4 ++-- homeassistant/components/ovo_energy/__init__.py | 4 ++-- homeassistant/components/picnic/coordinator.py | 4 ++-- homeassistant/components/ping/binary_sensor.py | 3 +-- homeassistant/components/point/config_flow.py | 3 +-- homeassistant/components/poolsense/__init__.py | 4 ++-- homeassistant/components/progettihwsw/binary_sensor.py | 4 ++-- homeassistant/components/progettihwsw/switch.py | 4 ++-- homeassistant/components/prowl/notify.py | 3 +-- homeassistant/components/prusalink/__init__.py | 4 ++-- homeassistant/components/prusalink/config_flow.py | 3 +-- homeassistant/components/push/camera.py | 3 +-- homeassistant/components/qnap_qsw/coordinator.py | 6 +++--- homeassistant/components/rainbird/config_flow.py | 3 +-- homeassistant/components/rainbird/coordinator.py | 4 ++-- homeassistant/components/rainforest_eagle/data.py | 6 +++--- homeassistant/components/renson/__init__.py | 4 ++-- homeassistant/components/reolink/__init__.py | 7 +++---- homeassistant/components/rest/switch.py | 5 ++--- homeassistant/components/rflink/__init__.py | 3 +-- homeassistant/components/rfxtrx/__init__.py | 3 +-- homeassistant/components/rfxtrx/config_flow.py | 5 ++--- homeassistant/components/roomba/__init__.py | 5 ++--- homeassistant/components/rtsp_to_webrtc/__init__.py | 6 +++--- homeassistant/components/samsungtv/media_player.py | 3 +-- homeassistant/components/sensibo/entity.py | 4 ++-- homeassistant/components/sensibo/util.py | 5 +++-- homeassistant/components/sharkiq/__init__.py | 5 ++--- homeassistant/components/sharkiq/config_flow.py | 3 +-- homeassistant/components/sharkiq/update_coordinator.py | 3 +-- homeassistant/components/shell_command/__init__.py | 3 +-- homeassistant/components/smarttub/controller.py | 3 +-- homeassistant/components/smarttub/switch.py | 4 ++-- homeassistant/components/smhi/weather.py | 3 +-- homeassistant/components/sms/__init__.py | 6 +++--- tests/components/rainbird/test_config_flow.py | 2 +- 41 files changed, 70 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index aa1e5ecbc0a..64b46a1da94 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -7,7 +7,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.image_processing import ( @@ -199,7 +198,7 @@ class OpenAlprCloudEntity(ImageProcessingAlprEntity): body = {"image_bytes": str(b64encode(image), "utf-8")} try: - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): request = await websession.post( OPENALPR_API_URL, params=params, data=body ) diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index 13060e19718..a61264dbf41 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -10,7 +10,6 @@ from aioopenexchangerates import ( OpenExchangeRatesAuthError, OpenExchangeRatesClientError, ) -import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -40,7 +39,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, """Validate the user input allows us to connect.""" client = Client(data[CONF_API_KEY], async_get_clientsession(hass)) - async with async_timeout.timeout(CLIENT_TIMEOUT): + async with asyncio.timeout(CLIENT_TIMEOUT): await client.get_latest(base=data[CONF_BASE]) return {"title": data[CONF_BASE]} @@ -119,7 +118,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not self.currencies: client = Client("dummy-api-key", async_get_clientsession(self.hass)) try: - async with async_timeout.timeout(CLIENT_TIMEOUT): + async with asyncio.timeout(CLIENT_TIMEOUT): self.currencies = await client.get_currencies() except OpenExchangeRatesClientError as err: raise AbortFlow("cannot_connect") from err diff --git a/homeassistant/components/openexchangerates/coordinator.py b/homeassistant/components/openexchangerates/coordinator.py index 3795f33aec5..beb588c7ce6 100644 --- a/homeassistant/components/openexchangerates/coordinator.py +++ b/homeassistant/components/openexchangerates/coordinator.py @@ -1,6 +1,7 @@ """Provide an OpenExchangeRates data coordinator.""" from __future__ import annotations +import asyncio from datetime import timedelta from aiohttp import ClientSession @@ -10,7 +11,6 @@ from aioopenexchangerates import ( OpenExchangeRatesAuthError, OpenExchangeRatesClientError, ) -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -40,7 +40,7 @@ class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): async def _async_update_data(self) -> Latest: """Update data from Open Exchange Rates.""" try: - async with async_timeout.timeout(CLIENT_TIMEOUT): + async with asyncio.timeout(CLIENT_TIMEOUT): latest = await self.client.get_latest(base=self.base) except OpenExchangeRatesAuthError as err: raise ConfigEntryAuthFailed(err) from err diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 3efe911b27f..0b8d4693cb8 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -3,7 +3,6 @@ import asyncio from datetime import date, datetime import logging -import async_timeout import pyotgw import pyotgw.vars as gw_vars from serial import SerialException @@ -113,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.add_update_listener(options_updated) try: - async with async_timeout.timeout(CONNECTION_TIMEOUT): + async with asyncio.timeout(CONNECTION_TIMEOUT): await gateway.connect_and_subscribe() except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: await gateway.cleanup() diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 87a51021657..07187f3a2ec 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import async_timeout import pyotgw from pyotgw import vars as gw_vars from serial import SerialException @@ -69,7 +68,7 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT) try: - async with async_timeout.timeout(CONNECTION_TIMEOUT): + async with asyncio.timeout(CONNECTION_TIMEOUT): await test_connection() except asyncio.TimeoutError: return self._show_form({"base": "timeout_connect"}) diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 521c1f87ca2..732557363d8 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -1,8 +1,8 @@ """Weather data coordinator for the OpenWeatherMap (OWM) service.""" +import asyncio from datetime import timedelta import logging -import async_timeout from pyowm.commons.exceptions import APIRequestError, UnauthorizedError from homeassistant.components.weather import ( @@ -80,7 +80,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update the data.""" data = {} - async with async_timeout.timeout(20): + async with asyncio.timeout(20): try: weather_response = await self._get_owm_weather() data = self._convert_weather_response(weather_response) diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index 1a871e99023..99dd02a36a1 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -1,11 +1,11 @@ """Support for OVO Energy.""" from __future__ import annotations +import asyncio from datetime import datetime, timedelta import logging import aiohttp -import async_timeout from ovoenergy import OVODailyUsage from ovoenergy.ovoenergy import OVOEnergy @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data() -> OVODailyUsage: """Fetch data from OVO Energy.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: authenticated = await client.authenticate( entry.data[CONF_USERNAME], diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 06f4efd944e..00a9f534852 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -1,10 +1,10 @@ """Coordinator to fetch data from the Picnic API.""" +import asyncio from contextlib import suppress import copy from datetime import timedelta import logging -import async_timeout from python_picnic_api import PicnicAPI from python_picnic_api.session import PicnicAuthError @@ -44,7 +44,7 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(10): + async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) # Update the auth token in the config entry if applicable diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 786012d466c..6a150b3dc4c 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -8,7 +8,6 @@ import logging import re from typing import TYPE_CHECKING, Any -import async_timeout from icmplib import NameLookupError, async_ping import voluptuous as vol @@ -218,7 +217,7 @@ class PingDataSubProcess(PingData): close_fds=False, # required for posix_spawn ) try: - async with async_timeout.timeout(self._count + PING_TIMEOUT): + async with asyncio.timeout(self._count + PING_TIMEOUT): out_data, out_error = await pinger.communicate() if out_data: diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index fad5b746252..201e397ba7d 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -3,7 +3,6 @@ import asyncio from collections import OrderedDict import logging -import async_timeout from pypoint import PointSession import voluptuous as vol @@ -94,7 +93,7 @@ class PointFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "follow_link" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): url = await self._get_authorization_url() except asyncio.TimeoutError: return self.async_abort(reason="authorize_url_timeout") diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index 312a3b4be58..56b7eaaac77 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -1,8 +1,8 @@ """The PoolSense integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from poolsense import PoolSense from poolsense.exceptions import PoolSenseError @@ -90,7 +90,7 @@ class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update data via library.""" data = {} - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: data = await self.poolsense.get_poolsense_data() except PoolSenseError as error: diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index b2019389fe3..e2d1025cc64 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -1,8 +1,8 @@ """Control binary sensor instances.""" +import asyncio from datetime import timedelta import logging -import async_timeout from ProgettiHWSW.input import Input from homeassistant.components.binary_sensor import BinarySensorEntity @@ -32,7 +32,7 @@ async def async_setup_entry( async def async_update_data(): """Fetch data from API endpoint of board.""" - async with async_timeout.timeout(5): + async with asyncio.timeout(5): return await board_api.get_inputs() coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index dc7f838bcbc..77cfb6ba4d1 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -1,9 +1,9 @@ """Control switches.""" +import asyncio from datetime import timedelta import logging from typing import Any -import async_timeout from ProgettiHWSW.relay import Relay from homeassistant.components.switch import SwitchEntity @@ -33,7 +33,7 @@ async def async_setup_entry( async def async_update_data(): """Fetch data from API endpoint of board.""" - async with async_timeout.timeout(5): + async with asyncio.timeout(5): return await board_api.get_switches() coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index 02d4f61f4e4..d0b35aaf4b9 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -5,7 +5,6 @@ import asyncio from http import HTTPStatus import logging -import async_timeout import voluptuous as vol from homeassistant.components.notify import ( @@ -64,7 +63,7 @@ class ProwlNotificationService(BaseNotificationService): session = async_get_clientsession(self._hass) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await session.post(url, data=payload) result = await response.text() diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 59708d76097..e81901dad52 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -2,12 +2,12 @@ from __future__ import annotations from abc import ABC, abstractmethod +import asyncio from datetime import timedelta import logging from time import monotonic from typing import Generic, TypeVar -import async_timeout from pyprusalink import InvalidAuth, JobInfo, PrinterInfo, PrusaLink, PrusaLinkError from homeassistant.config_entries import ConfigEntry @@ -77,7 +77,7 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator, Generic[T], ABC): async def _async_update_data(self) -> T: """Update the data.""" try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): data = await self._fetch_data() except InvalidAuth: raise UpdateFailed("Invalid authentication") from None diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index cef2bdf2f6e..b1faad6e3ea 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -6,7 +6,6 @@ import logging from typing import Any from aiohttp import ClientError -import async_timeout from awesomeversion import AwesomeVersion, AwesomeVersionException from pyprusalink import InvalidAuth, PrusaLink import voluptuous as vol @@ -39,7 +38,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, api = PrusaLink(async_get_clientsession(hass), data[CONF_HOST], data[CONF_API_KEY]) try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): version = await api.get_version() except (asyncio.TimeoutError, ClientError) as err: diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 77bcf63e17e..a4fec1c3d4d 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -7,7 +7,6 @@ from datetime import timedelta import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components import webhook @@ -74,7 +73,7 @@ async def async_setup_platform( async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook POST with image files.""" try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): data = dict(await request.post()) except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: _LOGGER.error("Could not get information from POST <%s>", error) diff --git a/homeassistant/components/qnap_qsw/coordinator.py b/homeassistant/components/qnap_qsw/coordinator.py index eb4e60bf9bd..6451b525004 100644 --- a/homeassistant/components/qnap_qsw/coordinator.py +++ b/homeassistant/components/qnap_qsw/coordinator.py @@ -1,13 +1,13 @@ """The QNAP QSW coordinator.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any from aioqsw.exceptions import QswError from aioqsw.localapi import QnapQswApi -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -36,7 +36,7 @@ class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - async with async_timeout.timeout(QSW_TIMEOUT_SEC): + async with asyncio.timeout(QSW_TIMEOUT_SEC): try: await self.qsw.update() except QswError as error: @@ -60,7 +60,7 @@ class QswFirmwareCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update firmware data via library.""" - async with async_timeout.timeout(QSW_TIMEOUT_SEC): + async with asyncio.timeout(QSW_TIMEOUT_SEC): try: await self.qsw.check_firmware() except QswError as error: diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 0409d0ff564..a784e4623d6 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -6,7 +6,6 @@ import asyncio import logging from typing import Any -import async_timeout from pyrainbird.async_client import ( AsyncRainbirdClient, AsyncRainbirdController, @@ -106,7 +105,7 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) ) try: - async with async_timeout.timeout(TIMEOUT_SECONDS): + async with asyncio.timeout(TIMEOUT_SECONDS): return await controller.get_serial_number() except asyncio.TimeoutError as err: raise ConfigFlowError( diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 91319b25e59..d81b942d669 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -2,12 +2,12 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass import datetime import logging from typing import TypeVar -import async_timeout from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, @@ -86,7 +86,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): async def _async_update_data(self) -> RainbirdDeviceState: """Fetch data from Rain Bird device.""" try: - async with async_timeout.timeout(TIMEOUT_SECONDS): + async with asyncio.timeout(TIMEOUT_SECONDS): return await self._fetch_data() except RainbirdDeviceBusyException as err: raise UpdateFailed("Rain Bird device is busy") from err diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index c7ef596bb61..f050e92f783 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -1,12 +1,12 @@ """Rainforest data.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging import aioeagle import aiohttp -import async_timeout from eagle100 import Eagle as Eagle100Reader from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout @@ -50,7 +50,7 @@ async def async_get_type(hass, cloud_id, install_code, host): ) try: - async with async_timeout.timeout(30): + async with asyncio.timeout(30): meters = await hub.get_device_list() except aioeagle.BadAuth as err: raise InvalidAuth from err @@ -150,7 +150,7 @@ class EagleDataCoordinator(DataUpdateCoordinator): else: is_connected = eagle200_meter.is_connected - async with async_timeout.timeout(30): + async with asyncio.timeout(30): data = await eagle200_meter.get_device_query() if self.eagle200_meter is None: diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 211f7c88e40..bac9bafa8a5 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -1,12 +1,12 @@ """The Renson integration.""" from __future__ import annotations +import asyncio from dataclasses import dataclass from datetime import timedelta import logging from typing import Any -import async_timeout from renson_endura_delta.renson import RensonVentilation from homeassistant.config_entries import ConfigEntry @@ -84,5 +84,5 @@ class RensonCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Fetch data from API endpoint.""" - async with async_timeout.timeout(30): + async with asyncio.timeout(30): return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 88eec9780a1..5cfb2ceecb7 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -8,7 +8,6 @@ from datetime import timedelta import logging from typing import Literal -import async_timeout from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError @@ -78,13 +77,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_device_config_update() -> None: """Update the host state cache and renew the ONVIF-subscription.""" - async with async_timeout.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): + async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): try: await host.update_states() except ReolinkError as err: raise UpdateFailed(str(err)) from err - async with async_timeout.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): + async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): await host.renew() async def async_check_firmware_update() -> str | Literal[False]: @@ -92,7 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if not host.api.supported(None, "update"): return False - async with async_timeout.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): + async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): try: return await host.api.check_new_firmware() except (ReolinkError, asyncio.exceptions.CancelledError) as err: diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 0a220204997..22570c3a245 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -6,7 +6,6 @@ from http import HTTPStatus import logging from typing import Any -import async_timeout import httpx import voluptuous as vol @@ -203,7 +202,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with async_timeout.timeout(self._timeout): + async with asyncio.timeout(self._timeout): req: httpx.Response = await getattr(websession, self._method)( self._resource, auth=self._auth, @@ -234,7 +233,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with async_timeout.timeout(self._timeout): + async with asyncio.timeout(self._timeout): req = await websession.get( self._state_resource, auth=self._auth, diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 8df2d7ec343..60e2b0fef58 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -5,7 +5,6 @@ import asyncio from collections import defaultdict import logging -import async_timeout from rflink.protocol import ProtocolBase, create_rflink_connection from serial import SerialException import voluptuous as vol @@ -280,7 +279,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) try: - async with async_timeout.timeout(CONNECTION_TIMEOUT): + async with asyncio.timeout(CONNECTION_TIMEOUT): transport, protocol = await connection except ( diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index e8d20ef9c10..9c5ffa586cd 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -8,7 +8,6 @@ import copy import logging from typing import Any, NamedTuple, cast -import async_timeout import RFXtrx as rfxtrxmod import voluptuous as vol @@ -165,7 +164,7 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: config = entry.data # Initialize library - async with async_timeout.timeout(30): + async with asyncio.timeout(30): rfx_object = await hass.async_add_executor_job(_create_rfx, config) # Setup some per device config diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 8d55208cbb7..179dd04cfaa 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -8,7 +8,6 @@ import itertools import os from typing import Any, TypedDict, cast -from async_timeout import timeout import RFXtrx as rfxtrxmod import serial import serial.tools.list_ports @@ -374,7 +373,7 @@ class OptionsFlow(config_entries.OptionsFlow): # Wait for entities to finish cleanup with suppress(asyncio.TimeoutError): - async with timeout(10): + async with asyncio.timeout(10): await wait_for_entities.wait() remove_track_state_changes() @@ -409,7 +408,7 @@ class OptionsFlow(config_entries.OptionsFlow): # Wait for entities to finish renaming with suppress(asyncio.TimeoutError): - async with timeout(10): + async with asyncio.timeout(10): await wait_for_entities.wait() remove_track_state_changes() diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 641c814d122..85dbbe14cdc 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -3,7 +3,6 @@ import asyncio from functools import partial import logging -import async_timeout from roombapy import RoombaConnectionError, RoombaFactory from homeassistant import exceptions @@ -86,7 +85,7 @@ async def async_connect_or_timeout(hass, roomba): """Connect to vacuum.""" try: name = None - async with async_timeout.timeout(10): + async with asyncio.timeout(10): _LOGGER.debug("Initialize connection to vacuum") await hass.async_add_executor_job(roomba.connect) while not roomba.roomba_connected or name is None: @@ -110,7 +109,7 @@ async def async_connect_or_timeout(hass, roomba): async def async_disconnect_or_timeout(hass, roomba): """Disconnect to vacuum.""" _LOGGER.debug("Disconnect vacuum") - async with async_timeout.timeout(3): + async with asyncio.timeout(3): await hass.async_add_executor_job(roomba.disconnect) return True diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py index f5f114bce9c..77bf7ffeb8f 100644 --- a/homeassistant/components/rtsp_to_webrtc/__init__.py +++ b/homeassistant/components/rtsp_to_webrtc/__init__.py @@ -18,10 +18,10 @@ Other integrations may use this integration with these steps: from __future__ import annotations +import asyncio import logging from typing import Any -import async_timeout from rtsp_to_webrtc.client import get_adaptive_client from rtsp_to_webrtc.exceptions import ClientError, ResponseError from rtsp_to_webrtc.interface import WebRTCClientInterface @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client: WebRTCClientInterface try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): client = await get_adaptive_client( async_get_clientsession(hass), entry.data[DATA_SERVER_URL] ) @@ -71,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: the stream itself happens directly between the client and proxy. """ try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): return await client.offer_stream_id(stream_id, offer_sdp, stream_source) except TimeoutError as err: raise HomeAssistantError("Timeout talking to RTSPtoWebRTC server") from err diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 2f82c979b94..06783314b4c 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -5,7 +5,6 @@ import asyncio from collections.abc import Coroutine, Sequence from typing import Any -import async_timeout from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory @@ -217,7 +216,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): # enter it unless we have to (Python 3.11 will have zero cost try) return try: - async with async_timeout.timeout(APP_LIST_DELAY): + async with asyncio.timeout(APP_LIST_DELAY): await self._app_list_event.wait() except asyncio.TimeoutError as err: # No need to try again diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 9fdd1ef9f21..4eff1a011a5 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -1,10 +1,10 @@ """Base entity for Sensibo integration.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar -import async_timeout from pysensibo.model import MotionSensor, SensiboDevice from homeassistant.exceptions import HomeAssistantError @@ -27,7 +27,7 @@ def async_handle_api_call( """Wrap services for api calls.""" res: bool = False try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): res = await function(*args, **kwargs) except SENSIBO_ERRORS as err: raise HomeAssistantError from err diff --git a/homeassistant/components/sensibo/util.py b/homeassistant/components/sensibo/util.py index 9070be3412a..98b843a9dfc 100644 --- a/homeassistant/components/sensibo/util.py +++ b/homeassistant/components/sensibo/util.py @@ -1,7 +1,8 @@ """Utils for Sensibo integration.""" from __future__ import annotations -import async_timeout +import asyncio + from pysensibo import SensiboClient from pysensibo.exceptions import AuthenticationError @@ -20,7 +21,7 @@ async def async_validate_api(hass: HomeAssistant, api_key: str) -> str: ) try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): device_query = await client.async_get_devices() user_query = await client.async_get_me() except AuthenticationError as err: diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index b6cae8ad605..f80e7acf9a6 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -2,7 +2,6 @@ import asyncio from contextlib import suppress -import async_timeout from sharkiq import ( AylaApi, SharkIqAuthError, @@ -35,7 +34,7 @@ class CannotConnect(exceptions.HomeAssistantError): async def async_connect_or_timeout(ayla_api: AylaApi) -> bool: """Connect to vacuum.""" try: - async with async_timeout.timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() except SharkIqAuthError: @@ -87,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_disconnect_or_timeout(coordinator: SharkIqUpdateCoordinator): """Disconnect to vacuum.""" LOGGER.debug("Disconnecting from Ayla Api") - async with async_timeout.timeout(5): + async with asyncio.timeout(5): with suppress( SharkIqAuthError, SharkIqAuthExpiringError, SharkIqNotAuthedError ): diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 4161a5f5357..1957d12048f 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -6,7 +6,6 @@ from collections.abc import Mapping from typing import Any import aiohttp -import async_timeout from sharkiq import SharkIqAuthError, get_ayla_api import voluptuous as vol @@ -51,7 +50,7 @@ async def _validate_input( ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() except (asyncio.TimeoutError, aiohttp.ClientError, TypeError) as error: diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/update_coordinator.py index 87f5aafe7a4..4cfbb033566 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/update_coordinator.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta -from async_timeout import timeout from sharkiq import ( AylaApi, SharkIqAuthError, @@ -55,7 +54,7 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): """Asynchronously update the data for a single vacuum.""" dsn = sharkiq.serial_number LOGGER.debug("Updating sharkiq data for device DSN %s", dsn) - async with timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): await sharkiq.async_update() async def _async_update_data(self) -> bool: diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index b2f38f54b20..67258d701e9 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -6,7 +6,6 @@ from contextlib import suppress import logging import shlex -import async_timeout import voluptuous as vol from homeassistant.core import ( @@ -89,7 +88,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: process = await create_process try: - async with async_timeout.timeout(COMMAND_TIMEOUT): + async with asyncio.timeout(COMMAND_TIMEOUT): stdout_data, stderr_data = await process.communicate() except asyncio.TimeoutError: _LOGGER.error( diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index 5d68b90145f..72157e086e3 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -5,7 +5,6 @@ from datetime import timedelta import logging from aiohttp import client_exceptions -import async_timeout from smarttub import APIError, LoginFailed, SmartTub from smarttub.api import Account @@ -85,7 +84,7 @@ class SmartTubController: data = {} try: - async with async_timeout.timeout(POLLING_TIMEOUT): + async with asyncio.timeout(POLLING_TIMEOUT): for spa in self.spas: data[spa.id] = await self._get_spa_data(spa) except APIError as err: diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py index d01b92c2186..e105963bc01 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -1,7 +1,7 @@ """Platform for switch integration.""" +import asyncio from typing import Any -import async_timeout from smarttub import SpaPump from homeassistant.components.switch import SwitchEntity @@ -80,6 +80,6 @@ class SmartTubPump(SmartTubEntity, SwitchEntity): async def async_toggle(self, **kwargs: Any) -> None: """Toggle the pump on or off.""" - async with async_timeout.timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): await self.pump.toggle() await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index db5d7287ccd..5b71d92b25f 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -8,7 +8,6 @@ import logging from typing import Any, Final import aiohttp -import async_timeout from smhi import Smhi from smhi.smhi_lib import SmhiForecast, SmhiForecastException @@ -164,7 +163,7 @@ class SmhiWeather(WeatherEntity): async def async_update(self) -> None: """Refresh the forecast data from SMHI weather API.""" try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): self._forecast_daily = await self._smhi_api.async_get_forecast() self._forecast_hourly = await self._smhi_api.async_get_forecast_hour() self._fail_count = 0 diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 27cb7ac034d..5b4ecc3a141 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -1,8 +1,8 @@ """The sms component.""" +import asyncio from datetime import timedelta import logging -import async_timeout import gammu # pylint: disable=import-error import voluptuous as vol @@ -125,7 +125,7 @@ class SignalCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Fetch device signal quality.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self._gateway.get_signal_quality_async() except gammu.GSMError as exc: raise UpdateFailed(f"Error communicating with device: {exc}") from exc @@ -147,7 +147,7 @@ class NetworkCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Fetch device network info.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self._gateway.get_network_info_async() except gammu.GSMError as exc: raise UpdateFailed(f"Error communicating with device: {exc}") from exc diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index 31650a0828a..f11eba4fed7 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -108,7 +108,7 @@ async def test_controller_timeout( """Test an error talking to the controller.""" with patch( - "homeassistant.components.rainbird.config_flow.async_timeout.timeout", + "homeassistant.components.rainbird.config_flow.asyncio.timeout", side_effect=asyncio.TimeoutError, ): result = await complete_flow(hass) From ffe3d7c25585fb147a4440acb32a3aeaf411af69 Mon Sep 17 00:00:00 2001 From: Sam Reed Date: Tue, 15 Aug 2023 16:44:24 +0100 Subject: [PATCH 081/180] Replace "percents" -> "percentage" in flux_led option flow (#98059) --- homeassistant/components/flux_led/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json index d1d812cb210..aa56708c645 100644 --- a/homeassistant/components/flux_led/strings.json +++ b/homeassistant/components/flux_led/strings.json @@ -27,7 +27,7 @@ "data": { "mode": "The chosen brightness mode.", "custom_effect_colors": "Custom Effect: List of 1 to 16 [R,G,B] colors. Example: [255,0,255],[60,128,0]", - "custom_effect_speed_pct": "Custom Effect: Speed in percents for the effect that switch colors.", + "custom_effect_speed_pct": "Custom Effect: Speed in percentage for the effects that switch colors.", "custom_effect_transition": "Custom Effect: Type of transition between the colors." } } From 90413daa8a55839b21f1cd4fc59d32f822a29111 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 18:15:23 +0200 Subject: [PATCH 082/180] Update buienweather data before adding entities (#98455) * Update buienweather data before adding entities * Fix tests --- homeassistant/components/buienradar/const.py | 4 +-- homeassistant/components/buienradar/sensor.py | 11 +++--- homeassistant/components/buienradar/util.py | 30 +++++++++------- .../components/buienradar/weather.py | 36 +++++++------------ tests/components/buienradar/test_sensor.py | 5 +++ tests/components/buienradar/test_weather.py | 5 +++ 6 files changed, 49 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index 8111f63c923..718812c5c73 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -14,10 +14,10 @@ CONF_TIMEFRAME = "timeframe" SUPPORTED_COUNTRY_CODES = ["NL", "BE"] DEFAULT_COUNTRY = "NL" -"""Schedule next call after (minutes).""" SCHEDULE_OK = 10 -"""When an error occurred, new call after (minutes).""" +"""Schedule next call after (minutes).""" SCHEDULE_NOK = 2 +"""When an error occurred, new call after (minutes).""" STATE_CONDITIONS = ["clear", "cloudy", "fog", "rainy", "snowy", "lightning"] diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 00740eb4801..fe3ce3164fe 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -714,17 +714,18 @@ async def async_setup_entry( timeframe, ) + # create weather entities: entities = [ BrSensor(config.get(CONF_NAME, "Buienradar"), coordinates, description) for description in SENSOR_TYPES ] - async_add_entities(entities) - + # create weather data: data = BrData(hass, coordinates, timeframe, entities) - # schedule the first update in 1 minute from now: - await data.schedule_update(1) hass.data[DOMAIN][entry.entry_id][Platform.SENSOR] = data + await data.async_update() + + async_add_entities(entities) class BrSensor(SensorEntity): @@ -755,7 +756,7 @@ class BrSensor(SensorEntity): @callback def data_updated(self, data: BrData): """Update data.""" - if self.hass and self._load_data(data.data): + if self._load_data(data.data) and self.hass: self.async_write_ha_state() @callback diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 9d0c2a575c9..3c50b3097cb 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -27,7 +27,7 @@ from buienradar.constants import ( from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import CALLBACK_TYPE +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util @@ -77,7 +77,8 @@ class BrData: for dev in self.devices: dev.data_updated(self) - async def schedule_update(self, minute=1): + @callback + def async_schedule_update(self, minute=1): """Schedule an update after minute minutes.""" _LOGGER.debug("Scheduling next update in %s minutes", minute) nxt = dt_util.utcnow() + timedelta(minutes=minute) @@ -110,7 +111,7 @@ class BrData: if resp is not None: await resp.release() - async def async_update(self, *_): + async def _async_update(self): """Update the data from buienradar.""" content = await self.get_data(JSON_FEED_URL) @@ -123,9 +124,7 @@ class BrData: content.get(MESSAGE), content.get(STATUS_CODE), ) - # schedule new call - await self.schedule_update(SCHEDULE_NOK) - return + return None self.load_error_count = 0 # rounding coordinates prevents unnecessary redirects/calls @@ -143,9 +142,7 @@ class BrData: raincontent.get(MESSAGE), raincontent.get(STATUS_CODE), ) - # schedule new call - await self.schedule_update(SCHEDULE_NOK) - return + return None self.rain_error_count = 0 result = parse_data( @@ -164,12 +161,21 @@ class BrData: "Unable to parse data from Buienradar. (Msg: %s)", result.get(MESSAGE), ) - await self.schedule_update(SCHEDULE_NOK) + return None + + return result[DATA] + + async def async_update(self, *_): + """Update the data from buienradar and schedule the next update.""" + data = await self._async_update() + + if data is None: + self.async_schedule_update(SCHEDULE_NOK) return - self.data = result.get(DATA) + self.data = data await self.update_devices() - await self.schedule_update(SCHEDULE_OK) + self.async_schedule_update(SCHEDULE_OK) @property def attribution(self): diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index cdb8adf1dac..66c3b23ec8b 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -82,6 +82,11 @@ CONDITION_CLASSES = { ATTR_CONDITION_WINDY_VARIANT: (), ATTR_CONDITION_EXCEPTIONAL: (), } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} async def async_setup_entry( @@ -106,20 +111,10 @@ async def async_setup_entry( # create weather data: data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, entities) hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data - - # create condition helper - if DATA_CONDITION not in hass.data[DOMAIN]: - cond_keys = [str(chr(x)) for x in range(97, 123)] - hass.data[DOMAIN][DATA_CONDITION] = dict.fromkeys(cond_keys) - for cond, condlst in CONDITION_CLASSES.items(): - for condi in condlst: - hass.data[DOMAIN][DATA_CONDITION][condi] = cond + await data.async_update() async_add_entities(entities) - # schedule the first update in 1 minute from now: - await data.schedule_update(1) - class BrWeather(WeatherEntity): """Representation of a weather condition.""" @@ -143,9 +138,6 @@ class BrWeather(WeatherEntity): @callback def data_updated(self, data: BrData) -> None: """Update data.""" - if not self.hass: - return - self._attr_attribution = data.attribution self._attr_condition = self._calc_condition(data) self._attr_forecast = self._calc_forecast(data) @@ -158,22 +150,20 @@ class BrWeather(WeatherEntity): self._attr_native_visibility = data.visibility self._attr_native_wind_speed = data.wind_speed self._attr_wind_bearing = data.wind_bearing + + if not self.hass: + return self.async_write_ha_state() def _calc_condition(self, data: BrData): """Return the current condition.""" - if ( - data.condition - and (ccode := data.condition.get(CONDCODE)) - and (conditions := self.hass.data[DOMAIN].get(DATA_CONDITION)) - ): - return conditions.get(ccode) + if data.condition and (ccode := data.condition.get(CONDCODE)): + return CONDITION_MAP.get(ccode) return None def _calc_forecast(self, data: BrData): """Return the forecast array.""" fcdata_out = [] - cond = self.hass.data[DOMAIN][DATA_CONDITION] if not data.forecast: return None @@ -181,10 +171,10 @@ class BrWeather(WeatherEntity): for data_in in data.forecast: # remap keys from external library to # keys understood by the weather component: - condcode = data_in.get(CONDITION, []).get(CONDCODE) + condcode = data_in.get(CONDITION, {}).get(CONDCODE) data_out = { ATTR_FORECAST_TIME: data_in.get(DATETIME).isoformat(), - ATTR_FORECAST_CONDITION: cond[condcode], + ATTR_FORECAST_CONDITION: CONDITION_MAP.get(condcode), ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), diff --git a/tests/components/buienradar/test_sensor.py b/tests/components/buienradar/test_sensor.py index 725b03a6cc5..fb83d7a13db 100644 --- a/tests/components/buienradar/test_sensor.py +++ b/tests/components/buienradar/test_sensor.py @@ -1,4 +1,6 @@ """The tests for the Buienradar sensor platform.""" +from http import HTTPStatus + from homeassistant.components.buienradar.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -18,6 +20,9 @@ async def test_smoke_test_setup_component( aioclient_mock: AiohttpClientMocker, hass: HomeAssistant ) -> None: """Smoke test for successfully set-up with default config.""" + aioclient_mock.get( + "https://data.buienradar.nl/2.0/feed/json", status=HTTPStatus.NOT_FOUND + ) mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=TEST_CFG_DATA) mock_entry.add_to_hass(hass) diff --git a/tests/components/buienradar/test_weather.py b/tests/components/buienradar/test_weather.py index c8b0d459b78..d4c4af5f62a 100644 --- a/tests/components/buienradar/test_weather.py +++ b/tests/components/buienradar/test_weather.py @@ -1,4 +1,6 @@ """The tests for the buienradar weather component.""" +from http import HTTPStatus + from homeassistant.components.buienradar.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -13,6 +15,9 @@ async def test_smoke_test_setup_component( aioclient_mock: AiohttpClientMocker, hass: HomeAssistant ) -> None: """Smoke test for successfully set-up with default config.""" + aioclient_mock.get( + "https://data.buienradar.nl/2.0/feed/json", status=HTTPStatus.NOT_FOUND + ) mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=TEST_CFG_DATA) mock_entry.add_to_hass(hass) From 5a69f9ed04b8a5074c489955b745eb27533a6313 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Aug 2023 12:37:16 -0500 Subject: [PATCH 083/180] Remove unused code in enphase_envoy (#98474) --- homeassistant/components/enphase_envoy/binary_sensor.py | 2 -- homeassistant/components/enphase_envoy/select.py | 5 ----- homeassistant/components/enphase_envoy/sensor.py | 2 -- homeassistant/components/enphase_envoy/switch.py | 2 -- 4 files changed, 11 deletions(-) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 0e70a9fe98b..eae8c8628d5 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -112,8 +112,6 @@ async def async_setup_entry( coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] envoy_data = coordinator.envoy.data assert envoy_data is not None - envoy_serial_num = config_entry.unique_id - assert envoy_serial_num is not None entities: list[BinarySensorEntity] = [] if envoy_data.encharge_inventory: entities.extend( diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 75c9ce0cf7c..59f2a16e7cf 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -import logging from typing import Any from pyenphase import EnvoyDryContactSettings @@ -19,8 +18,6 @@ from .const import DOMAIN from .coordinator import EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -_LOGGER = logging.getLogger(__name__) - @dataclass class EnvoyRelayRequiredKeysMixin: @@ -113,8 +110,6 @@ async def async_setup_entry( coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] envoy_data = coordinator.envoy.data assert envoy_data is not None - envoy_serial_num = config_entry.unique_id - assert envoy_serial_num is not None entities: list[SelectEntity] = [] if envoy_data.dry_contact_settings: entities.extend( diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 0e4a9b71232..0e0a2aacfd7 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -350,8 +350,6 @@ async def async_setup_entry( coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] envoy_data = coordinator.envoy.data assert envoy_data is not None - envoy_serial_num = config_entry.unique_id - assert envoy_serial_num is not None _LOGGER.debug("Envoy data: %s", envoy_data) entities: list[Entity] = [ diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 820b904e070..e0f211a1019 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -55,8 +55,6 @@ async def async_setup_entry( coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] envoy_data = coordinator.envoy.data assert envoy_data is not None - envoy_serial_num = config_entry.unique_id - assert envoy_serial_num is not None entities: list[SwitchEntity] = [] if envoy_data.enpower: entities.extend( From 92535277be30a52022933a73921c3cc312092135 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 15 Aug 2023 14:08:11 -0400 Subject: [PATCH 084/180] Add number platform & battery setpoint entities to Enphase integration (#98427) Co-authored-by: J. Nick Koston --- .coveragerc | 1 + .../components/enphase_envoy/const.py | 8 +- .../components/enphase_envoy/number.py | 116 ++++++++++++++++++ .../components/enphase_envoy/strings.json | 8 ++ 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/enphase_envoy/number.py diff --git a/.coveragerc b/.coveragerc index 014dc2f0f39..9930cbaf0b5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -305,6 +305,7 @@ omit = homeassistant/components/enphase_envoy/binary_sensor.py homeassistant/components/enphase_envoy/coordinator.py homeassistant/components/enphase_envoy/entity.py + homeassistant/components/enphase_envoy/number.py homeassistant/components/enphase_envoy/select.py homeassistant/components/enphase_envoy/sensor.py homeassistant/components/enphase_envoy/switch.py diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index d1c6618502e..c5656a65b6f 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -5,6 +5,12 @@ from homeassistant.const import Platform DOMAIN = "enphase_envoy" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired) diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py new file mode 100644 index 00000000000..50d4de18f12 --- /dev/null +++ b/homeassistant/components/enphase_envoy/number.py @@ -0,0 +1,116 @@ +"""Number platform for Enphase Envoy solar energy monitor.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyenphase import EnvoyDryContactSettings + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EnphaseUpdateCoordinator +from .entity import EnvoyBaseEntity + + +@dataclass +class EnvoyRelayRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyDryContactSettings], float] + + +@dataclass +class EnvoyRelayNumberEntityDescription( + NumberEntityDescription, EnvoyRelayRequiredKeysMixin +): + """Describes an Envoy Dry Contact Relay number entity.""" + + +RELAY_ENTITIES = ( + EnvoyRelayNumberEntityDescription( + key="soc_low", + translation_key="cutoff_battery_level", + device_class=NumberDeviceClass.BATTERY, + entity_category=EntityCategory.CONFIG, + value_fn=lambda relay: relay.soc_low, + ), + EnvoyRelayNumberEntityDescription( + key="soc_high", + translation_key="restore_battery_level", + device_class=NumberDeviceClass.BATTERY, + entity_category=EntityCategory.CONFIG, + value_fn=lambda relay: relay.soc_high, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Enphase Envoy number platform.""" + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + envoy_data = coordinator.envoy.data + assert envoy_data is not None + entities: list[NumberEntity] = [] + if envoy_data.dry_contact_settings: + entities.extend( + EnvoyRelayNumberEntity(coordinator, entity, relay) + for entity in RELAY_ENTITIES + for relay in envoy_data.dry_contact_settings + ) + async_add_entities(entities) + + +class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity): + """Representation of an Enphase Enpower number entity.""" + + entity_description: EnvoyRelayNumberEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyRelayNumberEntityDescription, + relay_id: str, + ) -> None: + """Initialize the Enphase relay number entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + enpower = self.data.enpower + assert enpower is not None + serial_number = enpower.serial_number + self._relay_id = relay_id + self._attr_unique_id = f"{serial_number}_relay_{relay_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, relay_id)}, + manufacturer="Enphase", + model="Dry contact relay", + name=self.data.dry_contact_settings[relay_id].load_name, + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, serial_number), + ) + + @property + def native_value(self) -> float: + """Return the state of the relay entity.""" + return self.entity_description.value_fn( + self.data.dry_contact_settings[self._relay_id] + ) + + async def async_set_native_value(self, value: float) -> None: + """Update the relay.""" + await self.envoy.update_dry_contact( + {"id": self._relay_id, self.entity_description.key: int(value)} + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 0f292dfa8e3..f023bc7d114 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -39,6 +39,14 @@ "name": "Relay status" } }, + "number": { + "cutoff_battery_level": { + "name": "Cutoff battery level" + }, + "restore_battery_level": { + "name": "Restore battery level" + } + }, "select": { "relay_mode": { "name": "Mode", From 73f882bf36f94b1207869bfec63e3897b7ef11de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Aug 2023 13:08:55 -0500 Subject: [PATCH 085/180] Small cleanups to enphase_envoy select platform (#98476) --- .../components/enphase_envoy/select.py | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 59f2a16e7cf..5ae73a315f2 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -1,11 +1,11 @@ """Select platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from pyenphase import EnvoyDryContactSettings +from pyenphase import Envoy, EnvoyDryContactSettings from pyenphase.models.dry_contacts import DryContactAction, DryContactMode from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -24,7 +24,9 @@ class EnvoyRelayRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyDryContactSettings], str] - update_fn: Callable[[Any, Any, Any], Any] + update_fn: Callable[ + [Envoy, EnvoyDryContactSettings, str], Coroutine[Any, Any, dict[str, Any]] + ] @dataclass @@ -129,36 +131,34 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): self, coordinator: EnphaseUpdateCoordinator, description: EnvoyRelaySelectEntityDescription, - relay: str, + relay_id: str, ) -> None: """Initialize the Enphase relay select entity.""" super().__init__(coordinator, description) self.envoy = coordinator.envoy - assert self.envoy is not None - assert self.data is not None - self.enpower = self.data.enpower - assert self.enpower is not None - self._serial_number = self.enpower.serial_number - self.relay = self.data.dry_contact_settings[relay] - self.relay_id = relay - self._attr_unique_id = ( - f"{self._serial_number}_relay_{relay}_{self.entity_description.key}" - ) + enpower = self.data.enpower + assert enpower is not None + serial_number = enpower.serial_number + self._relay_id = relay_id + self._attr_unique_id = f"{serial_number}_relay_{relay_id}_{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, relay)}, + identifiers={(DOMAIN, relay_id)}, manufacturer="Enphase", model="Dry contact relay", name=self.relay.load_name, - sw_version=str(self.enpower.firmware_version), - via_device=(DOMAIN, self._serial_number), + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, serial_number), ) + @property + def relay(self) -> EnvoyDryContactSettings: + """Return the relay object.""" + return self.data.dry_contact_settings[self._relay_id] + @property def current_option(self) -> str: """Return the state of the Enpower switch.""" - return self.entity_description.value_fn( - self.data.dry_contact_settings[self.relay_id] - ) + return self.entity_description.value_fn(self.relay) async def async_select_option(self, option: str) -> None: """Update the relay.""" From 80d608bb5b4a84e5b7230706c34d190a865301ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Aug 2023 13:16:05 -0500 Subject: [PATCH 086/180] Remove some bound attributes from enphase_envoy binary_sensor (#98477) Some of these were never used --- .../components/enphase_envoy/binary_sensor.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index eae8c8628d5..77d41ccf375 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -186,13 +186,12 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity): super().__init__(coordinator, description) enpower = self.data.enpower assert enpower is not None - self._serial_number = enpower.serial_number - self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_unique_id = f"{enpower.serial_number}_{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, + identifiers={(DOMAIN, enpower.serial_number)}, manufacturer="Enphase", model="Enpower", - name=f"Enpower {self._serial_number}", + name=f"Enpower {enpower.serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), ) @@ -218,15 +217,13 @@ class EnvoyRelayBinarySensorEntity(EnvoyBaseBinarySensorEntity): super().__init__(coordinator, description) enpower = self.data.enpower assert enpower is not None - self.relay_id = relay_id - self.relay = self.data.dry_contact_settings[self.relay_id] - self._serial_number = enpower.serial_number - self._attr_unique_id = f"{self._serial_number}_relay_{relay_id}" + self._relay_id = relay_id + self._attr_unique_id = f"{enpower.serial_number}_relay_{relay_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, relay_id)}, manufacturer="Enphase", model="Dry contact relay", - name=self.relay.load_name, + name=self.data.dry_contact_settings[relay_id].load_name, sw_version=str(enpower.firmware_version), via_device=(DOMAIN, enpower.serial_number), ) @@ -234,5 +231,5 @@ class EnvoyRelayBinarySensorEntity(EnvoyBaseBinarySensorEntity): @property def is_on(self) -> bool: """Return the state of the Enpower binary_sensor.""" - relay = self.data.dry_contact_status[self.relay_id] + relay = self.data.dry_contact_status[self._relay_id] return relay.status == DryContactStatus.CLOSED From 857369625a82c74f8e13ea396cab27bbd470f2f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Aug 2023 13:29:22 -0500 Subject: [PATCH 087/180] Remove some bound attributes from enphase_envoy sensor (#98479) --- homeassistant/components/enphase_envoy/sensor.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 0e0a2aacfd7..33b9e3a64df 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -563,16 +563,14 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity): ) -> None: """Initialize Enpower entity.""" super().__init__(coordinator, description) - assert coordinator.envoy.data is not None - enpower_data = coordinator.envoy.data.enpower + enpower_data = self.data.enpower assert enpower_data is not None - self._serial_number = enpower_data.serial_number - self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_unique_id = f"{enpower_data.serial_number}_{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, + identifiers={(DOMAIN, enpower_data.serial_number)}, manufacturer="Enphase", model="Enpower", - name=f"Enpower {self._serial_number}", + name=f"Enpower {enpower_data.serial_number}", sw_version=str(enpower_data.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), ) From caeb20f9c9bec2a2a4b4f90f4653aac4170ac725 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 20:55:16 +0200 Subject: [PATCH 088/180] Modernize aemet weather (#97969) * Modernize aemet weather * Improve test coverage * Only create a single entity for new config entries --- homeassistant/components/aemet/weather.py | 77 +- .../aemet/snapshots/test_weather.ambr | 1238 +++++++++++++++++ tests/components/aemet/test_weather.py | 145 +- 3 files changed, 1445 insertions(+), 15 deletions(-) create mode 100644 tests/components/aemet/snapshots/test_weather.ambr diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index aba5a2781d0..f9b0f7ef6ca 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -1,4 +1,6 @@ """Support for the AEMET OpenData service.""" +from typing import cast + from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_NATIVE_PRECIPITATION, @@ -8,7 +10,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + DOMAIN as WEATHER_DOMAIN, + Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -17,7 +22,8 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -79,10 +85,28 @@ async def async_setup_entry( weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] entities = [] - for mode in FORECAST_MODES: - name = f"{domain_data[ENTRY_NAME]} {mode}" - unique_id = f"{config_entry.unique_id} {mode}" - entities.append(AemetWeather(name, unique_id, weather_coordinator, mode)) + entity_registry = er.async_get(hass) + + # Add daily + hourly entity for legacy config entries, only add daily for new + # config entries. This can be removed in HA Core 2024.3 + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + f"{config_entry.unique_id} {FORECAST_MODE_HOURLY}", + ): + for mode in FORECAST_MODES: + name = f"{domain_data[ENTRY_NAME]} {mode}" + unique_id = f"{config_entry.unique_id} {mode}" + entities.append(AemetWeather(name, unique_id, weather_coordinator, mode)) + else: + entities.append( + AemetWeather( + domain_data[ENTRY_NAME], + config_entry.unique_id, + weather_coordinator, + FORECAST_MODE_DAILY, + ) + ) async_add_entities(entities, False) @@ -95,6 +119,9 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__( self, @@ -112,20 +139,44 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): self._attr_name = name self._attr_unique_id = unique_id + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + super()._handle_coordinator_update() + assert self.platform.config_entry + self.platform.config_entry.async_create_task( + self.hass, self.async_update_listeners(("daily", "hourly")) + ) + @property def condition(self): """Return the current condition.""" return self.coordinator.data[ATTR_API_CONDITION] - @property - def forecast(self): + def _forecast(self, forecast_mode: str) -> list[Forecast]: """Return the forecast array.""" - forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]] - forecast_map = FORECAST_MAP[self._forecast_mode] - return [ - {ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()} - for forecast in forecasts - ] + forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[forecast_mode]] + forecast_map = FORECAST_MAP[forecast_mode] + return cast( + list[Forecast], + [ + {ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()} + for forecast in forecasts + ], + ) + + @property + def forecast(self) -> list[Forecast]: + """Return the forecast array.""" + return self._forecast(self._forecast_mode) + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast(FORECAST_MODE_DAILY) + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast in native units.""" + return self._forecast(FORECAST_MODE_HOURLY) @property def humidity(self): diff --git a/tests/components/aemet/snapshots/test_weather.ambr b/tests/components/aemet/snapshots/test_weather.ambr new file mode 100644 index 00000000000..e9c922f041e --- /dev/null +++ b/tests/components/aemet/snapshots/test_weather.ambr @@ -0,0 +1,1238 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]), + }) +# --- +# name: test_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_speed': 10.0, + }), + ]), + }) +# --- +# name: test_forecast_subscription[daily] + list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]) +# --- +# name: test_forecast_subscription[daily].1 + list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]) +# --- +# name: test_forecast_subscription[hourly] + list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_speed': 10.0, + }), + ]) +# --- +# name: test_forecast_subscription[hourly].1 + list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_speed': 10.0, + }), + ]) +# --- diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index 30b11876e74..c64e824e18d 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -1,7 +1,16 @@ """The sensor tests for the AEMET OpenData platform.""" +import datetime from unittest.mock import patch -from homeassistant.components.aemet.const import ATTRIBUTION +from freezegun.api import FrozenDateTimeFactory +import pytest +import requests_mock +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aemet.const import ATTRIBUTION, DOMAIN +from homeassistant.components.aemet.weather_update_coordinator import ( + WEATHER_UPDATE_INTERVAL, +) from homeassistant.components.weather import ( ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_SNOWY, @@ -19,17 +28,65 @@ from homeassistant.components.weather import ( ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, ) from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from .util import async_init_integration +from .util import aemet_requests_mock, async_init_integration + +from tests.typing import WebSocketGenerator async def test_aemet_weather(hass: HomeAssistant) -> None: """Test states of the weather.""" + hass.config.set_time_zone("UTC") + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + with patch("homeassistant.util.dt.now", return_value=now), patch( + "homeassistant.util.dt.utcnow", return_value=now + ): + await async_init_integration(hass) + + state = hass.states.get("weather.aemet") + assert state + assert state.state == ATTR_CONDITION_SNOWY + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 + assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa + assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 + assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 + assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h + forecast = state.attributes.get(ATTR_FORECAST)[0] + assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY + assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None + assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 + assert forecast.get(ATTR_FORECAST_TEMP) == 4 + assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 + assert ( + forecast.get(ATTR_FORECAST_TIME) + == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() + ) + assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h + + state = hass.states.get("weather.aemet_hourly") + assert state is None + + +async def test_aemet_weather_legacy(hass: HomeAssistant) -> None: + """Test states of the weather.""" + + registry = er.async_get(hass) + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "None hourly", + ) + hass.config.set_time_zone("UTC") now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") with patch("homeassistant.util.dt.now", return_value=now), patch( @@ -61,3 +118,87 @@ async def test_aemet_weather(hass: HomeAssistant) -> None: state = hass.states.get("weather.aemet_hourly") assert state is None + + +async def test_forecast_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + hass.config.set_time_zone("UTC") + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + with patch("homeassistant.util.dt.now", return_value=now), patch( + "homeassistant.util.dt.utcnow", return_value=now + ): + await async_init_integration(hass) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.aemet", + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.aemet", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +@pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + forecast_type: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": "weather.aemet", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 == snapshot + + with requests_mock.mock() as _m: + aemet_requests_mock(_m) + freezer.tick(WEATHER_UPDATE_INTERVAL + datetime.timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 == snapshot From 3cf86d5d1faead577d66f72ef890bca22e0a25ef Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Aug 2023 20:56:19 +0200 Subject: [PATCH 089/180] Create a single entity for new met_eireann config entries (#98100) --- .../components/met_eireann/weather.py | 43 ++++++++++++------- tests/components/met_eireann/test_weather.py | 26 +++++++++++ 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index e31951ea8a2..c40c89892c9 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -1,10 +1,12 @@ """Support for Met Éireann weather service.""" import logging -from typing import cast +from types import MappingProxyType +from typing import Any, cast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TIME, + DOMAIN as WEATHER_DOMAIN, Forecast, WeatherEntity, WeatherEntityFeature, @@ -20,6 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -50,12 +53,28 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - [ - MetEireannWeather(coordinator, config_entry.data, False), - MetEireannWeather(coordinator, config_entry.data, True), - ] - ) + entity_registry = er.async_get(hass) + + entities = [MetEireannWeather(coordinator, config_entry.data, False)] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(config_entry.data, True), + ): + entities.append(MetEireannWeather(coordinator, config_entry.data, True)) + + async_add_entities(entities) + + +def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: + """Calculate unique ID.""" + name_appendix = "" + if hourly: + name_appendix = "-hourly" + + return f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}{name_appendix}" class MetEireannWeather( @@ -75,6 +94,7 @@ class MetEireannWeather( def __init__(self, coordinator, config, hourly): """Initialise the platform with a data instance and site.""" super().__init__(coordinator) + self._attr_unique_id = _calculate_unique_id(config, hourly) self._config = config self._hourly = hourly @@ -87,15 +107,6 @@ class MetEireannWeather( self.hass, self.async_update_listeners(("daily", "hourly")) ) - @property - def unique_id(self): - """Return unique ID.""" - name_appendix = "" - if self._hourly: - name_appendix = "-hourly" - - return f"{self._config[CONF_LATITUDE]}-{self._config[CONF_LONGITUDE]}{name_appendix}" - @property def name(self): """Return the name of the sensor.""" diff --git a/tests/components/met_eireann/test_weather.py b/tests/components/met_eireann/test_weather.py index e14cd485cc6..a3ca1fd55f7 100644 --- a/tests/components/met_eireann/test_weather.py +++ b/tests/components/met_eireann/test_weather.py @@ -13,6 +13,7 @@ from homeassistant.components.weather import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -31,6 +32,31 @@ async def setup_config_entry(hass: HomeAssistant) -> ConfigEntry: return mock_data +async def test_new_config_entry(hass: HomeAssistant, mock_weather) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + await setup_config_entry(hass) + assert len(hass.states.async_entity_ids("weather")) == 1 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + + +async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "10-20-hourly", + ) + await setup_config_entry(hass) + assert len(hass.states.async_entity_ids("weather")) == 2 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + + async def test_weather(hass: HomeAssistant, mock_weather) -> None: """Test weather entity.""" await setup_config_entry(hass) From c671b1069e8ce637b7cb00e7617f319b3f28e67e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Aug 2023 18:52:18 -0500 Subject: [PATCH 090/180] Bump protobuf to 4.24.0 (#98468) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37ec683aff0..389729fa4c2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -148,7 +148,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.23.3 +protobuf==4.24.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b2954dc777b..5a683660efe 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -149,7 +149,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.23.3 +protobuf==4.24.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder From 45966069b49e4146fef7d776381b5a3a54145685 Mon Sep 17 00:00:00 2001 From: dalinicus Date: Wed, 16 Aug 2023 01:15:28 -0500 Subject: [PATCH 091/180] Bump aiolyric to 1.1.0 (#98113) version bump aiolyric to include new features for additional room sensors --- homeassistant/components/lyric/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json index e517ce5118e..a55f9c1d7cb 100644 --- a/homeassistant/components/lyric/manifest.json +++ b/homeassistant/components/lyric/manifest.json @@ -22,5 +22,5 @@ "iot_class": "cloud_polling", "loggers": ["aiolyric"], "quality_scale": "silver", - "requirements": ["aiolyric==1.0.9"] + "requirements": ["aiolyric==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f86b6afc2e2..5a3df4e60c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -280,7 +280,7 @@ aiolivisi==0.0.19 aiolookin==1.0.0 # homeassistant.components.lyric -aiolyric==1.0.9 +aiolyric==1.1.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bc5a8b53ed..e5dda6a7715 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -255,7 +255,7 @@ aiolivisi==0.0.19 aiolookin==1.0.0 # homeassistant.components.lyric -aiolyric==1.0.9 +aiolyric==1.1.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 From c010b97abf11d253f0634905aacd720a6625fffc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 16 Aug 2023 09:07:14 +0200 Subject: [PATCH 092/180] Improve test recovery MQTT certificate files (#98223) * Improve test recovery MQTT certificate files * Spelling --- tests/components/mqtt/test_util.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index 96577bd3fa4..e93a5e376bb 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -37,7 +37,7 @@ def mock_temp_dir(): async def test_async_create_certificate_temp_files( hass: HomeAssistant, mock_temp_dir, option, content, file_created ) -> None: - """Test creating and reading certificate files.""" + """Test creating and reading and recovery certificate files.""" config = {option: content} await mqtt.util.async_create_certificate_temp_files(hass, config) @@ -47,6 +47,22 @@ async def test_async_create_certificate_temp_files( mqtt.util.migrate_certificate_file_to_content(file_path or content) == content ) + # Make sure certificate temp files are recovered + if file_path: + # Overwrite content of file (except for auto option) + file = open(file_path, "wb") + file.write(b"invalid") + file.close() + + await mqtt.util.async_create_certificate_temp_files(hass, config) + file_path2 = mqtt.util.get_file_path(option) + assert bool(file_path2) is file_created + assert ( + mqtt.util.migrate_certificate_file_to_content(file_path2 or content) == content + ) + + assert file_path == file_path2 + async def test_reading_non_exitisting_certificate_file() -> None: """Test reading a non existing certificate file.""" From 8efb9dad7e749ca93a5f8a2bee18117f656a904d Mon Sep 17 00:00:00 2001 From: Emma Vanbrabant Date: Wed, 16 Aug 2023 08:42:38 +0100 Subject: [PATCH 093/180] Add device_class to Renault charging remaining time (#98393) * Add device_class on charging remaining time Set `device_class to `duration` on the `charging_remaining_time` entity in the Renault integration. I had some issues showing this property on my dashboard, and setting this fixed it. The recommendation to open an issue against the original integration in these kinds of cases came from [here](https://community.home-assistant.io/t/how-to-format-a-card-to-show-hours-instead-of-seconds/425473/7). * Update test const to add duration * fix other cars * Update test_sensor.ambr * add duration in ambr --- homeassistant/components/renault/sensor.py | 1 + tests/components/renault/const.py | 3 +++ .../renault/snapshots/test_sensor.ambr | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 050c5a930f6..92deb3438de 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -190,6 +190,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( key="charging_remaining_time", coordinator="battery", data_key="chargingRemainingTime", + device_class=SensorDeviceClass.DURATION, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon="mdi:timer", native_unit_of_measurement=UnitOfTime.MINUTES, diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 4b2a7dfc72b..342ab803f33 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -198,6 +198,7 @@ MOCK_VEHICLES = { ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, }, { + ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", ATTR_ICON: "mdi:timer", ATTR_STATE: "145", @@ -433,6 +434,7 @@ MOCK_VEHICLES = { ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, }, { + ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", ATTR_ICON: "mdi:timer", ATTR_STATE: STATE_UNKNOWN, @@ -668,6 +670,7 @@ MOCK_VEHICLES = { ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, }, { + ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", ATTR_ICON: "mdi:timer", ATTR_STATE: "145", diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index b4e2f105b3b..46b231ac7ef 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -404,7 +404,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -811,6 +811,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -1100,7 +1101,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -1505,6 +1506,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -1790,7 +1792,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -2223,6 +2225,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -2803,7 +2806,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -3210,6 +3213,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -3499,7 +3503,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -3904,6 +3908,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -4189,7 +4194,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -4622,6 +4627,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , From 6c573953e332c615fdca28963c60a2fb260b6a3b Mon Sep 17 00:00:00 2001 From: Andy Barratt Date: Wed, 16 Aug 2023 09:06:37 +0100 Subject: [PATCH 094/180] Update Light flash description (#98252) * Update Light flash description `light.turn_on` service description for the `flash` option gave the impression of a boolean value being required when in fact a string of either `short` or `long` was required. Updated this to match the documentation found at https://www.home-assistant.io/integrations/light `light.turn_off` also described the existence of a `flash` option when none exists. I've removed this, which matches the aforementioned documentation too. * Revert removal of flash from turn-off As discussed in feedback, turn-off does indeed seem to support flash. --- homeassistant/components/light/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 80e2ca54562..8be954f4653 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -308,7 +308,7 @@ }, "flash": { "name": "Flash", - "description": "If the light should flash." + "description": "Tell light to flash, can be either value short or long." }, "effect": { "name": "Effect", From a0ea6e6a0c4a480f3ddf1964505f2623aee7e7ea Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 16 Aug 2023 02:10:02 -0700 Subject: [PATCH 095/180] Bump opower to 0.0.29 (#98503) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 97a605676e1..14720106f74 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.26"] + "requirements": ["opower==0.0.29"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5a3df4e60c1..99c0bb81432 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1365,7 +1365,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.26 +opower==0.0.29 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5dda6a7715..a82b48a4e29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1031,7 +1031,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.26 +opower==0.0.29 # homeassistant.components.oralb oralb-ble==0.17.6 From b680bca5e9cdb81abbf5285a1670d2e089a65ff4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Aug 2023 04:30:47 -0500 Subject: [PATCH 096/180] Bump aiohomekit to 2.6.16 (#98490) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 52a91d42e67..5096544ba05 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==2.6.15"], + "requirements": ["aiohomekit==2.6.16"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 99c0bb81432..2d8205e7ff9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -246,7 +246,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.15 +aiohomekit==2.6.16 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a82b48a4e29..89044c9b368 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -224,7 +224,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.15 +aiohomekit==2.6.16 # homeassistant.components.emulated_hue # homeassistant.components.http From b083f5bf89779a1db2b9accd356b6a9d000cdf53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Aug 2023 04:33:25 -0500 Subject: [PATCH 097/180] Add some typing to doorbird (#98483) --- .coveragerc | 3 +- homeassistant/components/doorbird/__init__.py | 130 +---------------- homeassistant/components/doorbird/const.py | 2 + homeassistant/components/doorbird/device.py | 137 ++++++++++++++++++ homeassistant/components/doorbird/entity.py | 7 +- homeassistant/components/doorbird/util.py | 17 ++- 6 files changed, 166 insertions(+), 130 deletions(-) create mode 100644 homeassistant/components/doorbird/device.py diff --git a/.coveragerc b/.coveragerc index 9930cbaf0b5..4dd8dea258d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -212,8 +212,9 @@ omit = homeassistant/components/dominos/* homeassistant/components/doods/* homeassistant/components/doorbird/__init__.py - homeassistant/components/doorbird/button.py homeassistant/components/doorbird/camera.py + homeassistant/components/doorbird/button.py + homeassistant/components/doorbird/device.py homeassistant/components/doorbird/entity.py homeassistant/components/doorbird/util.py homeassistant/components/dormakaba_dkey/__init__.py diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index deb37c1bfe3..8651f7de6de 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -24,11 +24,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util, slugify from .const import ( + API_URL, CONF_EVENTS, DOMAIN, DOOR_STATION, @@ -37,12 +36,11 @@ from .const import ( PLATFORMS, UNDO_UPDATE_LISTENER, ) +from .device import ConfiguredDoorBird from .util import get_doorstation_by_token _LOGGER = logging.getLogger(__name__) -API_URL = f"/api/{DOMAIN}" - CONF_CUSTOM_URL = "hass_url_override" RESET_DEVICE_FAVORITES = "doorbird_reset_favorites" @@ -128,9 +126,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) raise ConfigEntryNotReady - token = doorstation_config.get(CONF_TOKEN, config_entry_id) - custom_url = doorstation_config.get(CONF_CUSTOM_URL) - name = doorstation_config.get(CONF_NAME) + token: str = doorstation_config.get(CONF_TOKEN, config_entry_id) + custom_url: str | None = doorstation_config.get(CONF_CUSTOM_URL) + name: str | None = doorstation_config.get(CONF_NAME) events = doorstation_options.get(CONF_EVENTS, []) doorstation = ConfiguredDoorBird(device, name, custom_url, token) doorstation.update_events(events) @@ -151,7 +149,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def _init_doorbird_device(device): +def _init_doorbird_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]: return device.ready(), device.info() @@ -211,122 +209,6 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi hass.config_entries.async_update_entry(entry, options=options) -class ConfiguredDoorBird: - """Attach additional information to pass along with configured device.""" - - def __init__(self, device, name, custom_url, token): - """Initialize configured device.""" - self._name = name - self._device = device - self._custom_url = custom_url - self.events = None - self.doorstation_events = None - self._token = token - - def update_events(self, events): - """Update the doorbird events.""" - self.events = events - self.doorstation_events = [self._get_event_name(event) for event in self.events] - - @property - def name(self): - """Get custom device name.""" - return self._name - - @property - def device(self): - """Get the configured device.""" - return self._device - - @property - def custom_url(self): - """Get custom url for device.""" - return self._custom_url - - @property - def token(self): - """Get token for device.""" - return self._token - - def register_events(self, hass: HomeAssistant) -> None: - """Register events on device.""" - # Get the URL of this server - hass_url = get_url(hass, prefer_external=False) - - # Override url if another is specified in the configuration - if self.custom_url is not None: - hass_url = self.custom_url - - if not self.doorstation_events: - # User may not have permission to get the favorites - return - - favorites = self.device.favorites() - for event in self.doorstation_events: - if self._register_event(hass_url, event, favs=favorites): - _LOGGER.info( - "Successfully registered URL for %s on %s", event, self.name - ) - - @property - def slug(self): - """Get device slug.""" - return slugify(self._name) - - def _get_event_name(self, event): - return f"{self.slug}_{event}" - - def _register_event( - self, hass_url: str, event: str, favs: dict[str, Any] | None = None - ) -> bool: - """Add a schedule entry in the device for a sensor.""" - url = f"{hass_url}{API_URL}/{event}?token={self._token}" - - # Register HA URL as webhook if not already, then get the ID - if self.webhook_is_registered(url, favs=favs): - return True - - self.device.change_favorite("http", f"Home Assistant ({event})", url) - if not self.webhook_is_registered(url): - _LOGGER.warning( - 'Unable to set favorite URL "%s". Event "%s" will not fire', - url, - event, - ) - return False - return True - - def webhook_is_registered(self, url, favs=None) -> bool: - """Return whether the given URL is registered as a device favorite.""" - return self.get_webhook_id(url, favs) is not None - - def get_webhook_id(self, url, favs=None) -> str | None: - """Return the device favorite ID for the given URL. - - The favorite must exist or there will be problems. - """ - favs = favs if favs else self.device.favorites() - - if "http" not in favs: - return None - - for fav_id in favs["http"]: - if favs["http"][fav_id]["value"] == url: - return fav_id - - return None - - def get_event_data(self): - """Get data to pass along with HA event.""" - return { - "timestamp": dt_util.utcnow().isoformat(), - "live_video_url": self._device.live_video_url, - "live_image_url": self._device.live_image_url, - "rtsp_live_video_url": self._device.rtsp_live_video_url, - "html5_viewer_url": self._device.html5_viewer_url, - } - - class DoorBirdRequestView(HomeAssistantView): """Provide a page for the device to call.""" diff --git a/homeassistant/components/doorbird/const.py b/homeassistant/components/doorbird/const.py index 767366af734..416603a312c 100644 --- a/homeassistant/components/doorbird/const.py +++ b/homeassistant/components/doorbird/const.py @@ -19,3 +19,5 @@ DOORBIRD_INFO_KEY_PRIMARY_MAC_ADDR = "PRIMARY_MAC_ADDR" DOORBIRD_INFO_KEY_WIFI_MAC_ADDR = "WIFI_MAC_ADDR" UNDO_UPDATE_LISTENER = "undo_update_listener" + +API_URL = f"/api/{DOMAIN}" diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py new file mode 100644 index 00000000000..1c787feb934 --- /dev/null +++ b/homeassistant/components/doorbird/device.py @@ -0,0 +1,137 @@ +"""Support for DoorBird devices.""" +from __future__ import annotations + +import logging +from typing import Any + +from doorbirdpy import DoorBird + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.network import get_url +from homeassistant.util import dt as dt_util, slugify + +from .const import API_URL + +_LOGGER = logging.getLogger(__name__) + + +class ConfiguredDoorBird: + """Attach additional information to pass along with configured device.""" + + def __init__( + self, device: DoorBird, name: str | None, custom_url: str | None, token: str + ) -> None: + """Initialize configured device.""" + self._name = name + self._device = device + self._custom_url = custom_url + self.events = None + self.doorstation_events = None + self._token = token + + def update_events(self, events): + """Update the doorbird events.""" + self.events = events + self.doorstation_events = [self._get_event_name(event) for event in self.events] + + @property + def name(self) -> str | None: + """Get custom device name.""" + return self._name + + @property + def device(self) -> DoorBird: + """Get the configured device.""" + return self._device + + @property + def custom_url(self) -> str | None: + """Get custom url for device.""" + return self._custom_url + + @property + def token(self) -> str: + """Get token for device.""" + return self._token + + def register_events(self, hass: HomeAssistant) -> None: + """Register events on device.""" + # Get the URL of this server + hass_url = get_url(hass, prefer_external=False) + + # Override url if another is specified in the configuration + if self.custom_url is not None: + hass_url = self.custom_url + + if not self.doorstation_events: + # User may not have permission to get the favorites + return + + favorites = self.device.favorites() + for event in self.doorstation_events: + if self._register_event(hass_url, event, favs=favorites): + _LOGGER.info( + "Successfully registered URL for %s on %s", event, self.name + ) + + @property + def slug(self) -> str: + """Get device slug.""" + return slugify(self._name) + + def _get_event_name(self, event: str) -> str: + return f"{self.slug}_{event}" + + def _register_event( + self, hass_url: str, event: str, favs: dict[str, Any] | None = None + ) -> bool: + """Add a schedule entry in the device for a sensor.""" + url = f"{hass_url}{API_URL}/{event}?token={self._token}" + + # Register HA URL as webhook if not already, then get the ID + if self.webhook_is_registered(url, favs=favs): + return True + + self.device.change_favorite("http", f"Home Assistant ({event})", url) + if not self.webhook_is_registered(url): + _LOGGER.warning( + 'Unable to set favorite URL "%s". Event "%s" will not fire', + url, + event, + ) + return False + return True + + def webhook_is_registered( + self, url: str, favs: dict[str, Any] | None = None + ) -> bool: + """Return whether the given URL is registered as a device favorite.""" + return self.get_webhook_id(url, favs) is not None + + def get_webhook_id( + self, url: str, favs: dict[str, Any] | None = None + ) -> str | None: + """Return the device favorite ID for the given URL. + + The favorite must exist or there will be problems. + """ + favs = favs if favs else self.device.favorites() + + if "http" not in favs: + return None + + for fav_id in favs["http"]: + if favs["http"][fav_id]["value"] == url: + return fav_id + + return None + + def get_event_data(self) -> dict[str, str]: + """Get data to pass along with HA event.""" + return { + "timestamp": dt_util.utcnow().isoformat(), + "live_video_url": self._device.live_video_url, + "live_image_url": self._device.live_image_url, + "rtsp_live_video_url": self._device.rtsp_live_video_url, + "html5_viewer_url": self._device.html5_viewer_url, + } diff --git a/homeassistant/components/doorbird/entity.py b/homeassistant/components/doorbird/entity.py index 65431e38be1..32c9cfff784 100644 --- a/homeassistant/components/doorbird/entity.py +++ b/homeassistant/components/doorbird/entity.py @@ -1,5 +1,7 @@ """The DoorBird integration base entity.""" +from typing import Any + from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -10,6 +12,7 @@ from .const import ( DOORBIRD_INFO_KEY_FIRMWARE, MANUFACTURER, ) +from .device import ConfiguredDoorBird from .util import get_mac_address_from_doorstation_info @@ -18,7 +21,9 @@ class DoorBirdEntity(Entity): _attr_has_entity_name = True - def __init__(self, doorstation, doorstation_info): + def __init__( + self, doorstation: ConfiguredDoorBird, doorstation_info: dict[str, Any] + ) -> None: """Initialize the entity.""" super().__init__() self._doorstation = doorstation diff --git a/homeassistant/components/doorbird/util.py b/homeassistant/components/doorbird/util.py index 55974bc1866..7b406bc07fa 100644 --- a/homeassistant/components/doorbird/util.py +++ b/homeassistant/components/doorbird/util.py @@ -1,6 +1,9 @@ """DoorBird integration utils.""" +from homeassistant.core import HomeAssistant + from .const import DOMAIN, DOOR_STATION +from .device import ConfiguredDoorBird def get_mac_address_from_doorstation_info(doorstation_info): @@ -10,17 +13,23 @@ def get_mac_address_from_doorstation_info(doorstation_info): return doorstation_info["WIFI_MAC_ADDR"] -def get_doorstation_by_token(hass, token): +def get_doorstation_by_token( + hass: HomeAssistant, token: str +) -> ConfiguredDoorBird | None: """Get doorstation by token.""" return _get_doorstation_by_attr(hass, "token", token) -def get_doorstation_by_slug(hass, slug): +def get_doorstation_by_slug( + hass: HomeAssistant, slug: str +) -> ConfiguredDoorBird | None: """Get doorstation by slug.""" return _get_doorstation_by_attr(hass, "slug", slug) -def _get_doorstation_by_attr(hass, attr, val): +def _get_doorstation_by_attr( + hass: HomeAssistant, attr: str, val: str +) -> ConfiguredDoorBird | None: for entry in hass.data[DOMAIN].values(): if DOOR_STATION not in entry: continue @@ -33,7 +42,7 @@ def _get_doorstation_by_attr(hass, attr, val): return None -def get_all_doorstations(hass): +def get_all_doorstations(hass: HomeAssistant) -> list[ConfiguredDoorBird]: """Get all doorstations.""" return [ entry[DOOR_STATION] From 0bcc02e9086e954fec2c45cab678014d0380645a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 16 Aug 2023 11:36:52 +0200 Subject: [PATCH 098/180] Skip writing pyc files [ci] (#98423) --- .github/workflows/ci.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6d41c4e1e7f..5cb51a30dda 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -735,6 +735,8 @@ jobs: if: needs.info.outputs.test_full_suite == 'true' timeout-minutes: 60 id: pytest-full + env: + PYTHONDONTWRITEBYTECODE: 1 run: | . venv/bin/activate python --version @@ -759,6 +761,8 @@ jobs: timeout-minutes: 10 id: pytest-partial shell: bash + env: + PYTHONDONTWRITEBYTECODE: 1 run: | . venv/bin/activate python --version @@ -877,6 +881,8 @@ jobs: timeout-minutes: 20 id: pytest-partial shell: bash + env: + PYTHONDONTWRITEBYTECODE: 1 run: | . venv/bin/activate python --version @@ -994,6 +1000,8 @@ jobs: timeout-minutes: 20 id: pytest-partial shell: bash + env: + PYTHONDONTWRITEBYTECODE: 1 run: | . venv/bin/activate python --version From e69090b943d7d25689ca794917b6fe9bdcc3da05 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 11:41:11 +0200 Subject: [PATCH 099/180] Map meteo_france weather condition codes once (#98513) --- homeassistant/components/meteo_france/const.py | 5 +++++ homeassistant/components/meteo_france/weather.py | 9 +++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index fad1a33e25c..f1e6ae8d0eb 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -89,3 +89,8 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index a2e9dc30c53..6459827b601 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -34,7 +34,7 @@ from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, COORDINATOR_FORECAST, DOMAIN, FORECAST_MODE_DAILY, @@ -47,11 +47,8 @@ _LOGGER = logging.getLogger(__name__) def format_condition(condition: str): - """Return condition from dict CONDITION_CLASSES.""" - for key, value in CONDITION_CLASSES.items(): - if condition in value: - return key - return condition + """Return condition from dict CONDITION_MAP.""" + return CONDITION_MAP.get(condition, condition) async def async_setup_entry( From 636cb6279d076512fbba1d9fd7656324f35ec3db Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 11:59:59 +0200 Subject: [PATCH 100/180] Push updated ecobee weather forecast to listeners (#98511) --- homeassistant/components/ecobee/weather.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index 729ab463fb3..3e71b05af1d 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -197,6 +197,7 @@ class EcobeeWeather(WeatherEntity): await self.data.update() thermostat = self.data.ecobee.get_thermostat(self._index) self.weather = thermostat.get("weather") + await self.async_update_listeners(("daily",)) def _process_forecast(json): From ed2f067c5294ef56e6821f7e4f4c6dfc6ae7c184 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Aug 2023 05:03:40 -0500 Subject: [PATCH 101/180] Bump zeroconf to 0.80.0 (#98416) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 6f3020244fa..0d63e87db17 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.78.0"] + "requirements": ["zeroconf==0.80.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 389729fa4c2..bac607545e6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.78.0 +zeroconf==0.80.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 2d8205e7ff9..db56052eb82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2752,7 +2752,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.78.0 +zeroconf==0.80.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89044c9b368..d69c88eb7a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2025,7 +2025,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.78.0 +zeroconf==0.80.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From abf065ed7672f90f92aa183fd3122d4ae75ce680 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 16 Aug 2023 11:56:47 +0100 Subject: [PATCH 102/180] Fix checks for duplicated config entries in IPMA (#98319) * fix unique_id * old unique id detection * update tests * match entry not unique_id * address review --- homeassistant/components/ipma/config_flow.py | 14 +- homeassistant/components/ipma/strings.json | 4 +- tests/components/ipma/conftest.py | 36 +++++ tests/components/ipma/test_config_flow.py | 145 ++++++------------- 4 files changed, 93 insertions(+), 106 deletions(-) create mode 100644 tests/components/ipma/conftest.py diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index 9434aed3097..d7b8b8cc003 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -22,14 +22,14 @@ class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._errors = {} if user_input is not None: - if user_input[CONF_NAME] not in self.hass.config_entries.async_entries( - DOMAIN - ): - return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input - ) + self._async_abort_entries_match( + { + CONF_LATITUDE: user_input[CONF_LATITUDE], + CONF_LONGITUDE: user_input[CONF_LONGITUDE], + } + ) - self._errors[CONF_NAME] = "name_exists" + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) # default location is set hass configuration return await self._show_config_form( diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index b9f50c66f9e..012550d8bd1 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -12,7 +12,9 @@ } } }, - "error": { "name_exists": "Name already exists" } + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + } }, "system_health": { "info": { diff --git a/tests/components/ipma/conftest.py b/tests/components/ipma/conftest.py new file mode 100644 index 00000000000..dda0e69d118 --- /dev/null +++ b/tests/components/ipma/conftest.py @@ -0,0 +1,36 @@ +"""Define test fixtures for IPMA.""" + +import pytest + +from homeassistant.components.ipma import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=config, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(): + """Define a config entry data fixture.""" + return { + CONF_NAME: "Home", + CONF_LATITUDE: 0, + CONF_LONGITUDE: 0, + } + + +@pytest.fixture(name="setup_config_entry") +async def setup_config_entry_fixture(hass, config_entry): + """Define a fixture to set up ipma.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py index 5bb1d8b8364..18b68a5a44d 100644 --- a/tests/components/ipma/test_config_flow.py +++ b/tests/components/ipma/test_config_flow.py @@ -1,116 +1,65 @@ """Tests for IPMA config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import patch -from homeassistant.components.ipma import config_flow -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.ipma.const import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -async def test_show_config_form() -> None: - """Test show configuration form.""" - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass +@pytest.fixture(name="ipma_setup", autouse=True) +def ipma_setup_fixture(request): + """Patch ipma setup entry.""" + with patch("homeassistant.components.ipma.async_setup_entry", return_value=True): + yield - result = await flow._show_config_form() + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test configuration form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == "form" assert result["step_id"] == "user" + test_data = { + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } -async def test_show_config_form_default_values() -> None: - """Test show configuration form.""" - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + test_data, + ) - result = await flow._show_config_form(name="test", latitude="0", longitude="0") - - assert result["type"] == "form" - assert result["step_id"] == "user" + assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Home" + assert result["data"] == { + CONF_NAME: "Home", + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } -async def test_flow_with_home_location(hass: HomeAssistant) -> None: - """Test config flow . +async def test_flow_entry_already_exists(hass: HomeAssistant, config_entry) -> None: + """Test user input for config_entry that already exists. - Tests the flow when a default location is configured - then it should return a form with default values + Test when the form should show when user puts existing location + in the config gui. Then the form should show with error. """ - flow = config_flow.IpmaFlowHandler() - flow.hass = hass + test_data = { + CONF_NAME: "Home", + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } - hass.config.location_name = "Home" - hass.config.latitude = 1 - hass.config.longitude = 1 + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + ) + await hass.async_block_till_done() - result = await flow.async_step_user() - assert result["type"] == "form" - assert result["step_id"] == "user" - - -async def test_flow_show_form() -> None: - """Test show form scenarios first time. - - Test when the form should show when no configurations exists - """ - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass - - with patch( - "homeassistant.components.ipma.config_flow.IpmaFlowHandler._show_config_form" - ) as config_form: - await flow.async_step_user() - assert len(config_form.mock_calls) == 1 - - -async def test_flow_entry_created_from_user_input() -> None: - """Test that create data from user input. - - Test when the form should show when no configurations exists - """ - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass - - test_data = {"name": "home", CONF_LONGITUDE: "0", CONF_LATITUDE: "0"} - - # Test that entry created when user_input name not exists - with patch( - "homeassistant.components.ipma.config_flow.IpmaFlowHandler._show_config_form" - ) as config_form, patch.object( - flow.hass.config_entries, - "async_entries", - return_value=[], - ) as config_entries: - result = await flow.async_step_user(user_input=test_data) - - assert result["type"] == "create_entry" - assert result["data"] == test_data - assert len(config_entries.mock_calls) == 1 - assert not config_form.mock_calls - - -async def test_flow_entry_config_entry_already_exists() -> None: - """Test that create data from user input and config_entry already exists. - - Test when the form should show when user puts existing name - in the config gui. Then the form should show with error - """ - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass - - test_data = {"name": "home", CONF_LONGITUDE: "0", CONF_LATITUDE: "0"} - - # Test that entry created when user_input name not exists - with patch( - "homeassistant.components.ipma.config_flow.IpmaFlowHandler._show_config_form" - ) as config_form, patch.object( - flow.hass.config_entries, "async_entries", return_value={"home": test_data} - ) as config_entries: - await flow.async_step_user(user_input=test_data) - - assert len(config_form.mock_calls) == 1 - assert len(config_entries.mock_calls) == 1 - assert len(flow._errors) == 1 + assert result["type"] == "abort" + assert result["reason"] == "already_configured" From 5ed3e906070c516e8bbfc61f466e3afc01874393 Mon Sep 17 00:00:00 2001 From: VidFerris <29590790+VidFerris@users.noreply.github.com> Date: Wed, 16 Aug 2023 20:57:16 +1000 Subject: [PATCH 103/180] Use Local Timezone for Withings Integration (#98137) --- homeassistant/components/withings/common.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 9282e3977c1..ef3b6456d20 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -438,22 +438,16 @@ class DataManager: async def async_get_sleep_summary(self) -> dict[Measurement, Any]: """Get the sleep summary data.""" _LOGGER.debug("Updating withing sleep summary") - now = dt_util.utcnow() + now = dt_util.now() yesterday = now - datetime.timedelta(days=1) - yesterday_noon = datetime.datetime( - yesterday.year, - yesterday.month, - yesterday.day, - 12, - 0, - 0, - 0, - datetime.UTC, + yesterday_noon = dt_util.start_of_local_day(yesterday) + datetime.timedelta( + hours=12 ) + yesterday_noon_utc = dt_util.as_utc(yesterday_noon) def get_sleep_summary() -> SleepGetSummaryResponse: return self._api.sleep_get_summary( - lastupdate=yesterday_noon, + lastupdate=yesterday_noon_utc, data_fields=[ GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, GetSleepSummaryField.DEEP_SLEEP_DURATION, From 91faa5384370e5df661bf93865f13637005d2948 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 13:00:14 +0200 Subject: [PATCH 104/180] Don't allow hass.config.config_dir to be None (#98442) --- homeassistant/bootstrap.py | 6 ++--- homeassistant/components/cloud/client.py | 1 - homeassistant/components/file/notify.py | 3 --- .../components/homematicip_cloud/services.py | 2 +- .../components/system_log/__init__.py | 1 - homeassistant/components/verisure/camera.py | 1 - homeassistant/components/zha/core/gateway.py | 1 - homeassistant/config.py | 6 +---- homeassistant/core.py | 12 ++++----- homeassistant/helpers/check_config.py | 2 -- homeassistant/loader.py | 6 +---- homeassistant/scripts/auth.py | 3 +-- homeassistant/scripts/benchmark/__init__.py | 2 +- homeassistant/scripts/check_config.py | 3 +-- homeassistant/scripts/ensure_config.py | 3 +-- tests/common.py | 3 +-- tests/conftest.py | 4 +-- tests/test_core.py | 25 ++++++++----------- 18 files changed, 28 insertions(+), 56 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 196a00dda7c..81ae4eb6e18 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -110,8 +110,7 @@ async def async_setup_hass( runtime_config: RuntimeConfig, ) -> core.HomeAssistant | None: """Set up Home Assistant.""" - hass = core.HomeAssistant() - hass.config.config_dir = runtime_config.config_dir + hass = core.HomeAssistant(runtime_config.config_dir) async_enable_logging( hass, @@ -178,14 +177,13 @@ async def async_setup_hass( old_config = hass.config old_logging = hass.data.get(DATA_LOGGING) - hass = core.HomeAssistant() + hass = core.HomeAssistant(old_config.config_dir) if old_logging: hass.data[DATA_LOGGING] = old_logging hass.config.skip_pip = old_config.skip_pip hass.config.skip_pip_packages = old_config.skip_pip_packages hass.config.internal_url = old_config.internal_url hass.config.external_url = old_config.external_url - hass.config.config_dir = old_config.config_dir # Setup loader cache after the config dir has been set loader.async_setup(hass) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 7bd80000ca4..6fbcfc30f69 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -54,7 +54,6 @@ class CloudClient(Interface): @property def base_path(self) -> Path: """Return path to base dir.""" - assert self._hass.config.config_dir is not None return Path(self._hass.config.config_dir) @property diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 3238fe91102..ca0deb89c7b 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -51,9 +51,6 @@ class FileNotificationService(BaseNotificationService): def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a file.""" file: TextIO - if not self.hass.config.config_dir: - return - filepath: str = os.path.join(self.hass.config.config_dir, self.filename) with open(filepath, "a", encoding="utf8") as file: if os.stat(filepath).st_size == 0: diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index a8393ff88ac..09457ce0792 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -286,7 +286,7 @@ async def _set_active_climate_profile( async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None: """Service to dump the configuration of a Homematic IP Access Point.""" config_path: str = ( - service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir or "." + service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir ) config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] anonymize = service.data[ATTR_ANONYMIZE] diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index f025013cc2b..cba8082d23c 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -234,7 +234,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass_path: str = HOMEASSISTANT_PATH[0] config_dir = hass.config.config_dir - assert config_dir is not None paths_re = re.compile( r"(?:{})/(.*)".format("|".join([re.escape(x) for x in (hass_path, config_dir)])) ) diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index c9d98041a2c..a240d45cf7e 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -36,7 +36,6 @@ async def async_setup_entry( VerisureSmartcam.capture_smartcam.__name__, ) - assert hass.config.config_dir async_add_entities( VerisureSmartcam(coordinator, serial_number, hass.config.config_dir) for serial_number in coordinator.data["cameras"] diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 1f3a71f4cbf..1320e77ba3c 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -802,7 +802,6 @@ class LogRelayHandler(logging.Handler): hass_path: str = HOMEASSISTANT_PATH[0] config_dir = self.hass.config.config_dir - assert config_dir is not None paths_re = re.compile( r"(?:{})/(.*)".format( "|".join([re.escape(x) for x in (hass_path, config_dir)]) diff --git a/homeassistant/config.py b/homeassistant/config.py index eed296baf0e..0d9e1d9034e 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -337,7 +337,6 @@ async def async_create_default_config(hass: HomeAssistant) -> bool: Return if creation was successful. """ - assert hass.config.config_dir return await hass.async_add_executor_job( _write_default_config, hass.config.config_dir ) @@ -390,10 +389,7 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: This function allow a component inside the asyncio loop to reload its configuration by itself. Include package merge. """ - if hass.config.config_dir is None: - secrets = None - else: - secrets = Secrets(Path(hass.config.config_dir)) + secrets = Secrets(Path(hass.config.config_dir)) # Not using async_add_executor_job because this is an internal method. config = await hass.loop.run_in_executor( diff --git a/homeassistant/core.py b/homeassistant/core.py index a025eacd4bc..140cf203e70 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -288,13 +288,13 @@ class HomeAssistant: http: HomeAssistantHTTP = None # type: ignore[assignment] config_entries: ConfigEntries = None # type: ignore[assignment] - def __new__(cls) -> HomeAssistant: + def __new__(cls, config_dir: str) -> HomeAssistant: """Set the _hass thread local data.""" hass = super().__new__(cls) _hass.hass = hass return hass - def __init__(self) -> None: + def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" self.loop = asyncio.get_running_loop() self._tasks: set[asyncio.Future[Any]] = set() @@ -302,7 +302,7 @@ class HomeAssistant: self.bus = EventBus(self) self.services = ServiceRegistry(self) self.states = StateMachine(self.bus, self.loop) - self.config = Config(self) + self.config = Config(self, config_dir) self.components = loader.Components(self) self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. @@ -2011,7 +2011,7 @@ class ServiceRegistry: class Config: """Configuration settings for Home Assistant.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_dir: str) -> None: """Initialize a new config object.""" self.hass = hass @@ -2047,7 +2047,7 @@ class Config: self.api: ApiConfig | None = None # Directory that holds the configuration - self.config_dir: str | None = None + self.config_dir: str = config_dir # List of allowed external dirs to access self.allowlist_external_dirs: set[str] = set() @@ -2078,8 +2078,6 @@ class Config: Async friendly. """ - if self.config_dir is None: - raise HomeAssistantError("config_dir is not set") return os.path.join(self.config_dir, *path) def is_allowed_external_url(self, url: str) -> bool: diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index a580c013cd0..1e1cac050f1 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -94,8 +94,6 @@ async def async_check_ha_config_file( # noqa: C901 if not await hass.async_add_executor_job(os.path.isfile, config_path): return result.add_error("File configuration.yaml not found.") - assert hass.config.config_dir is not None - config = await hass.async_add_executor_job( load_yaml_config_file, config_path, diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 340888a2f7a..697e47187ce 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1148,17 +1148,13 @@ async def _async_component_dependencies( return loaded -def _async_mount_config_dir(hass: HomeAssistant) -> bool: +def _async_mount_config_dir(hass: HomeAssistant) -> None: """Mount config dir in order to load custom_component. Async friendly but not a coroutine. """ - if hass.config.config_dir is None: - _LOGGER.error("Can't load integrations - configuration directory is not set") - return False if hass.config.config_dir not in sys.path: sys.path.insert(0, hass.config.config_dir) - return True def _lookup_path(hass: HomeAssistant) -> list[str]: diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index 11ab6aadfbf..5714e5814a4 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -50,8 +50,7 @@ def run(args): async def run_command(args): """Run the command.""" - hass = HomeAssistant() - hass.config.config_dir = os.path.join(os.getcwd(), args.config) + hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) provider = hass.auth.auth_providers[0] await provider.async_initialize() diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 3627e4096d3..a04493a8935 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -49,7 +49,7 @@ def run(args): async def run_benchmark(bench): """Run a benchmark.""" - hass = core.HomeAssistant() + hass = core.HomeAssistant("") runtime = await bench(hass) print(f"Benchmark {bench.__name__} done in {runtime}s") await hass.async_stop() diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 7c4a200bbc5..5c81c4664da 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -231,9 +231,8 @@ def check(config_dir, secrets=False): async def async_check_config(config_dir): """Check the HA config.""" - hass = core.HomeAssistant() + hass = core.HomeAssistant(config_dir) loader.async_setup(hass) - hass.config.config_dir = config_dir hass.config_entries = ConfigEntries(hass, {}) await ar.async_load(hass) await dr.async_load(hass) diff --git a/homeassistant/scripts/ensure_config.py b/homeassistant/scripts/ensure_config.py index 6dbda59522f..786b16ca923 100644 --- a/homeassistant/scripts/ensure_config.py +++ b/homeassistant/scripts/ensure_config.py @@ -39,8 +39,7 @@ def run(args): async def async_run(config_dir): """Make sure config exists.""" - hass = HomeAssistant() - hass.config.config_dir = config_dir + hass = HomeAssistant(config_dir) path = await config_util.async_ensure_config_exists(hass) await hass.async_stop(force=True) return path diff --git a/tests/common.py b/tests/common.py index 95947719ef4..6f2209276ce 100644 --- a/tests/common.py +++ b/tests/common.py @@ -179,7 +179,7 @@ def get_test_home_assistant(): async def async_test_home_assistant(event_loop, load_registries=True): """Return a Home Assistant object pointing at test config dir.""" - hass = HomeAssistant() + hass = HomeAssistant(get_test_config_dir()) store = auth_store.AuthStore(hass) hass.auth = auth.AuthManager(hass, store, {}, {}) ensure_auth_manager_loaded(hass.auth) @@ -231,7 +231,6 @@ async def async_test_home_assistant(event_loop, load_registries=True): hass.data[loader.DATA_CUSTOM_COMPONENTS] = {} hass.config.location_name = "test home" - hass.config.config_dir = get_test_config_dir() hass.config.latitude = 32.87336 hass.config.longitude = -117.22743 hass.config.elevation = 0 diff --git a/tests/conftest.py b/tests/conftest.py index 31900dff6de..f90984e1c7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -552,8 +552,8 @@ async def stop_hass( created = [] - def mock_hass(): - hass_inst = orig_hass() + def mock_hass(*args): + hass_inst = orig_hass(*args) created.append(hass_inst) return hass_inst diff --git a/tests/test_core.py b/tests/test_core.py index 9f6e5aeb2dd..4f7916e757b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1428,7 +1428,7 @@ async def test_serviceregistry_return_response_optional( async def test_config_defaults() -> None: """Test config defaults.""" hass = Mock() - config = ha.Config(hass) + config = ha.Config(hass, "/test/ha-config") assert config.hass is hass assert config.latitude == 0 assert config.longitude == 0 @@ -1442,7 +1442,7 @@ async def test_config_defaults() -> None: assert config.skip_pip_packages == [] assert config.components == set() assert config.api is None - assert config.config_dir is None + assert config.config_dir == "/test/ha-config" assert config.allowlist_external_dirs == set() assert config.allowlist_external_urls == set() assert config.media_dirs == {} @@ -1455,22 +1455,19 @@ async def test_config_defaults() -> None: async def test_config_path_with_file() -> None: """Test get_config_path method.""" - config = ha.Config(None) - config.config_dir = "/test/ha-config" + config = ha.Config(None, "/test/ha-config") assert config.path("test.conf") == "/test/ha-config/test.conf" async def test_config_path_with_dir_and_file() -> None: """Test get_config_path method.""" - config = ha.Config(None) - config.config_dir = "/test/ha-config" + config = ha.Config(None, "/test/ha-config") assert config.path("dir", "test.conf") == "/test/ha-config/dir/test.conf" async def test_config_as_dict() -> None: """Test as dict.""" - config = ha.Config(None) - config.config_dir = "/test/ha-config" + config = ha.Config(None, "/test/ha-config") config.hass = MagicMock() type(config.hass.state).value = PropertyMock(return_value="RUNNING") expected = { @@ -1501,7 +1498,7 @@ async def test_config_as_dict() -> None: async def test_config_is_allowed_path() -> None: """Test is_allowed_path method.""" - config = ha.Config(None) + config = ha.Config(None, "/test/ha-config") with TemporaryDirectory() as tmp_dir: # The created dir is in /tmp. This is a symlink on OS X # causing this test to fail unless we resolve path first. @@ -1533,7 +1530,7 @@ async def test_config_is_allowed_path() -> None: async def test_config_is_allowed_external_url() -> None: """Test is_allowed_external_url method.""" - config = ha.Config(None) + config = ha.Config(None, "/test/ha-config") config.allowlist_external_urls = [ "http://x.com/", "https://y.com/bla/", @@ -1584,7 +1581,7 @@ async def test_start_taking_too_long( event_loop, caplog: pytest.LogCaptureFixture ) -> None: """Test when async_start takes too long.""" - hass = ha.HomeAssistant() + hass = ha.HomeAssistant("/test/ha-config") caplog.set_level(logging.WARNING) hass.async_create_task(asyncio.sleep(0)) @@ -1751,7 +1748,7 @@ async def test_additional_data_in_core_config( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test that we can handle additional data in core configuration.""" - config = ha.Config(hass) + config = ha.Config(hass, "/test/ha-config") hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, "data": {"location_name": "Test Name", "additional_valid_key": "value"}, @@ -1764,7 +1761,7 @@ async def test_incorrect_internal_external_url( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: """Test that we warn when detecting invalid internal/external url.""" - config = ha.Config(hass) + config = ha.Config(hass, "/test/ha-config") hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, @@ -1777,7 +1774,7 @@ async def test_incorrect_internal_external_url( assert "Invalid external_url set" not in caplog.text assert "Invalid internal_url set" not in caplog.text - config = ha.Config(hass) + config = ha.Config(hass, "/test/ha-config") hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, From 732dac6f051794c4df84806f2caa2687d3c3cb79 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Aug 2023 13:24:41 +0200 Subject: [PATCH 105/180] Create abstraction for Generic YeeLight (#97939) * Create abstraction for Generic YeeLight * Update light.py --- homeassistant/components/yeelight/light.py | 24 ++++++++++++++-------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index f5f39e9997d..a442540109a 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -238,7 +238,7 @@ def _parse_custom_effects(effects_config) -> dict[str, dict[str, Any]]: def _async_cmd(func): """Define a wrapper to catch exceptions from the bulb.""" - async def _async_wrap(self: YeelightGenericLight, *args, **kwargs): + async def _async_wrap(self: YeelightBaseLight, *args, **kwargs): for attempts in range(2): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) @@ -403,8 +403,8 @@ def _async_setup_services(hass: HomeAssistant): ) -class YeelightGenericLight(YeelightEntity, LightEntity): - """Representation of a Yeelight generic light.""" +class YeelightBaseLight(YeelightEntity, LightEntity): + """Abstract Yeelight light.""" _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -861,7 +861,13 @@ class YeelightGenericLight(YeelightEntity, LightEntity): await self._bulb.async_set_scene(scene_class, *args) -class YeelightColorLightSupport(YeelightGenericLight): +class YeelightGenericLight(YeelightBaseLight): + """Representation of a generic Yeelight.""" + + _attr_name = None + + +class YeelightColorLightSupport(YeelightBaseLight): """Representation of a Color Yeelight light support.""" _attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS, ColorMode.RGB} @@ -884,7 +890,7 @@ class YeelightColorLightSupport(YeelightGenericLight): return YEELIGHT_COLOR_EFFECT_LIST -class YeelightWhiteTempLightSupport(YeelightGenericLight): +class YeelightWhiteTempLightSupport(YeelightBaseLight): """Representation of a White temp Yeelight light.""" _attr_name = None @@ -904,7 +910,7 @@ class YeelightNightLightSupport: return PowerMode.NORMAL -class YeelightWithoutNightlightSwitchMixIn(YeelightGenericLight): +class YeelightWithoutNightlightSwitchMixIn(YeelightBaseLight): """A mix-in for yeelights without a nightlight switch.""" @property @@ -940,7 +946,7 @@ class YeelightColorLightWithoutNightlightSwitchLight( class YeelightColorLightWithNightlightSwitch( - YeelightNightLightSupport, YeelightColorLightSupport, YeelightGenericLight + YeelightNightLightSupport, YeelightColorLightSupport, YeelightBaseLight ): """Representation of a Yeelight with rgb support and nightlight. @@ -964,7 +970,7 @@ class YeelightWhiteTempWithoutNightlightSwitch( class YeelightWithNightLight( - YeelightNightLightSupport, YeelightWhiteTempLightSupport, YeelightGenericLight + YeelightNightLightSupport, YeelightWhiteTempLightSupport, YeelightBaseLight ): """Representation of a Yeelight with temp only support and nightlight. @@ -979,7 +985,7 @@ class YeelightWithNightLight( return super().is_on and not self.device.is_nightlight_enabled -class YeelightNightLightMode(YeelightGenericLight): +class YeelightNightLightMode(YeelightBaseLight): """Representation of a Yeelight when in nightlight mode.""" _attr_color_mode = ColorMode.BRIGHTNESS From cf8c9ad184fa9695ce22218910b7fed3095543f1 Mon Sep 17 00:00:00 2001 From: Mike Heath Date: Wed, 16 Aug 2023 05:38:53 -0600 Subject: [PATCH 106/180] Add PoE switch tests (#95087) * Add PoE switch tests * Update tests/components/tplink_omada/test_switch.py Co-authored-by: Erik Montnemery * Remove files covered by tests from exclusion * Rename entity_name to entity_id * Fix test, use snapshot, other improvements --------- Co-authored-by: Erik Montnemery --- .coveragerc | 3 - tests/components/tplink_omada/conftest.py | 87 ++ .../fixtures/switch-TL-SG3210XHP-M2.json | 683 ++++++++++++ .../switch-ports-TL-SG3210XHP-M2.json | 974 ++++++++++++++++++ .../tplink_omada/snapshots/test_switch.ambr | 345 +++++++ tests/components/tplink_omada/test_switch.py | 122 +++ 6 files changed, 2211 insertions(+), 3 deletions(-) create mode 100644 tests/components/tplink_omada/conftest.py create mode 100644 tests/components/tplink_omada/fixtures/switch-TL-SG3210XHP-M2.json create mode 100644 tests/components/tplink_omada/fixtures/switch-ports-TL-SG3210XHP-M2.json create mode 100644 tests/components/tplink_omada/snapshots/test_switch.ambr create mode 100644 tests/components/tplink_omada/test_switch.py diff --git a/.coveragerc b/.coveragerc index 4dd8dea258d..71542ebad3a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1328,9 +1328,6 @@ omit = homeassistant/components/tplink_omada/__init__.py homeassistant/components/tplink_omada/binary_sensor.py homeassistant/components/tplink_omada/controller.py - homeassistant/components/tplink_omada/coordinator.py - homeassistant/components/tplink_omada/entity.py - homeassistant/components/tplink_omada/switch.py homeassistant/components/tplink_omada/update.py homeassistant/components/traccar/device_tracker.py homeassistant/components/tractive/__init__.py diff --git a/tests/components/tplink_omada/conftest.py b/tests/components/tplink_omada/conftest.py new file mode 100644 index 00000000000..8f977de588c --- /dev/null +++ b/tests/components/tplink_omada/conftest.py @@ -0,0 +1,87 @@ +"""Test fixtures for TP-Link Omada integration.""" +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails + +from homeassistant.components.tplink_omada.config_flow import CONF_SITE +from homeassistant.components.tplink_omada.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Test Omada Controller", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "mocked-password", + CONF_USERNAME: "mocked-user", + CONF_VERIFY_SSL: False, + CONF_SITE: "Default", + }, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tplink_omada.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_omada_site_client() -> Generator[AsyncMock, None, None]: + """Mock Omada site client.""" + site_client = AsyncMock() + switch1_data = json.loads(load_fixture("switch-TL-SG3210XHP-M2.json", DOMAIN)) + switch1 = OmadaSwitch(switch1_data) + site_client.get_switches.return_value = [switch1] + + switch1_ports_data = json.loads( + load_fixture("switch-ports-TL-SG3210XHP-M2.json", DOMAIN) + ) + switch1_ports = [OmadaSwitchPortDetails(p) for p in switch1_ports_data] + site_client.get_switch_ports.return_value = switch1_ports + + return site_client + + +@pytest.fixture +def mock_omada_client( + mock_omada_site_client: AsyncMock, +) -> Generator[MagicMock, None, None]: + """Mock Omada client.""" + with patch( + "homeassistant.components.tplink_omada.create_omada_client", + autospec=True, + ) as client_mock: + client = client_mock.return_value + + client.get_site_client.return_value = mock_omada_site_client + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, +) -> MockConfigEntry: + """Set up the TP-Link Omada integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/tplink_omada/fixtures/switch-TL-SG3210XHP-M2.json b/tests/components/tplink_omada/fixtures/switch-TL-SG3210XHP-M2.json new file mode 100644 index 00000000000..2e3f21406b0 --- /dev/null +++ b/tests/components/tplink_omada/fixtures/switch-TL-SG3210XHP-M2.json @@ -0,0 +1,683 @@ +{ + "type": "switch", + "mac": "54-AF-97-00-00-01", + "name": "Test PoE Switch", + "model": "TL-SG3210XHP-M2", + "modelVersion": "1.0", + "compoundModel": "TL-SG3210XHP-M2 v1.0", + "showModel": "TL-SG3210XHP-M2 v1.0", + "firmwareVersion": "1.0.12 Build 20230203 Rel.36545", + "version": "1.0.12", + "hwVersion": "1.0", + "status": 14, + "statusCategory": 1, + "site": "000000000000000000000000", + "omadacId": "00000000000000000000000000000000", + "compatible": 0, + "sn": "Y220000000001", + "addedInAdvanced": false, + "deviceMisc": { + "portNum": 10 + }, + "devCap": { + "maxMirrorGroup": 1, + "maxMirroredPort": 9, + "maxLagNum": 8, + "maxLagMember": 8, + "poePortNum": 8, + "poeSupport": true, + "supportBt": false, + "jumboSupport": true, + "jumboOddSupport": false, + "lagCap": { + "lacpModSupport": true, + "lagHashAlgSupport": true, + "lagHashAlgs": [0, 1, 2, 3, 4, 5] + }, + "eeeSupport": true, + "flowControlSupport": true, + "loopbackVlanBasedSupport": true, + "dhcpL2RelaySupport": true, + "sfpBeginNum": 9, + "sfpNum": 2 + }, + "ledSetting": 2, + "mvlanNetworkId": "000000000000000000000000", + "ipSetting": { + "mode": "dhcp", + "fallback": true, + "fallbackIp": "192.168.0.1", + "fallbackMask": "255.255.255.0" + }, + "loopbackDetectEnable": true, + "stp": 0, + "priority": 32768, + "snmp": { + "location": "", + "contact": "" + }, + "ports": [ + { + "id": "000000000000000000000001", + "port": 1, + "name": "Port1", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 1, + "linkStatus": 1, + "linkSpeed": 2, + "duplex": 2, + "poe": true, + "poePower": 2.7, + "tx": 22048870335, + "rx": 6155774646, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000002", + "port": 2, + "name": "Port2", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 2, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": false, + "poePower": 0.0, + "tx": 2111818481511, + "rx": 297809855535, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000003", + "port": 3, + "name": "Primary AP", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 3, + "linkStatus": 1, + "linkSpeed": 4, + "duplex": 2, + "poe": true, + "poePower": 9.8, + "tx": 2118915311852, + "rx": 1222744181939, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000004", + "port": 4, + "name": "Port4", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 4, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "poePower": 0.0, + "tx": 0, + "rx": 0, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000005", + "port": 5, + "name": "Port5", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 5, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": true, + "poePower": 7.2, + "tx": 357059477760, + "rx": 59530432926, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000006", + "port": 6, + "name": "Port6", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 6, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": false, + "poePower": 0.0, + "tx": 20729276425, + "rx": 1260359882, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000007", + "port": 7, + "name": "Port7", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 7, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "poePower": 0.0, + "tx": 6884938116575, + "rx": 3015211000000, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000008", + "port": 8, + "name": "Family Room Kiosk", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 8, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": true, + "poePower": 1.9, + "tx": 17735212467, + "rx": 2751725454, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000009", + "port": 9, + "name": "Port9", + "disable": false, + "type": 3, + "maxSpeed": 5, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 9, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "tx": 0, + "rx": 0, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 5, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 3, + "duplex": 2 + } + ] + }, + { + "id": "00000000000000000000000a", + "port": 10, + "name": "Uplink", + "disable": false, + "type": 3, + "maxSpeed": 5, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 10, + "linkStatus": 1, + "linkSpeed": 5, + "duplex": 2, + "poe": false, + "tx": 4599788287992, + "rx": 11431810000000, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 5, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 3, + "duplex": 2 + } + ] + } + ], + "lags": [], + "tagIds": [], + "ip": "192.168.0.12", + "lastSeen": 1687385981898, + "needUpgrade": false, + "uptime": "97day(s) 23h 57m 34s", + "uptimeLong": 8467054, + "cpuUtil": 18, + "memUtil": 72, + "poeRemain": 218.399994, + "poeRemainPercent": 91.0, + "fanStatus": 0, + "downlinkList": [ + { + "port": 3, + "model": "EAP660 HD", + "hwVersion": "1.0", + "modelVersion": "1.0", + "mac": "B4-B0-24-00-00-01", + "name": "Access Point 1", + "linkSpeed": 4, + "duplex": 2 + }, + { + "port": 5, + "model": "EAP653", + "hwVersion": "1.0", + "modelVersion": "1.0", + "mac": "34-60-F9-00-00-01E", + "name": "Access Point 2", + "linkSpeed": 3, + "duplex": 2 + } + ], + "download": 16037273330382, + "upload": 16133033034917, + "supportVlanIf": true, + "jumbo": 1518, + "lagHashAlg": 2, + "speeds": [2, 3, 4, 5] +} diff --git a/tests/components/tplink_omada/fixtures/switch-ports-TL-SG3210XHP-M2.json b/tests/components/tplink_omada/fixtures/switch-ports-TL-SG3210XHP-M2.json new file mode 100644 index 00000000000..b079b2d2fb7 --- /dev/null +++ b/tests/components/tplink_omada/fixtures/switch-ports-TL-SG3210XHP-M2.json @@ -0,0 +1,974 @@ +[ + { + "id": "000000000000000000000001", + "port": 1, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port1", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 1, + "linkStatus": 1, + "linkSpeed": 2, + "duplex": 2, + "poe": true, + "poePower": 2.7, + "tx": 22265663705, + "rx": 6202420396, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000002", + "port": 2, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port2", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": true, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 1, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 2, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": false, + "poePower": 0.0, + "tx": 2136778000000, + "rx": 298419647322, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000003", + "port": 3, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port3", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 3, + "linkStatus": 1, + "linkSpeed": 4, + "duplex": 2, + "poe": true, + "poePower": 10.0, + "tx": 2139129000000, + "rx": 1241262105432, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000004", + "port": 4, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port4", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": false, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 4, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "poePower": 0.0, + "tx": 0, + "rx": 0, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000005", + "port": 5, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port5", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 5, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": true, + "poePower": 7.7, + "tx": 358431854723, + "rx": 62202058965, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000006", + "port": 6, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port6", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": false, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 6, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": false, + "poePower": 0.0, + "tx": 21045680895, + "rx": 1266702649, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000007", + "port": 7, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port7", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": false, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 7, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "poePower": 0.0, + "tx": 6884938116575, + "rx": 3015211000000, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000008", + "port": 8, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port8", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": false, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 8, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": true, + "poePower": 1.9, + "tx": 17983115259, + "rx": 2764463784, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000009", + "port": 9, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port9", + "disable": false, + "type": 3, + "maxSpeed": 5, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 5, + "duplex": 2, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 9, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "tx": 0, + "rx": 0, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 5, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 3, + "duplex": 2 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "00000000000000000000000a", + "port": 10, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port10", + "disable": false, + "type": 3, + "maxSpeed": 5, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 5, + "duplex": 2, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 10, + "linkStatus": 1, + "linkSpeed": 5, + "duplex": 2, + "poe": false, + "tx": 4621489812572, + "rx": 11477190000000, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 5, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 3, + "duplex": 2 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + } +] diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr new file mode 100644 index 00000000000..b48f6a5e749 --- /dev/null +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -0,0 +1,345 @@ +# serializer version: 1 +# name: test_poe_switches + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 1 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_1_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_poe_switch_port_1_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 1 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000001_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 6 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_6_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.11 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_poe_switch_port_6_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 6 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000006_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.12 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 7 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_7_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.13 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_poe_switch_port_7_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 7 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000007_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.14 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 8 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_8_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.15 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_poe_switch_port_8_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 8 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000008_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 2 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_2_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.3 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_poe_switch_port_2_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 2 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000002_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 3 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_3_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.5 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_poe_switch_port_3_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 3 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000003_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 4 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_4_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.7 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_poe_switch_port_4_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 4 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000004_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 5 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_5_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.9 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_poe_switch_port_5_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 5 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000005_poe', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/tplink_omada/test_switch.py b/tests/components/tplink_omada/test_switch.py new file mode 100644 index 00000000000..dd8b520e0a8 --- /dev/null +++ b/tests/components/tplink_omada/test_switch.py @@ -0,0 +1,122 @@ +"""Tests for TP-Link Omada switch entities.""" +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion +from tplink_omada_client.definitions import PoEMode +from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from tplink_omada_client.omadasiteclient import SwitchPortOverrides + +from homeassistant.components import switch +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_poe_switches( + hass: HomeAssistant, + mock_omada_site_client: MagicMock, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test PoE switch.""" + poe_switch_mac = "54-AF-97-00-00-01" + for i in range(1, 9): + await _test_poe_switch( + hass, + mock_omada_site_client, + f"switch.test_poe_switch_port_{i}_poe", + poe_switch_mac, + i, + snapshot, + ) + + +async def _test_poe_switch( + hass: HomeAssistant, + mock_omada_site_client: MagicMock, + entity_id: str, + network_switch_mac: str, + port_num: int, + snapshot: SnapshotAssertion, +) -> None: + entity_registry = er.async_get(hass) + + def assert_update_switch_port( + device: OmadaSwitch, + switch_port_details: OmadaSwitchPortDetails, + poe_enabled: bool, + overrides: SwitchPortOverrides = None, + ) -> None: + assert device + assert device.mac == network_switch_mac + assert switch_port_details + assert switch_port_details.port == port_num + assert overrides + assert overrides.enable_poe == poe_enabled + + entity = hass.states.get(entity_id) + assert entity == snapshot + entry = entity_registry.async_get(entity_id) + assert entry == snapshot + + mock_omada_site_client.update_switch_port.reset_mock() + mock_omada_site_client.update_switch_port.return_value = await _update_port_details( + mock_omada_site_client, port_num, False + ) + await call_service(hass, "turn_off", entity_id) + mock_omada_site_client.update_switch_port.assert_called_once() + ( + device, + switch_port_details, + ) = mock_omada_site_client.update_switch_port.call_args.args + assert_update_switch_port( + device, + switch_port_details, + False, + **mock_omada_site_client.update_switch_port.call_args.kwargs, + ) + entity = hass.states.get(entity_id) + assert entity.state == "off" + + mock_omada_site_client.update_switch_port.reset_mock() + mock_omada_site_client.update_switch_port.return_value = await _update_port_details( + mock_omada_site_client, port_num, True + ) + await call_service(hass, "turn_on", entity_id) + mock_omada_site_client.update_switch_port.assert_called_once() + device, switch_port = mock_omada_site_client.update_switch_port.call_args.args + assert_update_switch_port( + device, + switch_port, + True, + **mock_omada_site_client.update_switch_port.call_args.kwargs, + ) + entity = hass.states.get(entity_id) + assert entity.state == "on" + + +async def _update_port_details( + mock_omada_site_client: MagicMock, + port_num: int, + poe_enabled: bool, +) -> OmadaSwitchPortDetails: + switch_ports = await mock_omada_site_client.get_switch_ports() + port_details: OmadaSwitchPortDetails = None + for details in switch_ports: + if details.port == port_num: + port_details = details + break + + assert port_details is not None + raw_data = port_details.raw_data.copy() + raw_data["poe"] = PoEMode.ENABLED if poe_enabled else PoEMode.DISABLED + return OmadaSwitchPortDetails(raw_data) + + +def call_service(hass: HomeAssistant, service: str, entity_id: str): + """Call any service on entity.""" + return hass.services.async_call( + switch.DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) From 2c48f0e4167d37126bfbb9141752c6733b83d4ca Mon Sep 17 00:00:00 2001 From: Nick Whyte Date: Wed, 16 Aug 2023 21:56:52 +1000 Subject: [PATCH 107/180] Fix ness alarm armed_home state appearing as disarmed/armed_away (#94351) * Fix nessclient arm home appearing as arm away * patch arming mode enum and use dynamic access * Revert "patch arming mode enum and use dynamic access" This reverts commit b9cca8e92bcb382abe364381a8cb1674c32d1d2a. * Remove mock enums --- .../components/ness_alarm/__init__.py | 8 ++-- .../ness_alarm/alarm_control_panel.py | 22 ++++++++-- .../components/ness_alarm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ness_alarm/test_init.py | 44 +++++++------------ 6 files changed, 43 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index c1d97f781af..b5d30219550 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -3,7 +3,7 @@ from collections import namedtuple import datetime import logging -from nessclient import ArmingState, Client +from nessclient import ArmingMode, ArmingState, Client import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -136,9 +136,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, SIGNAL_ZONE_CHANGED, ZoneChangedData(zone_id=zone_id, state=state) ) - def on_state_change(arming_state: ArmingState): + def on_state_change(arming_state: ArmingState, arming_mode: ArmingMode | None): """Receives and propagates arming state updates.""" - async_dispatcher_send(hass, SIGNAL_ARMING_STATE_CHANGED, arming_state) + async_dispatcher_send( + hass, SIGNAL_ARMING_STATE_CHANGED, arming_state, arming_mode + ) client.on_zone_change(on_zone_change) client.on_state_change(on_state_change) diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py index 2f54b3abde6..92feaba13aa 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -3,12 +3,15 @@ from __future__ import annotations import logging -from nessclient import ArmingState, Client +from nessclient import ArmingMode, ArmingState, Client import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, @@ -23,6 +26,15 @@ from . import DATA_NESS, SIGNAL_ARMING_STATE_CHANGED _LOGGER = logging.getLogger(__name__) +ARMING_MODE_TO_STATE = { + ArmingMode.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, + ArmingMode.ARMED_HOME: STATE_ALARM_ARMED_HOME, + ArmingMode.ARMED_DAY: STATE_ALARM_ARMED_AWAY, # no applicable state, fallback to away + ArmingMode.ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, + ArmingMode.ARMED_VACATION: STATE_ALARM_ARMED_VACATION, + ArmingMode.ARMED_HIGHEST: STATE_ALARM_ARMED_AWAY, # no applicable state, fallback to away +} + async def async_setup_platform( hass: HomeAssistant, @@ -79,7 +91,9 @@ class NessAlarmPanel(alarm.AlarmControlPanelEntity): await self._client.panic(code) @callback - def _handle_arming_state_change(self, arming_state: ArmingState) -> None: + def _handle_arming_state_change( + self, arming_state: ArmingState, arming_mode: ArmingMode | None + ) -> None: """Handle arming state update.""" if arming_state == ArmingState.UNKNOWN: @@ -91,7 +105,9 @@ class NessAlarmPanel(alarm.AlarmControlPanelEntity): elif arming_state == ArmingState.EXIT_DELAY: self._attr_state = STATE_ALARM_ARMING elif arming_state == ArmingState.ARMED: - self._attr_state = STATE_ALARM_ARMED_AWAY + self._attr_state = ARMING_MODE_TO_STATE.get( + arming_mode, STATE_ALARM_ARMED_AWAY + ) elif arming_state == ArmingState.ENTRY_DELAY: self._attr_state = STATE_ALARM_PENDING elif arming_state == ArmingState.TRIGGERED: diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json index d92a3d02c7a..e4c5b5fb344 100644 --- a/homeassistant/components/ness_alarm/manifest.json +++ b/homeassistant/components/ness_alarm/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ness_alarm", "iot_class": "local_push", "loggers": ["nessclient"], - "requirements": ["nessclient==0.10.0"] + "requirements": ["nessclient==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index db56052eb82..ec345859233 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1240,7 +1240,7 @@ nad-receiver==0.3.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==0.10.0 +nessclient==1.0.0 # homeassistant.components.netdata netdata==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d69c88eb7a3..d65f0676a65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -951,7 +951,7 @@ mutesync==0.0.1 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==0.10.0 +nessclient==1.0.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 diff --git a/tests/components/ness_alarm/test_init.py b/tests/components/ness_alarm/test_init.py index 908e23ec795..5bf48e0667e 100644 --- a/tests/components/ness_alarm/test_init.py +++ b/tests/components/ness_alarm/test_init.py @@ -1,7 +1,7 @@ """Tests for the ness_alarm component.""" -from enum import Enum from unittest.mock import MagicMock, patch +from nessclient import ArmingMode, ArmingState import pytest from homeassistant.components import alarm_control_panel @@ -24,6 +24,8 @@ from homeassistant.const import ( SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, @@ -84,7 +86,7 @@ async def test_dispatch_state_change(hass: HomeAssistant, mock_nessclient) -> No await hass.async_block_till_done() on_state_change = mock_nessclient.on_state_change.call_args[0][0] - on_state_change(MockArmingState.ARMING) + on_state_change(ArmingState.ARMING, None) await hass.async_block_till_done() assert hass.states.is_state("alarm_control_panel.alarm_panel", STATE_ALARM_ARMING) @@ -174,13 +176,16 @@ async def test_dispatch_zone_change(hass: HomeAssistant, mock_nessclient) -> Non async def test_arming_state_change(hass: HomeAssistant, mock_nessclient) -> None: """Test arming state change handing.""" states = [ - (MockArmingState.UNKNOWN, STATE_UNKNOWN), - (MockArmingState.DISARMED, STATE_ALARM_DISARMED), - (MockArmingState.ARMING, STATE_ALARM_ARMING), - (MockArmingState.EXIT_DELAY, STATE_ALARM_ARMING), - (MockArmingState.ARMED, STATE_ALARM_ARMED_AWAY), - (MockArmingState.ENTRY_DELAY, STATE_ALARM_PENDING), - (MockArmingState.TRIGGERED, STATE_ALARM_TRIGGERED), + (ArmingState.UNKNOWN, None, STATE_UNKNOWN), + (ArmingState.DISARMED, None, STATE_ALARM_DISARMED), + (ArmingState.ARMING, None, STATE_ALARM_ARMING), + (ArmingState.EXIT_DELAY, None, STATE_ALARM_ARMING), + (ArmingState.ARMED, None, STATE_ALARM_ARMED_AWAY), + (ArmingState.ARMED, ArmingMode.ARMED_AWAY, STATE_ALARM_ARMED_AWAY), + (ArmingState.ARMED, ArmingMode.ARMED_HOME, STATE_ALARM_ARMED_HOME), + (ArmingState.ARMED, ArmingMode.ARMED_NIGHT, STATE_ALARM_ARMED_NIGHT), + (ArmingState.ENTRY_DELAY, None, STATE_ALARM_PENDING), + (ArmingState.TRIGGERED, None, STATE_ALARM_TRIGGERED), ] await async_setup_component(hass, DOMAIN, VALID_CONFIG) @@ -188,24 +193,12 @@ async def test_arming_state_change(hass: HomeAssistant, mock_nessclient) -> None assert hass.states.is_state("alarm_control_panel.alarm_panel", STATE_UNKNOWN) on_state_change = mock_nessclient.on_state_change.call_args[0][0] - for arming_state, expected_state in states: - on_state_change(arming_state) + for arming_state, arming_mode, expected_state in states: + on_state_change(arming_state, arming_mode) await hass.async_block_till_done() assert hass.states.is_state("alarm_control_panel.alarm_panel", expected_state) -class MockArmingState(Enum): - """Mock nessclient.ArmingState enum.""" - - UNKNOWN = "UNKNOWN" - DISARMED = "DISARMED" - ARMING = "ARMING" - EXIT_DELAY = "EXIT_DELAY" - ARMED = "ARMED" - ENTRY_DELAY = "ENTRY_DELAY" - TRIGGERED = "TRIGGERED" - - class MockClient: """Mock nessclient.Client stub.""" @@ -253,10 +246,5 @@ def mock_nessclient(): with patch( "homeassistant.components.ness_alarm.Client", new=_mock_factory, create=True - ), patch( - "homeassistant.components.ness_alarm.ArmingState", new=MockArmingState - ), patch( - "homeassistant.components.ness_alarm.alarm_control_panel.ArmingState", - new=MockArmingState, ): yield _mock_instance From a2e619155a25864c0860a03a4c58881db01e7f26 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 15:01:54 +0200 Subject: [PATCH 108/180] Map ipma weather condition codes once (#98512) --- homeassistant/components/ipma/const.py | 9 ++++++++- homeassistant/components/ipma/weather.py | 7 ++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py index c7482770f48..26fdee779b6 100644 --- a/homeassistant/components/ipma/const.py +++ b/homeassistant/components/ipma/const.py @@ -1,4 +1,6 @@ """Constants for IPMA component.""" +from __future__ import annotations + from datetime import timedelta from homeassistant.components.weather import ( @@ -31,7 +33,7 @@ ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) -CONDITION_CLASSES = { +CONDITION_CLASSES: dict[str, list[int]] = { ATTR_CONDITION_CLOUDY: [4, 5, 24, 25, 27], ATTR_CONDITION_FOG: [16, 17, 26], ATTR_CONDITION_HAIL: [21, 22], @@ -48,5 +50,10 @@ CONDITION_CLASSES = { ATTR_CONDITION_EXCEPTIONAL: [], ATTR_CONDITION_CLEAR_NIGHT: [-1], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} ATTRIBUTION = "Instituto Português do Mar e Atmosfera" diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index d4d11aa26e8..1f948bcc4e1 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -37,7 +37,7 @@ from homeassistant.util import Throttle from .const import ( ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, DATA_API, DATA_LOCATION, DOMAIN, @@ -135,10 +135,7 @@ class IPMAWeather(WeatherEntity, IPMADevice): if identifier == 1 and not is_up(self.hass, forecast_dt): identifier = -identifier - return next( - (k for k, v in CONDITION_CLASSES.items() if identifier in v), - None, - ) + return CONDITION_MAP.get(identifier) @property def condition(self): From 4180e2e4771b14f69d6d6abc10a9de695fe2c599 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 15:22:48 +0200 Subject: [PATCH 109/180] Make EnOceanSensor a RestoreSensor (#98527) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/enocean/sensor.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 5d1c0027791..db386a2d9fc 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -9,8 +9,8 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + RestoreSensor, SensorDeviceClass, - SensorEntity, SensorEntityDescription, SensorStateClass, ) @@ -27,7 +27,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .device import EnOceanEntity @@ -151,9 +150,8 @@ def setup_platform( add_entities(entities) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): - """Representation of an EnOcean sensor device such as a power meter.""" +class EnOceanSensor(EnOceanEntity, RestoreSensor): + """Representation of an EnOcean sensor device such as a power meter.""" def __init__( self, @@ -174,14 +172,13 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): if self._attr_native_value is not None: return - if (state := await self.async_get_last_state()) is not None: - self._attr_native_value = state.state + if (sensor_data := await self.async_get_last_sensor_data()) is not None: + self._attr_native_value = sensor_data.native_value def value_changed(self, packet): """Update the internal state of the sensor.""" -# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanPowerSensor(EnOceanSensor): """Representation of an EnOcean power sensor. @@ -202,7 +199,6 @@ class EnOceanPowerSensor(EnOceanSensor): self.schedule_update_ha_state() -# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanTemperatureSensor(EnOceanSensor): """Representation of an EnOcean temperature sensor device. @@ -252,7 +248,6 @@ class EnOceanTemperatureSensor(EnOceanSensor): self.schedule_update_ha_state() -# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanHumiditySensor(EnOceanSensor): """Representation of an EnOcean humidity sensor device. @@ -271,7 +266,6 @@ class EnOceanHumiditySensor(EnOceanSensor): self.schedule_update_ha_state() -# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanWindowHandle(EnOceanSensor): """Representation of an EnOcean window handle device. From 5bf80a0f6d0a2630851aaf29bb01ad9a5399e1e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Aug 2023 11:05:22 -0500 Subject: [PATCH 110/180] Make ESPHome deep sleep tests more robust (#98535) --- tests/components/esphome/test_entity.py | 50 ++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index ac121a93eff..fdc57b2dc24 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -8,10 +8,12 @@ from aioesphomeapi import ( BinarySensorState, EntityInfo, EntityState, + SensorInfo, + SensorState, UserService, ) -from homeassistant.const import ATTR_RESTORED, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_RESTORED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from .conftest import MockESPHomeDevice @@ -149,10 +151,17 @@ async def test_deep_sleep_device( name="my binary_sensor", unique_id="my_binary_sensor", ), + SensorInfo( + object_id="my_sensor", + key=3, + name="my sensor", + unique_id="my_sensor", + ), ] states = [ BinarySensorState(key=1, state=True, missing_state=False), BinarySensorState(key=2, state=True, missing_state=False), + SensorState(key=3, state=123.0, missing_state=False), ] user_service = [] mock_device = await mock_esphome_device( @@ -165,12 +174,18 @@ async def test_deep_sleep_device( state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "123" await mock_device.mock_disconnect(False) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == STATE_UNAVAILABLE await mock_device.mock_connect() await hass.async_block_till_done() @@ -178,12 +193,43 @@ async def test_deep_sleep_device( state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "123" + + await mock_device.mock_disconnect(True) + await hass.async_block_till_done() + await mock_device.mock_connect() + await hass.async_block_till_done() + mock_device.set_state(BinarySensorState(key=1, state=False, missing_state=False)) + mock_device.set_state(SensorState(key=3, state=56, missing_state=False)) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_OFF + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "56" await mock_device.mock_disconnect(True) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None - assert state.state == STATE_ON + assert state.state == STATE_OFF + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "56" + + await mock_device.mock_connect() + await hass.async_block_till_done() + await mock_device.mock_disconnect(False) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == STATE_UNAVAILABLE async def test_esphome_device_without_friendly_name( From 3e1d2a10009a77eefc4bd50728aaed63adff346c Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 16 Aug 2023 12:59:34 -0400 Subject: [PATCH 111/180] Handle missing keys in Honeywell (#98392) --- homeassistant/components/honeywell/climate.py | 6 +++--- homeassistant/components/honeywell/sensor.py | 3 ++- tests/components/honeywell/test_climate.py | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 19eb5c649d7..6bfefcf3a8c 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -146,13 +146,13 @@ class HoneywellUSThermostat(ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) - if device._data["canControlHumidification"]: + if device._data.get("canControlHumidification"): self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY - if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: + if device.raw_ui_data.get("SwitchEmergencyHeatAllowed"): self._attr_supported_features |= ClimateEntityFeature.AUX_HEAT - if not device._data["hasFan"]: + if not device._data.get("hasFan"): return # not all honeywell fans support all modes diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 8c25216b2ff..c1f70bbdd1f 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -20,6 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from . import HoneywellData from .const import DOMAIN, HUMIDITY_STATUS_KEY, TEMPERATURE_STATUS_KEY @@ -71,7 +72,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Honeywell thermostat.""" - data = hass.data[DOMAIN][config_entry.entry_id] + data: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] sensors = [] for device in data.devices.values(): diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index afb49cbffca..4d6989d79e8 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -48,13 +48,13 @@ FAN_ACTION = "fan_action" PRESET_HOLD = "Hold" -async def test_no_thermostats( +async def test_no_thermostat_options( hass: HomeAssistant, device: MagicMock, config_entry: MagicMock ) -> None: - """Test the setup of the climate entities when there are no appliances available.""" + """Test the setup of the climate entities when there are no additional options available.""" device._data = {} await init_integration(hass, config_entry) - assert len(hass.states.async_all()) == 0 + assert len(hass.states.async_all()) == 1 async def test_static_attributes( From b9203cbeaffa32351519507e9e93271b95555667 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Aug 2023 19:18:46 +0200 Subject: [PATCH 112/180] Add base entity for Dexcom (#98158) --- homeassistant/components/dexcom/sensor.py | 35 +++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index b9958dc7309..cbe24088378 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -6,7 +6,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import COORDINATOR, DOMAIN, GLUCOSE_TREND_ICON, GLUCOSE_VALUE_ICON, MG_DL @@ -29,18 +32,33 @@ async def async_setup_entry( ) -class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity): +class DexcomSensorEntity(CoordinatorEntity, SensorEntity): + """Base Dexcom sensor entity.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, username: str, key: str + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{username}-{key}" + + +class DexcomGlucoseValueSensor(DexcomSensorEntity): """Representation of a Dexcom glucose value sensor.""" _attr_icon = GLUCOSE_VALUE_ICON - def __init__(self, coordinator, username, unit_of_measurement): + def __init__( + self, + coordinator: DataUpdateCoordinator, + username: str, + unit_of_measurement: str, + ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, username, "value") self._attr_native_unit_of_measurement = unit_of_measurement self._key = "mg_dl" if unit_of_measurement == MG_DL else "mmol_l" self._attr_name = f"{DOMAIN}_{username}_glucose_value" - self._attr_unique_id = f"{username}-value" @property def native_value(self): @@ -50,14 +68,13 @@ class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity): return None -class DexcomGlucoseTrendSensor(CoordinatorEntity, SensorEntity): +class DexcomGlucoseTrendSensor(DexcomSensorEntity): """Representation of a Dexcom glucose trend sensor.""" - def __init__(self, coordinator, username): + def __init__(self, coordinator: DataUpdateCoordinator, username: str) -> None: """Initialize the sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, username, "trend") self._attr_name = f"{DOMAIN}_{username}_glucose_trend" - self._attr_unique_id = f"{username}-trend" @property def icon(self): From 31f5932fe4467c8d0db4b8d5c4df457f9fbe9a89 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 20:14:49 +0200 Subject: [PATCH 113/180] Log events with no listeners (#98540) * Log events with no listeners * Unconditionally create the Event object * Reformat code --- homeassistant/core.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 140cf203e70..49c288188f3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1025,6 +1025,11 @@ class EventBus: listeners = self._listeners.get(event_type, []) match_all_listeners = self._match_all_listeners + event = Event(event_type, event_data, origin, time_fired, context) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Bus:Handling %s", event) + if not listeners and not match_all_listeners: return @@ -1032,11 +1037,6 @@ class EventBus: if event_type != EVENT_HOMEASSISTANT_CLOSE: listeners = match_all_listeners + listeners - event = Event(event_type, event_data, origin, time_fired, context) - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Bus:Handling %s", event) - for job, event_filter, run_immediately in listeners: if event_filter is not None: try: From 4eb0f1cf373dc2a76aa96fbeb363402f6a309f5d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 20:15:47 +0200 Subject: [PATCH 114/180] Make eufylife_ble sensors inherit RestoreSensor (#98528) --- .../components/eufylife_ble/sensor.py | 57 ++++++------------- 1 file changed, 17 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index 7bc732b911e..3278f1c1387 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -7,19 +7,16 @@ from eufylife_ble_client import MODEL_TO_NAME from homeassistant import config_entries from homeassistant.components.bluetooth import async_address_present -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - UnitOfMass, +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntity, ) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfMass from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util.unit_conversion import MassConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import DOMAIN @@ -111,16 +108,13 @@ class EufyLifeRealTimeWeightSensorEntity(EufyLifeSensorEntity): return UnitOfMass.KILOGRAMS -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): +class EufyLifeWeightSensorEntity(RestoreSensor, EufyLifeSensorEntity): """Representation of an EufyLife weight sensor.""" _attr_translation_key = "weight" _attr_native_unit_of_measurement = UnitOfMass.KILOGRAMS _attr_device_class = SensorDeviceClass.WEIGHT - _weight_kg: float | None = None - def __init__(self, data: EufyLifeData) -> None: """Initialize the weight sensor entity.""" super().__init__(data) @@ -131,11 +125,6 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Determine if the entity is available.""" return True - @property - def native_value(self) -> float | None: - """Return the native value.""" - return self._weight_kg - @property def suggested_unit_of_measurement(self) -> str | None: """Set the suggested unit based on the unit system.""" @@ -149,7 +138,7 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Handle state update.""" state = self._data.client.state if state is not None and state.final_weight_kg is not None: - self._weight_kg = state.final_weight_kg + self._attr_native_value = state.final_weight_kg super()._handle_state_update(args) @@ -158,30 +147,21 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or last_state.state in IGNORED_STATES: + last_sensor_data = await self.async_get_last_sensor_data() + + if not last_state or not last_sensor_data or last_state.state in IGNORED_STATES: return - last_weight = float(last_state.state) - last_weight_unit = last_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - # Since the RestoreEntity stores the state using the displayed unit, - # not the native unit, we need to convert the state back to the native - # unit. - self._weight_kg = MassConverter.convert( - last_weight, last_weight_unit, self.native_unit_of_measurement - ) + self._attr_native_value = last_sensor_data.native_value -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): +class EufyLifeHeartRateSensorEntity(RestoreSensor, EufyLifeSensorEntity): """Representation of an EufyLife heart rate sensor.""" _attr_translation_key = "heart_rate" _attr_icon = "mdi:heart-pulse" _attr_native_unit_of_measurement = "bpm" - _heart_rate: int | None = None - def __init__(self, data: EufyLifeData) -> None: """Initialize the heart rate sensor entity.""" super().__init__(data) @@ -192,17 +172,12 @@ class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Determine if the entity is available.""" return True - @property - def native_value(self) -> float | None: - """Return the native value.""" - return self._heart_rate - @callback def _handle_state_update(self, *args: Any) -> None: """Handle state update.""" state = self._data.client.state if state is not None and state.heart_rate is not None: - self._heart_rate = state.heart_rate + self._attr_native_value = state.heart_rate super()._handle_state_update(args) @@ -211,7 +186,9 @@ class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or last_state.state in IGNORED_STATES: + last_sensor_data = await self.async_get_last_sensor_data() + + if not last_state or not last_sensor_data or last_state.state in IGNORED_STATES: return - self._heart_rate = int(last_state.state) + self._attr_native_value = last_sensor_data.native_value From 8ed7d2dd3e27080b6be157b8603382bd338cad99 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 20:20:14 +0200 Subject: [PATCH 115/180] Don't create certain start.ca sensors for unlimited plans (#98525) Don't create certain startca sensors for unlimited setups --- homeassistant/components/startca/sensor.py | 20 +++++++++++--------- tests/components/startca/test_sensor.py | 15 ++++----------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 50224944849..ab53b039756 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -157,6 +157,13 @@ async def async_setup_platform( name = config[CONF_NAME] monitored_variables = config[CONF_MONITORED_VARIABLES] + if bandwidthcap <= 0: + monitored_variables = list( + filter( + lambda itm: itm not in {"limit", "usage", "used_remaining"}, + monitored_variables, + ) + ) entities = [ StartcaSensor(ts_data, name, description) for description in SENSOR_TYPES @@ -193,11 +200,9 @@ class StartcaData: self.api_key = api_key self.bandwidth_cap = bandwidth_cap # Set unlimited users to infinite, otherwise the cap. - self.data = ( - {"limit": self.bandwidth_cap} - if self.bandwidth_cap > 0 - else {"limit": float("inf")} - ) + self.data = {} + if self.bandwidth_cap > 0: + self.data["limit"] = self.bandwidth_cap @staticmethod def bytes_to_gb(value): @@ -232,11 +237,9 @@ class StartcaData: total_dl = self.bytes_to_gb(xml_data["usage"]["total"]["download"]) total_ul = self.bytes_to_gb(xml_data["usage"]["total"]["upload"]) - limit = self.data["limit"] if self.bandwidth_cap > 0: self.data["usage"] = 100 * used_dl / self.bandwidth_cap - else: - self.data["usage"] = 0 + self.data["used_remaining"] = self.data["limit"] - used_dl self.data["usage_gb"] = used_dl self.data["used_download"] = used_dl self.data["used_upload"] = used_ul @@ -246,6 +249,5 @@ class StartcaData: self.data["grace_total"] = grace_dl + grace_ul self.data["total_download"] = total_dl self.data["total_upload"] = total_ul - self.data["used_remaining"] = limit - used_dl return True diff --git a/tests/components/startca/test_sensor.py b/tests/components/startca/test_sensor.py index 3907427bbd3..7b691410907 100644 --- a/tests/components/startca/test_sensor.py +++ b/tests/components/startca/test_sensor.py @@ -157,18 +157,15 @@ async def test_unlimited_setup( await async_setup_component(hass, "sensor", {"sensor": config}) await hass.async_block_till_done() - state = hass.states.get("sensor.start_ca_usage_ratio") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.state == "0" + # These sensors should not be created for unlimited setups + assert hass.states.get("sensor.start_ca_usage_ratio") is None + assert hass.states.get("sensor.start_ca_data_limit") is None + assert hass.states.get("sensor.start_ca_remaining") is None state = hass.states.get("sensor.start_ca_usage") assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES assert state.state == "0.0" - state = hass.states.get("sensor.start_ca_data_limit") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES - assert state.state == "inf" - state = hass.states.get("sensor.start_ca_used_download") assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES assert state.state == "0.0" @@ -201,10 +198,6 @@ async def test_unlimited_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES assert state.state == "6.48" - state = hass.states.get("sensor.start_ca_remaining") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES - assert state.state == "inf" - async def test_bad_return_code( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker From b1053e8077291527402191d2ca5418631b725c50 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 20:20:47 +0200 Subject: [PATCH 116/180] Map accuweather weather condition codes once (#98509) Map accuweather condition codes once --- homeassistant/components/accuweather/const.py | 5 +++++ homeassistant/components/accuweather/weather.py | 15 +++------------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 87bc8eaef89..2e18977d112 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -50,3 +50,8 @@ CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_SUNNY: [1, 2, 5], ATTR_CONDITION_WINDY: [32], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index c2889bae102..518714b3874 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -40,7 +40,7 @@ from .const import ( ATTR_SPEED, ATTR_VALUE, ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, DOMAIN, ) @@ -80,14 +80,7 @@ class AccuWeatherEntity( @property def condition(self) -> str | None: """Return the current condition.""" - try: - return [ - k - for k, v in CONDITION_CLASSES.items() - if self.coordinator.data["WeatherIcon"] in v - ][0] - except IndexError: - return None + return CONDITION_MAP.get(self.coordinator.data["WeatherIcon"]) @property def cloud_coverage(self) -> float: @@ -177,9 +170,7 @@ class AccuWeatherEntity( ], ATTR_FORECAST_UV_INDEX: item["UVIndex"][ATTR_VALUE], ATTR_FORECAST_WIND_BEARING: item["WindDay"][ATTR_DIRECTION]["Degrees"], - ATTR_FORECAST_CONDITION: [ - k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v - ][0], + ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["IconDay"]), } for item in self.coordinator.data[ATTR_FORECAST] ] From 827e06a5c88deefe706d066ddd2a4a41299a5e1e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 20:21:07 +0200 Subject: [PATCH 117/180] Improve typing of nws (#98485) * Improve typing of nws * Address review comments --- homeassistant/components/nws/__init__.py | 33 +++++++++++++----------- homeassistant/components/nws/const.py | 5 ---- homeassistant/components/nws/sensor.py | 23 +++++------------ homeassistant/components/nws/weather.py | 22 +++++++--------- 4 files changed, 34 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 54c239664dc..f0f2a12cfec 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from dataclasses import dataclass import datetime import logging from typing import TYPE_CHECKING @@ -18,15 +19,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import utcnow -from .const import ( - CONF_STATION, - COORDINATOR_FORECAST, - COORDINATOR_FORECAST_HOURLY, - COORDINATOR_OBSERVATION, - DOMAIN, - NWS_DATA, - UPDATE_TIME_PERIOD, -) +from .const import CONF_STATION, DOMAIN, UPDATE_TIME_PERIOD _LOGGER = logging.getLogger(__name__) @@ -42,6 +35,16 @@ def base_unique_id(latitude: float, longitude: float) -> str: return f"{latitude}_{longitude}" +@dataclass +class NWSData: + """Data for the National Weather Service integration.""" + + api: SimpleNWS + coordinator_observation: NwsDataUpdateCoordinator + coordinator_forecast: NwsDataUpdateCoordinator + coordinator_forecast_hourly: NwsDataUpdateCoordinator + + class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): """NWS data update coordinator. @@ -150,12 +153,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) nws_hass_data = hass.data.setdefault(DOMAIN, {}) - nws_hass_data[entry.entry_id] = { - NWS_DATA: nws_data, - COORDINATOR_OBSERVATION: coordinator_observation, - COORDINATOR_FORECAST: coordinator_forecast, - COORDINATOR_FORECAST_HOURLY: coordinator_forecast_hourly, - } + nws_hass_data[entry.entry_id] = NWSData( + nws_data, + coordinator_observation, + coordinator_forecast, + coordinator_forecast_hourly, + ) # Fetch initial data so we have data when entities subscribe await coordinator_observation.async_refresh() diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index e5718d5132f..5db541106b9 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -74,11 +74,6 @@ CONDITION_CLASSES: dict[str, list[str]] = { DAYNIGHT = "daynight" HOURLY = "hourly" -NWS_DATA = "nws data" -COORDINATOR_OBSERVATION = "coordinator_observation" -COORDINATOR_FORECAST = "coordinator_forecast" -COORDINATOR_FORECAST_HOURLY = "coordinator_forecast_hourly" - OBSERVATION_VALID_TIME = timedelta(minutes=20) FORECAST_VALID_TIME = timedelta(minutes=45) # A lot of stations update once hourly plus some wiggle room diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 71eeda0d8cf..7c49ca278a7 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -5,8 +5,6 @@ from dataclasses import dataclass from types import MappingProxyType from typing import Any -from pynws import SimpleNWS - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -36,15 +34,8 @@ from homeassistant.util.unit_conversion import ( ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import NwsDataUpdateCoordinator, base_unique_id, device_info -from .const import ( - ATTRIBUTION, - CONF_STATION, - COORDINATOR_OBSERVATION, - DOMAIN, - NWS_DATA, - OBSERVATION_VALID_TIME, -) +from . import NWSData, NwsDataUpdateCoordinator, base_unique_id, device_info +from .const import ATTRIBUTION, CONF_STATION, DOMAIN, OBSERVATION_VALID_TIME PARALLEL_UPDATES = 0 @@ -152,14 +143,14 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the NWS weather platform.""" - hass_data = hass.data[DOMAIN][entry.entry_id] + nws_data: NWSData = hass.data[DOMAIN][entry.entry_id] station = entry.data[CONF_STATION] async_add_entities( NWSSensor( hass=hass, entry_data=entry.data, - hass_data=hass_data, + nws_data=nws_data, description=description, station=station, ) @@ -177,13 +168,13 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): self, hass: HomeAssistant, entry_data: MappingProxyType[str, Any], - hass_data: dict[str, Any], + nws_data: NWSData, description: NWSSensorEntityDescription, station: str, ) -> None: """Initialise the platform with a data instance.""" - super().__init__(hass_data[COORDINATOR_OBSERVATION]) - self._nws: SimpleNWS = hass_data[NWS_DATA] + super().__init__(nws_data.coordinator_observation) + self._nws = nws_data.api self._latitude = entry_data[CONF_LATITUDE] self._longitude = entry_data[CONF_LONGITUDE] self.entity_description = description diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 0c491723117..0e5fd412e0c 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -35,19 +35,15 @@ from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter from homeassistant.util.unit_system import UnitSystem -from . import base_unique_id, device_info +from . import NWSData, base_unique_id, device_info from .const import ( ATTR_FORECAST_DETAILED_DESCRIPTION, ATTRIBUTION, CONDITION_CLASSES, - COORDINATOR_FORECAST, - COORDINATOR_FORECAST_HOURLY, - COORDINATOR_OBSERVATION, DAYNIGHT, DOMAIN, FORECAST_VALID_TIME, HOURLY, - NWS_DATA, OBSERVATION_VALID_TIME, ) @@ -84,12 +80,12 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the NWS weather platform.""" - hass_data = hass.data[DOMAIN][entry.entry_id] + nws_data: NWSData = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - NWSWeather(entry.data, hass_data, DAYNIGHT, hass.config.units), - NWSWeather(entry.data, hass_data, HOURLY, hass.config.units), + NWSWeather(entry.data, nws_data, DAYNIGHT, hass.config.units), + NWSWeather(entry.data, nws_data, HOURLY, hass.config.units), ], False, ) @@ -112,19 +108,19 @@ class NWSWeather(WeatherEntity): def __init__( self, entry_data: MappingProxyType[str, Any], - hass_data: dict[str, Any], + nws_data: NWSData, mode: str, units: UnitSystem, ) -> None: """Initialise the platform with a data instance and station name.""" - self.nws = hass_data[NWS_DATA] + self.nws = nws_data.api self.latitude = entry_data[CONF_LATITUDE] self.longitude = entry_data[CONF_LONGITUDE] - self.coordinator_observation = hass_data[COORDINATOR_OBSERVATION] + self.coordinator_observation = nws_data.coordinator_observation if mode == DAYNIGHT: - self.coordinator_forecast = hass_data[COORDINATOR_FORECAST] + self.coordinator_forecast = nws_data.coordinator_forecast else: - self.coordinator_forecast = hass_data[COORDINATOR_FORECAST_HOURLY] + self.coordinator_forecast = nws_data.coordinator_forecast_hourly self.station = self.nws.station self.mode = mode From 5c1c8dc682a13b6aa0a0a71a400d4ec82ff7ab52 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 20:22:38 +0200 Subject: [PATCH 118/180] Modernize tomorrowio weather (#98466) * Modernize tomorrowio weather * Add test snapshot * Update snapshots * Address review comments * Improve test coverage --- .../components/tomorrowio/weather.py | 88 +- .../tomorrowio/snapshots/test_weather.ambr | 1097 +++++++++++++++++ tests/components/tomorrowio/test_weather.py | 216 +++- 3 files changed, 1368 insertions(+), 33 deletions(-) create mode 100644 tests/components/tomorrowio/snapshots/test_weather.ambr diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 86b84ec3ca6..333aa0cd472 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime -from typing import Any from pytomorrowio.const import DAILY, FORECASTS, HOURLY, NOWCAST, WeatherCode @@ -15,7 +14,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + DOMAIN as WEATHER_DOMAIN, + Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -27,7 +29,8 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util @@ -63,14 +66,30 @@ async def async_setup_entry( ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] + entity_registry = er.async_get(hass) + + entities = [TomorrowioWeatherEntity(config_entry, coordinator, 4, DAILY)] + + # Add hourly and nowcast entities to legacy config entries + for forecast_type in (HOURLY, NOWCAST): + if not entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(config_entry.unique_id, forecast_type), + ): + continue + entities.append( + TomorrowioWeatherEntity(config_entry, coordinator, 4, forecast_type) + ) - entities = [ - TomorrowioWeatherEntity(config_entry, coordinator, 4, forecast_type) - for forecast_type in (DAILY, HOURLY, NOWCAST) - ] async_add_entities(entities) +def _calculate_unique_id(config_entry_unique_id: str | None, forecast_type: str) -> str: + """Calculate unique ID.""" + return f"{config_entry_unique_id}_{forecast_type}" + + class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): """Entity that talks to Tomorrow.io v4 API to retrieve weather data.""" @@ -79,6 +98,9 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__( self, @@ -94,7 +116,18 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): forecast_type == DEFAULT_FORECAST_TYPE ) self._attr_name = f"{config_entry.data[CONF_NAME]} - {forecast_type.title()}" - self._attr_unique_id = f"{config_entry.unique_id}_{forecast_type}" + self._attr_unique_id = _calculate_unique_id( + config_entry.unique_id, forecast_type + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + super()._handle_coordinator_update() + assert self.platform.config_entry + self.platform.config_entry.async_create_task( + self.hass, self.async_update_listeners(("daily", "hourly")) + ) def _forecast_dict( self, @@ -102,12 +135,12 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): use_datetime: bool, condition: int, precipitation: float | None, - precipitation_probability: float | None, + precipitation_probability: int | None, temp: float | None, temp_low: float | None, wind_direction: float | None, wind_speed: float | None, - ) -> dict[str, Any]: + ) -> Forecast: """Return formatted Forecast dict from Tomorrow.io forecast data.""" if use_datetime: translated_condition = self._translate_condition( @@ -116,7 +149,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): else: translated_condition = self._translate_condition(condition, True) - data = { + return { ATTR_FORECAST_TIME: forecast_dt.isoformat(), ATTR_FORECAST_CONDITION: translated_condition, ATTR_FORECAST_NATIVE_PRECIPITATION: precipitation, @@ -127,8 +160,6 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): ATTR_FORECAST_NATIVE_WIND_SPEED: wind_speed, } - return {k: v for k, v in data.items() if v is not None} - @staticmethod def _translate_condition( condition: int | None, sun_is_up: bool = True @@ -187,20 +218,19 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): """Return the raw visibility.""" return self._get_current_property(TMRW_ATTR_VISIBILITY) - @property - def forecast(self): + def _forecast(self, forecast_type: str) -> list[Forecast] | None: """Return the forecast.""" # Check if forecasts are available raw_forecasts = ( self.coordinator.data.get(self._config_entry.entry_id, {}) .get(FORECASTS, {}) - .get(self.forecast_type) + .get(forecast_type) ) if not raw_forecasts: return None - forecasts = [] - max_forecasts = MAX_FORECASTS[self.forecast_type] + forecasts: list[Forecast] = [] + max_forecasts = MAX_FORECASTS[forecast_type] forecast_count = 0 # Convert utcnow to local to be compatible with tests @@ -212,7 +242,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): forecast_dt = dt_util.parse_datetime(forecast[TMRW_ATTR_TIMESTAMP]) # Throw out past data - if dt_util.as_local(forecast_dt).date() < today: + if forecast_dt is None or dt_util.as_local(forecast_dt).date() < today: continue values = forecast["values"] @@ -222,18 +252,23 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): precipitation = values.get(TMRW_ATTR_PRECIPITATION) precipitation_probability = values.get(TMRW_ATTR_PRECIPITATION_PROBABILITY) + try: + precipitation_probability = round(precipitation_probability) + except TypeError: + precipitation_probability = None + temp = values.get(TMRW_ATTR_TEMPERATURE_HIGH) temp_low = None wind_direction = values.get(TMRW_ATTR_WIND_DIRECTION) wind_speed = values.get(TMRW_ATTR_WIND_SPEED) - if self.forecast_type == DAILY: + if forecast_type == DAILY: use_datetime = False temp_low = values.get(TMRW_ATTR_TEMPERATURE_LOW) if precipitation: precipitation = precipitation * 24 - elif self.forecast_type == NOWCAST: + elif forecast_type == NOWCAST: # Precipitation is forecasted in CONF_TIMESTEP increments but in a # per hour rate, so value needs to be converted to an amount. if precipitation: @@ -260,3 +295,16 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): break return forecasts + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast array.""" + return self._forecast(self.forecast_type) + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self._forecast(DAILY) + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return self._forecast(HOURLY) diff --git a/tests/components/tomorrowio/snapshots/test_weather.ambr b/tests/components/tomorrowio/snapshots/test_weather.ambr new file mode 100644 index 00000000000..40ff18658c6 --- /dev/null +++ b/tests/components/tomorrowio/snapshots/test_weather.ambr @@ -0,0 +1,1097 @@ +# serializer version: 1 +# name: test_forecast_subscription[daily] + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]) +# --- +# name: test_forecast_subscription[daily].1 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]) +# --- +# name: test_forecast_subscription[hourly] + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]) +# --- +# name: test_forecast_subscription[hourly].1 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]) +# --- +# name: test_v4_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]), + }) +# --- +# name: test_v4_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]), + }) +# --- diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 586fd87f681..8490b94a7f9 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -1,10 +1,13 @@ """Tests for Tomorrow.io weather entity.""" from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from typing import Any from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, @@ -41,16 +44,19 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, SOURCE_USER from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, CONF_NAME from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util from .const import API_V4_ENTRY_DATA from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator @callback @@ -65,23 +71,47 @@ def _enable_entity(hass: HomeAssistant, entity_name: str) -> None: assert updated_entry.disabled is False +async def _setup_config_entry(hass: HomeAssistant, config: dict[str, Any]) -> State: + """Set up entry and return entity state.""" + data = _get_config_schema(hass, SOURCE_USER)(config) + data[CONF_NAME] = DEFAULT_NAME + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, + unique_id=_get_unique_id(hass, data), + version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State: """Set up entry and return entity state.""" + with freeze_time(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)): + await _setup_config_entry(hass, config) + + return hass.states.get("weather.tomorrow_io_daily") + + +async def _setup_legacy(hass: HomeAssistant, config: dict[str, Any]) -> State: + """Set up entry and return entity state.""" + registry = er.async_get(hass) + data = _get_config_schema(hass, SOURCE_USER)(config) + for entity_name in ("hourly", "nowcast"): + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + f"{_get_unique_id(hass, data)}_{entity_name}", + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + suggested_object_id=f"tomorrow_io_{entity_name}", + ) + with freeze_time( datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC) ) as frozen_time: - data = _get_config_schema(hass, SOURCE_USER)(config) - data[CONF_NAME] = DEFAULT_NAME - config_entry = MockConfigEntry( - domain=DOMAIN, - data=data, - options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, - unique_id=_get_unique_id(hass, data), - version=1, - ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await _setup_config_entry(hass, config) for entity_name in ("hourly", "nowcast"): _enable_entity(hass, f"weather.tomorrow_io_{entity_name}") await hass.async_block_till_done() @@ -94,6 +124,33 @@ async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State: return hass.states.get("weather.tomorrow_io_daily") +async def test_new_config_entry(hass: HomeAssistant) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + await _setup(hass, API_V4_ENTRY_DATA) + assert len(hass.states.async_entity_ids("weather")) == 1 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 28 + + +async def test_legacy_config_entry(hass: HomeAssistant) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + data = _get_config_schema(hass, SOURCE_USER)(API_V4_ENTRY_DATA) + for entity_name in ("hourly", "nowcast"): + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + f"{_get_unique_id(hass, data)}_{entity_name}", + ) + await _setup(hass, API_V4_ENTRY_DATA) + assert len(hass.states.async_entity_ids("weather")) == 3 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 30 + + async def test_v4_weather(hass: HomeAssistant) -> None: """Test v4 weather data.""" weather_state = await _setup(hass, API_V4_ENTRY_DATA) @@ -123,3 +180,136 @@ async def test_v4_weather(hass: HomeAssistant) -> None: assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 33.59 # 9.33 m/s ->km/h assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == "km/h" + + +async def test_v4_weather_legacy_entities(hass: HomeAssistant) -> None: + """Test v4 weather data.""" + weather_state = await _setup_legacy(hass, API_V4_ENTRY_DATA) + assert weather_state.state == ATTR_CONDITION_SUNNY + assert weather_state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert len(weather_state.attributes[ATTR_FORECAST]) == 14 + assert weather_state.attributes[ATTR_FORECAST][0] == { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_SUNNY, + ATTR_FORECAST_TIME: "2021-03-07T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 45.9, + ATTR_FORECAST_TEMP_LOW: 26.1, + ATTR_FORECAST_WIND_BEARING: 239.6, + ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h + } + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" + assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 + assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 + assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" + assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 30.35 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE_UNIT] == "hPa" + assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 44.1 + assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == "°C" + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 8.15 + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == "km" + assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 33.59 # 9.33 m/s ->km/h + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == "km/h" + + +@freeze_time(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) +async def test_v4_forecast_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + entity_id = weather_state.entity_id + + for forecast_type in ("daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": entity_id, + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + +async def test_v4_bad_forecast( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + tomorrowio_config_entry_update, + snapshot: SnapshotAssertion, +) -> None: + """Test bad forecast data.""" + freezer.move_to(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) + + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + entity_id = weather_state.entity_id + hourly_forecast = tomorrowio_config_entry_update.return_value["forecasts"]["hourly"] + hourly_forecast[0]["values"]["precipitationProbability"] = "blah" + + # Trigger data refetch + freezer.tick(timedelta(minutes=32) + timedelta(seconds=1)) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"][0]["precipitation_probability"] is None + + +@pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + forecast_type: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + freezer.move_to(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) + + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + entity_id = weather_state.entity_id + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + freezer.tick(timedelta(minutes=32) + timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot From f643d2de46de31ab2542de25638b2688c068a13b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 22:07:12 +0200 Subject: [PATCH 119/180] Map SMHI weather condition codes once (#98517) --- homeassistant/components/smhi/weather.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 5b71d92b25f..c8ff9127ba8 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -81,6 +81,11 @@ CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} TIMEOUT = 10 # 5 minutes between retrying connect to API again @@ -148,7 +153,6 @@ class SmhiWeather(WeatherEntity): name=name, configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) - self._attr_native_temperature = None @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -183,14 +187,7 @@ class SmhiWeather(WeatherEntity): self._attr_native_pressure = self._forecast_daily[0].pressure self._attr_native_wind_gust_speed = self._forecast_daily[0].wind_gust self._attr_cloud_coverage = self._forecast_daily[0].cloudiness - self._attr_condition = next( - ( - k - for k, v in CONDITION_CLASSES.items() - if self._forecast_daily[0].symbol in v - ), - None, - ) + self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0].symbol) await self.async_update_listeners(("daily", "hourly")) async def retry_update(self, _: datetime) -> None: @@ -208,9 +205,7 @@ class SmhiWeather(WeatherEntity): data: list[Forecast] = [] for forecast in self._forecast_daily[1:]: - condition = next( - (k for k, v in CONDITION_CLASSES.items() if forecast.symbol in v), None - ) + condition = CONDITION_MAP.get(forecast.symbol) data.append( { @@ -240,9 +235,7 @@ class SmhiWeather(WeatherEntity): data: list[Forecast] = [] for forecast in forecast_data[1:]: - condition = next( - (k for k, v in CONDITION_CLASSES.items() if forecast.symbol in v), None - ) + condition = CONDITION_MAP.get(forecast.symbol) data.append( { From f135c42524587c7c311f968711a1616b9924720b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 22:08:17 +0200 Subject: [PATCH 120/180] Map openweathermap weather condition codes once (#98516) --- homeassistant/components/openweathermap/const.py | 5 +++++ .../components/openweathermap/weather_update_coordinator.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index d53fbc136b2..1420b1170ca 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -157,3 +157,8 @@ CONDITION_CLASSES = { 904, ], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 732557363d8..cf0c941f0df 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -46,7 +46,7 @@ from .const import ( ATTR_API_WIND_BEARING, ATTR_API_WIND_GUST, ATTR_API_WIND_SPEED, - CONDITION_CLASSES, + CONDITION_MAP, DOMAIN, FORECAST_MODE_DAILY, FORECAST_MODE_HOURLY, @@ -267,7 +267,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return ATTR_CONDITION_SUNNY return ATTR_CONDITION_CLEAR_NIGHT - return [k for k, v in CONDITION_CLASSES.items() if weather_code in v][0] + return CONDITION_MAP.get(weather_code) class LegacyWeather: From 227d4a590da30780c32f0fa73f2a5ac9d9af8c49 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 22:09:06 +0200 Subject: [PATCH 121/180] Map metoffice weather condition codes once (#98515) --- homeassistant/components/metoffice/const.py | 5 +++++ homeassistant/components/metoffice/sensor.py | 8 ++------ homeassistant/components/metoffice/weather.py | 13 +++---------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/metoffice/const.py b/homeassistant/components/metoffice/const.py index e4843d1235e..8b86784b70b 100644 --- a/homeassistant/components/metoffice/const.py +++ b/homeassistant/components/metoffice/const.py @@ -52,6 +52,11 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} VISIBILITY_CLASSES = { "VP": "Very Poor", diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index fcb8e5b134e..371c396a829 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -29,7 +29,7 @@ from homeassistant.helpers.update_coordinator import ( from . import get_device_info from .const import ( ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, DOMAIN, METOFFICE_COORDINATES, METOFFICE_DAILY_COORDINATOR, @@ -221,11 +221,7 @@ class MetOfficeCurrentSensor( elif self.entity_description.key == "weather" and hasattr( self.coordinator.data.now, self.entity_description.key ): - value = [ - k - for k, v in CONDITION_CLASSES.items() - if self.coordinator.data.now.weather.value in v - ][0] + value = CONDITION_MAP.get(self.coordinator.data.now.weather.value) elif hasattr(self.coordinator.data.now, self.entity_description.key): value = getattr(self.coordinator.data.now, self.entity_description.key) diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 8257c8a3c35..0b4672ddec8 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -26,7 +26,7 @@ from homeassistant.helpers.update_coordinator import ( from . import get_device_info from .const import ( ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, DOMAIN, METOFFICE_COORDINATES, METOFFICE_DAILY_COORDINATOR, @@ -55,7 +55,7 @@ async def async_setup_entry( def _build_forecast_data(timestep: Timestep) -> Forecast: data = Forecast(datetime=timestep.date.isoformat()) if timestep.weather: - data[ATTR_FORECAST_CONDITION] = _get_weather_condition(timestep.weather.value) + data[ATTR_FORECAST_CONDITION] = CONDITION_MAP.get(timestep.weather.value) if timestep.precipitation: data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = timestep.precipitation.value if timestep.temperature: @@ -67,13 +67,6 @@ def _build_forecast_data(timestep: Timestep) -> Forecast: return data -def _get_weather_condition(metoffice_code: str) -> str | None: - for hass_name, metoffice_codes in CONDITION_CLASSES.items(): - if metoffice_code in metoffice_codes: - return hass_name - return None - - class MetOfficeWeather( CoordinatorEntity[DataUpdateCoordinator[MetOfficeData]], WeatherEntity ): @@ -107,7 +100,7 @@ class MetOfficeWeather( def condition(self) -> str | None: """Return the current condition.""" if self.coordinator.data.now: - return _get_weather_condition(self.coordinator.data.now.weather.value) + return CONDITION_MAP.get(self.coordinator.data.now.weather.value) return None @property From f85c2e5a92d3ca4c37b7353da29c1c101f86e4b8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 22:10:48 +0200 Subject: [PATCH 122/180] Modernize environment_canada weather (#98502) --- .../components/environment_canada/weather.py | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index a9f79907b54..bdc300dc9a3 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -21,7 +21,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, + DOMAIN as WEATHER_DOMAIN, + Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,7 +33,8 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -63,7 +67,24 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] - async_add_entities([ECWeather(coordinator, False), ECWeather(coordinator, True)]) + entity_registry = er.async_get(hass) + + entities = [ECWeather(coordinator, False)] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(config_entry.unique_id, True), + ): + entities.append(ECWeather(coordinator, True)) + + async_add_entities(entities) + + +def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str: + """Calculate unique ID.""" + return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" class ECWeather(CoordinatorEntity, WeatherEntity): @@ -74,6 +95,9 @@ class ECWeather(CoordinatorEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__(self, coordinator, hourly): """Initialize Environment Canada weather.""" @@ -81,13 +105,22 @@ class ECWeather(CoordinatorEntity, WeatherEntity): self.ec_data = coordinator.ec_data self._attr_attribution = self.ec_data.metadata["attribution"] self._attr_translation_key = "hourly_forecast" if hourly else "forecast" - self._attr_unique_id = ( - f"{coordinator.config_entry.unique_id}{'-hourly' if hourly else '-daily'}" + self._attr_unique_id = _calculate_unique_id( + coordinator.config_entry.unique_id, hourly ) self._attr_entity_registry_enabled_default = not hourly self._hourly = hourly self._attr_device_info = device_info(coordinator.config_entry) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + super()._handle_coordinator_update() + assert self.platform.config_entry + self.platform.config_entry.async_create_task( + self.hass, self.async_update_listeners(("daily", "hourly")) + ) + @property def native_temperature(self): """Return the temperature.""" @@ -155,20 +188,28 @@ class ECWeather(CoordinatorEntity, WeatherEntity): return "" @property - def forecast(self): + def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" return get_forecast(self.ec_data, self._hourly) + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return get_forecast(self.ec_data, False) -def get_forecast(ec_data, hourly): + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return get_forecast(self.ec_data, True) + + +def get_forecast(ec_data, hourly) -> list[Forecast] | None: """Build the forecast array.""" - forecast_array = [] + forecast_array: list[Forecast] = [] if not hourly: if not (half_days := ec_data.daily_forecasts): return None - today = { + today: Forecast = { ATTR_FORECAST_TIME: dt_util.now().isoformat(), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(half_days[0]["icon_code"]) From 614d6e929d9e4a2cb4efc56c2ff577ca530a3957 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 22:11:27 +0200 Subject: [PATCH 123/180] Map meteoclimatic weather condition codes once (#98514) --- homeassistant/components/meteoclimatic/const.py | 5 +++++ homeassistant/components/meteoclimatic/weather.py | 9 ++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/meteoclimatic/const.py b/homeassistant/components/meteoclimatic/const.py index 4de299f1cf7..4a7276d4e42 100644 --- a/homeassistant/components/meteoclimatic/const.py +++ b/homeassistant/components/meteoclimatic/const.py @@ -54,3 +54,8 @@ CONDITION_CLASSES = { ATTR_CONDITION_WINDY: [], ATTR_CONDITION_WINDY_VARIANT: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index d275707488b..f9b341cf114 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -12,14 +12,13 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ATTRIBUTION, CONDITION_CLASSES, DOMAIN, MANUFACTURER, MODEL +from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN, MANUFACTURER, MODEL def format_condition(condition): - """Return condition from dict CONDITION_CLASSES.""" - for key, value in CONDITION_CLASSES.items(): - if condition in value: - return key + """Return condition from dict CONDITION_MAP.""" + if condition in CONDITION_MAP: + return CONDITION_MAP[condition] if isinstance(condition, Condition): return condition.value return condition From 1897be146751e077307d119a47503958738c297b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 22:12:22 +0200 Subject: [PATCH 124/180] Map demo and kitchen_sink weather condition codes once (#98510) Map demo and kitchen_sink condition codes once --- homeassistant/components/demo/weather.py | 9 ++++++--- homeassistant/components/kitchen_sink/weather.py | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index 887a9212335..758b5075041 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -46,6 +46,11 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} WEATHER_UPDATE_INTERVAL = timedelta(minutes=30) @@ -237,9 +242,7 @@ class DemoWeather(WeatherEntity): @property def condition(self) -> str: """Return the weather condition.""" - return [ - k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v - ][0] + return CONDITION_MAP[self._condition.lower()] async def async_forecast_daily(self) -> list[Forecast]: """Return the daily forecast.""" diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py index aba30013746..8449b68b460 100644 --- a/homeassistant/components/kitchen_sink/weather.py +++ b/homeassistant/components/kitchen_sink/weather.py @@ -45,6 +45,11 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} async def async_setup_entry( @@ -352,9 +357,7 @@ class DemoWeather(WeatherEntity): @property def condition(self) -> str: """Return the weather condition.""" - return [ - k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v - ][0] + return CONDITION_MAP[self._condition.lower()] @property def forecast(self) -> list[Forecast]: From 992cc56c7ea2a762006b94b1a5315b170ec1a50c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Aug 2023 22:19:22 +0200 Subject: [PATCH 125/180] Modernize buienradar weather (#98473) --- homeassistant/components/buienradar/weather.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 66c3b23ec8b..de00faadd64 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -34,7 +34,9 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -125,6 +127,7 @@ class BrWeather(WeatherEntity): _attr_native_visibility_unit = UnitOfLength.METERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND _attr_should_poll = False + _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY def __init__(self, config, coordinates): """Initialize the platform with a data instance and station name.""" @@ -154,6 +157,10 @@ class BrWeather(WeatherEntity): if not self.hass: return self.async_write_ha_state() + assert self.platform.config_entry + self.platform.config_entry.async_create_task( + self.hass, self.async_update_listeners(("daily",)) + ) def _calc_condition(self, data: BrData): """Return the current condition.""" @@ -185,3 +192,7 @@ class BrWeather(WeatherEntity): fcdata_out.append(data_out) return fcdata_out + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self._attr_forecast From a776ecddb72e7451a8095ed0aa0a9de1ab4c0bec Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 17 Aug 2023 01:44:02 +0200 Subject: [PATCH 126/180] Update mypy to 1.5.1 (#98554) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index acb70d5fb8c..de135e4a997 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==2.15.4 coverage==7.3.0 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.5.0 +mypy==1.5.1 pre-commit==3.3.3 pydantic==1.10.12 pylint==2.17.4 From 52a8f0109620029f6876a07a276101f3652c9fb7 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 16 Aug 2023 21:15:35 -0400 Subject: [PATCH 127/180] Make IKEA fan sensors diagnostic in ZHA (#97747) --- homeassistant/components/zha/binary_sensor.py | 1 + homeassistant/components/zha/sensor.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 48fbf1f0bb2..50cfb783370 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -265,6 +265,7 @@ class ReplaceFilter(BinarySensor, id_suffix="replace_filter"): SENSOR_ATTR = "replace_filter" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_name: str = "Replace filter" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 49ba46038f9..0e520d98b52 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -968,6 +968,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): _attr_icon = "mdi:timer" _attr_name: str = "Device run time" _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") @@ -980,6 +981,7 @@ class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): _attr_icon = "mdi:timer" _attr_name: str = "Filter run time" _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC class AqaraFeedingSource(types.enum8): From fde498586ed95ac987e85d169adc78b6f4ccef7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Thu, 17 Aug 2023 08:45:23 +0300 Subject: [PATCH 128/180] Expose dew point in Met.no (#98543) --- homeassistant/components/met/const.py | 2 ++ homeassistant/components/met/manifest.json | 2 +- homeassistant/components/met/weather.py | 8 ++++++++ homeassistant/components/norway_air/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/met/conftest.py | 1 + 7 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index dcc493570ba..b690f1b6723 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -22,6 +22,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, @@ -199,4 +200,5 @@ ATTR_MAP = { ATTR_WEATHER_WIND_SPEED: "wind_speed", ATTR_WEATHER_WIND_GUST_SPEED: "wind_gust", ATTR_WEATHER_CLOUD_COVERAGE: "cloudiness", + ATTR_WEATHER_DEW_POINT: "dew_point", } diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index 5c476b10665..d6466bb64c4 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["PyMetno==0.10.0"] + "requirements": ["PyMetno==0.11.0"] } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 500cb3c5716..2fcde1e05f0 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -8,6 +8,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TIME, ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, @@ -202,6 +203,13 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ATTR_MAP[ATTR_WEATHER_CLOUD_COVERAGE] ) + @property + def native_dew_point(self) -> float | None: + """Return the dew point.""" + return self.coordinator.data.current_weather_data.get( + ATTR_MAP[ATTR_WEATHER_DEW_POINT] + ) + def _forecast(self, hourly: bool) -> list[Forecast] | None: """Return the forecast array.""" if hourly: diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index 4a3fc7cee96..84af1313cf5 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/norway_air", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["PyMetno==0.10.0"] + "requirements": ["PyMetno==0.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ec345859233..37f4404f48c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -73,7 +73,7 @@ PyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -PyMetno==0.10.0 +PyMetno==0.11.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d65f0676a65..c5164ac72f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -63,7 +63,7 @@ PyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -PyMetno==0.10.0 +PyMetno==0.11.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.9 diff --git a/tests/components/met/conftest.py b/tests/components/met/conftest.py index e6b975023d1..a007620988f 100644 --- a/tests/components/met/conftest.py +++ b/tests/components/met/conftest.py @@ -17,6 +17,7 @@ def mock_weather(): "humidity": 50, "wind_speed": 10, "wind_bearing": "NE", + "dew_point": 12.1, } mock_data.get_forecast.return_value = {} yield mock_data From 6faa9abc756344a67732979ce3cf5bedeefc7f70 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 17 Aug 2023 08:51:59 +0200 Subject: [PATCH 129/180] Fix Verisure config entry migration (#98546) --- homeassistant/components/verisure/__init__.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 302bd23b66f..62f41913862 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -83,21 +83,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: - config_entry_default_code = entry.options.get(CONF_LOCK_DEFAULT_CODE) - entity_reg = er.async_get(hass) - entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) - for entity in entries: - if entity.entity_id.startswith("lock"): - entity_reg.async_update_entity_options( - entity.entity_id, - LOCK_DOMAIN, - {CONF_DEFAULT_CODE: config_entry_default_code}, - ) - new_options = entry.options.copy() - del new_options[CONF_LOCK_DEFAULT_CODE] + if config_entry_default_code := entry.options.get(CONF_LOCK_DEFAULT_CODE): + entity_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) + for entity in entries: + if entity.entity_id.startswith("lock"): + entity_reg.async_update_entity_options( + entity.entity_id, + LOCK_DOMAIN, + {CONF_DEFAULT_CODE: config_entry_default_code}, + ) + new_options = entry.options.copy() + del new_options[CONF_LOCK_DEFAULT_CODE] + + hass.config_entries.async_update_entry(entry, options=new_options) entry.version = 2 - hass.config_entries.async_update_entry(entry, options=new_options) LOGGER.info("Migration to version %s successful", entry.version) From 8b4937f627126f5b6a16776e6def1a5070124641 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 17 Aug 2023 10:24:58 +0200 Subject: [PATCH 130/180] Bump odp-amsterdam to v5.3.0 (#98555) * Bump package to v5.3.0 * Load only the garages for cars --- homeassistant/components/garages_amsterdam/__init__.py | 2 +- homeassistant/components/garages_amsterdam/config_flow.py | 2 +- homeassistant/components/garages_amsterdam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index 2af4227391b..35d177b2cca 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -45,7 +45,7 @@ async def get_coordinator( garage.garage_name: garage for garage in await ODPAmsterdam( session=aiohttp_client.async_get_clientsession(hass) - ).all_garages() + ).all_garages(vehicle="car") } coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/garages_amsterdam/config_flow.py b/homeassistant/components/garages_amsterdam/config_flow.py index cd1591c9bc0..7799630ddee 100644 --- a/homeassistant/components/garages_amsterdam/config_flow.py +++ b/homeassistant/components/garages_amsterdam/config_flow.py @@ -32,7 +32,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: api_data = await ODPAmsterdam( session=aiohttp_client.async_get_clientsession(self.hass) - ).all_garages() + ).all_garages(vehicle="car") except ClientResponseError: _LOGGER.error("Unexpected response from server") return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index e2f068b961c..e67bdaa04d0 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==5.1.0"] + "requirements": ["odp-amsterdam==5.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 37f4404f48c..23e85a5bd25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1314,7 +1314,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.1.0 +odp-amsterdam==5.3.0 # homeassistant.components.oem oemthermostat==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c5164ac72f3..d1234e1e1a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1004,7 +1004,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.1.0 +odp-amsterdam==5.3.0 # homeassistant.components.omnilogic omnilogic==0.4.5 From 1954539e6534252c146295923782bd16b40bec59 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 17 Aug 2023 11:13:11 +0200 Subject: [PATCH 131/180] Add state_class to tomorrowio UV Index (#98541) * Added state_class to UV Index Forgot to add a state_class as other sensors got their state_class from their device class. As there is no UV Index device class I left it out. * Forgotten a comma, whoops * Changed measurement to string. * Changed from "measurement" to SensorStateClass --- homeassistant/components/tomorrowio/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index aba5b44f284..7ccb4f673cd 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -295,6 +296,7 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_UV_INDEX, name="UV Index", + state_class=SensorStateClass.MEASUREMENT, icon="mdi:sun-wireless", ), TomorrowioSensorEntityDescription( From d6a7127b845e420280971082cbdb655d64ed1c76 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 17 Aug 2023 10:15:36 +0000 Subject: [PATCH 132/180] Improve availability of Tractive entities (#97091) Co-authored-by: Robert Resch --- homeassistant/components/tractive/__init__.py | 10 +- .../components/tractive/binary_sensor.py | 57 +++----- .../components/tractive/device_tracker.py | 24 ++-- homeassistant/components/tractive/entity.py | 49 ++++++- homeassistant/components/tractive/sensor.py | 127 ++++-------------- homeassistant/components/tractive/switch.py | 50 ++----- 6 files changed, 123 insertions(+), 194 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 351b39f61e7..e08ea954e21 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -89,7 +89,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from error tractive = TractiveClient(hass, client, creds["user_id"], entry) - tractive.subscribe() try: trackable_objects = await client.trackable_objects() @@ -97,7 +96,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: *(_generate_trackables(client, item) for item in trackable_objects) ) except aiotractive.exceptions.TractiveError as error: - await tractive.unsubscribe() raise ConfigEntryNotReady from error # When the pet defined in Tractive has no tracker linked we get None as `trackable`. @@ -173,6 +171,14 @@ class TractiveClient: """Return user id.""" return self._user_id + @property + def subscribed(self) -> bool: + """Return True if subscribed.""" + if self._listen_task is None: + return False + + return not self._listen_task.cancelled() + async def trackable_objects( self, ) -> list[aiotractive.trackable_object.TrackableObject]: diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index d7968f15bf8..940ff82687e 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -11,17 +11,10 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Trackables -from .const import ( - CLIENT, - DOMAIN, - SERVER_UNAVAILABLE, - TRACKABLES, - TRACKER_HARDWARE_STATUS_UPDATED, -) +from . import Trackables, TractiveClient +from .const import CLIENT, DOMAIN, TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED from .entity import TractiveEntity @@ -29,45 +22,29 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): """Tractive sensor.""" def __init__( - self, user_id: str, item: Trackables, description: BinarySensorEntityDescription + self, + client: TractiveClient, + item: Trackables, + description: BinarySensorEntityDescription, ) -> None: """Initialize sensor entity.""" - super().__init__(user_id, item.trackable, item.tracker_details) + super().__init__( + client, + item.trackable, + item.tracker_details, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{item.tracker_details['_id']}", + ) self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" + self._attr_available = False self.entity_description = description @callback - def handle_server_unavailable(self) -> None: - """Handle server unavailable.""" - self._attr_available = False - self.async_write_ha_state() - - @callback - def handle_hardware_status_update(self, event: dict[str, Any]) -> None: - """Handle hardware status update.""" + def handle_status_update(self, event: dict[str, Any]) -> None: + """Handle status update.""" self._attr_is_on = event[self.entity_description.key] - self._attr_available = True - self.async_write_ha_state() - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", - self.handle_hardware_status_update, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) + super().handle_status_update(event) SENSOR_TYPE = BinarySensorEntityDescription( @@ -86,7 +63,7 @@ async def async_setup_entry( trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] entities = [ - TractiveBinarySensor(client.user_id, item, SENSOR_TYPE) + TractiveBinarySensor(client, item, SENSOR_TYPE) for item in trackables if item.tracker_details.get("charging_state") is not None ] diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index a97ea963362..0e373e1a44f 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Trackables +from . import Trackables, TractiveClient from .const import ( CLIENT, DOMAIN, @@ -28,7 +28,7 @@ async def async_setup_entry( client = hass.data[DOMAIN][entry.entry_id][CLIENT] trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] - entities = [TractiveDeviceTracker(client.user_id, item) for item in trackables] + entities = [TractiveDeviceTracker(client, item) for item in trackables] async_add_entities(entities) @@ -39,9 +39,14 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): _attr_icon = "mdi:paw" _attr_translation_key = "tracker" - def __init__(self, user_id: str, item: Trackables) -> None: + def __init__(self, client: TractiveClient, item: Trackables) -> None: """Initialize tracker entity.""" - super().__init__(user_id, item.trackable, item.tracker_details) + super().__init__( + client, + item.trackable, + item.tracker_details, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{item.tracker_details['_id']}", + ) self._battery_level: int | None = item.hw_info.get("battery_level") self._latitude: float = item.pos_report["latlong"][0] @@ -94,18 +99,15 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): self._attr_available = True self.async_write_ha_state() - @callback - def _handle_server_unavailable(self) -> None: - self._attr_available = False - self.async_write_ha_state() - async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" + if not self._client.subscribed: + self._client.subscribe() self.async_on_remove( async_dispatcher_connect( self.hass, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", + self._dispatcher_signal, self._handle_hardware_status_update, ) ) @@ -122,6 +124,6 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): async_dispatcher_connect( self.hass, f"{SERVER_UNAVAILABLE}-{self._user_id}", - self._handle_server_unavailable, + self.handle_server_unavailable, ) ) diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py index d142fe69db5..da7beb8bcdd 100644 --- a/homeassistant/components/tractive/entity.py +++ b/homeassistant/components/tractive/entity.py @@ -3,10 +3,13 @@ from __future__ import annotations from typing import Any +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from . import TractiveClient +from .const import DOMAIN, SERVER_UNAVAILABLE class TractiveEntity(Entity): @@ -15,7 +18,11 @@ class TractiveEntity(Entity): _attr_has_entity_name = True def __init__( - self, user_id: str, trackable: dict[str, Any], tracker_details: dict[str, Any] + self, + client: TractiveClient, + trackable: dict[str, Any], + tracker_details: dict[str, Any], + dispatcher_signal: str, ) -> None: """Initialize tracker entity.""" self._attr_device_info = DeviceInfo( @@ -26,6 +33,40 @@ class TractiveEntity(Entity): sw_version=tracker_details["fw_version"], model=tracker_details["model_number"], ) - self._user_id = user_id + self._user_id = client.user_id self._tracker_id = tracker_details["_id"] - self._trackable = trackable + self._client = client + self._dispatcher_signal = dispatcher_signal + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + if not self._client.subscribed: + self._client.subscribe() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._dispatcher_signal, + self.handle_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + @callback + def handle_status_update(self, event: dict[str, Any]) -> None: + """Handle status update.""" + self._attr_available = event[self.entity_description.key] is not None + self.async_write_ha_state() + + @callback + def handle_server_unavailable(self) -> None: + """Handle server unavailable.""" + self._attr_available = False + self.async_write_ha_state() diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index 493b627f9b4..6891b74d31b 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -18,10 +18,9 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Trackables +from . import Trackables, TractiveClient from .const import ( ATTR_CALORIES, ATTR_DAILY_GOAL, @@ -32,7 +31,6 @@ from .const import ( ATTR_TRACKER_STATE, CLIENT, DOMAIN, - SERVER_UNAVAILABLE, TRACKABLES, TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, @@ -45,7 +43,7 @@ from .entity import TractiveEntity class TractiveRequiredKeysMixin: """Mixin for required keys.""" - entity_class: type[TractiveSensor] + signal_prefix: str @dataclass @@ -54,112 +52,39 @@ class TractiveSensorEntityDescription( ): """Class describing Tractive sensor entities.""" + hardware_sensor: bool = False + class TractiveSensor(TractiveEntity, SensorEntity): """Tractive sensor.""" def __init__( self, - user_id: str, + client: TractiveClient, item: Trackables, description: TractiveSensorEntityDescription, ) -> None: """Initialize sensor entity.""" - super().__init__(user_id, item.trackable, item.tracker_details) + if description.hardware_sensor: + dispatcher_signal = ( + f"{description.signal_prefix}-{item.tracker_details['_id']}" + ) + else: + dispatcher_signal = f"{description.signal_prefix}-{item.trackable['_id']}" + super().__init__( + client, item.trackable, item.tracker_details, dispatcher_signal + ) self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" - self.entity_description = description - - @callback - def handle_server_unavailable(self) -> None: - """Handle server unavailable.""" self._attr_available = False - self.async_write_ha_state() - - -class TractiveHardwareSensor(TractiveSensor): - """Tractive hardware sensor.""" - - @callback - def handle_hardware_status_update(self, event: dict[str, Any]) -> None: - """Handle hardware status update.""" - if (_state := event[self.entity_description.key]) is None: - return - self._attr_native_value = _state - self._attr_available = True - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", - self.handle_hardware_status_update, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) - - -class TractiveActivitySensor(TractiveSensor): - """Tractive active sensor.""" + self.entity_description = description @callback def handle_status_update(self, event: dict[str, Any]) -> None: """Handle status update.""" self._attr_native_value = event[self.entity_description.key] - self._attr_available = True - self.async_write_ha_state() - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_ACTIVITY_STATUS_UPDATED}-{self._trackable['_id']}", - self.handle_status_update, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) - - -class TractiveWellnessSensor(TractiveActivitySensor): - """Tractive wellness sensor.""" - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_WELLNESS_STATUS_UPDATED}-{self._trackable['_id']}", - self.handle_status_update, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) + super().handle_status_update(event) SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( @@ -168,13 +93,15 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="tracker_battery_level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, - entity_class=TractiveHardwareSensor, + signal_prefix=TRACKER_HARDWARE_STATUS_UPDATED, + hardware_sensor=True, entity_category=EntityCategory.DIAGNOSTIC, ), TractiveSensorEntityDescription( key=ATTR_TRACKER_STATE, translation_key="tracker_state", - entity_class=TractiveHardwareSensor, + signal_prefix=TRACKER_HARDWARE_STATUS_UPDATED, + hardware_sensor=True, icon="mdi:radar", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, @@ -190,7 +117,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="activity_time", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveActivitySensor, + signal_prefix=TRACKER_ACTIVITY_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( @@ -198,7 +125,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="rest_time", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveWellnessSensor, + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( @@ -206,7 +133,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="calories", icon="mdi:fire", native_unit_of_measurement="kcal", - entity_class=TractiveWellnessSensor, + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( @@ -214,14 +141,14 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="daily_goal", icon="mdi:flag-checkered", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveActivitySensor, + signal_prefix=TRACKER_ACTIVITY_STATUS_UPDATED, ), TractiveSensorEntityDescription( key=ATTR_MINUTES_DAY_SLEEP, translation_key="minutes_day_sleep", icon="mdi:sleep", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveWellnessSensor, + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( @@ -229,7 +156,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="minutes_night_sleep", icon="mdi:sleep", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveWellnessSensor, + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), ) @@ -243,7 +170,7 @@ async def async_setup_entry( trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] entities = [ - description.entity_class(client.user_id, item, description) + TractiveSensor(client, item, description) for description in SENSOR_TYPES for item in trackables ] diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 6d8274df253..55acdb9bdcd 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -11,17 +11,15 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Trackables +from . import Trackables, TractiveClient from .const import ( ATTR_BUZZER, ATTR_LED, ATTR_LIVE_TRACKING, CLIENT, DOMAIN, - SERVER_UNAVAILABLE, TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED, ) @@ -77,7 +75,7 @@ async def async_setup_entry( trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] entities = [ - TractiveSwitch(client.user_id, item, description) + TractiveSwitch(client, item, description) for description in SWITCH_TYPES for item in trackables ] @@ -92,12 +90,17 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): def __init__( self, - user_id: str, + client: TractiveClient, item: Trackables, description: TractiveSwitchEntityDescription, ) -> None: """Initialize switch entity.""" - super().__init__(user_id, item.trackable, item.tracker_details) + super().__init__( + client, + item.trackable, + item.tracker_details, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{item.tracker_details['_id']}", + ) self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" self._attr_available = False @@ -106,38 +109,11 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): self.entity_description = description @callback - def handle_server_unavailable(self) -> None: - """Handle server unavailable.""" - self._attr_available = False - self.async_write_ha_state() + def handle_status_update(self, event: dict[str, Any]) -> None: + """Handle status update.""" + self._attr_is_on = event[self.entity_description.key] - @callback - def handle_hardware_status_update(self, event: dict[str, Any]) -> None: - """Handle hardware status update.""" - if (state := event[self.entity_description.key]) is None: - return - self._attr_is_on = state - self._attr_available = True - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", - self.handle_hardware_status_update, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) + super().handle_status_update(event) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on a switch.""" From cb4917f8805efd9c75f245dc22e29b240d8ba559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Thu, 17 Aug 2023 15:12:35 +0200 Subject: [PATCH 133/180] Fix GoGoGate2 configuration URL when remote access is disabled (#98387) --- homeassistant/components/gogogate2/common.py | 7 ++++--- tests/components/gogogate2/__init__.py | 2 +- tests/components/gogogate2/test_cover.py | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 4a811373cb1..ba1426e1201 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -113,9 +113,10 @@ class GoGoGate2Entity(CoordinatorEntity[DeviceDataUpdateCoordinator]): def device_info(self) -> DeviceInfo: """Device info for the controller.""" data = self.coordinator.data - configuration_url = ( - f"https://{data.remoteaccess}" if data.remoteaccess else None - ) + if data.remoteaccessenabled: + configuration_url = f"https://{data.remoteaccess}" + else: + configuration_url = f"http://{self._config_entry.data[CONF_IP_ADDRESS]}" return DeviceInfo( configuration_url=configuration_url, identifiers={(DOMAIN, str(self._config_entry.unique_id))}, diff --git a/tests/components/gogogate2/__init__.py b/tests/components/gogogate2/__init__.py index f7e3d40a44b..08675c58709 100644 --- a/tests/components/gogogate2/__init__.py +++ b/tests/components/gogogate2/__init__.py @@ -77,7 +77,7 @@ def _mocked_ismartgate_closed_door_response(): ismartgatename="ismartgatename0", model="ismartgatePRO", apiversion="", - remoteaccessenabled=False, + remoteaccessenabled=True, remoteaccess="abc321.blah.blah", firmwareversion="555", pin=123, diff --git a/tests/components/gogogate2/test_cover.py b/tests/components/gogogate2/test_cover.py index 00cc0057d7c..ca6509d53b9 100644 --- a/tests/components/gogogate2/test_cover.py +++ b/tests/components/gogogate2/test_cover.py @@ -340,6 +340,7 @@ async def test_device_info_ismartgate( assert device.name == "mycontroller" assert device.model == "ismartgatePRO" assert device.sw_version == "555" + assert device.configuration_url == "https://abc321.blah.blah" @patch("homeassistant.components.gogogate2.common.GogoGate2Api") @@ -375,3 +376,4 @@ async def test_device_info_gogogate2( assert device.name == "mycontroller" assert device.model == "gogogate2" assert device.sw_version == "222" + assert device.configuration_url == "http://127.0.0.1" From ea5272ba62f8457d6903481c869fa6a770b66170 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 17 Aug 2023 15:44:23 +0200 Subject: [PATCH 134/180] Revert "Fix fanSpeed issue in Tado" (#98506) Revert "Fix fanSpeed issue (#98293)" This reverts commit d6498aa39e40f5aa34707533d440736f7b19b963. --- homeassistant/components/tado/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 0ef6dc17934..b57d384124c 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -327,7 +327,7 @@ class TadoConnector: device_type, "ON", mode, - fan_speed=fan_speed, + fanSpeed=fan_speed, swing=swing, ) From 6f4294dc62019b928868a49620cacf1a7c3821b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Aug 2023 16:02:22 +0200 Subject: [PATCH 135/180] Migrate IPMA to has entity name (#98572) * Migrate IPMA to has entity name * Migrate IPMA to has entity name --- homeassistant/components/ipma/entity.py | 12 +++++++++--- homeassistant/components/ipma/sensor.py | 3 +-- homeassistant/components/ipma/weather.py | 16 ++++++++-------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/ipma/entity.py b/homeassistant/components/ipma/entity.py index 6424084c533..7eb8e2fe1a7 100644 --- a/homeassistant/components/ipma/entity.py +++ b/homeassistant/components/ipma/entity.py @@ -1,6 +1,9 @@ """Base Entity for IPMA.""" from __future__ import annotations +from pyipma.api import IPMA_API +from pyipma.location import Location + from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity @@ -10,17 +13,20 @@ from .const import DOMAIN class IPMADevice(Entity): """Common IPMA Device Information.""" - def __init__(self, location) -> None: + _attr_has_entity_name = True + + def __init__(self, api: IPMA_API, location: Location) -> None: """Initialize device information.""" + self._api = api self._location = location self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={ ( DOMAIN, - f"{self._location.station_latitude}, {self._location.station_longitude}", + f"{location.station_latitude}, {location.station_longitude}", ) }, manufacturer=DOMAIN, - name=self._location.name, + name=location.name, ) diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 1bd257a3994..7f5782f3f89 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -75,9 +75,8 @@ class IPMASensor(SensorEntity, IPMADevice): description: IPMASensorEntityDescription, ) -> None: """Initialize the IPMA Sensor.""" - IPMADevice.__init__(self, location) + IPMADevice.__init__(self, api, location) self.entity_description = description - self._api = api self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self.entity_description.key}" @Throttle(MIN_TIME_BETWEEN_UPDATES) diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 1f948bcc4e1..a5bb3981575 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -25,7 +25,6 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_MODE, - CONF_NAME, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, @@ -56,13 +55,14 @@ async def async_setup_entry( """Add a weather entity from a config_entry.""" api = hass.data[DOMAIN][config_entry.entry_id][DATA_API] location = hass.data[DOMAIN][config_entry.entry_id][DATA_LOCATION] - async_add_entities([IPMAWeather(location, api, config_entry.data)], True) + async_add_entities([IPMAWeather(api, location, config_entry)], True) class IPMAWeather(WeatherEntity, IPMADevice): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION + _attr_name = None _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR @@ -70,13 +70,13 @@ class IPMAWeather(WeatherEntity, IPMADevice): WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__(self, location: Location, api: IPMA_API, config) -> None: + def __init__( + self, api: IPMA_API, location: Location, config_entry: ConfigEntry + ) -> None: """Initialise the platform with a data instance and station name.""" - IPMADevice.__init__(self, location) - self._api = api - self._attr_name = config.get(CONF_NAME, location.name) - self._mode = config.get(CONF_MODE) - self._period = 1 if config.get(CONF_MODE) == "hourly" else 24 + IPMADevice.__init__(self, api, location) + self._mode = config_entry.data.get(CONF_MODE) + self._period = 1 if config_entry.data.get(CONF_MODE) == "hourly" else 24 self._observation = None self._daily_forecast: list[IPMAForecast] | None = None self._hourly_forecast: list[IPMAForecast] | None = None From 2d4decc9b1626416fcaaca6bbe9b98b5631ed9cf Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 17 Aug 2023 16:16:47 +0200 Subject: [PATCH 136/180] Revert "Integration tado bump" (#98505) Revert "Integration tado bump (#97791)" This reverts commit 65365d1db57a5e8cdf58d925c6e52871eb75f6be. --- homeassistant/components/tado/__init__.py | 39 +++++++++++---------- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index b57d384124c..1cd21634c8e 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -163,11 +163,12 @@ class TadoConnector: def setup(self): """Connect to Tado and fetch the zones.""" - self.tado = Tado(self._username, self._password, None, True) + self.tado = Tado(self._username, self._password) + self.tado.setDebugging(True) # Load zones and devices - self.zones = self.tado.get_zones() - self.devices = self.tado.get_devices() - tado_home = self.tado.get_me()["homes"][0] + self.zones = self.tado.getZones() + self.devices = self.tado.getDevices() + tado_home = self.tado.getMe()["homes"][0] self.home_id = tado_home["id"] self.home_name = tado_home["name"] @@ -180,7 +181,7 @@ class TadoConnector: def update_devices(self): """Update the device data from Tado.""" - devices = self.tado.get_devices() + devices = self.tado.getDevices() for device in devices: device_short_serial_no = device["shortSerialNo"] _LOGGER.debug("Updating device %s", device_short_serial_no) @@ -189,7 +190,7 @@ class TadoConnector: INSIDE_TEMPERATURE_MEASUREMENT in device["characteristics"]["capabilities"] ): - device[TEMP_OFFSET] = self.tado.get_device_info( + device[TEMP_OFFSET] = self.tado.getDeviceInfo( device_short_serial_no, TEMP_OFFSET ) except RuntimeError: @@ -217,7 +218,7 @@ class TadoConnector: def update_zones(self): """Update the zone data from Tado.""" try: - zone_states = self.tado.get_zone_states()["zoneStates"] + zone_states = self.tado.getZoneStates()["zoneStates"] except RuntimeError: _LOGGER.error("Unable to connect to Tado while updating zones") return @@ -229,7 +230,7 @@ class TadoConnector: """Update the internal data from Tado.""" _LOGGER.debug("Updating zone %s", zone_id) try: - data = self.tado.get_zone_state(zone_id) + data = self.tado.getZoneState(zone_id) except RuntimeError: _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id) return @@ -250,8 +251,8 @@ class TadoConnector: def update_home(self): """Update the home data from Tado.""" try: - self.data["weather"] = self.tado.get_weather() - self.data["geofence"] = self.tado.get_home_state() + self.data["weather"] = self.tado.getWeather() + self.data["geofence"] = self.tado.getHomeState() dispatcher_send( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), @@ -264,15 +265,15 @@ class TadoConnector: def get_capabilities(self, zone_id): """Return the capabilities of the devices.""" - return self.tado.get_capabilities(zone_id) + return self.tado.getCapabilities(zone_id) def get_auto_geofencing_supported(self): """Return whether the Tado Home supports auto geofencing.""" - return self.tado.get_auto_geofencing_supported() + return self.tado.getAutoGeofencingSupported() def reset_zone_overlay(self, zone_id): """Reset the zone back to the default operation.""" - self.tado.reset_zone_overlay(zone_id) + self.tado.resetZoneOverlay(zone_id) self.update_zone(zone_id) def set_presence( @@ -281,11 +282,11 @@ class TadoConnector: ): """Set the presence to home, away or auto.""" if presence == PRESET_AWAY: - self.tado.set_away() + self.tado.setAway() elif presence == PRESET_HOME: - self.tado.set_home() + self.tado.setHome() elif presence == PRESET_AUTO: - self.tado.set_auto() + self.tado.setAuto() # Update everything when changing modes self.update_zones() @@ -319,7 +320,7 @@ class TadoConnector: ) try: - self.tado.set_zone_overlay( + self.tado.setZoneOverlay( zone_id, overlay_mode, temperature, @@ -339,7 +340,7 @@ class TadoConnector: def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): """Set a zone to off.""" try: - self.tado.set_zone_overlay( + self.tado.setZoneOverlay( zone_id, overlay_mode, None, None, device_type, "OFF" ) except RequestException as exc: @@ -350,6 +351,6 @@ class TadoConnector: def set_temperature_offset(self, device_id, offset): """Set temperature offset of device.""" try: - self.tado.set_temp_offset(device_id, offset) + self.tado.setTempOffset(device_id, offset) except RequestException as exc: _LOGGER.error("Could not set temperature offset: %s", exc) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index bea608514bd..62f7a377239 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.16.0"] + "requirements": ["python-tado==0.15.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23e85a5bd25..d91e6c58b0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2162,7 +2162,7 @@ python-smarttub==0.0.33 python-songpal==0.15.2 # homeassistant.components.tado -python-tado==0.16.0 +python-tado==0.15.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1234e1e1a9..ccf963c1b3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1588,7 +1588,7 @@ python-smarttub==0.0.33 python-songpal==0.15.2 # homeassistant.components.tado -python-tado==0.16.0 +python-tado==0.15.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 From 30a88e9e61201368dec3f41c238cad096a8f60d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Aug 2023 09:37:54 -0500 Subject: [PATCH 137/180] Additional doorbird cleanups to prepare for event entities (#98542) --- homeassistant/components/doorbird/__init__.py | 113 +++++++----------- homeassistant/components/doorbird/button.py | 25 ++-- homeassistant/components/doorbird/camera.py | 67 ++++------- .../components/doorbird/config_flow.py | 11 +- homeassistant/components/doorbird/device.py | 26 ++-- homeassistant/components/doorbird/entity.py | 24 ++-- homeassistant/components/doorbird/logbook.py | 26 ++-- homeassistant/components/doorbird/models.py | 26 ++++ homeassistant/components/doorbird/util.py | 49 ++------ 9 files changed, 158 insertions(+), 209 deletions(-) create mode 100644 homeassistant/components/doorbird/models.py diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 8651f7de6de..d1ad91bbb2c 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -14,30 +14,21 @@ from homeassistant.components import persistent_notification from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import ( - API_URL, - CONF_EVENTS, - DOMAIN, - DOOR_STATION, - DOOR_STATION_EVENT_ENTITY_IDS, - DOOR_STATION_INFO, - PLATFORMS, - UNDO_UPDATE_LISTENER, -) +from .const import API_URL, CONF_EVENTS, DOMAIN, PLATFORMS from .device import ConfiguredDoorBird -from .util import get_doorstation_by_token +from .models import DoorBirdData +from .util import get_door_station_by_token _LOGGER = logging.getLogger(__name__) @@ -64,26 +55,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the DoorBird component.""" hass.data.setdefault(DOMAIN, {}) - # Provide an endpoint for the doorstations to call to trigger events + # Provide an endpoint for the door stations to call to trigger events hass.http.register_view(DoorBirdRequestView) - def _reset_device_favorites_handler(event): + def _reset_device_favorites_handler(event: Event) -> None: """Handle clearing favorites on device.""" if (token := event.data.get("token")) is None: return - doorstation = get_doorstation_by_token(hass, token) + door_station = get_door_station_by_token(hass, token) - if doorstation is None: + if door_station is None: _LOGGER.error("Device not found for provided token") return # Clear webhooks - favorites = doorstation.device.favorites() - - for favorite_type in favorites: - for favorite_id in favorites[favorite_type]: - doorstation.device.delete_favorite(favorite_type, favorite_id) + favorites: dict[str, list[str]] = door_station.device.favorites() + for favorite_type, favorite_ids in favorites.items(): + for favorite_id in favorite_ids: + door_station.device.delete_favorite(favorite_type, favorite_id) hass.bus.async_listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler) @@ -95,17 +85,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _async_import_options_from_data_if_missing(hass, entry) - doorstation_config = entry.data - doorstation_options = entry.options + door_station_config = entry.data config_entry_id = entry.entry_id - device_ip = doorstation_config[CONF_HOST] - username = doorstation_config[CONF_USERNAME] - password = doorstation_config[CONF_PASSWORD] + device_ip = door_station_config[CONF_HOST] + username = door_station_config[CONF_USERNAME] + password = door_station_config[CONF_PASSWORD] device = DoorBird(device_ip, username, password) try: - status, info = await hass.async_add_executor_job(_init_doorbird_device, device) + status, info = await hass.async_add_executor_job(_init_door_bird_device, device) except requests.exceptions.HTTPError as err: if err.response.status_code == HTTPStatus.UNAUTHORIZED: _LOGGER.error( @@ -126,50 +115,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) raise ConfigEntryNotReady - token: str = doorstation_config.get(CONF_TOKEN, config_entry_id) - custom_url: str | None = doorstation_config.get(CONF_CUSTOM_URL) - name: str | None = doorstation_config.get(CONF_NAME) - events = doorstation_options.get(CONF_EVENTS, []) - doorstation = ConfiguredDoorBird(device, name, custom_url, token) - doorstation.update_events(events) + token: str = door_station_config.get(CONF_TOKEN, config_entry_id) + custom_url: str | None = door_station_config.get(CONF_CUSTOM_URL) + name: str | None = door_station_config.get(CONF_NAME) + events = entry.options.get(CONF_EVENTS, []) + event_entity_ids: dict[str, str] = {} + door_station = ConfiguredDoorBird(device, name, custom_url, token, event_entity_ids) + door_bird_data = DoorBirdData(door_station, info, event_entity_ids) + door_station.update_events(events) # Subscribe to doorbell or motion events - if not await _async_register_events(hass, doorstation): + if not await _async_register_events(hass, door_station): raise ConfigEntryNotReady - undo_listener = entry.add_update_listener(_update_listener) - - hass.data[DOMAIN][config_entry_id] = { - DOOR_STATION: doorstation, - DOOR_STATION_INFO: info, - UNDO_UPDATE_LISTENER: undo_listener, - } - + entry.async_on_unload(entry.add_update_listener(_update_listener)) + hass.data[DOMAIN][config_entry_id] = door_bird_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -def _init_doorbird_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]: +def _init_door_bird_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]: + """Verify we can connect to the device and return the status.""" return device.ready(), device.info() async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - + data: dict[str, DoorBirdData] = hass.data[DOMAIN] + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + data.pop(entry.entry_id) return unload_ok async def _async_register_events( - hass: HomeAssistant, doorstation: ConfiguredDoorBird + hass: HomeAssistant, door_station: ConfiguredDoorBird ) -> bool: try: - await hass.async_add_executor_job(doorstation.register_events, hass) + await hass.async_add_executor_job(door_station.register_events, hass) except requests.exceptions.HTTPError: persistent_notification.async_create( hass, @@ -190,10 +172,11 @@ async def _async_register_events( async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" config_entry_id = entry.entry_id - doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION] - doorstation.update_events(entry.options[CONF_EVENTS]) + data: DoorBirdData = hass.data[DOMAIN][config_entry_id] + door_station = data.door_station + door_station.update_events(entry.options[CONF_EVENTS]) # Subscribe to doorbell or motion events - await _async_register_events(hass, doorstation) + await _async_register_events(hass, door_station) @callback @@ -217,21 +200,17 @@ class DoorBirdRequestView(HomeAssistantView): name = API_URL[1:].replace("/", ":") extra_urls = [API_URL + "/{event}"] - async def get(self, request, event): + async def get(self, request: web.Request, event: str) -> web.Response: """Respond to requests from the device.""" - hass = request.app["hass"] - - token = request.query.get("token") - - device = get_doorstation_by_token(hass, token) - - if device is None: + hass: HomeAssistant = request.app["hass"] + token: str | None = request.query.get("token") + if token is None or (device := get_door_station_by_token(hass, token)) is None: return web.Response( status=HTTPStatus.UNAUTHORIZED, text="Invalid token provided." ) if device: - event_data = device.get_event_data() + event_data = device.get_event_data(event) else: event_data = {} @@ -241,10 +220,6 @@ class DoorBirdRequestView(HomeAssistantView): message = f"HTTP Favorites cleared for {device.slug}" return web.Response(text=message) - event_data[ATTR_ENTITY_ID] = hass.data[DOMAIN][ - DOOR_STATION_EVENT_ENTITY_IDS - ].get(event) - hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) return web.Response(text="OK") diff --git a/homeassistant/components/doorbird/button.py b/homeassistant/components/doorbird/button.py index fb13a6f5be3..1c69429d3c7 100644 --- a/homeassistant/components/doorbird/button.py +++ b/homeassistant/components/doorbird/button.py @@ -10,8 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, DOOR_STATION, DOOR_STATION_INFO +from .const import DOMAIN from .entity import DoorBirdEntity +from .models import DoorBirdData IR_RELAY = "__ir_light__" @@ -49,20 +50,14 @@ async def async_setup_entry( ) -> None: """Set up the DoorBird button platform.""" config_entry_id = config_entry.entry_id - - data = hass.data[DOMAIN][config_entry_id] - doorstation = data[DOOR_STATION] - doorstation_info = data[DOOR_STATION_INFO] - - relays = doorstation_info["RELAYS"] + door_bird_data: DoorBirdData = hass.data[DOMAIN][config_entry_id] + relays = door_bird_data.door_station_info["RELAYS"] entities = [ - DoorBirdButton(doorstation, doorstation_info, relay, RELAY_ENTITY_DESCRIPTION) + DoorBirdButton(door_bird_data, relay, RELAY_ENTITY_DESCRIPTION) for relay in relays ] - entities.append( - DoorBirdButton(doorstation, doorstation_info, IR_RELAY, IR_ENTITY_DESCRIPTION) - ) + entities.append(DoorBirdButton(door_bird_data, IR_RELAY, IR_ENTITY_DESCRIPTION)) async_add_entities(entities) @@ -74,16 +69,14 @@ class DoorBirdButton(DoorBirdEntity, ButtonEntity): def __init__( self, - doorstation: DoorBird, - doorstation_info, + door_bird_data: DoorBirdData, relay: str, entity_description: DoorbirdButtonEntityDescription, ) -> None: """Initialize a relay in a DoorBird device.""" - super().__init__(doorstation, doorstation_info) + super().__init__(door_bird_data) self._relay = relay self.entity_description = entity_description - if self._relay == IR_RELAY: self._attr_name = "IR" else: @@ -92,4 +85,4 @@ class DoorBirdButton(DoorBirdEntity, ButtonEntity): def press(self) -> None: """Power the relay.""" - self.entity_description.press_action(self._doorstation.device, self._relay) + self.entity_description.press_action(self._door_station.device, self._relay) diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 63eb646972d..06bdb494463 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -14,13 +14,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import ( - DOMAIN, - DOOR_STATION, - DOOR_STATION_EVENT_ENTITY_IDS, - DOOR_STATION_INFO, -) +from .const import DOMAIN from .entity import DoorBirdEntity +from .models import DoorBirdData _LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=2) _LAST_MOTION_INTERVAL = datetime.timedelta(seconds=30) @@ -36,39 +32,31 @@ async def async_setup_entry( ) -> None: """Set up the DoorBird camera platform.""" config_entry_id = config_entry.entry_id - config_data = hass.data[DOMAIN][config_entry_id] - doorstation = config_data[DOOR_STATION] - doorstation_info = config_data[DOOR_STATION_INFO] - device = doorstation.device + door_bird_data: DoorBirdData = hass.data[DOMAIN][config_entry_id] + device = door_bird_data.door_station.device async_add_entities( [ DoorBirdCamera( - doorstation, - doorstation_info, + door_bird_data, device.live_image_url, "live", "live", - doorstation.doorstation_events, _LIVE_INTERVAL, device.rtsp_live_video_url, ), DoorBirdCamera( - doorstation, - doorstation_info, + door_bird_data, device.history_image_url(1, "doorbell"), "last_ring", "last_ring", - [], _LAST_VISITOR_INTERVAL, ), DoorBirdCamera( - doorstation, - doorstation_info, + door_bird_data, device.history_image_url(1, "motionsensor"), "last_motion", "last_motion", - [], _LAST_MOTION_INTERVAL, ), ] @@ -80,17 +68,15 @@ class DoorBirdCamera(DoorBirdEntity, Camera): def __init__( self, - doorstation, - doorstation_info, - url, - camera_id, - translation_key, - doorstation_events, - interval, - stream_url=None, + door_bird_data: DoorBirdData, + url: str, + camera_id: str, + translation_key: str, + interval: datetime.timedelta, + stream_url: str | None = None, ) -> None: """Initialize the camera on a DoorBird device.""" - super().__init__(doorstation, doorstation_info) + super().__init__(door_bird_data) self._url = url self._stream_url = stream_url self._attr_translation_key = translation_key @@ -100,7 +86,6 @@ class DoorBirdCamera(DoorBirdEntity, Camera): self._interval = interval self._last_update = datetime.datetime.min self._attr_unique_id = f"{self._mac_addr}_{camera_id}" - self._doorstation_events = doorstation_events async def stream_source(self): """Return the stream source.""" @@ -133,19 +118,15 @@ class DoorBirdCamera(DoorBirdEntity, Camera): return self._last_image async def async_added_to_hass(self) -> None: - """Add callback after being added to hass. - - Registers entity_id map for the logbook - """ - event_to_entity_id = self.hass.data[DOMAIN].setdefault( - DOOR_STATION_EVENT_ENTITY_IDS, {} - ) - for event in self._doorstation_events: + """Subscribe to events.""" + await super().async_added_to_hass() + event_to_entity_id = self._door_bird_data.event_entity_ids + for event in self._door_station.events: event_to_entity_id[event] = self.entity_id - async def will_remove_from_hass(self): - """Unregister entity_id map for the logbook.""" - event_to_entity_id = self.hass.data[DOMAIN][DOOR_STATION_EVENT_ENTITY_IDS] - for event in self._doorstation_events: - if event in event_to_entity_id: - del event_to_entity_id[event] + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from events.""" + event_to_entity_id = self._door_bird_data.event_entity_ids + for event in self._door_station.events: + del event_to_entity_id[event] + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 4ad5e24247e..d2197de93c9 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from http import HTTPStatus from ipaddress import ip_address import logging +from typing import Any from doorbirdpy import DoorBird import requests @@ -12,12 +13,12 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.util.network import is_ipv4_address, is_link_local from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI -from .util import get_mac_address_from_doorstation_info +from .util import get_mac_address_from_door_station_info _LOGGER = logging.getLogger(__name__) @@ -33,7 +34,7 @@ def _schema_with_defaults(host=None, name=None): ) -def _check_device(device): +def _check_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]: """Verify we can connect to the device and return the status.""" return device.ready(), device.info() @@ -53,13 +54,13 @@ async def validate_input(hass: core.HomeAssistant, data): if not status[0]: raise CannotConnect - mac_addr = get_mac_address_from_doorstation_info(info) + mac_addr = get_mac_address_from_door_station_info(info) # Return info that you want to store in the config entry. return {"title": data[CONF_HOST], "mac_addr": mac_addr} -async def async_verify_supported_device(hass, host): +async def async_verify_supported_device(hass: HomeAssistant, host: str) -> bool: """Verify the doorbell state endpoint returns a 401.""" device = DoorBird(host, "", "") try: diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py index 1c787feb934..aced0d8723f 100644 --- a/homeassistant/components/doorbird/device.py +++ b/homeassistant/components/doorbird/device.py @@ -6,6 +6,7 @@ from typing import Any from doorbirdpy import DoorBird +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.network import get_url from homeassistant.util import dt as dt_util, slugify @@ -19,20 +20,28 @@ class ConfiguredDoorBird: """Attach additional information to pass along with configured device.""" def __init__( - self, device: DoorBird, name: str | None, custom_url: str | None, token: str + self, + device: DoorBird, + name: str | None, + custom_url: str | None, + token: str, + event_entity_ids: dict[str, str], ) -> None: """Initialize configured device.""" self._name = name self._device = device self._custom_url = custom_url - self.events = None - self.doorstation_events = None self._token = token + self._event_entity_ids = event_entity_ids + self.events: list[str] = [] + self.door_station_events: list[str] = [] - def update_events(self, events): + def update_events(self, events: list[str]) -> None: """Update the doorbird events.""" self.events = events - self.doorstation_events = [self._get_event_name(event) for event in self.events] + self.door_station_events = [ + self._get_event_name(event) for event in self.events + ] @property def name(self) -> str | None: @@ -63,12 +72,12 @@ class ConfiguredDoorBird: if self.custom_url is not None: hass_url = self.custom_url - if not self.doorstation_events: + if not self.door_station_events: # User may not have permission to get the favorites return favorites = self.device.favorites() - for event in self.doorstation_events: + for event in self.door_station_events: if self._register_event(hass_url, event, favs=favorites): _LOGGER.info( "Successfully registered URL for %s on %s", event, self.name @@ -126,7 +135,7 @@ class ConfiguredDoorBird: return None - def get_event_data(self) -> dict[str, str]: + def get_event_data(self, event: str) -> dict[str, str | None]: """Get data to pass along with HA event.""" return { "timestamp": dt_util.utcnow().isoformat(), @@ -134,4 +143,5 @@ class ConfiguredDoorBird: "live_image_url": self._device.live_image_url, "rtsp_live_video_url": self._device.rtsp_live_video_url, "html5_viewer_url": self._device.html5_viewer_url, + ATTR_ENTITY_ID: self._event_entity_ids.get(event), } diff --git a/homeassistant/components/doorbird/entity.py b/homeassistant/components/doorbird/entity.py index 32c9cfff784..4360a8ff490 100644 --- a/homeassistant/components/doorbird/entity.py +++ b/homeassistant/components/doorbird/entity.py @@ -1,6 +1,5 @@ """The DoorBird integration base entity.""" -from typing import Any from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -12,8 +11,8 @@ from .const import ( DOORBIRD_INFO_KEY_FIRMWARE, MANUFACTURER, ) -from .device import ConfiguredDoorBird -from .util import get_mac_address_from_doorstation_info +from .models import DoorBirdData +from .util import get_mac_address_from_door_station_info class DoorBirdEntity(Entity): @@ -21,21 +20,20 @@ class DoorBirdEntity(Entity): _attr_has_entity_name = True - def __init__( - self, doorstation: ConfiguredDoorBird, doorstation_info: dict[str, Any] - ) -> None: + def __init__(self, door_bird_data: DoorBirdData) -> None: """Initialize the entity.""" super().__init__() - self._doorstation = doorstation - self._mac_addr = get_mac_address_from_doorstation_info(doorstation_info) - - firmware = doorstation_info[DOORBIRD_INFO_KEY_FIRMWARE] - firmware_build = doorstation_info[DOORBIRD_INFO_KEY_BUILD_NUMBER] + self._door_bird_data = door_bird_data + self._door_station = door_bird_data.door_station + door_station_info = door_bird_data.door_station_info + self._mac_addr = get_mac_address_from_door_station_info(door_station_info) + firmware = door_station_info[DOORBIRD_INFO_KEY_FIRMWARE] + firmware_build = door_station_info[DOORBIRD_INFO_KEY_BUILD_NUMBER] self._attr_device_info = DeviceInfo( configuration_url="https://webadmin.doorbird.com/", connections={(dr.CONNECTION_NETWORK_MAC, self._mac_addr)}, manufacturer=MANUFACTURER, - model=doorstation_info[DOORBIRD_INFO_KEY_DEVICE_TYPE], - name=self._doorstation.name, + model=door_station_info[DOORBIRD_INFO_KEY_DEVICE_TYPE], + name=self._door_station.name, sw_version=f"{firmware} {firmware_build}", ) diff --git a/homeassistant/components/doorbird/logbook.py b/homeassistant/components/doorbird/logbook.py index f3beebe6971..7c8e3cd3c51 100644 --- a/homeassistant/components/doorbird/logbook.py +++ b/homeassistant/components/doorbird/logbook.py @@ -1,43 +1,35 @@ """Describe logbook events.""" from __future__ import annotations -from typing import Any - from homeassistant.components.logbook import ( LOGBOOK_ENTRY_ENTITY_ID, LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME, ) from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import callback +from homeassistant.core import Event, HomeAssistant, callback -from .const import DOMAIN, DOOR_STATION, DOOR_STATION_EVENT_ENTITY_IDS +from .const import DOMAIN +from .models import DoorBirdData @callback -def async_describe_events(hass, async_describe_event): +def async_describe_events(hass: HomeAssistant, async_describe_event): """Describe logbook events.""" @callback - def async_describe_logbook_event(event): + def async_describe_logbook_event(event: Event): """Describe a logbook event.""" - doorbird_event = event.event_type.split("_", 1)[1] - return { LOGBOOK_ENTRY_NAME: "Doorbird", LOGBOOK_ENTRY_MESSAGE: f"Event {event.event_type} was fired", - LOGBOOK_ENTRY_ENTITY_ID: hass.data[DOMAIN][ - DOOR_STATION_EVENT_ENTITY_IDS - ].get(doorbird_event, event.data.get(ATTR_ENTITY_ID)), + # Database entries before Jun 25th 2020 will not have an entity ID + LOGBOOK_ENTRY_ENTITY_ID: event.data.get(ATTR_ENTITY_ID), } - domain_data: dict[str, Any] = hass.data[DOMAIN] - + domain_data: dict[str, DoorBirdData] = hass.data[DOMAIN] for data in domain_data.values(): - if DOOR_STATION not in data: - # We need to skip door_station_event_entity_ids - continue - for event in data[DOOR_STATION].doorstation_events: + for event in data.door_station.door_station_events: async_describe_event( DOMAIN, f"{DOMAIN}_{event}", async_describe_logbook_event ) diff --git a/homeassistant/components/doorbird/models.py b/homeassistant/components/doorbird/models.py new file mode 100644 index 00000000000..f8fb8687e59 --- /dev/null +++ b/homeassistant/components/doorbird/models.py @@ -0,0 +1,26 @@ +"""The doorbird integration models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .device import ConfiguredDoorBird + + +@dataclass +class DoorBirdData: + """Data for the doorbird integration.""" + + door_station: ConfiguredDoorBird + door_station_info: dict[str, Any] + + # + # This integration uses a different event for + # each entity id. It would be a major breaking + # change to change this to a single event at this + # point. + # + # Do not copy this pattern in the future + # for any new integrations. + # + event_entity_ids: dict[str, str] diff --git a/homeassistant/components/doorbird/util.py b/homeassistant/components/doorbird/util.py index 7b406bc07fa..52c1417a67c 100644 --- a/homeassistant/components/doorbird/util.py +++ b/homeassistant/components/doorbird/util.py @@ -2,50 +2,23 @@ from homeassistant.core import HomeAssistant -from .const import DOMAIN, DOOR_STATION +from .const import DOMAIN from .device import ConfiguredDoorBird +from .models import DoorBirdData -def get_mac_address_from_doorstation_info(doorstation_info): +def get_mac_address_from_door_station_info(door_station_info): """Get the mac address depending on the device type.""" - if "PRIMARY_MAC_ADDR" in doorstation_info: - return doorstation_info["PRIMARY_MAC_ADDR"] - return doorstation_info["WIFI_MAC_ADDR"] + return door_station_info.get("PRIMARY_MAC_ADDR", door_station_info["WIFI_MAC_ADDR"]) -def get_doorstation_by_token( +def get_door_station_by_token( hass: HomeAssistant, token: str ) -> ConfiguredDoorBird | None: - """Get doorstation by token.""" - return _get_doorstation_by_attr(hass, "token", token) - - -def get_doorstation_by_slug( - hass: HomeAssistant, slug: str -) -> ConfiguredDoorBird | None: - """Get doorstation by slug.""" - return _get_doorstation_by_attr(hass, "slug", slug) - - -def _get_doorstation_by_attr( - hass: HomeAssistant, attr: str, val: str -) -> ConfiguredDoorBird | None: - for entry in hass.data[DOMAIN].values(): - if DOOR_STATION not in entry: - continue - - doorstation = entry[DOOR_STATION] - - if getattr(doorstation, attr) == val: - return doorstation - + """Get door station by token.""" + domain_data: dict[str, DoorBirdData] = hass.data[DOMAIN] + for data in domain_data.values(): + door_station = data.door_station + if door_station.token == token: + return door_station return None - - -def get_all_doorstations(hass: HomeAssistant) -> list[ConfiguredDoorBird]: - """Get all doorstations.""" - return [ - entry[DOOR_STATION] - for entry in hass.data[DOMAIN].values() - if DOOR_STATION in entry - ] From d44847bb239e180f86c0979bee443bd4c966ecdc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 17 Aug 2023 15:09:16 +0000 Subject: [PATCH 138/180] Log Tractive events on debug level (#98539) --- homeassistant/components/tractive/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index e08ea954e21..043e074270e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -207,6 +207,7 @@ class TractiveClient: while True: try: async for event in self._client.events(): + _LOGGER.debug("Received event: %s", event) if server_was_unavailable: _LOGGER.debug("Tractive is back online") server_was_unavailable = False From 740cabc21e034684b91a0f8a2ca644f1588d7b1f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 17 Aug 2023 17:36:22 +0200 Subject: [PATCH 139/180] Pin setuptools to 68.0.0 (#98582) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3003e3a29ca..4e477440cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools~=68.0", "wheel~=0.40.0"] +requires = ["setuptools==68.0.0", "wheel~=0.40.0"] build-backend = "setuptools.build_meta" [project] From e95979e9af235691ae4437ecd02c5f8999b9023c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Aug 2023 10:39:35 -0500 Subject: [PATCH 140/180] Bump ESPHome recommended BLE version to 2023.8.0 (#98586) --- homeassistant/components/esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index f0e3972f197..575c57c8672 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -11,7 +11,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False -STABLE_BLE_VERSION_STR = "2023.6.0" +STABLE_BLE_VERSION_STR = "2023.8.0" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", From 529bc507a07003ee57f05215ccc9ffb961a20a01 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 17 Aug 2023 17:42:20 +0200 Subject: [PATCH 141/180] Fix aiohttp test RuntimeWarning (#98568) --- homeassistant/components/buienradar/util.py | 2 +- tests/test_util/aiohttp.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 3c50b3097cb..63e0004dc43 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -109,7 +109,7 @@ class BrData: return result finally: if resp is not None: - await resp.release() + resp.release() async def _async_update(self): """Update the data from buienradar.""" diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 5e7284eb9c2..356240dc37a 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -255,7 +255,7 @@ class AiohttpClientMockResponse: """Return mock response as a json.""" return loads(self.response.decode(encoding)) - async def release(self): + def release(self): """Mock release.""" def raise_for_status(self): From 3e14e5acbad1b9141fd8bc9cedc1f9143f1121d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Aug 2023 10:46:21 -0500 Subject: [PATCH 142/180] Bump aioesphomeapi to 16.0.1 (#98536) --- homeassistant/components/esphome/manager.py | 6 ++++-- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index a0f49340c1a..35939dc9b1f 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -18,6 +18,7 @@ from aioesphomeapi import ( UserServiceArgType, VoiceAssistantEventType, ) +from aioesphomeapi.model import VoiceAssistantCommandFlag from awesomeversion import AwesomeVersion import voluptuous as vol @@ -319,7 +320,7 @@ class ESPHomeManager: self.voice_assistant_udp_server = None async def _handle_pipeline_start( - self, conversation_id: str, use_vad: bool + self, conversation_id: str, use_vad: int ) -> int | None: """Start a voice assistant pipeline.""" if self.voice_assistant_udp_server is not None: @@ -339,7 +340,8 @@ class ESPHomeManager: voice_assistant_udp_server.run_pipeline( device_id=self.device_id, conversation_id=conversation_id or None, - use_vad=use_vad, + use_vad=VoiceAssistantCommandFlag(use_vad) + == VoiceAssistantCommandFlag.USE_VAD, ), "esphome.voice_assistant_udp_server.run_pipeline", ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index c44c8b3e28d..313ba5355bb 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async_interrupt==1.1.1", - "aioesphomeapi==15.1.15", + "aioesphomeapi==16.0.1", "bluetooth-data-tools==1.8.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index d91e6c58b0c..21aba7da4e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -228,7 +228,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==15.1.15 +aioesphomeapi==16.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ccf963c1b3c..93994110627 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -209,7 +209,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==15.1.15 +aioesphomeapi==16.0.1 # homeassistant.components.flo aioflo==2021.11.0 From 49995a4667600a7d0adb7c86266d26432a6e4c6c Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Thu, 17 Aug 2023 08:58:52 -0700 Subject: [PATCH 143/180] Add tests for device tracker in Prometheus (#98054) --- tests/components/prometheus/test_init.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 09c8a37dc2a..446666c4a6a 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -509,6 +509,23 @@ async def test_cover(client, cover_entities) -> None: assert tilt_position_metric in body +@pytest.mark.parametrize("namespace", [""]) +async def test_device_tracker(client, device_tracker_entities) -> None: + """Test prometheus metrics for device_tracker.""" + body = await generate_latest_metrics(client) + + assert ( + 'device_tracker_state{domain="device_tracker",' + 'entity="device_tracker.phone",' + 'friendly_name="Phone"} 1.0' in body + ) + assert ( + 'device_tracker_state{domain="device_tracker",' + 'entity="device_tracker.watch",' + 'friendly_name="Watch"} 0.0' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_counter(client, counter_entities) -> None: """Test prometheus metrics for counter.""" From a9b1f23b7ffc03f16bdefa37ade5d314d6d6f4f6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 17 Aug 2023 18:16:32 +0200 Subject: [PATCH 144/180] Bump renault-api to 0.2.0 (#98587) --- homeassistant/components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 5f2670fb170..e5470259aa4 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.1.13"] + "requirements": ["renault-api==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 21aba7da4e7..65a6ab08bb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2278,7 +2278,7 @@ raspyrfm-client==1.2.8 regenmaschine==2023.06.0 # homeassistant.components.renault -renault-api==0.1.13 +renault-api==0.2.0 # homeassistant.components.renson renson-endura-delta==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93994110627..7df3a7172b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1671,7 +1671,7 @@ rapt-ble==0.1.2 regenmaschine==2023.06.0 # homeassistant.components.renault -renault-api==0.1.13 +renault-api==0.2.0 # homeassistant.components.renson renson-endura-delta==1.5.0 From dd69ba31366e9baf0612fc537d326f6f26953101 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Aug 2023 18:29:20 +0200 Subject: [PATCH 145/180] Migrate Cert Expiry to has entity name (#98160) * Migrate Cert Expiry to has entity name * Migrate Cert Expiry to has entity name * Fix entity name --- homeassistant/components/cert_expiry/sensor.py | 3 ++- .../components/cert_expiry/strings.json | 7 +++++++ tests/components/cert_expiry/test_init.py | 10 +++++----- tests/components/cert_expiry/test_sensors.py | 18 +++++++++--------- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index aeae8a5afe9..645642067e6 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -77,6 +77,7 @@ class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]): """Defines a base Cert Expiry entity.""" _attr_icon = "mdi:certificate" + _attr_has_entity_name = True @property def extra_state_attributes(self): @@ -91,6 +92,7 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): """Implementation of the Cert Expiry timestamp sensor.""" _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_translation_key = "certificate_expiry" def __init__( self, @@ -98,7 +100,6 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): ) -> None: """Initialize a Cert Expiry timestamp sensor.""" super().__init__(coordinator) - self._attr_name = f"Cert Expiry Timestamp ({coordinator.name})" self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{coordinator.host}:{coordinator.port}")}, diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json index 5c8af4df931..b8c7ffe037f 100644 --- a/homeassistant/components/cert_expiry/strings.json +++ b/homeassistant/components/cert_expiry/strings.json @@ -20,5 +20,12 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "import_failed": "Import from config failed" } + }, + "entity": { + "sensor": { + "certificate_expiry": { + "name": "Cert expiry" + } + } } } diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 2113ff5cc42..29fbf372ec4 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -99,7 +99,7 @@ async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == timestamp.isoformat() assert state.attributes.get("error") == "None" assert state.attributes.get("is_valid") @@ -107,12 +107,12 @@ async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is None @@ -129,7 +129,7 @@ async def test_delay_load_during_startup(hass: HomeAssistant) -> None: assert hass.state is CoreState.not_running assert entry.state is ConfigEntryState.LOADED - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is None timestamp = future_timestamp(100) @@ -142,7 +142,7 @@ async def test_delay_load_during_startup(hass: HomeAssistant) -> None: assert hass.state is CoreState.running - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == timestamp.isoformat() assert state.attributes.get("error") == "None" assert state.attributes.get("is_valid") diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 0fbf276cdea..e6a526c7c9e 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -36,7 +36,7 @@ async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -62,7 +62,7 @@ async def test_async_setup_entry_bad_cert(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.attributes.get("error") == "some error" @@ -90,7 +90,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -105,7 +105,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow() + timedelta(hours=24)) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -134,7 +134,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -152,7 +152,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=48) - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( @@ -162,7 +162,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow() + timedelta(hours=48)) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -178,7 +178,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow() + timedelta(hours=72)) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state == STATE_UNKNOWN assert state.attributes.get("error") == "something bad" @@ -192,5 +192,5 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE From d761b5ddbf1d956c2ef81fef5d4c8f97f8684296 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 17 Aug 2023 19:37:34 +0200 Subject: [PATCH 146/180] Add tests and typing to Tado config flow (#98281) * Upgrading tests * Code improvements and removing unused function * Update homeassistant/components/tado/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tado/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tado/config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tado/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tado/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tado/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tado/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tado/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Importing Any * Rerunning Blackformatter * Adding fallback scenario to options flow * Adding constants * Adding a retry on the exceptions * Refactoring to standard * Update homeassistant/components/tado/config_flow.py Co-authored-by: G Johansson * Adding type to validate_input * Updating test --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: G Johansson --- homeassistant/components/tado/config_flow.py | 24 ++-- tests/components/tado/test_config_flow.py | 127 ++++++++++++++++--- 2 files changed, 122 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index ec195573203..a755622ea76 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from PyTado.interface import Tado import requests.exceptions @@ -31,7 +32,9 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -66,7 +69,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: @@ -105,13 +110,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return await self.async_step_user() - def _username_already_configured(self, user_input): - """See if we already have a username matching user input configured.""" - existing_username = { - entry.data[CONF_USERNAME] for entry in self._async_current_entries() - } - return user_input[CONF_USERNAME] in existing_username - @staticmethod @callback def async_get_options_flow( @@ -122,16 +120,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for tado.""" + """Handle an option flow for Tado.""" def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle options flow.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(data=user_input) data_schema = vol.Schema( { diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index f0fef1dff5a..dcbb33b587e 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -2,18 +2,24 @@ from http import HTTPStatus from unittest.mock import MagicMock, patch +import pytest import requests from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.components.tado.const import DOMAIN +from homeassistant.components.tado.const import ( + CONF_FALLBACK, + CONST_OVERLAY_TADO_DEFAULT, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -def _get_mock_tado_api(getMe=None): +def _get_mock_tado_api(getMe=None) -> MagicMock: mock_tado = MagicMock() if isinstance(getMe, Exception): type(mock_tado).getMe = MagicMock(side_effect=getMe) @@ -22,13 +28,100 @@ def _get_mock_tado_api(getMe=None): return mock_tado -async def test_form(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "error"), + [ + (KeyError, "invalid_auth"), + (RuntimeError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test we handle Form Exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Test a retry to recover, upon failure + mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + + with patch( + "homeassistant.components.tado.config_flow.Tado", + return_value=mock_tado_api, + ), patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "myhome" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={"username": "test-username"}) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} + + +async def test_create_entry(hass: HomeAssistant) -> None: """Test we can setup though the user path.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) @@ -40,15 +133,15 @@ async def test_form(hass: HomeAssistant) -> None: "homeassistant.components.tado.async_setup_entry", return_value=True, ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "test-username", "password": "test-password"}, ) await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["title"] == "myhome" - assert result2["data"] == { + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "myhome" + assert result["data"] == { "username": "test-username", "password": "test-password", } @@ -69,13 +162,13 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "test-username", "password": "test-password"}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -92,13 +185,13 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "test-username", "password": "test-password"}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} async def test_no_homes(hass: HomeAssistant) -> None: @@ -113,13 +206,13 @@ async def test_no_homes(hass: HomeAssistant) -> None: "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "test-username", "password": "test-password"}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "no_homes"} + assert result["type"] == "form" + assert result["errors"] == {"base": "no_homes"} async def test_form_homekit(hass: HomeAssistant) -> None: From c17f08a3f543e63da66d5a10156776ee150c5f11 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 17 Aug 2023 19:41:11 +0200 Subject: [PATCH 147/180] Create a single entity for new met.no config entries (#98098) * Create a single entity for new met.no config entries * Fix lying docstring * Fix test --- homeassistant/components/met/weather.py | 55 +++++++++++++++---------- tests/components/met/test_weather.py | 50 ++++++++++++---------- 2 files changed, 61 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 2fcde1e05f0..e7aea21875a 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -15,6 +15,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, Forecast, WeatherEntity, WeatherEntityFeature, @@ -30,6 +31,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -48,19 +50,38 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" coordinator: MetDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - [ - MetWeather( - coordinator, - config_entry.data, - hass.config.units is METRIC_SYSTEM, - False, - ), + entity_registry = er.async_get(hass) + + entities = [ + MetWeather( + coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, False + ) + ] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(config_entry.data, True), + ): + entities.append( MetWeather( coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, True - ), - ] - ) + ) + ) + + async_add_entities(entities) + + +def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: + """Calculate unique ID.""" + name_appendix = "" + if hourly: + name_appendix = "-hourly" + if config.get(CONF_TRACK_HOME): + return f"home{name_appendix}" + + return f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}{name_appendix}" def format_condition(condition: str) -> str: @@ -96,6 +117,7 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) + self._attr_unique_id = _calculate_unique_id(config, hourly) self._config = config self._is_metric = is_metric self._hourly = hourly @@ -105,17 +127,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Return if we are tracking home.""" return self._config.get(CONF_TRACK_HOME, False) - @property - def unique_id(self) -> str: - """Return unique ID.""" - name_appendix = "" - if self._hourly: - name_appendix = "-hourly" - if self.track_home: - return f"home{name_appendix}" - - return f"{self._config[CONF_LATITUDE]}-{self._config[CONF_LONGITUDE]}{name_appendix}" - @property def name(self) -> str: """Return the name of the sensor.""" diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py index 2941935fcfc..5a28b8eceb0 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -6,6 +6,33 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +async def test_new_config_entry(hass: HomeAssistant, mock_weather) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + await hass.config_entries.flow.async_init("met", context={"source": "onboarding"}) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids("weather")) == 1 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + + +async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "home-hourly", + ) + await hass.config_entries.flow.async_init("met", context={"source": "onboarding"}) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids("weather")) == 2 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + + async def test_tracking_home(hass: HomeAssistant, mock_weather) -> None: """Test we track home.""" await hass.config_entries.flow.async_init("met", context={"source": "onboarding"}) @@ -13,17 +40,6 @@ async def test_tracking_home(hass: HomeAssistant, mock_weather) -> None: assert len(hass.states.async_entity_ids("weather")) == 1 assert len(mock_weather.mock_calls) == 4 - # Test the hourly sensor is disabled by default - registry = er.async_get(hass) - - state = hass.states.get("weather.forecast_test_home_hourly") - assert state is None - - entry = registry.async_get("weather.forecast_test_home_hourly") - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - # Test we track config await hass.config.async_update(latitude=10, longitude=20) await hass.async_block_till_done() @@ -44,23 +60,13 @@ async def test_tracking_home(hass: HomeAssistant, mock_weather) -> None: async def test_not_tracking_home(hass: HomeAssistant, mock_weather) -> None: """Test when we not track home.""" - # Pre-create registry entry for disabled by default hourly weather - registry = er.async_get(hass) - registry.async_get_or_create( - WEATHER_DOMAIN, - DOMAIN, - "10-20-hourly", - suggested_object_id="forecast_somewhere_hourly", - disabled_by=None, - ) - await hass.config_entries.flow.async_init( "met", context={"source": config_entries.SOURCE_USER}, data={"name": "Somewhere", "latitude": 10, "longitude": 20, "elevation": 0}, ) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids("weather")) == 2 + assert len(hass.states.async_entity_ids("weather")) == 1 assert len(mock_weather.mock_calls) == 4 # Test we do not track config From 49d2c60992bb3741921c968d7e3d9dc810cf7cb7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 17 Aug 2023 18:58:58 -0500 Subject: [PATCH 148/180] Add pipeline VAD events (#98603) * Add stt-vad-start and stt-vad-end pipeline events * Update tests --- .../components/assist_pipeline/pipeline.py | 22 +++++++++++++++++++ .../assist_pipeline/snapshots/test_init.ambr | 6 +++++ tests/components/assist_pipeline/test_init.py | 12 +++++----- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 3303895eec2..320812b2039 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -254,6 +254,8 @@ class PipelineEventType(StrEnum): WAKE_WORD_START = "wake_word-start" WAKE_WORD_END = "wake_word-end" STT_START = "stt-start" + STT_VAD_START = "stt-vad-start" + STT_VAD_END = "stt-vad-end" STT_END = "stt-end" INTENT_START = "intent-start" INTENT_END = "intent-end" @@ -612,11 +614,31 @@ class PipelineRun: stream: AsyncIterable[bytes], ) -> AsyncGenerator[bytes, None]: """Stop stream when voice command is finished.""" + sent_vad_start = False + timestamp_ms = 0 async for chunk in stream: if not segmenter.process(chunk): + # Silence detected at the end of voice command + self.process_event( + PipelineEvent( + PipelineEventType.STT_VAD_END, + {"timestamp": timestamp_ms}, + ) + ) break + if segmenter.in_command and (not sent_vad_start): + # Speech detected at start of voice command + self.process_event( + PipelineEvent( + PipelineEventType.STT_VAD_START, + {"timestamp": timestamp_ms}, + ) + ) + sent_vad_start = True + yield chunk + timestamp_ms += (len(chunk) // 2) // 16 # milliseconds @ 16Khz # Transcribe audio stream result = await self.stt_provider.async_process_audio_stream( diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index d0330952f04..58835e37973 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -311,6 +311,12 @@ }), 'type': , }), + dict({ + 'data': dict({ + 'timestamp': 0, + }), + 'type': , + }), dict({ 'data': dict({ 'stt_output': dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 44e448aa785..184f479f830 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -40,7 +40,7 @@ async def test_pipeline_from_audio_stream_auto( In this test, no pipeline is specified. """ - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -79,7 +79,7 @@ async def test_pipeline_from_audio_stream_legacy( """ client = await hass_ws_client(hass) - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -139,7 +139,7 @@ async def test_pipeline_from_audio_stream_entity( """ client = await hass_ws_client(hass) - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -199,7 +199,7 @@ async def test_pipeline_from_audio_stream_no_stt( """ client = await hass_ws_client(hass) - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -257,7 +257,7 @@ async def test_pipeline_from_audio_stream_unknown_pipeline( In this test, the pipeline does not exist. """ - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -294,7 +294,7 @@ async def test_pipeline_from_audio_stream_wake_word( ) -> None: """Test creating a pipeline from an audio stream with wake word.""" - events = [] + events: list[assist_pipeline.PipelineEvent] = [] # [0, 1, ...] wake_chunk_1 = bytes(it.islice(it.cycle(range(256)), BYTES_ONE_SECOND)) From f6a9be937b8144973ea9402a9f0a7852048cc6f1 Mon Sep 17 00:00:00 2001 From: lymanepp <4195527+lymanepp@users.noreply.github.com> Date: Fri, 18 Aug 2023 01:41:25 -0400 Subject: [PATCH 149/180] Add humidity and dew point to tomorrow.io integration (#98496) * Add humidity and dew point to tomorrow.io integration * Fix ruff complaints * Make mypy happy * Merge emontnemery's changes * Fix formatting error * Add fake humidity and dew point to test data (first interval only) * Fix inconsistency * Fix inconsistency --- homeassistant/components/tomorrowio/weather.py | 11 +++++++++++ tests/components/tomorrowio/fixtures/v4.json | 4 ++++ .../tomorrowio/snapshots/test_weather.ambr | 12 ++++++++++++ tests/components/tomorrowio/test_weather.py | 6 ++++++ 4 files changed, 33 insertions(+) diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 333aa0cd472..ec77a2c8040 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -7,6 +7,8 @@ from pytomorrowio.const import DAILY, FORECASTS, HOURLY, NOWCAST, WeatherCode from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_DEW_POINT, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, @@ -44,6 +46,7 @@ from .const import ( DOMAIN, MAX_FORECASTS, TMRW_ATTR_CONDITION, + TMRW_ATTR_DEW_POINT, TMRW_ATTR_HUMIDITY, TMRW_ATTR_OZONE, TMRW_ATTR_PRECIPITATION, @@ -138,6 +141,8 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): precipitation_probability: int | None, temp: float | None, temp_low: float | None, + humidity: float | None, + dew_point: float | None, wind_direction: float | None, wind_speed: float | None, ) -> Forecast: @@ -156,6 +161,8 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): ATTR_FORECAST_PRECIPITATION_PROBABILITY: precipitation_probability, ATTR_FORECAST_NATIVE_TEMP: temp, ATTR_FORECAST_NATIVE_TEMP_LOW: temp_low, + ATTR_FORECAST_HUMIDITY: humidity, + ATTR_FORECAST_NATIVE_DEW_POINT: dew_point, ATTR_FORECAST_WIND_BEARING: wind_direction, ATTR_FORECAST_NATIVE_WIND_SPEED: wind_speed, } @@ -259,6 +266,8 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): temp = values.get(TMRW_ATTR_TEMPERATURE_HIGH) temp_low = None + dew_point = values.get(TMRW_ATTR_DEW_POINT) + humidity = values.get(TMRW_ATTR_HUMIDITY) wind_direction = values.get(TMRW_ATTR_WIND_DIRECTION) wind_speed = values.get(TMRW_ATTR_WIND_SPEED) @@ -285,6 +294,8 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): precipitation_probability, temp, temp_low, + humidity, + dew_point, wind_direction, wind_speed, ) diff --git a/tests/components/tomorrowio/fixtures/v4.json b/tests/components/tomorrowio/fixtures/v4.json index 0ca4f348956..c511263fb5f 100644 --- a/tests/components/tomorrowio/fixtures/v4.json +++ b/tests/components/tomorrowio/fixtures/v4.json @@ -908,6 +908,8 @@ "values": { "temperatureMin": 44.13, "temperatureMax": 44.13, + "dewPoint": 12.76, + "humidity": 58.46, "windSpeed": 9.33, "windDirection": 315.14, "weatherCode": 1000, @@ -2206,6 +2208,8 @@ "values": { "temperatureMin": 26.11, "temperatureMax": 45.93, + "dewPoint": 12.76, + "humidity": 58.46, "windSpeed": 9.49, "windDirection": 239.6, "weatherCode": 1000, diff --git a/tests/components/tomorrowio/snapshots/test_weather.ambr b/tests/components/tomorrowio/snapshots/test_weather.ambr index 40ff18658c6..a938cb10e44 100644 --- a/tests/components/tomorrowio/snapshots/test_weather.ambr +++ b/tests/components/tomorrowio/snapshots/test_weather.ambr @@ -4,6 +4,8 @@ dict({ 'condition': 'sunny', 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 0, 'temperature': 45.9, @@ -148,6 +150,8 @@ dict({ 'condition': 'sunny', 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 0, 'temperature': 45.9, @@ -292,6 +296,8 @@ dict({ 'condition': 'sunny', 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 0, 'temperature': 44.1, @@ -512,6 +518,8 @@ dict({ 'condition': 'sunny', 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 0, 'temperature': 44.1, @@ -733,6 +741,8 @@ dict({ 'condition': 'sunny', 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 0, 'temperature': 45.9, @@ -879,6 +889,8 @@ dict({ 'condition': 'sunny', 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 0, 'temperature': 44.1, diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 8490b94a7f9..a6a5e935614 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -24,6 +24,8 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_DEW_POINT, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TEMP, @@ -164,6 +166,8 @@ async def test_v4_weather(hass: HomeAssistant) -> None: ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, ATTR_FORECAST_TEMP: 45.9, ATTR_FORECAST_TEMP_LOW: 26.1, + ATTR_FORECAST_DEW_POINT: 12.8, + ATTR_FORECAST_HUMIDITY: 58, ATTR_FORECAST_WIND_BEARING: 239.6, ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } @@ -191,6 +195,8 @@ async def test_v4_weather_legacy_entities(hass: HomeAssistant) -> None: assert weather_state.attributes[ATTR_FORECAST][0] == { ATTR_FORECAST_CONDITION: ATTR_CONDITION_SUNNY, ATTR_FORECAST_TIME: "2021-03-07T11:00:00+00:00", + ATTR_FORECAST_DEW_POINT: 12.8, + ATTR_FORECAST_HUMIDITY: 58, ATTR_FORECAST_PRECIPITATION: 0, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, ATTR_FORECAST_TEMP: 45.9, From 9fdad592c213aa29cca1c9f7bfaefaec05769289 Mon Sep 17 00:00:00 2001 From: Faidon Liambotis Date: Fri, 18 Aug 2023 09:23:48 +0300 Subject: [PATCH 150/180] Add option to disable MQTT Alarm Control Panel supported features (#98363) * Make MQTT Alarm Control Panel features conditional The MQTT Alarm Control Panel currently enables all features (arm home, arm away, arm night, arm vacation, arm custom bypass) unconditionally. This clutters the interface and can even be potentially dangerous, by enabling modes that the remote alarm may not support. Make all the features conditional, by adding a new "supported_features" configuration option, comprising a list of the supported features as options. Feature enablement seems inconsistent across the MQTT component; this implementation is most alike to the Humidifier modes option, but using a generic "supported_features" name that other implementations may reuse in the future. The default value of this new setting remains to be all features, which while it may be overly expansive, is necessary to maintain backwards compatibility. * Apply suggestions from code review * Use vol.Optional() instead of vol.Required() for "supported_features". * Move the initialization of _attr_supported_features to _setup_from_config. Co-authored-by: Jan Bouwhuis * Apply suggestions from emontnemery's code review * Use vol.In() instead of cv.multi_seelct() * Remove superfluous _attr_supported_features initializers, already present in the base class. Co-authored-by: Erik Montnemery * Add invalid config tests for the MQTT Alarm Control Panel * Set expected_features to None in the invalid MQTT Alarm Control Panel tests * Add another expected_features=None in the invalid tests Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis Co-authored-by: Erik Montnemery --- .../components/mqtt/alarm_control_panel.py | 28 +++--- homeassistant/components/mqtt/const.py | 1 + .../mqtt/test_alarm_control_panel.py | 93 +++++++++++++++++++ 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 06f91403057..a0939fdc615 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -39,6 +39,7 @@ from .const import ( CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + CONF_SUPPORTED_FEATURES, ) from .debug_info import log_messages from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper @@ -47,6 +48,15 @@ from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) +_SUPPORTED_FEATURES = { + "arm_home": AlarmControlPanelEntityFeature.ARM_HOME, + "arm_away": AlarmControlPanelEntityFeature.ARM_AWAY, + "arm_night": AlarmControlPanelEntityFeature.ARM_NIGHT, + "arm_vacation": AlarmControlPanelEntityFeature.ARM_VACATION, + "arm_custom_bypass": AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + "trigger": AlarmControlPanelEntityFeature.TRIGGER, +} + CONF_CODE_ARM_REQUIRED = "code_arm_required" CONF_CODE_DISARM_REQUIRED = "code_disarm_required" CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required" @@ -81,6 +91,9 @@ REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT" PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { + vol.Optional(CONF_SUPPORTED_FEATURES, default=list(_SUPPORTED_FEATURES)): [ + vol.In(_SUPPORTED_FEATURES) + ], vol.Optional(CONF_CODE): cv.string, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_CODE_DISARM_REQUIRED, default=True): cv.boolean, @@ -167,6 +180,9 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): config[CONF_COMMAND_TEMPLATE], entity=self ).async_render + for feature in self._config[CONF_SUPPORTED_FEATURES]: + self._attr_supported_features |= _SUPPORTED_FEATURES[feature] + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -214,18 +230,6 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): """Return the state of the device.""" return self._state - @property - def supported_features(self) -> AlarmControlPanelEntityFeature: - """Return the list of supported features.""" - return ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_VACATION - | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - | AlarmControlPanelEntityFeature.TRIGGER - ) - @property def code_format(self) -> alarm.CodeFormat | None: """Return one or more digits/characters.""" diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index fcdfeb4bd7d..97d2e1473f5 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -28,6 +28,7 @@ CONF_WS_PATH = "ws_path" CONF_WS_HEADERS = "ws_headers" CONF_WILL_MESSAGE = "will_message" CONF_PAYLOAD_RESET = "payload_reset" +CONF_SUPPORTED_FEATURES = "supported_features" CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index e69839e6b16..35fba9e2a0c 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest from homeassistant.components import alarm_control_panel, mqtt +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.mqtt.alarm_control_panel import ( MQTT_ALARM_ATTRIBUTES_BLOCKED, ) @@ -74,6 +75,15 @@ from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient CODE_NUMBER = "1234" CODE_TEXT = "HELLO_CODE" +DEFAULT_FEATURES = ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + | AlarmControlPanelEntityFeature.TRIGGER +) + DEFAULT_CONFIG = { mqtt.DOMAIN: { alarm_control_panel.DOMAIN: { @@ -223,6 +233,89 @@ async def test_ignore_update_state_if_unknown_via_state_topic( assert hass.states.get(entity_id).state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("hass_config", "expected_features", "valid"), + [ + ( + DEFAULT_CONFIG, + DEFAULT_FEATURES, + True, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": []},), + ), + AlarmControlPanelEntityFeature(0), + True, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": ["arm_home"]},), + ), + AlarmControlPanelEntityFeature.ARM_HOME, + True, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": ["arm_home", "arm_away"]},), + ), + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY, + True, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": "invalid"},), + ), + None, + False, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": ["invalid"]},), + ), + None, + False, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": ["arm_home", "invalid"]},), + ), + None, + False, + ), + ], +) +async def test_supported_features( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_features: AlarmControlPanelEntityFeature | None, + valid: bool, +) -> None: + """Test conditional enablement of supported features.""" + if valid: + await mqtt_mock_entry() + assert ( + hass.states.get("alarm_control_panel.test").attributes["supported_features"] + == expected_features + ) + else: + with pytest.raises(AssertionError): + await mqtt_mock_entry() + + @pytest.mark.parametrize( ("hass_config", "service", "payload"), [ From ab9d6ce61ac27338372a1563aca800966b170433 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 18 Aug 2023 08:40:23 +0200 Subject: [PATCH 151/180] New integration for Comelit SimpleHome (#96552) * New integration for Comelit SimpleHome * Address first review comments * cleanup * aiocomelit bump and coordinator cleanup * address review comments * Fix some review comments * Use config_entry.unique_id as last resort * review comments * Add config_flow tests * fix pre-commit missing checks * test_conflig_flow coverage to 100% * fix tests * address latest review comments * new ruff rule * address review comments * simplify unique_id --- .coveragerc | 4 + CODEOWNERS | 2 + homeassistant/components/comelit/__init__.py | 34 ++++ .../components/comelit/config_flow.py | 145 +++++++++++++++++ homeassistant/components/comelit/const.py | 6 + .../components/comelit/coordinator.py | 50 ++++++ homeassistant/components/comelit/light.py | 78 +++++++++ .../components/comelit/manifest.json | 10 ++ homeassistant/components/comelit/strings.json | 31 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/comelit/__init__.py | 1 + tests/components/comelit/const.py | 16 ++ tests/components/comelit/test_config_flow.py | 154 ++++++++++++++++++ 16 files changed, 544 insertions(+) create mode 100644 homeassistant/components/comelit/__init__.py create mode 100644 homeassistant/components/comelit/config_flow.py create mode 100644 homeassistant/components/comelit/const.py create mode 100644 homeassistant/components/comelit/coordinator.py create mode 100644 homeassistant/components/comelit/light.py create mode 100644 homeassistant/components/comelit/manifest.json create mode 100644 homeassistant/components/comelit/strings.json create mode 100644 tests/components/comelit/__init__.py create mode 100644 tests/components/comelit/const.py create mode 100644 tests/components/comelit/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 71542ebad3a..93958a67973 100644 --- a/.coveragerc +++ b/.coveragerc @@ -168,6 +168,10 @@ omit = homeassistant/components/cmus/media_player.py homeassistant/components/coinbase/sensor.py homeassistant/components/comed_hourly_pricing/sensor.py + homeassistant/components/comelit/__init__.py + homeassistant/components/comelit/const.py + homeassistant/components/comelit/coordinator.py + homeassistant/components/comelit/light.py homeassistant/components/comfoconnect/fan.py homeassistant/components/concord232/alarm_control_panel.py homeassistant/components/concord232/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index bd1b8ed49f0..812caea4da5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -209,6 +209,8 @@ build.json @home-assistant/supervisor /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent /tests/components/color_extractor/ @GenericStudent +/homeassistant/components/comelit/ @chemelli74 +/tests/components/comelit/ @chemelli74 /homeassistant/components/comfoconnect/ @michaelarnauts /tests/components/comfoconnect/ @michaelarnauts /homeassistant/components/command_line/ @gjohansson-ST diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py new file mode 100644 index 00000000000..2c73922582c --- /dev/null +++ b/homeassistant/components/comelit/__init__.py @@ -0,0 +1,34 @@ +"""Comelit integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PIN, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + +PLATFORMS = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Comelit platform.""" + coordinator = ComelitSerialBridge(hass, entry.data[CONF_HOST], entry.data[CONF_PIN]) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + coordinator: ComelitSerialBridge = hass.data[DOMAIN][entry.entry_id] + await coordinator.api.logout() + await coordinator.api.close() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py new file mode 100644 index 00000000000..dd6227a6583 --- /dev/null +++ b/homeassistant/components/comelit/config_flow.py @@ -0,0 +1,145 @@ +"""Config flow for Comelit integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiocomelit import ComeliteSerialBridgeAPi, exceptions as aiocomelit_exceptions +import voluptuous as vol + +from homeassistant import core, exceptions +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PIN +from homeassistant.data_entry_flow import FlowResult + +from .const import _LOGGER, DOMAIN + +DEFAULT_HOST = "192.168.1.252" +DEFAULT_PIN = "111111" + + +def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: + """Return user form schema.""" + user_input = user_input or {} + return vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): str, + } + ) + + +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): str}) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: + """Validate the user input allows us to connect.""" + + api = ComeliteSerialBridgeAPi(data[CONF_HOST], data[CONF_PIN]) + + try: + await api.login() + except aiocomelit_exceptions.CannotConnect as err: + raise CannotConnect from err + except aiocomelit_exceptions.CannotAuthenticate as err: + raise InvalidAuth from err + finally: + await api.logout() + await api.close() + + return {"title": data[CONF_HOST]} + + +class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Comelit.""" + + VERSION = 1 + _reauth_entry: ConfigEntry | None + _reauth_host: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=user_form_schema(user_input) + ) + + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=user_form_schema(user_input), errors=errors + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauth flow.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + self._reauth_host = entry_data[CONF_HOST] + self.context["title_placeholders"] = {"host": self._reauth_host} + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauth confirm.""" + assert self._reauth_entry + errors = {} + + if user_input is not None: + try: + await validate_input( + self.hass, {CONF_HOST: self._reauth_host} | user_input + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={ + CONF_HOST: self._reauth_host, + CONF_PIN: user_input[CONF_PIN], + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_HOST: self._reauth_entry.data[CONF_HOST]}, + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py new file mode 100644 index 00000000000..e08caa55f76 --- /dev/null +++ b/homeassistant/components/comelit/const.py @@ -0,0 +1,6 @@ +"""Comelit constants.""" +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "comelit" diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py new file mode 100644 index 00000000000..beb7266c403 --- /dev/null +++ b/homeassistant/components/comelit/coordinator.py @@ -0,0 +1,50 @@ +"""Support for Comelit.""" +import asyncio +from datetime import timedelta +from typing import Any + +from aiocomelit import ComeliteSerialBridgeAPi +import aiohttp + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import _LOGGER, DOMAIN + + +class ComelitSerialBridge(DataUpdateCoordinator): + """Queries Comelit Serial Bridge.""" + + def __init__(self, hass: HomeAssistant, host: str, pin: int) -> None: + """Initialize the scanner.""" + + self._host = host + self._pin = pin + + self.api = ComeliteSerialBridgeAPi(host, pin) + + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"{DOMAIN}-{host}-coordinator", + update_interval=timedelta(seconds=5), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update router data.""" + _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + try: + logged = await self.api.login() + except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err: + _LOGGER.warning("Connection error for %s", self._host) + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + + if not logged: + raise ConfigEntryAuthFailed + + devices_data = await self.api.get_all_devices() + alarm_data = await self.api.get_alarm_config() + await self.api.logout() + + return devices_data | alarm_data diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py new file mode 100644 index 00000000000..9a893bd929c --- /dev/null +++ b/homeassistant/components/comelit/light.py @@ -0,0 +1,78 @@ +"""Support for lights.""" +from __future__ import annotations + +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import LIGHT, LIGHT_OFF, LIGHT_ON + +from homeassistant.components.light import LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit lights.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + async_add_entities( + ComelitLightEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[LIGHT].values() + ) + + +class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): + """Light device.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_unique_id: str | None, + ) -> None: + """Init light entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + self._attr_unique_id = f"{config_entry_unique_id}-{device.index}" + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self._attr_unique_id), + }, + manufacturer="Comelit", + model="Serial Bridge", + name=device.name, + ) + + async def _light_set_state(self, state: int) -> None: + """Set desired light state.""" + await self.coordinator.api.light_switch(self._device.index, state) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + await self._light_set_state(LIGHT_ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._light_set_state(LIGHT_OFF) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.coordinator.data[LIGHT][self._device.index].status == LIGHT_ON diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json new file mode 100644 index 00000000000..fc7f2a3fc12 --- /dev/null +++ b/homeassistant/components/comelit/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "comelit", + "name": "Comelit SimpleHome", + "codeowners": ["@chemelli74"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/comelit", + "iot_class": "local_polling", + "loggers": ["aiocomelit"], + "requirements": ["aiocomelit==0.0.5"] +} diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json new file mode 100644 index 00000000000..6508f58412e --- /dev/null +++ b/homeassistant/components/comelit/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "flow_title": "{host}", + "step": { + "reauth_confirm": { + "description": "Please enter the correct PIN for VEDO system: {host}", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "pin": "[%key:common::config_flow::data::pin%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7de32dc5071..0bfbf362eb3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -81,6 +81,7 @@ FLOWS = { "cloudflare", "co2signal", "coinbase", + "comelit", "control4", "coolmaster", "cpuspeed", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ed51bcc7dbf..40883ef3d7c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -883,6 +883,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "comelit": { + "name": "Comelit SimpleHome", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "comfoconnect": { "name": "Zehnder ComfoAir Q", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 65a6ab08bb2..66e73e07026 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -208,6 +208,9 @@ aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 +# homeassistant.components.comelit +aiocomelit==0.0.5 + # homeassistant.components.dhcp aiodiscover==1.4.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7df3a7172b4..36450cb31da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,6 +189,9 @@ aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 +# homeassistant.components.comelit +aiocomelit==0.0.5 + # homeassistant.components.dhcp aiodiscover==1.4.16 diff --git a/tests/components/comelit/__init__.py b/tests/components/comelit/__init__.py new file mode 100644 index 00000000000..916a684de4b --- /dev/null +++ b/tests/components/comelit/__init__.py @@ -0,0 +1 @@ +"""Tests for the Comelit SimpleHome integration.""" diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py new file mode 100644 index 00000000000..36955b0b0a9 --- /dev/null +++ b/tests/components/comelit/const.py @@ -0,0 +1,16 @@ +"""Common stuff for Comelit SimpleHome tests.""" +from homeassistant.components.comelit.const import DOMAIN +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN + +MOCK_CONFIG = { + DOMAIN: { + CONF_DEVICES: [ + { + CONF_HOST: "fake_host", + CONF_PIN: "1234", + } + ] + } +} + +MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py new file mode 100644 index 00000000000..2fb9e836efb --- /dev/null +++ b/tests/components/comelit/test_config_flow.py @@ -0,0 +1,154 @@ +"""Tests for Comelit SimpleHome config flow.""" +from unittest.mock import patch + +from aiocomelit import CannotAuthenticate, CannotConnect +import pytest + +from homeassistant.components.comelit.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_user(hass: HomeAssistant) -> None: + """Test starting a flow by user.""" + with patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.login", + ), patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.logout", + ), patch( + "homeassistant.components.comelit.async_setup_entry" + ) as mock_setup_entry, patch( + "requests.get" + ) as mock_request_get: + mock_request_get.return_value.status_code = 200 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_PIN] == "1234" + assert not result["result"].unique_id + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (ConnectionResetError, "unknown"), + ], +) +async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: + """Test starting a flow by user with a connection error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.login", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + +async def test_reauth_successful(hass: HomeAssistant) -> None: + """Test starting a reauthentication flow.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.login", + ), patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.logout", + ), patch("homeassistant.components.comelit.async_setup_entry"), patch( + "requests.get" + ) as mock_request_get: + mock_request_get.return_value.status_code = 200 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "other_fake_pin", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (ConnectionResetError, "unknown"), + ], +) +async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: + """Test starting a reauthentication flow but no connection found.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.login", side_effect=side_effect + ), patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.logout", + ), patch( + "homeassistant.components.comelit.async_setup_entry" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "other_fake_pin", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == error From 6b82bf2bc7bc56eb1b0b1ca59c70b3d243816721 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 18 Aug 2023 01:07:44 -0700 Subject: [PATCH 152/180] Fix Flume leak detected sensor (#98560) --- homeassistant/components/flume/coordinator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flume/coordinator.py b/homeassistant/components/flume/coordinator.py index 70a99f56968..1f590b0cd16 100644 --- a/homeassistant/components/flume/coordinator.py +++ b/homeassistant/components/flume/coordinator.py @@ -93,8 +93,11 @@ class FlumeNotificationDataUpdateCoordinator(DataUpdateCoordinator[None]): def _update_lists(self): """Query flume for notification list.""" + # Get notifications (read or unread). + # The related binary sensors (leak detected, high flow, low battery) + # will be active until the notification is deleted in the Flume app. self.notifications: list[dict[str, Any]] = pyflume.FlumeNotificationList( - self.auth, read="true" + self.auth, read=None ).notification_list _LOGGER.debug("Notifications %s", self.notifications) From d3ee2366b0e7660087b1c70075960c20f99de9e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Aug 2023 03:09:15 -0500 Subject: [PATCH 153/180] Bump dbus-fast to 1.91.4 (#98600) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b1281af2bc2..99cbfe918b7 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", "bluetooth-data-tools==1.8.0", - "dbus-fast==1.91.2" + "dbus-fast==1.91.4" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bac607545e6..72b9872d3bf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.8.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.91.2 +dbus-fast==1.91.4 fnv-hash-fast==0.4.0 ha-av==10.1.1 hass-nabucasa==0.69.0 diff --git a/requirements_all.txt b/requirements_all.txt index 66e73e07026..b32eb8a2d05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -635,7 +635,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.91.2 +dbus-fast==1.91.4 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36450cb31da..36356e0588c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -515,7 +515,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.91.2 +dbus-fast==1.91.4 # homeassistant.components.debugpy debugpy==1.6.7 From 89705a22cf9bd0362bd67c2ef7b4ba221b4e0c16 Mon Sep 17 00:00:00 2001 From: Niels Perfors Date: Fri, 18 Aug 2023 10:26:01 +0200 Subject: [PATCH 154/180] Verisure unpack (#98605) --- .../components/verisure/coordinator.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index bc3b68922b0..bbfaed0a0a4 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -83,13 +83,16 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): raise UpdateFailed("Could not read overview") from err def unpack(overview: list, value: str) -> dict | list: - return next( - ( - item["data"]["installation"][value] - for item in overview - if value in item.get("data", {}).get("installation", {}) - ), - [], + return ( + next( + ( + item["data"]["installation"][value] + for item in overview + if value in item.get("data", {}).get("installation", {}) + ), + [], + ) + or [] ) # Store data in a way Home Assistant can easily consume it From 2f204d5747a97de4659b792a3b84c78f3089f1a7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Aug 2023 10:38:21 +0200 Subject: [PATCH 155/180] Remove unneeded startswith in content check of image upload (#98599) --- homeassistant/components/image_upload/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 6486d584b0e..6faa690b4cb 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -78,8 +78,10 @@ class ImageStorageCollection(collection.DictStorageCollection): data = self.CREATE_SCHEMA(dict(data)) uploaded_file: FileField = data["file"] - if not uploaded_file.content_type.startswith( - ("image/gif", "image/jpeg", "image/png") + if uploaded_file.content_type not in ( + "image/gif", + "image/jpeg", + "image/png", ): raise vol.Invalid("Only jpeg, png, and gif images are allowed") From 5a7084e78c4f66660ff59a239f0d2bdf83c5dc80 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 18 Aug 2023 10:48:57 +0200 Subject: [PATCH 156/180] Correct number of registers to read for sensors for modbus (#98534) --- homeassistant/components/modbus/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index bed5932a303..e4f4f9b8d66 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -68,7 +68,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): """Initialize the modbus register sensor.""" super().__init__(hub, entry) if slave_count: - self._count = self._count * slave_count + self._count = self._count * (slave_count + 1) self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = entry.get(CONF_STATE_CLASS) From d5338e88f214ad89761a0fb5432b267a3d2f018b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 18 Aug 2023 08:49:43 +0000 Subject: [PATCH 157/180] Fix the availability condition for Shelly N current sensor (#98518) --- homeassistant/components/shelly/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 896ffd72327..cd9980921c8 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -541,7 +541,8 @@ RPC_SENSORS: Final = { native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - available=lambda status: status["n_current"] is not None, + available=lambda status: (status and status["n_current"]) is not None, + removal_condition=lambda _config, status, _key: "n_current" not in status, entity_registry_enabled_default=False, ), "total_current": RpcSensorDescription( From 9be532cea9370ac5a0e6415825a945f2c448de9b Mon Sep 17 00:00:00 2001 From: Luca Leonardo Scorcia Date: Fri, 18 Aug 2023 04:52:22 -0400 Subject: [PATCH 158/180] Fix inconsistent lyric temperature unit (#98457) --- homeassistant/components/lyric/climate.py | 22 +++++++++++++--------- homeassistant/components/lyric/sensor.py | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 099a0a028d0..df90ebcd6cf 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -21,7 +21,12 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform @@ -113,7 +118,6 @@ async def async_setup_entry( ), location, device, - hass.config.units.temperature_unit, ) ) @@ -140,10 +144,15 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): description: ClimateEntityDescription, location: LyricLocation, device: LyricDevice, - temperature_unit: str, ) -> None: """Initialize Honeywell Lyric climate entity.""" - self._temperature_unit = temperature_unit + # Use the native temperature unit from the device settings + if device.units == "Fahrenheit": + self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._attr_precision = PRECISION_WHOLE + else: + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_precision = PRECISION_HALVES # Setup supported hvac modes self._attr_hvac_modes = [HVACMode.OFF] @@ -176,11 +185,6 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): return SUPPORT_FLAGS_LCC return SUPPORT_FLAGS_TCC - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - return self._temperature_unit - @property def current_temperature(self) -> float | None: """Return the current temperature.""" diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 1201a675a5d..1e15ff58b18 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -76,6 +76,11 @@ async def async_setup_entry( for location in coordinator.data.locations: for device in location.devices: if device.indoorTemperature: + if device.units == "Fahrenheit": + native_temperature_unit = UnitOfTemperature.FAHRENHEIT + else: + native_temperature_unit = UnitOfTemperature.CELSIUS + entities.append( LyricSensor( coordinator, @@ -84,7 +89,7 @@ async def async_setup_entry( name="Indoor Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=hass.config.units.temperature_unit, + native_unit_of_measurement=native_temperature_unit, value=lambda device: device.indoorTemperature, ), location, @@ -108,6 +113,11 @@ async def async_setup_entry( ) ) if device.outdoorTemperature: + if device.units == "Fahrenheit": + native_temperature_unit = UnitOfTemperature.FAHRENHEIT + else: + native_temperature_unit = UnitOfTemperature.CELSIUS + entities.append( LyricSensor( coordinator, @@ -116,7 +126,7 @@ async def async_setup_entry( name="Outdoor Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=hass.config.units.temperature_unit, + native_unit_of_measurement=native_temperature_unit, value=lambda device: device.outdoorTemperature, ), location, From e42b9e6c4c7a85847d3de25e4b40093b2917c3c0 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 18 Aug 2023 10:52:57 +0200 Subject: [PATCH 159/180] Modbus: set state_class etc in slaves. (#98332) --- homeassistant/components/modbus/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index e4f4f9b8d66..97794729ab2 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_SENSORS, CONF_UNIQUE_ID, @@ -72,6 +73,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = entry.get(CONF_STATE_CLASS) + self._attr_device_class = entry.get(CONF_DEVICE_CLASS) async def async_setup_slaves( self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] @@ -160,6 +162,8 @@ class SlaveSensor( self._attr_unique_id = entry.get(CONF_UNIQUE_ID) if self._attr_unique_id: self._attr_unique_id = f"{self._attr_unique_id}_{idx}" + self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_state_class = entry.get(CONF_STATE_CLASS) self._attr_available = False super().__init__(coordinator) From 59d37f65d5d59e2077ccc35e5440cbf56fdfeffe Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 18 Aug 2023 10:55:39 +0200 Subject: [PATCH 160/180] Correct modbus config validator: slave/swap (#97798) --- homeassistant/components/modbus/validators.py | 71 +++++++++++-------- tests/components/modbus/test_init.py | 11 ++- tests/components/modbus/test_sensor.py | 2 +- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index ee9d40dd874..40461e3effd 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -65,25 +65,14 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: name = config[CONF_NAME] structure = config.get(CONF_STRUCTURE) slave_count = config.get(CONF_SLAVE_COUNT, 0) + 1 - swap_type = config.get(CONF_SWAP) - if config[CONF_DATA_TYPE] != DataType.CUSTOM: - if structure: - error = f"{name} structure: cannot be mixed with {data_type}" + slave = config.get(CONF_SLAVE, 0) + swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) + if config[CONF_DATA_TYPE] == DataType.CUSTOM: + if slave or slave_count > 1: + error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SLAVE_COUNT}` / `{CONF_SLAVE}`" raise vol.Invalid(error) - if data_type not in DEFAULT_STRUCT_FORMAT: - error = f"Error in sensor {name}. data_type `{data_type}` not supported" - raise vol.Invalid(error) - - structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" - if CONF_COUNT not in config: - config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count - if slave_count > 1: - structure = f">{slave_count}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" - else: - structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" - else: - if slave_count > 1: - error = f"{name} structure: cannot be mixed with {CONF_SLAVE_COUNT}" + if swap_type != CONF_SWAP_NONE: + error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SWAP}`" raise vol.Invalid(error) if not structure: error = ( @@ -102,19 +91,43 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: f"Structure request {size} bytes, " f"but {count} registers have a size of {bytecount} bytes" ) + return { + **config, + CONF_STRUCTURE: structure, + CONF_SWAP: swap_type, + } - if swap_type != CONF_SWAP_NONE: - if swap_type == CONF_SWAP_BYTE: - regs_needed = 1 - else: # CONF_SWAP_WORD_BYTE, CONF_SWAP_WORD - regs_needed = 2 - if count < regs_needed or (count % regs_needed) != 0: - raise vol.Invalid( - f"Error in sensor {name} swap({swap_type}) " - "not possible due to the registers " - f"count: {count}, needed: {regs_needed}" - ) + if structure: + error = f"{name} structure: cannot be mixed with {data_type}" + raise vol.Invalid(error) + if data_type not in DEFAULT_STRUCT_FORMAT: + error = f"Error in sensor {name}. data_type `{data_type}` not supported" + raise vol.Invalid(error) + if (slave or slave_count > 1) and data_type == DataType.STRING: + error = ( + f"{name}: `{data_type}` illegal with `{CONF_SLAVE_COUNT}` / `{CONF_SLAVE}`" + ) + raise vol.Invalid(error) + if CONF_COUNT not in config: + config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count + if swap_type != CONF_SWAP_NONE: + if swap_type == CONF_SWAP_BYTE: + regs_needed = 1 + else: # CONF_SWAP_WORD_BYTE, CONF_SWAP_WORD + regs_needed = 2 + count = config[CONF_COUNT] + if count < regs_needed or (count % regs_needed) != 0: + raise vol.Invalid( + f"Error in sensor {name} swap({swap_type}) " + "not possible due to the registers " + f"count: {count}, needed: {regs_needed}" + ) + structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" + if slave_count > 1: + structure = f">{slave_count}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" + else: + structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" return { **config, CONF_STRUCTURE: structure, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 35c01ec478b..6ad1e33821c 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -181,7 +181,6 @@ async def test_nan_validator() -> None: CONF_COUNT: 2, CONF_DATA_TYPE: DataType.CUSTOM, CONF_STRUCTURE: ">i", - CONF_SWAP: CONF_SWAP_BYTE, }, ], ) @@ -239,6 +238,16 @@ async def test_ok_struct_validator(do_config) -> None: CONF_STRUCTURE: ">f", CONF_SLAVE_COUNT: 5, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_DATA_TYPE: DataType.STRING, + CONF_SLAVE_COUNT: 2, + }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_DATA_TYPE: DataType.INT16, + CONF_SWAP: CONF_SWAP_WORD, + }, ], ) async def test_exception_struct_validator(do_config) -> None: diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 06b0b68a746..e7d15c971c9 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -246,7 +246,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"Error in sensor {TEST_ENTITY_NAME} swap(word) not possible due to the registers count: 1, needed: 2", + f"{TEST_ENTITY_NAME}: `structure` illegal with `swap`", ), ], ) From 7ac2c61f2431c54541d2d84772755290f5e65486 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 18 Aug 2023 11:02:30 +0200 Subject: [PATCH 161/180] Fix copy-paste error in comments of number tests (#98615) --- tests/components/number/test_init.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 37c0b175faa..d77a67e4ada 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -818,22 +818,22 @@ async def test_name(hass: HomeAssistant) -> None: ), ) - # Unnamed sensor without device class -> no name + # Unnamed number without device class -> no name entity1 = NumberEntity() entity1.entity_id = "number.test1" - # Unnamed sensor with device class but has_entity_name False -> no name + # Unnamed number with device class but has_entity_name False -> no name entity2 = NumberEntity() entity2.entity_id = "number.test2" entity2._attr_device_class = NumberDeviceClass.TEMPERATURE - # Unnamed sensor with device class and has_entity_name True -> named + # Unnamed number with device class and has_entity_name True -> named entity3 = NumberEntity() entity3.entity_id = "number.test3" entity3._attr_device_class = NumberDeviceClass.TEMPERATURE entity3._attr_has_entity_name = True - # Unnamed sensor with device class and has_entity_name True -> named + # Unnamed number with device class and has_entity_name True -> named entity4 = NumberEntity() entity4.entity_id = "number.test4" entity4.entity_description = NumberEntityDescription( From 80a5e341b52d863396c63a69a68d30134f36e8ab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 18 Aug 2023 11:48:00 +0200 Subject: [PATCH 162/180] Add device to Garage Amsterdam entity (#98573) --- homeassistant/components/garages_amsterdam/entity.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/garages_amsterdam/entity.py b/homeassistant/components/garages_amsterdam/entity.py index 894506f7da9..df06f47dff5 100644 --- a/homeassistant/components/garages_amsterdam/entity.py +++ b/homeassistant/components/garages_amsterdam/entity.py @@ -1,12 +1,13 @@ """Generic entity for Garages Amsterdam.""" from __future__ import annotations +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import ATTRIBUTION +from .const import ATTRIBUTION, DOMAIN class GaragesAmsterdamEntity(CoordinatorEntity): @@ -22,3 +23,8 @@ class GaragesAmsterdamEntity(CoordinatorEntity): self._attr_unique_id = f"{garage_name}-{info_type}" self._garage_name = garage_name self._info_type = info_type + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, garage_name)}, + name=garage_name, + entry_type=DeviceEntryType.SERVICE, + ) From 5ef6c036102290042bfe9a3b87609c9f031a557f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 18 Aug 2023 13:05:53 +0200 Subject: [PATCH 163/180] Log entity_id payload and template on MQTT value template error (#98353) * Log entity_id payload and template on error * Also handle cases with default values. * Do not log payload twice Co-authored-by: Erik Montnemery * Tweak test to assert without payload * black --------- Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/models.py | 39 ++++++++++++++++++++----- tests/components/mqtt/test_init.py | 32 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 9afa3de3f48..a936c9e420d 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -231,11 +231,21 @@ class MqttValueTemplate: values, self._value_template, ) - rendered_payload = ( - self._value_template.async_render_with_possible_json_value( - payload, variables=values + try: + rendered_payload = ( + self._value_template.async_render_with_possible_json_value( + payload, variables=values + ) ) - ) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error( + "%s: %s rendering template for entity '%s', template: '%s'", + type(ex).__name__, + ex, + self._entity.entity_id if self._entity else "n/a", + self._value_template.template, + ) + raise ex return rendered_payload _LOGGER.debug( @@ -248,9 +258,24 @@ class MqttValueTemplate: default, self._value_template, ) - rendered_payload = self._value_template.async_render_with_possible_json_value( - payload, default, variables=values - ) + try: + rendered_payload = ( + self._value_template.async_render_with_possible_json_value( + payload, default, variables=values + ) + ) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error( + "%s: %s rendering template for entity '%s', template: " + "'%s', default value: %s and payload: %s", + type(ex).__name__, + ex, + self._entity.entity_id if self._entity else "n/a", + self._value_template.template, + default, + payload, + ) + raise ex return rendered_payload diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index c0d7a94de5b..e3a12a2c24e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -41,6 +41,7 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, + MockEntity, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -417,6 +418,37 @@ async def test_value_template_value(hass: HomeAssistant) -> None: assert template_state_calls.call_count == 1 +async def test_value_template_fails( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the rendering of MQTT value template fails.""" + + # test rendering a value fails + entity = MockEntity(entity_id="sensor.test") + entity.hass = hass + tpl = template.Template("{{ value_json.some_var * 2 }}") + val_tpl = mqtt.MqttValueTemplate(tpl, hass=hass, entity=entity) + with pytest.raises(TypeError): + val_tpl.async_render_with_possible_json_value('{"some_var": null }') + await hass.async_block_till_done() + assert ( + "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' " + "rendering template for entity 'sensor.test', " + "template: '{{ value_json.some_var * 2 }}'" + ) in caplog.text + caplog.clear() + with pytest.raises(TypeError): + val_tpl.async_render_with_possible_json_value( + '{"some_var": null }', default=100 + ) + assert ( + "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' " + "rendering template for entity 'sensor.test', " + "template: '{{ value_json.some_var * 2 }}', default value: 100 and payload: " + '{"some_var": null }' + ) in caplog.text + + async def test_service_call_without_topic_does_not_publish( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: From 66685b796d9a2e932688f0699f5c4d81443eaa97 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 18 Aug 2023 13:09:35 +0200 Subject: [PATCH 164/180] Update frontend to 20230802.1 (#98616) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 84d1d4f5e27..986dfd6ba52 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230802.0"] + "requirements": ["home-assistant-frontend==20230802.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 72b9872d3bf..7a93328f3ff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.69.0 hassil==1.2.5 home-assistant-bluetooth==1.10.2 -home-assistant-frontend==20230802.0 +home-assistant-frontend==20230802.1 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b32eb8a2d05..02e462f94e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -988,7 +988,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230802.0 +home-assistant-frontend==20230802.1 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36356e0588c..8bb737295fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -771,7 +771,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230802.0 +home-assistant-frontend==20230802.1 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From 1c56c398971611e511a0728cb75860ef5a639523 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 18 Aug 2023 13:10:13 +0200 Subject: [PATCH 165/180] modbus config: count and slave_count can normally not be mixed. (#97902) --- homeassistant/components/modbus/validators.py | 21 ++++++++++++------- tests/components/modbus/test_init.py | 6 ++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 40461e3effd..f3336e5cb0c 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -67,6 +67,17 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: slave_count = config.get(CONF_SLAVE_COUNT, 0) + 1 slave = config.get(CONF_SLAVE, 0) swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) + if ( + slave_count > 1 + and count > 1 + and data_type not in (DataType.CUSTOM, DataType.STRING) + ): + error = f"{name} {CONF_COUNT} cannot be mixed with {data_type}" + raise vol.Invalid(error) + if config[CONF_DATA_TYPE] != DataType.CUSTOM: + if structure: + error = f"{name} structure: cannot be mixed with {data_type}" + if config[CONF_DATA_TYPE] == DataType.CUSTOM: if slave or slave_count > 1: error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SLAVE_COUNT}` / `{CONF_SLAVE}`" @@ -96,17 +107,11 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: CONF_STRUCTURE: structure, CONF_SWAP: swap_type, } - - if structure: - error = f"{name} structure: cannot be mixed with {data_type}" - raise vol.Invalid(error) if data_type not in DEFAULT_STRUCT_FORMAT: error = f"Error in sensor {name}. data_type `{data_type}` not supported" raise vol.Invalid(error) - if (slave or slave_count > 1) and data_type == DataType.STRING: - error = ( - f"{name}: `{data_type}` illegal with `{CONF_SLAVE_COUNT}` / `{CONF_SLAVE}`" - ) + if slave_count > 1 and data_type == DataType.STRING: + error = f"{name}: `{data_type}` illegal with `{CONF_SLAVE_COUNT}`" raise vol.Invalid(error) if CONF_COUNT not in config: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 6ad1e33821c..e305a0294c8 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -248,6 +248,12 @@ async def test_ok_struct_validator(do_config) -> None: CONF_DATA_TYPE: DataType.INT16, CONF_SWAP: CONF_SWAP_WORD, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_COUNT: 2, + CONF_SLAVE_COUNT: 2, + CONF_DATA_TYPE: DataType.INT32, + }, ], ) async def test_exception_struct_validator(do_config) -> None: From fc444e4cd63476477d0e6cf4db9de5e151aae590 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 18 Aug 2023 13:15:59 +0200 Subject: [PATCH 166/180] Allow control of pump mode for nibe (#98499) * Allow control of pump mode --------- Co-authored-by: G Johansson --- .../components/nibe_heatpump/climate.py | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 0df787de986..4ab709ae947 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -24,6 +24,7 @@ from homeassistant.components.climate import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -70,7 +71,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE ) - _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF, HVACMode.HEAT] + _attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.HEAT_COOL] _attr_target_temperature_step = 0.5 _attr_max_temp = 35.0 _attr_min_temp = 5.0 @@ -101,7 +102,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): self._attr_unique_id = f"{coordinator.unique_id}-{key}" self._attr_device_info = coordinator.device_info self._attr_hvac_action = HVACAction.IDLE - self._attr_hvac_mode = HVACMode.OFF + self._attr_hvac_mode = HVACMode.AUTO self._attr_target_temperature_high = None self._attr_target_temperature_low = None self._attr_target_temperature = None @@ -138,7 +139,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): self._attr_current_temperature = _get_float(self._coil_current) - mode = HVACMode.OFF + mode = HVACMode.AUTO if _get_value(self._coil_use_room_sensor) == "ON": if ( _get_value(self._coil_cooling_with_room_sensor) @@ -225,3 +226,25 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): if (temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH)) is not None: await coordinator.async_write_coil(self._coil_setpoint_cool, temperature) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + coordinator = self.coordinator + + if hvac_mode == HVACMode.HEAT_COOL: + await coordinator.async_write_coil( + self._coil_cooling_with_room_sensor, "ON" + ) + await coordinator.async_write_coil(self._coil_use_room_sensor, "ON") + elif hvac_mode == HVACMode.HEAT: + await coordinator.async_write_coil( + self._coil_cooling_with_room_sensor, "OFF" + ) + await coordinator.async_write_coil(self._coil_use_room_sensor, "ON") + elif hvac_mode == HVACMode.AUTO: + await coordinator.async_write_coil( + self._coil_cooling_with_room_sensor, "OFF" + ) + await coordinator.async_write_coil(self._coil_use_room_sensor, "OFF") + else: + raise HomeAssistantError(f"{hvac_mode} mode not supported for {self.name}") From c268adb07e9c9dfc72260cac9a25639fad60df09 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 18 Aug 2023 13:23:04 +0200 Subject: [PATCH 167/180] modbus: Repair swap for slaves (#97960) --- .../components/modbus/base_platform.py | 19 +- homeassistant/components/modbus/climate.py | 2 +- homeassistant/components/modbus/sensor.py | 5 +- tests/components/modbus/test_sensor.py | 183 +++++++++++++++++- 4 files changed, 192 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index e4c657a6c54..9cf582a5dda 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -50,10 +50,12 @@ from .const import ( CONF_NAN_VALUE, CONF_PRECISION, CONF_SCALE, + CONF_SLAVE_COUNT, CONF_STATE_OFF, CONF_STATE_ON, CONF_SWAP, CONF_SWAP_BYTE, + CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, @@ -154,15 +156,25 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): """Initialize the switch.""" super().__init__(hub, config) self._swap = config[CONF_SWAP] + if self._swap == CONF_SWAP_NONE: + self._swap = None self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] self._offset = config[CONF_OFFSET] - self._count = config[CONF_COUNT] + self._slave_count = config.get(CONF_SLAVE_COUNT, 0) + self._slave_size = self._count = config[CONF_COUNT] - def _swap_registers(self, registers: list[int]) -> list[int]: + def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: """Do swap as needed.""" + if slave_count: + swapped = [] + for i in range(0, self._slave_count + 1): + inx = i * self._slave_size + inx2 = inx + self._slave_size + swapped.extend(self._swap_registers(registers[inx:inx2], 0)) + return swapped if self._swap in (CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE): # convert [12][34] --> [21][43] for i, register in enumerate(registers): @@ -192,7 +204,8 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): def unpack_structure_result(self, registers: list[int]) -> str | None: """Convert registers to proper result.""" - registers = self._swap_registers(registers) + if self._swap: + registers = self._swap_registers(registers, self._slave_count) byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) if self._data_type == DataType.STRING: return byte_string.decode() diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 95f8bee0bc9..7170716d43e 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -210,7 +210,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): int.from_bytes(as_bytes[i : i + 2], "big") for i in range(0, len(as_bytes), 2) ] - registers = self._swap_registers(raw_regs) + registers = self._swap_registers(raw_regs, 0) if self._data_type in ( DataType.INT16, diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 97794729ab2..fe2d4bc415d 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -134,10 +134,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): self._coordinator.async_set_updated_data(None) else: self._attr_native_value = result - if self._attr_native_value is None: - self._attr_available = False - else: - self._attr_available = True + self._attr_available = self._attr_native_value is not None self._lazy_errors = self._lazy_error_count self.async_write_ha_state() diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index e7d15c971c9..298daa1397f 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -615,9 +615,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: CONF_ADDRESS: 51, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DATA_TYPE: DataType.UINT32, - CONF_SCALE: 1, - CONF_OFFSET: 0, - CONF_PRECISION: 0, + CONF_SCAN_INTERVAL: 1, }, ], }, @@ -689,17 +687,184 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: ) async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: """Run test for sensor.""" - assert hass.states.get(ENTITY_ID).state == expected[0] entity_registry = er.async_get(hass) - - for i in range(1, len(expected)): - entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i}".replace(" ", "_") - assert hass.states.get(entity_id).state == expected[i] - unique_id = f"{SLAVE_UNIQUE_ID}_{i}" + for i in range(0, len(expected)): + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") + unique_id = f"{SLAVE_UNIQUE_ID}" + if i: + entity_id = f"{entity_id}_{i}" + unique_id = f"{unique_id}_{i}" entry = entity_registry.async_get(entity_id) + state = hass.states.get(entity_id).state + assert state == expected[i] assert entry.unique_id == unique_id +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 1, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + ("config_addon", "register_words", "do_exception", "expected"), + [ + ( + { + CONF_SLAVE_COUNT: 0, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_SWAP: CONF_SWAP_BYTE, + CONF_DATA_TYPE: DataType.UINT16, + }, + [0x0102], + False, + [str(int(0x0201))], + ), + ( + { + CONF_SLAVE_COUNT: 0, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_SWAP: CONF_SWAP_WORD, + CONF_DATA_TYPE: DataType.UINT32, + }, + [0x0102, 0x0304], + False, + [str(int(0x03040102))], + ), + ( + { + CONF_SLAVE_COUNT: 0, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_SWAP: CONF_SWAP_WORD, + CONF_DATA_TYPE: DataType.UINT64, + }, + [0x0102, 0x0304, 0x0506, 0x0708], + False, + [str(int(0x0708050603040102))], + ), + ( + { + CONF_SLAVE_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.UINT16, + CONF_SWAP: CONF_SWAP_BYTE, + }, + [0x0102, 0x0304], + False, + [str(int(0x0201)), str(int(0x0403))], + ), + ( + { + CONF_SLAVE_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.UINT32, + CONF_SWAP: CONF_SWAP_WORD, + }, + [0x0102, 0x0304, 0x0506, 0x0708], + False, + [str(int(0x03040102)), str(int(0x07080506))], + ), + ( + { + CONF_SLAVE_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.UINT64, + CONF_SWAP: CONF_SWAP_WORD, + }, + [0x0102, 0x0304, 0x0506, 0x0708, 0x0901, 0x0902, 0x0903, 0x0904], + False, + [str(int(0x0708050603040102)), str(int(0x0904090309020901))], + ), + ( + { + CONF_SLAVE_COUNT: 3, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.UINT16, + CONF_SWAP: CONF_SWAP_BYTE, + }, + [0x0102, 0x0304, 0x0506, 0x0708], + False, + [str(int(0x0201)), str(int(0x0403)), str(int(0x0605)), str(int(0x0807))], + ), + ( + { + CONF_SLAVE_COUNT: 3, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.UINT32, + CONF_SWAP: CONF_SWAP_WORD, + }, + [ + 0x0102, + 0x0304, + 0x0506, + 0x0708, + 0x090A, + 0x0B0C, + 0x0D0E, + 0x0F00, + ], + False, + [ + str(int(0x03040102)), + str(int(0x07080506)), + str(int(0x0B0C090A)), + str(int(0x0F000D0E)), + ], + ), + ( + { + CONF_SLAVE_COUNT: 3, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.UINT64, + CONF_SWAP: CONF_SWAP_WORD, + }, + [ + 0x0601, + 0x0602, + 0x0603, + 0x0604, + 0x0701, + 0x0702, + 0x0703, + 0x0704, + 0x0801, + 0x0802, + 0x0803, + 0x0804, + 0x0901, + 0x0902, + 0x0903, + 0x0904, + ], + False, + [ + str(int(0x0604060306020601)), + str(int(0x0704070307020701)), + str(int(0x0804080308020801)), + str(int(0x0904090309020901)), + ], + ), + ], +) +async def test_slave_swap_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: + """Run test for sensor.""" + for i in range(0, len(expected)): + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") + if i: + entity_id = f"{entity_id}_{i}" + state = hass.states.get(entity_id).state + assert state == expected[i] + + @pytest.mark.parametrize( "do_config", [ From 790523126effe84de725b469f3dd3946709719ca Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 18 Aug 2023 13:40:35 +0200 Subject: [PATCH 168/180] Name unnamed update entities by their device class (#98579) --- homeassistant/components/ezviz/strings.json | 5 - homeassistant/components/ezviz/update.py | 1 - .../components/litterrobot/strings.json | 5 - .../components/litterrobot/update.py | 1 - .../components/rainmachine/strings.json | 5 - .../components/rainmachine/update.py | 1 - homeassistant/components/update/__init__.py | 7 ++ tests/components/update/test_init.py | 114 +++++++++++++++++- 8 files changed, 120 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 373f9af22fc..11144f8ae71 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -223,11 +223,6 @@ "name": "Follow movement" } }, - "update": { - "firmware": { - "name": "[%key:component::update::entity_component::firmware::name%]" - } - }, "siren": { "siren": { "name": "[%key:component::siren::title%]" diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index 6a80a579080..003397d8dda 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -24,7 +24,6 @@ PARALLEL_UPDATES = 1 UPDATE_ENTITY_TYPES = UpdateEntityDescription( key="version", - translation_key="firmware", device_class=UpdateDeviceClass.FIRMWARE, ) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 8436d24902c..7acfad69735 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -131,11 +131,6 @@ "litter_box": { "name": "Litter box" } - }, - "update": { - "firmware": { - "name": "Firmware" - } } }, "services": { diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index 9b8391c5bae..584a6af77c2 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -24,7 +24,6 @@ SCAN_INTERVAL = timedelta(days=1) FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription( key="firmware", - translation_key="firmware", device_class=UpdateDeviceClass.FIRMWARE, ) diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index fc48ebce4eb..ac2b86754e5 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -91,11 +91,6 @@ "hot_days_extra_watering": { "name": "Extra water on hot days" } - }, - "update": { - "firmware": { - "name": "Firmware" - } } }, "services": { diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py index 372319ba9a0..8d5690b5320 100644 --- a/homeassistant/components/rainmachine/update.py +++ b/homeassistant/components/rainmachine/update.py @@ -44,7 +44,6 @@ UPDATE_STATE_MAP = { UPDATE_DESCRIPTION = RainMachineEntityDescription( key="update", - translation_key="firmware", api_category=DATA_MACHINE_FIRMWARE_UPDATE_STATUS, ) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index b9d01629536..e23032e24fe 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -216,6 +216,13 @@ class UpdateEntity(RestoreEntity): """Version installed and in use.""" return self._attr_installed_version + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For updates this is True if the entity has a device class. + """ + return self.device_class is not None + @property def device_class(self) -> UpdateDeviceClass | None: """Return the class of this entity.""" diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index a7780f54f70..73f98c9e2db 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -1,4 +1,5 @@ """The tests for the Update component.""" +from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest @@ -24,6 +25,7 @@ from homeassistant.components.update.const import ( ATTR_TITLE, UpdateEntityFeature, ) +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, @@ -34,12 +36,24 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -from tests.common import MockEntityPlatform, mock_restore_cache +from tests.common import ( + MockConfigEntry, + MockEntityPlatform, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, +) from tests.typing import WebSocketGenerator +TEST_DOMAIN = "test" + class MockUpdateEntity(UpdateEntity): """Mock UpdateEntity to use in tests.""" @@ -752,3 +766,101 @@ async def test_release_notes_entity_does_not_support_release_notes( result = await client.receive_json() assert result["error"]["code"] == "not_supported" assert result["error"]["message"] == "Entity does not support release notes" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +async def test_name(hass: HomeAssistant) -> None: + """Test update name.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed update entity without device class -> no name + entity1 = UpdateEntity() + entity1.entity_id = "update.test1" + + # Unnamed update entity with device class but has_entity_name False -> no name + entity2 = UpdateEntity() + entity2.entity_id = "update.test2" + entity2._attr_device_class = UpdateDeviceClass.FIRMWARE + + # Unnamed update entity with device class and has_entity_name True -> named + entity3 = UpdateEntity() + entity3.entity_id = "update.test3" + entity3._attr_device_class = UpdateDeviceClass.FIRMWARE + entity3._attr_has_entity_name = True + + # Unnamed update entity with device class and has_entity_name True -> named + entity4 = UpdateEntity() + entity4.entity_id = "update.test4" + entity4.entity_description = UpdateEntityDescription( + "test", + UpdateDeviceClass.FIRMWARE, + has_entity_name=True, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test update platform via config entry.""" + async_add_entities([entity1, entity2, entity3, entity4]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity1.entity_id) + assert state + assert "device_class" not in state.attributes + assert "friendly_name" not in state.attributes + + state = hass.states.get(entity2.entity_id) + assert state + assert state.attributes.get("device_class") == "firmware" + assert "friendly_name" not in state.attributes + + expected = { + "device_class": "firmware", + "friendly_name": "Firmware", + } + state = hass.states.get(entity3.entity_id) + assert state + assert expected.items() <= state.attributes.items() + + state = hass.states.get(entity4.entity_id) + assert state + assert expected.items() <= state.attributes.items() From 4096de2dad415c52fe42b4c60ae7885c1d8268ab Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 18 Aug 2023 16:31:07 +0200 Subject: [PATCH 169/180] Add Yale Smart Living diagnostics test (#98590) * Yale test diagnostics * clean * From review --- tests/components/yale_smart_alarm/conftest.py | 64 ++++ .../yale_smart_alarm/fixtures/get_all.json | 331 +++++++++++++++++ .../snapshots/test_diagnostics.ambr | 344 ++++++++++++++++++ .../yale_smart_alarm/test_diagnostics.py | 24 ++ 4 files changed, 763 insertions(+) create mode 100644 tests/components/yale_smart_alarm/conftest.py create mode 100644 tests/components/yale_smart_alarm/fixtures/get_all.json create mode 100644 tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr create mode 100644 tests/components/yale_smart_alarm/test_diagnostics.py diff --git a/tests/components/yale_smart_alarm/conftest.py b/tests/components/yale_smart_alarm/conftest.py new file mode 100644 index 00000000000..144a24a4897 --- /dev/null +++ b/tests/components/yale_smart_alarm/conftest.py @@ -0,0 +1,64 @@ +"""Fixtures for the Yale Smart Living integration.""" +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import patch + +import pytest +from yalesmartalarmclient.const import YALE_STATE_ARM_FULL + +from homeassistant.components.yale_smart_alarm.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + +ENTRY_CONFIG = { + "username": "test-username", + "password": "new-test-password", + "name": "Yale Smart Alarm", + "area_id": "1", +} +OPTIONS_CONFIG = {"lock_code_digits": 6} + + +@pytest.fixture +async def load_config_entry( + hass: HomeAssistant, load_json: dict[str, Any] +) -> MockConfigEntry: + """Set up the Yale Smart Living integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="username", + version=1, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + autospec=True, + ) as mock_client_class: + client = mock_client_class.return_value + client.auth = None + client.lock_api = None + client.get_all.return_value = load_json + client.get_armed_status.return_value = YALE_STATE_ARM_FULL + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="load_json", scope="session") +def load_json_from_fixture() -> dict[str, Any]: + """Load fixture with json data and return.""" + + data_fixture = load_fixture("get_all.json", "yale_smart_alarm") + json_data: dict[str, Any] = json.loads(data_fixture) + return json_data diff --git a/tests/components/yale_smart_alarm/fixtures/get_all.json b/tests/components/yale_smart_alarm/fixtures/get_all.json new file mode 100644 index 00000000000..08f60fafd3f --- /dev/null +++ b/tests/components/yale_smart_alarm/fixtures/get_all.json @@ -0,0 +1,331 @@ +{ + "DEVICES": [ + { + "area": "1", + "no": "1", + "rf": null, + "address": "123", + "type": "device_type.door_lock", + "name": "Device1", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:01", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "35", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "123", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + } + ], + "MODE": [ + { + "area": "1", + "mode": "disarm" + } + ], + "STATUS": { + "acfail": "main.normal", + "battery": "main.normal", + "tamper": "main.normal", + "jam": "main.normal", + "rssi": "1", + "gsm_rssi": "0", + "imei": "", + "imsi": "" + }, + "CYCLE": { + "model": [ + { + "area": "1", + "mode": "disarm" + } + ], + "panel_status": { + "warning_snd_mute": "0" + }, + "device_status": [ + { + "area": "1", + "no": "1", + "rf": null, + "address": "124", + "type": "device_type.door_lock", + "name": "Device2", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:02", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "35", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "124", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + } + ], + "capture_latest": null, + "report_event_latest": { + "utc_event_time": null, + "time": "1692271914", + "report_id": "1027299996", + "id": "9999", + "event_time": null, + "cid_code": "1807" + }, + "alarm_event_latest": null + }, + "ONLINE": "online", + "HISTORY": [ + { + "report_id": "1027299996", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 11:31:54", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027299889", + "cid": "18180201101", + "event_type": "1802", + "user": 101, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 11:31:43", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027299587", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 11:31:11", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027296099", + "cid": "18180101001", + "event_type": "1801", + "user": 1, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 11:24:52", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027273782", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 10:43:21", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027273230", + "cid": "18180201101", + "event_type": "1802", + "user": 101, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 10:42:09", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027100172", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 05:28:57", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027099978", + "cid": "18180101001", + "event_type": "1801", + "user": 1, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 05:28:39", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027093266", + "cid": "18160200000", + "event_type": "1602", + "user": "", + "area": 0, + "zone": 0, + "name": "", + "type": "", + "event_time": null, + "time": "2023/08/17 05:17:12", + "status_temp_format": "C", + "cid_source": "SYSTEM" + }, + { + "report_id": "1026912623", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/16 20:29:36", + "status_temp_format": "C", + "cid_source": "DEVICE" + } + ], + "PANEL INFO": { + "mac": "00:00:00:00:10", + "report_account": "username", + "xml_version": "2", + "version": "MINIGW-MZ-1_G 1.0.1.29A,,4.1.2.6.2,00:1D:94:0B:5E:A7,10111112,ML_yamga", + "net_version": "MINIGW-MZ-1_G 1.0.1.29A", + "rf51_version": "", + "zb_version": "4.1.2.6.2", + "zw_version": "", + "SMS_Balance": "50", + "voice_balance": "0", + "name": "", + "contact": "", + "mail_address": "username@fake.com", + "phone": "UK-01902364606 / Sweden-0770373710 / Demark-89887818 / Norway-81569036", + "service_time": "UK - Mon to Fri 8:30 til 17:30 / Scandinavia - Mon to Fri 8:00 til 20:00, Sat to Sun 10:00 til 15:00", + "dealer_name": "Poland" + }, + "AUTH CHECK": { + "user_id": "username", + "id": "username", + "mail_address": "username@fake.com", + "mac": "00:00:00:00:20", + "is_auth": "1", + "master": "1", + "first_login": "1", + "name": "Device1", + "token_time": "2023-08-17 16:19:20", + "agent": false, + "xml_version": "2", + "dealer_id": "605", + "dealer_group": "yale" + } +} diff --git a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..faff1c5103a --- /dev/null +++ b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr @@ -0,0 +1,344 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'AUTH CHECK': dict({ + 'agent': False, + 'dealer_group': 'yale', + 'dealer_id': '605', + 'first_login': '1', + 'id': '**REDACTED**', + 'is_auth': '1', + 'mac': '**REDACTED**', + 'mail_address': '**REDACTED**', + 'master': '1', + 'name': '**REDACTED**', + 'token_time': '2023-08-17 16:19:20', + 'user_id': '**REDACTED**', + 'xml_version': '2', + }), + 'CYCLE': dict({ + 'alarm_event_latest': None, + 'capture_latest': None, + 'device_status': list([ + dict({ + '_state': 'locked', + '_state2': 'closed', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '35', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '1', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + ]), + 'model': list([ + dict({ + 'area': '1', + 'mode': 'disarm', + }), + ]), + 'panel_status': dict({ + 'warning_snd_mute': '0', + }), + 'report_event_latest': dict({ + 'cid_code': '1807', + 'event_time': None, + 'id': '**REDACTED**', + 'report_id': '1027299996', + 'time': '1692271914', + 'utc_event_time': None, + }), + }), + 'DEVICES': list([ + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '35', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '1', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + ]), + 'HISTORY': list([ + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027299996', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:31:54', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180201101', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1802', + 'name': '**REDACTED**', + 'report_id': '1027299889', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:31:43', + 'type': 'device_type.door_lock', + 'user': 101, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027299587', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:31:11', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180101001', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1801', + 'name': '**REDACTED**', + 'report_id': '1027296099', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:24:52', + 'type': 'device_type.door_lock', + 'user': 1, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027273782', + 'status_temp_format': 'C', + 'time': '2023/08/17 10:43:21', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180201101', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1802', + 'name': '**REDACTED**', + 'report_id': '1027273230', + 'status_temp_format': 'C', + 'time': '2023/08/17 10:42:09', + 'type': 'device_type.door_lock', + 'user': 101, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027100172', + 'status_temp_format': 'C', + 'time': '2023/08/17 05:28:57', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180101001', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1801', + 'name': '**REDACTED**', + 'report_id': '1027099978', + 'status_temp_format': 'C', + 'time': '2023/08/17 05:28:39', + 'type': 'device_type.door_lock', + 'user': 1, + 'zone': 1, + }), + dict({ + 'area': 0, + 'cid': '18160200000', + 'cid_source': 'SYSTEM', + 'event_time': None, + 'event_type': '1602', + 'name': '', + 'report_id': '1027093266', + 'status_temp_format': 'C', + 'time': '2023/08/17 05:17:12', + 'type': '', + 'user': '', + 'zone': 0, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1026912623', + 'status_temp_format': 'C', + 'time': '2023/08/16 20:29:36', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + ]), + 'MODE': list([ + dict({ + 'area': '1', + 'mode': 'disarm', + }), + ]), + 'ONLINE': 'online', + 'PANEL INFO': dict({ + 'SMS_Balance': '50', + 'contact': '', + 'dealer_name': 'Poland', + 'mac': '**REDACTED**', + 'mail_address': '**REDACTED**', + 'name': '', + 'net_version': 'MINIGW-MZ-1_G 1.0.1.29A', + 'phone': 'UK-01902364606 / Sweden-0770373710 / Demark-89887818 / Norway-81569036', + 'report_account': '**REDACTED**', + 'rf51_version': '', + 'service_time': 'UK - Mon to Fri 8:30 til 17:30 / Scandinavia - Mon to Fri 8:00 til 20:00, Sat to Sun 10:00 til 15:00', + 'version': 'MINIGW-MZ-1_G 1.0.1.29A,,4.1.2.6.2,00:1D:94:0B:5E:A7,10111112,ML_yamga', + 'voice_balance': '0', + 'xml_version': '2', + 'zb_version': '4.1.2.6.2', + 'zw_version': '', + }), + 'STATUS': dict({ + 'acfail': 'main.normal', + 'battery': 'main.normal', + 'gsm_rssi': '0', + 'imei': '', + 'imsi': '', + 'jam': 'main.normal', + 'rssi': '1', + 'tamper': 'main.normal', + }), + }) +# --- diff --git a/tests/components/yale_smart_alarm/test_diagnostics.py b/tests/components/yale_smart_alarm/test_diagnostics.py new file mode 100644 index 00000000000..8796eeb465b --- /dev/null +++ b/tests/components/yale_smart_alarm/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Test Yale Smart Living diagnostics.""" +from __future__ import annotations + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + load_config_entry: ConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + entry = load_config_entry + + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diag == snapshot From 4073f56c5eb4205457b219d3f04a2edd868fa279 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 18 Aug 2023 16:40:24 +0200 Subject: [PATCH 170/180] Remove default code in Yale Smart Living (#94675) * Remove default code in Yale Smart Living * Test and remove check * Finalize * migration * add back * add back 2 * Fix tests * Fix migration if code not exist --- .../components/yale_smart_alarm/__init__.py | 32 ++++++++++- .../yale_smart_alarm/config_flow.py | 28 +++------ .../components/yale_smart_alarm/lock.py | 6 +- .../components/yale_smart_alarm/strings.json | 4 -- .../yale_smart_alarm/test_config_flow.py | 57 +++++-------------- 5 files changed, 56 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 763742cce70..830d8d9f69e 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -1,11 +1,14 @@ """The yale_smart_alarm component.""" from __future__ import annotations +from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CODE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import entity_registry as er -from .const import COORDINATOR, DOMAIN, PLATFORMS +from .const import COORDINATOR, DOMAIN, LOGGER, PLATFORMS from .coordinator import YaleDataUpdateCoordinator @@ -39,3 +42,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return True return False + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + if config_entry_default_code := entry.options.get(CONF_CODE): + entity_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) + for entity in entries: + if entity.entity_id.startswith("lock"): + entity_reg.async_update_entity_options( + entity.entity_id, + LOCK_DOMAIN, + {CONF_DEFAULT_CODE: config_entry_default_code}, + ) + new_options = entry.options.copy() + del new_options[CONF_CODE] + + hass.config_entries.async_update_entry(entry, options=new_options) + + entry.version = 2 + + LOGGER.info("Migration to version %s successful", entry.version) + + return True diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index a2462df41cb..ff813d43d78 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -9,7 +9,7 @@ from yalesmartalarmclient.client import YaleSmartAlarmClient from yalesmartalarmclient.exceptions import AuthenticationError from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow -from homeassistant.const import CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv @@ -44,7 +44,7 @@ DATA_SCHEMA_AUTH = vol.Schema( class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yale integration.""" - VERSION = 1 + VERSION = 2 entry: ConfigEntry | None @@ -155,32 +155,22 @@ class YaleOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage Yale options.""" - errors = {} + errors: dict[str, Any] = {} if user_input: - if len(user_input.get(CONF_CODE, "")) not in [ - 0, - user_input[CONF_LOCK_CODE_DIGITS], - ]: - errors["base"] = "code_format_mismatch" - else: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(data=user_input) return self.async_show_form( step_id="init", data_schema=vol.Schema( { - vol.Optional( - CONF_CODE, - description={ - "suggested_value": self.entry.options.get(CONF_CODE) - }, - ): str, vol.Optional( CONF_LOCK_CODE_DIGITS, - default=self.entry.options.get( - CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS - ), + description={ + "suggested_value": self.entry.options.get( + CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS + ) + }, ): int, } ), diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 397a9cc8db1..50d7b28c52b 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CODE, CONF_CODE +from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -52,9 +52,7 @@ class YaleDoorlock(YaleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" - code: str | None = kwargs.get( - ATTR_CODE, self.coordinator.entry.options.get(CONF_CODE) - ) + code: str | None = kwargs.get(ATTR_CODE) return await self.async_set_lock("unlocked", code) async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index ec0c5d0702a..a51d151d7d9 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -31,13 +31,9 @@ "step": { "init": { "data": { - "code": "Default code for locks, used if none is given", "lock_code_digits": "Number of digits in PIN code for locks" } } - }, - "error": { - "code_format_mismatch": "The code does not match the required number of digits" } }, "entity": { diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py index 4553a120060..90c0b78baf5 100644 --- a/tests/components/yale_smart_alarm/test_config_flow.py +++ b/tests/components/yale_smart_alarm/test_config_flow.py @@ -121,6 +121,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: "name": "Yale Smart Alarm", "area_id": "1", }, + version=2, ) entry.add_to_hass(hass) @@ -187,6 +188,7 @@ async def test_reauth_flow_error( "name": "Yale Smart Alarm", "area_id": "1", }, + version=2, ) entry.add_to_hass(hass) @@ -248,11 +250,20 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, unique_id="test-username", - data={}, + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + version=2, ) entry.add_to_hass(hass) with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value=True, + ), patch( "homeassistant.components.yale_smart_alarm.async_setup_entry", return_value=True, ): @@ -266,48 +277,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"code": "123456", "lock_code_digits": 6}, + user_input={"lock_code_digits": 6}, ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"code": "123456", "lock_code_digits": 6} - - -async def test_options_flow_format_mismatch(hass: HomeAssistant) -> None: - """Test options config flow with a code format mismatch error.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="test-username", - data={}, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.yale_smart_alarm.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] == {} - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"code": "123", "lock_code_digits": 6}, - ) - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] == {"base": "code_format_mismatch"} - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"code": "123456", "lock_code_digits": 6}, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"code": "123456", "lock_code_digits": 6} + assert result["data"] == {"lock_code_digits": 6} From 93683cef2742b84c4c4c1cf20254e2508f2dec73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 18 Aug 2023 20:10:29 +0300 Subject: [PATCH 171/180] Use zoneinfo instead of pytz, mark pytz as banned in ruff (#98613) Refs #43439, #49643. --- pyproject.toml | 4 ++++ tests/components/unifiprotect/test_media_source.py | 9 ++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4e477440cde..1587adbea74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -488,6 +488,7 @@ select = [ "SIM401", # Use get from dict with default instead of an if block "T100", # Trace found: {name} used "T20", # flake8-print + "TID251", # Banned imports "TRY004", # Prefer TypeError exception for invalid type "TRY200", # Use raise from to specify exception cause "TRY302", # Remove exception handler; error is immediately re-raised @@ -531,6 +532,9 @@ voluptuous = "vol" [tool.ruff.flake8-pytest-style] fixture-parentheses = false +[tool.ruff.flake8-tidy-imports.banned-api] +"pytz".msg = "use zoneinfo instead" + [tool.ruff.isort] force-sort-within-sections = true known-first-party = [ diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index e19985aea3f..c5690ef5e92 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -5,7 +5,6 @@ from ipaddress import IPv4Address from unittest.mock import AsyncMock, Mock, patch import pytest -import pytz from pyunifiprotect.data import ( Bootstrap, Camera, @@ -441,7 +440,7 @@ ONE_MONTH_SIMPLE = ( minute=0, second=0, microsecond=0, - tzinfo=pytz.timezone("US/Pacific"), + tzinfo=dt_util.get_time_zone("US/Pacific"), ), 1, ) @@ -454,7 +453,7 @@ TWO_MONTH_SIMPLE = ( minute=0, second=0, microsecond=0, - tzinfo=pytz.timezone("US/Pacific"), + tzinfo=dt_util.get_time_zone("US/Pacific"), ), 2, ) @@ -513,7 +512,7 @@ ONE_MONTH_TIMEZONE = ( minute=0, second=0, microsecond=0, - tzinfo=pytz.timezone("US/Pacific"), + tzinfo=dt_util.get_time_zone("US/Pacific"), ), 1, ) @@ -526,7 +525,7 @@ TWO_MONTH_TIMEZONE = ( minute=0, second=0, microsecond=0, - tzinfo=pytz.timezone("US/Pacific"), + tzinfo=dt_util.get_time_zone("US/Pacific"), ), 2, ) From 90b976457841bea4204b249f46a7f8e54cf77c19 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 18 Aug 2023 19:24:33 +0200 Subject: [PATCH 172/180] Bump hatasmota to 0.7.0 (#98636) * Bump hatasmota to 0.7.0 * Update tests according to new entity naming --- .../components/tasmota/manifest.json | 2 +- homeassistant/components/tasmota/mixins.py | 2 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/tasmota/test_binary_sensor.py | 20 +- tests/components/tasmota/test_common.py | 70 ++--- tests/components/tasmota/test_cover.py | 14 +- tests/components/tasmota/test_discovery.py | 10 +- tests/components/tasmota/test_fan.py | 41 +-- tests/components/tasmota/test_light.py | 262 +++++++++--------- tests/components/tasmota/test_switch.py | 26 +- 11 files changed, 238 insertions(+), 213 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index f235256f772..220bc4e31fb 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.6.5"] + "requirements": ["HATasmota==0.7.0"] } diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index 859b11ebd4c..e99106d09e8 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -32,6 +32,8 @@ _LOGGER = logging.getLogger(__name__) class TasmotaEntity(Entity): """Base class for Tasmota entities.""" + _attr_has_entity_name = True + def __init__(self, tasmota_entity: HATasmotaEntity) -> None: """Initialize.""" self._tasmota_entity = tasmota_entity diff --git a/requirements_all.txt b/requirements_all.txt index 02e462f94e4..1afbd67743c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -29,7 +29,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.6.5 +HATasmota==0.7.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bb737295fb..3d0f59d7bca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.6.5 +HATasmota==0.7.0 # homeassistant.components.doods # homeassistant.components.generic diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 6a82a0f0e73..2bfb4a9d5e2 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -125,13 +125,13 @@ async def test_controlling_state_via_mqtt_switchname( ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -139,35 +139,35 @@ async def test_controlling_state_via_mqtt_switchname( async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", '{"Custom Name":{"Action":"ON"}}' ) - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_ON async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", '{"Custom Name":{"Action":"OFF"}}' ) - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_OFF # Test periodic state update async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", '{"Custom Name":"ON"}') - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", '{"Custom Name":"OFF"}') - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_OFF # Test polled state update async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", '{"StatusSNS":{"Custom Name":"ON"}}' ) - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_ON async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", '{"StatusSNS":{"Custom Name":"OFF"}}' ) - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_OFF @@ -243,9 +243,9 @@ async def test_friendly_names( assert state.state == "unavailable" assert state.attributes.get("friendly_name") == "Tasmota binary_sensor 1" - state = hass.states.get("binary_sensor.beer") + state = hass.states.get("binary_sensor.tasmota_beer") assert state.state == "unavailable" - assert state.attributes.get("friendly_name") == "Beer" + assert state.attributes.get("friendly_name") == "Tasmota Beer" async def test_off_delay( diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 703dd2a1893..a184f650fae 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -129,7 +129,7 @@ async def help_test_availability_when_connection_lost( domain, config, sensor_config=None, - entity_id="test", + object_id="tasmota_test", ): """Test availability after MQTT disconnection. @@ -156,7 +156,7 @@ async def help_test_availability_when_connection_lost( config_get_state_online(config), ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE # Disconnected from MQTT server -> state changed to unavailable @@ -165,7 +165,7 @@ async def help_test_availability_when_connection_lost( await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE # Reconnected to MQTT server -> state still unavailable @@ -174,7 +174,7 @@ async def help_test_availability_when_connection_lost( await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE # Receive LWT again @@ -184,7 +184,7 @@ async def help_test_availability_when_connection_lost( config_get_state_online(config), ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE @@ -194,7 +194,7 @@ async def help_test_availability( domain, config, sensor_config=None, - entity_id="test", + object_id="tasmota_test", ): """Test availability. @@ -214,7 +214,7 @@ async def help_test_availability( ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE async_fire_mqtt_message( @@ -223,7 +223,7 @@ async def help_test_availability( config_get_state_online(config), ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message( @@ -232,7 +232,7 @@ async def help_test_availability( config_get_state_offline(config), ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE @@ -242,7 +242,7 @@ async def help_test_availability_discovery_update( domain, config, sensor_config=None, - entity_id="test", + object_id="tasmota_test", ): """Test update of discovered TasmotaAvailability. @@ -280,17 +280,17 @@ async def help_test_availability_discovery_update( ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE async_fire_mqtt_message(hass, availability_topic1, online1) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message(hass, availability_topic1, offline1) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE # Change availability settings @@ -302,13 +302,13 @@ async def help_test_availability_discovery_update( async_fire_mqtt_message(hass, availability_topic1, online2) async_fire_mqtt_message(hass, availability_topic2, online1) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE # Verify we are subscribing to the new topic async_fire_mqtt_message(hass, availability_topic2, online2) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE @@ -390,8 +390,8 @@ async def help_test_discovery_removal( config2, sensor_config1=None, sensor_config2=None, - entity_id="test", - name="Test", + object_id="tasmota_test", + name="Tasmota Test", ): """Test removal of discovered entity.""" device_reg = dr.async_get(hass) @@ -416,11 +416,11 @@ async def help_test_discovery_removal( connections={(dr.CONNECTION_NETWORK_MAC, config1[CONF_MAC])} ) assert device_entry is not None - entity_entry = entity_reg.async_get(f"{domain}.{entity_id}") + entity_entry = entity_reg.async_get(f"{domain}.{object_id}") assert entity_entry is not None # Verify state is added - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is not None assert state.name == name @@ -439,11 +439,11 @@ async def help_test_discovery_removal( connections={(dr.CONNECTION_NETWORK_MAC, config2[CONF_MAC])} ) assert device_entry is not None - entity_entry = entity_reg.async_get(f"{domain}.{entity_id}") + entity_entry = entity_reg.async_get(f"{domain}.{object_id}") assert entity_entry is None # Verify state is removed - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is None @@ -455,8 +455,8 @@ async def help_test_discovery_update_unchanged( config, discovery_update, sensor_config=None, - entity_id="test", - name="Test", + object_id="tasmota_test", + name="Tasmota Test", ): """Test update of discovered component with and without changes. @@ -479,7 +479,7 @@ async def help_test_discovery_update_unchanged( ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is not None assert state.name == name @@ -538,7 +538,13 @@ async def help_test_discovery_device_remove( async def help_test_entity_id_update_subscriptions( - hass, mqtt_mock, domain, config, topics=None, sensor_config=None, entity_id="test" + hass, + mqtt_mock, + domain, + config, + topics=None, + sensor_config=None, + object_id="tasmota_test", ): """Test MQTT subscriptions are managed when entity_id is updated.""" entity_reg = er.async_get(hass) @@ -562,7 +568,7 @@ async def help_test_entity_id_update_subscriptions( topics = [get_topic_tele_state(config), get_topic_tele_will(config)] assert len(topics) > 0 - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is not None assert mqtt_mock.async_subscribe.call_count == len(topics) for topic in topics: @@ -570,11 +576,11 @@ async def help_test_entity_id_update_subscriptions( mqtt_mock.async_subscribe.reset_mock() entity_reg.async_update_entity( - f"{domain}.{entity_id}", new_entity_id=f"{domain}.milk" + f"{domain}.{object_id}", new_entity_id=f"{domain}.milk" ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is None state = hass.states.get(f"{domain}.milk") @@ -584,7 +590,7 @@ async def help_test_entity_id_update_subscriptions( async def help_test_entity_id_update_discovery_update( - hass, mqtt_mock, domain, config, sensor_config=None, entity_id="test" + hass, mqtt_mock, domain, config, sensor_config=None, object_id="tasmota_test" ): """Test MQTT discovery update after entity_id is updated.""" entity_reg = er.async_get(hass) @@ -606,16 +612,16 @@ async def help_test_entity_id_update_discovery_update( async_fire_mqtt_message(hass, topic, config_get_state_online(config)) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message(hass, topic, config_get_state_offline(config)) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE entity_reg.async_update_entity( - f"{domain}.{entity_id}", new_entity_id=f"{domain}.milk" + f"{domain}.{object_id}", new_entity_id=f"{domain}.milk" ) await hass.async_block_till_done() assert hass.states.get(f"{domain}.milk") diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index 156ea365b48..5c1364f1f77 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -658,7 +658,7 @@ async def test_availability_when_connection_lost( mqtt_mock, Platform.COVER, config, - entity_id="test_cover_1", + object_id="test_cover_1", ) @@ -671,7 +671,7 @@ async def test_availability( config["rl"][0] = 3 config["rl"][1] = 3 await help_test_availability( - hass, mqtt_mock, Platform.COVER, config, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" ) @@ -684,7 +684,7 @@ async def test_availability_discovery_update( config["rl"][0] = 3 config["rl"][1] = 3 await help_test_availability_discovery_update( - hass, mqtt_mock, Platform.COVER, config, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" ) @@ -727,7 +727,7 @@ async def test_discovery_removal_cover( Platform.COVER, config1, config2, - entity_id="test_cover_1", + object_id="test_cover_1", name="Test cover 1", ) @@ -753,7 +753,7 @@ async def test_discovery_update_unchanged_cover( Platform.COVER, config, discovery_update, - entity_id="test_cover_1", + object_id="test_cover_1", name="Test cover 1", ) @@ -787,7 +787,7 @@ async def test_entity_id_update_subscriptions( get_topic_tele_will(config), ] await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, Platform.COVER, config, topics, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, topics, object_id="test_cover_1" ) @@ -800,5 +800,5 @@ async def test_entity_id_update_discovery_update( config["rl"][0] = 3 config["rl"][1] = 3 await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, Platform.COVER, config, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" ) diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 9a3f4f91ec7..4fd9f293498 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -143,12 +143,12 @@ async def test_correct_config_discovery( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None - entity_entry = entity_reg.async_get("switch.test") + entity_entry = entity_reg.async_get("switch.tasmota_test") assert entity_entry is not None - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state is not None - assert state.name == "Test" + assert state.name == "Tasmota Test" assert (mac, "switch", "relay", 0) in hass.data[ALREADY_DISCOVERED] @@ -530,11 +530,11 @@ async def test_entity_duplicate_discovery( ) await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") state_duplicate = hass.states.get("binary_sensor.beer1") assert state is not None - assert state.name == "Test" + assert state.name == "Tasmota Test" assert state_duplicate is None assert ( f"Entity already added, sending update: switch ('{mac}', 'switch', 'relay', 0)" diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 0b99036518e..2a50e2d43b5 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -226,10 +226,9 @@ async def test_availability_when_connection_lost( ) -> None: """Test availability after MQTT disconnection.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 await help_test_availability_when_connection_lost( - hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config + hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config, object_id="tasmota" ) @@ -238,9 +237,10 @@ async def test_availability( ) -> None: """Test availability.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 - await help_test_availability(hass, mqtt_mock, Platform.FAN, config) + await help_test_availability( + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) async def test_availability_discovery_update( @@ -248,9 +248,10 @@ async def test_availability_discovery_update( ) -> None: """Test availability discovery update.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 - await help_test_availability_discovery_update(hass, mqtt_mock, Platform.FAN, config) + await help_test_availability_discovery_update( + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) async def test_availability_poll_state( @@ -276,14 +277,19 @@ async def test_discovery_removal_fan( ) -> None: """Test removal of discovered fan.""" config1 = copy.deepcopy(DEFAULT_CONFIG) - config1["dn"] = "Test" config1["if"] = 1 config2 = copy.deepcopy(DEFAULT_CONFIG) - config2["dn"] = "Test" config2["if"] = 0 await help_test_discovery_removal( - hass, mqtt_mock, caplog, Platform.FAN, config1, config2 + hass, + mqtt_mock, + caplog, + Platform.FAN, + config1, + config2, + object_id="tasmota", + name="Tasmota", ) @@ -295,13 +301,19 @@ async def test_discovery_update_unchanged_fan( ) -> None: """Test update of discovered fan.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 with patch( "homeassistant.components.tasmota.fan.TasmotaFan.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, Platform.FAN, config, discovery_update + hass, + mqtt_mock, + caplog, + Platform.FAN, + config, + discovery_update, + object_id="tasmota", + name="Tasmota", ) @@ -310,7 +322,6 @@ async def test_discovery_device_remove( ) -> None: """Test device registry remove.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 unique_id = f"{DEFAULT_CONFIG['mac']}_fan_fan_ifan" await help_test_discovery_device_remove( @@ -323,7 +334,6 @@ async def test_entity_id_update_subscriptions( ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 topics = [ get_topic_stat_result(config), @@ -331,7 +341,7 @@ async def test_entity_id_update_subscriptions( get_topic_tele_will(config), ] await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, Platform.FAN, config, topics + hass, mqtt_mock, Platform.FAN, config, topics, object_id="tasmota" ) @@ -340,8 +350,7 @@ async def test_entity_id_update_discovery_update( ) -> None: """Test MQTT discovery update when entity_id is updated.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, Platform.FAN, config + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" ) diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 612bda8bb08..5c8339a6f89 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -53,7 +53,7 @@ async def test_attributes_on_off( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") is None assert state.attributes.get("max_mireds") is None @@ -82,7 +82,7 @@ async def test_attributes_dimmer_tuya( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") is None assert state.attributes.get("max_mireds") is None @@ -110,7 +110,7 @@ async def test_attributes_dimmer( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") is None assert state.attributes.get("max_mireds") is None @@ -138,7 +138,7 @@ async def test_attributes_ct( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") == 153 assert state.attributes.get("max_mireds") == 500 @@ -167,7 +167,7 @@ async def test_attributes_ct_reduced( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") == 200 assert state.attributes.get("max_mireds") == 380 @@ -195,7 +195,7 @@ async def test_attributes_rgb( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") == [ "Solid", "Wake up", @@ -232,7 +232,7 @@ async def test_attributes_rgbw( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") == [ "Solid", "Wake up", @@ -269,7 +269,7 @@ async def test_attributes_rgbww( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") == [ "Solid", "Wake up", @@ -307,7 +307,7 @@ async def test_attributes_rgbww_reduced( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") == [ "Solid", "Wake up", @@ -341,37 +341,37 @@ async def test_controlling_state_via_mqtt_on_off( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "onoff" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "onoff" async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes @@ -392,32 +392,32 @@ async def test_controlling_state_via_mqtt_ct( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" @@ -425,7 +425,7 @@ async def test_controlling_state_via_mqtt_ct( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","CT":300}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_temp") == 300 assert state.attributes.get("color_mode") == "color_temp" @@ -434,7 +434,7 @@ async def test_controlling_state_via_mqtt_ct( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Color":"255,128"}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_temp") == 300 assert state.attributes.get("brightness") == 128 @@ -457,32 +457,32 @@ async def test_controlling_state_via_mqtt_rgbw( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "hs" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"White":0}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "hs" @@ -490,7 +490,7 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":75,"White":75}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 191 assert state.attributes.get("color_mode") == "white" @@ -500,7 +500,7 @@ async def test_controlling_state_via_mqtt_rgbw( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"HSBColor":"30,100,50","White":0}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("hs_color") == (30, 100) @@ -509,7 +509,7 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","White":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("rgb_color") is None @@ -518,7 +518,7 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":0}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 0 assert state.attributes.get("rgb_color") is None @@ -527,18 +527,18 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Scheme":3}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("effect") == "Cycle down" async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF @@ -558,32 +558,32 @@ async def test_controlling_state_via_mqtt_rgbww( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" @@ -593,7 +593,7 @@ async def test_controlling_state_via_mqtt_rgbww( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"HSBColor":"30,100,50","White":0}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("hs_color") == (30, 100) assert state.attributes.get("color_mode") == "hs" @@ -601,7 +601,7 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","White":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white > 0 should clear the color assert "rgb_color" not in state.attributes @@ -610,7 +610,7 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","CT":300}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_temp") == 300 assert state.attributes.get("color_mode") == "color_temp" @@ -618,7 +618,7 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","White":0}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white to 0 should clear the color_temp assert "color_temp" not in state.attributes @@ -628,18 +628,18 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Scheme":3}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("effect") == "Cycle down" async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF @@ -660,32 +660,32 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" @@ -695,7 +695,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","HSBColor":"30,100,0","White":0}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("hs_color") == (30, 100) assert state.attributes.get("color_mode") == "hs" @@ -705,7 +705,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":0}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("hs_color") == (30, 100) assert state.attributes.get("color_mode") == "hs" @@ -713,7 +713,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"White":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white > 0 should clear the color assert "rgb_color" not in state.attributes @@ -722,7 +722,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","CT":300}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_temp") == 300 assert state.attributes.get("color_mode") == "color_temp" @@ -730,7 +730,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","White":0}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white to 0 should clear the color_temp assert not state.attributes.get("color_temp") @@ -739,18 +739,18 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Scheme":3}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("effect") == "Cycle down" async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF @@ -772,25 +772,25 @@ async def test_sending_mqtt_commands_on_off( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Power1", "ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Power1", "OFF", 0, False ) @@ -816,32 +816,32 @@ async def test_sending_mqtt_commands_rgbww_tuya( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer3 75", 0, False ) @@ -866,39 +866,39 @@ async def test_sending_mqtt_commands_rgbw_legacy( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer 75", 0, False ) mqtt_mock.async_publish.reset_mock() # Set color when setting color - await common.async_turn_on(hass, "light.test", hs_color=[0, 100]) + await common.async_turn_on(hass, "light.tasmota_test", hs_color=[0, 100]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 0;NoDelay;HsbColor2 100", @@ -908,7 +908,7 @@ async def test_sending_mqtt_commands_rgbw_legacy( mqtt_mock.async_publish.reset_mock() # Set white when setting white - await common.async_turn_on(hass, "light.test", white=128) + await common.async_turn_on(hass, "light.tasmota_test", white=128) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;White 50", @@ -918,7 +918,7 @@ async def test_sending_mqtt_commands_rgbw_legacy( mqtt_mock.async_publish.reset_mock() # rgbw_color should be converted - await common.async_turn_on(hass, "light.test", rgbw_color=[128, 64, 32, 0]) + await common.async_turn_on(hass, "light.tasmota_test", rgbw_color=[128, 64, 32, 0]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 20;NoDelay;HsbColor2 75", @@ -928,7 +928,7 @@ async def test_sending_mqtt_commands_rgbw_legacy( mqtt_mock.async_publish.reset_mock() # rgbw_color should be converted - await common.async_turn_on(hass, "light.test", rgbw_color=[16, 64, 32, 128]) + await common.async_turn_on(hass, "light.tasmota_test", rgbw_color=[16, 64, 32, 128]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 141;NoDelay;HsbColor2 25", @@ -937,7 +937,7 @@ async def test_sending_mqtt_commands_rgbw_legacy( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", effect="Random") + await common.async_turn_on(hass, "light.tasmota_test", effect="Random") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;Scheme 4", @@ -965,39 +965,39 @@ async def test_sending_mqtt_commands_rgbw( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer 75", 0, False ) mqtt_mock.async_publish.reset_mock() # Set color when setting color - await common.async_turn_on(hass, "light.test", hs_color=[180, 50]) + await common.async_turn_on(hass, "light.tasmota_test", hs_color=[180, 50]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 180;NoDelay;HsbColor2 50", @@ -1007,7 +1007,7 @@ async def test_sending_mqtt_commands_rgbw( mqtt_mock.async_publish.reset_mock() # Set white when setting white - await common.async_turn_on(hass, "light.test", white=128) + await common.async_turn_on(hass, "light.tasmota_test", white=128) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;White 50", @@ -1017,7 +1017,7 @@ async def test_sending_mqtt_commands_rgbw( mqtt_mock.async_publish.reset_mock() # rgbw_color should be converted - await common.async_turn_on(hass, "light.test", rgbw_color=[128, 64, 32, 0]) + await common.async_turn_on(hass, "light.tasmota_test", rgbw_color=[128, 64, 32, 0]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 20;NoDelay;HsbColor2 75", @@ -1027,7 +1027,7 @@ async def test_sending_mqtt_commands_rgbw( mqtt_mock.async_publish.reset_mock() # rgbw_color should be converted - await common.async_turn_on(hass, "light.test", rgbw_color=[16, 64, 32, 128]) + await common.async_turn_on(hass, "light.tasmota_test", rgbw_color=[16, 64, 32, 128]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 141;NoDelay;HsbColor2 25", @@ -1036,7 +1036,7 @@ async def test_sending_mqtt_commands_rgbw( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", effect="Random") + await common.async_turn_on(hass, "light.tasmota_test", effect="Random") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;Scheme 4", @@ -1064,38 +1064,38 @@ async def test_sending_mqtt_commands_rgbww( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer 75", 0, False ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", hs_color=[240, 75]) + await common.async_turn_on(hass, "light.tasmota_test", hs_color=[240, 75]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 240;NoDelay;HsbColor2 75", @@ -1104,7 +1104,7 @@ async def test_sending_mqtt_commands_rgbww( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", color_temp=200) + await common.async_turn_on(hass, "light.tasmota_test", color_temp=200) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;CT 200", @@ -1113,7 +1113,7 @@ async def test_sending_mqtt_commands_rgbww( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", effect="Random") + await common.async_turn_on(hass, "light.tasmota_test", effect="Random") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;Scheme 4", @@ -1142,32 +1142,32 @@ async def test_sending_mqtt_commands_power_unlinked( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent; POWER should be sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer 75;NoDelay;Power1 ON", @@ -1195,14 +1195,14 @@ async def test_transition( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Dim the light from 0->100: Speed should be 4*2=8 - await common.async_turn_on(hass, "light.test", brightness=255, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=255, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Dimmer 100", @@ -1212,7 +1212,9 @@ async def test_transition( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->100: Speed should be capped at 40 - await common.async_turn_on(hass, "light.test", brightness=255, transition=100) + await common.async_turn_on( + hass, "light.tasmota_test", brightness=255, transition=100 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Dimmer 100", @@ -1222,7 +1224,7 @@ async def test_transition( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->0: Speed should be 1 - await common.async_turn_on(hass, "light.test", brightness=0, transition=100) + await common.async_turn_on(hass, "light.tasmota_test", brightness=0, transition=100) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 1;NoDelay;Power1 OFF", @@ -1232,7 +1234,7 @@ async def test_transition( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->50: Speed should be 4*2*2=16 - await common.async_turn_on(hass, "light.test", brightness=128, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=128, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 16;NoDelay;Dimmer 50", @@ -1245,12 +1247,12 @@ async def test_transition( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 # Dim the light from 50->0: Speed should be 6*2*2=24 - await common.async_turn_off(hass, "light.test", transition=6) + await common.async_turn_off(hass, "light.tasmota_test", transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 24;NoDelay;Power1 OFF", @@ -1263,12 +1265,12 @@ async def test_transition( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":100}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 # Dim the light from 100->0: Speed should be 0 - await common.async_turn_off(hass, "light.test", transition=0) + await common.async_turn_off(hass, "light.tasmota_test", transition=0) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 0;NoDelay;Power1 OFF", @@ -1286,13 +1288,15 @@ async def test_transition( ' "Color":"0,255,0","HSBColor":"120,100,50","White":0}' ), ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("rgb_color") == (0, 255, 0) # Set color of the light from 0,255,0 to 255,0,0 @ 50%: Speed should be 6*2*2=24 - await common.async_turn_on(hass, "light.test", rgb_color=[255, 0, 0], transition=6) + await common.async_turn_on( + hass, "light.tasmota_test", rgb_color=[255, 0, 0], transition=6 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", ( @@ -1310,13 +1314,15 @@ async def test_transition( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":100, "Color":"0,255,0","HSBColor":"120,100,50"}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 assert state.attributes.get("rgb_color") == (0, 255, 0) # Set color of the light from 0,255,0 to 255,0,0 @ 100%: Speed should be 6*2=12 - await common.async_turn_on(hass, "light.test", rgb_color=[255, 0, 0], transition=6) + await common.async_turn_on( + hass, "light.tasmota_test", rgb_color=[255, 0, 0], transition=6 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", ( @@ -1334,13 +1340,13 @@ async def test_transition( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50, "CT":153, "White":50}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_temp") == 153 # Set color_temp of the light from 153 to 500 @ 50%: Speed should be 6*2*2=24 - await common.async_turn_on(hass, "light.test", color_temp=500, transition=6) + await common.async_turn_on(hass, "light.tasmota_test", color_temp=500, transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 24;NoDelay;Power1 ON;NoDelay;CT 500", @@ -1353,13 +1359,13 @@ async def test_transition( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50, "CT":500}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_temp") == 500 # Set color_temp of the light from 500 to 326 @ 50%: Speed should be 6*2*2*2=48->40 - await common.async_turn_on(hass, "light.test", color_temp=326, transition=6) + await common.async_turn_on(hass, "light.tasmota_test", color_temp=326, transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Power1 ON;NoDelay;CT 326", @@ -1388,14 +1394,14 @@ async def test_transition_fixed( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Dim the light from 0->100: Speed should be 4*2=8 - await common.async_turn_on(hass, "light.test", brightness=255, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=255, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Dimmer 100", @@ -1405,7 +1411,9 @@ async def test_transition_fixed( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->100: Speed should be capped at 40 - await common.async_turn_on(hass, "light.test", brightness=255, transition=100) + await common.async_turn_on( + hass, "light.tasmota_test", brightness=255, transition=100 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Dimmer 100", @@ -1415,7 +1423,7 @@ async def test_transition_fixed( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->0: Speed should be 4*2=8 - await common.async_turn_on(hass, "light.test", brightness=0, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=0, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Power1 OFF", @@ -1425,7 +1433,7 @@ async def test_transition_fixed( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->50: Speed should be 4*2=8 - await common.async_turn_on(hass, "light.test", brightness=128, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=128, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Dimmer 50", @@ -1435,7 +1443,7 @@ async def test_transition_fixed( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->50: Speed should be 0 - await common.async_turn_on(hass, "light.test", brightness=128, transition=0) + await common.async_turn_on(hass, "light.tasmota_test", brightness=128, transition=0) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 0;NoDelay;Dimmer 50", @@ -1463,7 +1471,7 @@ async def test_relay_as_light( state = hass.states.get("switch.test") assert state is None - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state is not None @@ -1631,14 +1639,14 @@ async def test_discovery_update_reconfigure_light( # Simple dimmer async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data1) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("supported_features") == LightEntityFeature.TRANSITION assert state.attributes.get("supported_color_modes") == ["brightness"] # Reconfigure as RGB light async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data2) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert ( state.attributes.get("supported_features") == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index b79560214a8..b8d0ed2d060 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -47,34 +47,34 @@ async def test_controlling_state_via_mqtt( ) await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF @@ -95,30 +95,30 @@ async def test_sending_mqtt_commands( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the switch on and verify MQTT message is sent - await common.async_turn_on(hass, "switch.test") + await common.async_turn_on(hass, "switch.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Power1", "ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF # Turn the switch off and verify MQTT message is sent - await common.async_turn_off(hass, "switch.test") + await common.async_turn_off(hass, "switch.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Power1", "OFF", 0, False ) - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF @@ -138,9 +138,9 @@ async def test_relay_as_light( ) await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state is None - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state is not None From 7fcc2dd44e84711f07f2ff9e55b5979851be3fbd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 18 Aug 2023 20:15:00 +0200 Subject: [PATCH 173/180] Make the check_config script open issue_registry read only (#98545) * Don't blow up if validators can't access the issue registry * Make the check_config script open issue_registry read only * Update tests/helpers/test_issue_registry.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/helpers/config_validation.py | 1 + homeassistant/helpers/issue_registry.py | 8 ++- homeassistant/helpers/storage.py | 5 ++ homeassistant/scripts/check_config.py | 2 + tests/helpers/test_issue_registry.py | 73 +++++++++++++++++++++- tests/helpers/test_storage.py | 30 +++++++++ 6 files changed, 115 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 122fd752a84..10f4918a20b 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1122,6 +1122,7 @@ def _no_yaml_config_schema( # pylint: disable-next=import-outside-toplevel from .issue_registry import IssueSeverity, async_create_issue + # HomeAssistantError is raised if called from the wrong thread with contextlib.suppress(HomeAssistantError): hass = async_get_hass() async_create_issue( diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 30866ccf7cd..27d568a13de 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -95,16 +95,18 @@ class IssueRegistryStore(Store[dict[str, list[dict[str, Any]]]]): class IssueRegistry: """Class to hold a registry of issues.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, *, read_only: bool = False) -> None: """Initialize the issue registry.""" self.hass = hass self.issues: dict[tuple[str, str], IssueEntry] = {} + self._read_only = read_only self._store = IssueRegistryStore( hass, STORAGE_VERSION_MAJOR, STORAGE_KEY, atomic_writes=True, minor_version=STORAGE_VERSION_MINOR, + read_only=read_only, ) @callback @@ -278,10 +280,10 @@ def async_get(hass: HomeAssistant) -> IssueRegistry: return cast(IssueRegistry, hass.data[DATA_REGISTRY]) -async def async_load(hass: HomeAssistant) -> None: +async def async_load(hass: HomeAssistant, *, read_only: bool = False) -> None: """Load issue registry.""" assert DATA_REGISTRY not in hass.data - hass.data[DATA_REGISTRY] = IssueRegistry(hass) + hass.data[DATA_REGISTRY] = IssueRegistry(hass, read_only=read_only) await hass.data[DATA_REGISTRY].async_load() diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index dd394c84f91..c83481365ab 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -93,6 +93,7 @@ class Store(Generic[_T]): atomic_writes: bool = False, encoder: type[JSONEncoder] | None = None, minor_version: int = 1, + read_only: bool = False, ) -> None: """Initialize storage class.""" self.version = version @@ -107,6 +108,7 @@ class Store(Generic[_T]): self._load_task: asyncio.Future[_T | None] | None = None self._encoder = encoder self._atomic_writes = atomic_writes + self._read_only = read_only @property def path(self): @@ -344,6 +346,9 @@ class Store(Generic[_T]): self._data = None + if self._read_only: + return + try: await self._async_write_data(self.path, data) except (json_util.SerializationError, WriteError) as err: diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 5c81c4664da..38fa9cc2463 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -19,6 +19,7 @@ from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.check_config import async_check_ha_config_file from homeassistant.util.yaml import Secrets @@ -237,6 +238,7 @@ async def async_check_config(config_dir): await ar.async_load(hass) await dr.async_load(hass) await er.async_load(hass) + await ir.async_load(hass, read_only=True) components = await async_check_ha_config_file(hass) await hass.async_stop(force=True) return components diff --git a/tests/helpers/test_issue_registry.py b/tests/helpers/test_issue_registry.py index d184ccf0a2b..88f97a65421 100644 --- a/tests/helpers/test_issue_registry.py +++ b/tests/helpers/test_issue_registry.py @@ -9,7 +9,7 @@ from homeassistant.helpers import issue_registry as ir from tests.common import async_capture_events, flush_store -async def test_load_issues(hass: HomeAssistant) -> None: +async def test_load_save_issues(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" issues = [ { @@ -209,6 +209,77 @@ async def test_load_issues(hass: HomeAssistant) -> None: assert issue4_registry2 == issue4 +@pytest.mark.parametrize("load_registries", [False]) +async def test_load_save_issues_read_only( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Make sure that we don't save data when opened in read-only mode.""" + hass_storage[ir.STORAGE_KEY] = { + "version": ir.STORAGE_VERSION_MAJOR, + "minor_version": ir.STORAGE_VERSION_MINOR, + "data": { + "issues": [ + { + "created": "2022-07-19T09:41:13.746514+00:00", + "dismissed_version": "2022.7.0.dev0", + "domain": "test", + "is_persistent": False, + "issue_id": "issue_1", + }, + ] + }, + } + + issues = [ + { + "breaks_in_ha_version": "2022.8", + "domain": "test", + "issue_id": "issue_2", + "is_fixable": True, + "is_persistent": False, + "learn_more_url": "https://theuselessweb.com/abc", + "severity": "other", + "translation_key": "even_worse", + "translation_placeholders": {"def": "456"}, + }, + ] + + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await ir.async_load(hass, read_only=True) + + for issue in issues: + ir.async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + breaks_in_ha_version=issue["breaks_in_ha_version"], + is_fixable=issue["is_fixable"], + is_persistent=issue["is_persistent"], + learn_more_url=issue["learn_more_url"], + severity=issue["severity"], + translation_key=issue["translation_key"], + translation_placeholders=issue["translation_placeholders"], + ) + + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data == { + "action": "create", + "domain": "test", + "issue_id": "issue_2", + } + + registry = ir.async_get(hass) + assert len(registry.issues) == 2 + + registry2 = ir.IssueRegistry(hass) + await flush_store(registry._store) + await registry2.async_load() + + assert len(registry2.issues) == 1 + + @pytest.mark.parametrize("load_registries", [False]) async def test_loading_issues_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 81953c7d785..85aa4d2de0e 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -60,6 +60,12 @@ def store_v_2_1(hass): ) +@pytest.fixture +def read_only_store(hass): + """Fixture of a read only store.""" + return storage.Store(hass, MOCK_VERSION, MOCK_KEY, read_only=True) + + async def test_loading(hass: HomeAssistant, store) -> None: """Test we can save and load data.""" await store.async_save(MOCK_DATA) @@ -703,3 +709,27 @@ async def test_os_error_is_fatal(tmpdir: py.path.local) -> None: await store.async_load() await hass.async_stop(force=True) + + +async def test_read_only_store( + hass: HomeAssistant, read_only_store, hass_storage: dict[str, Any] +) -> None: + """Test store opened in read only mode does not save.""" + read_only_store.async_delay_save(lambda: MOCK_DATA, 1) + assert read_only_store.key not in hass_storage + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert read_only_store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.state = CoreState.stopping + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert read_only_store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) + await hass.async_block_till_done() + assert read_only_store.key not in hass_storage From 268e5244f0746d1779ed00403392df604e2df998 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 18 Aug 2023 20:19:17 +0200 Subject: [PATCH 174/180] Cleanup ManualTriggerSensorEntity (#98629) * Cleanup ManualTriggerSensorEntity * ConfigType --- .../components/command_line/sensor.py | 40 ++++++----- homeassistant/components/rest/sensor.py | 3 +- homeassistant/components/scrape/sensor.py | 57 +++++++--------- homeassistant/components/sql/sensor.py | 68 +++++++------------ homeassistant/helpers/template_entity.py | 8 +-- 5 files changed, 74 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 2ccbdbc4785..f04320b159e 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -16,13 +16,12 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, SensorDeviceClass, - SensorEntity, - SensorStateClass, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, + CONF_ICON, CONF_NAME, CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, @@ -36,7 +35,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, + ManualTriggerSensorEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -47,6 +50,16 @@ CONF_JSON_ATTRIBUTES = "json_attributes" DEFAULT_NAME = "Command Sensor" +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + SCAN_INTERVAL = timedelta(seconds=60) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -87,30 +100,25 @@ async def async_setup_platform( name: str = sensor_config[CONF_NAME] command: str = sensor_config[CONF_COMMAND] - unit: str | None = sensor_config.get(CONF_UNIT_OF_MEASUREMENT) value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE) command_timeout: int = sensor_config[CONF_COMMAND_TIMEOUT] - unique_id: str | None = sensor_config.get(CONF_UNIQUE_ID) if value_template is not None: value_template.hass = hass json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - state_class: SensorStateClass | None = sensor_config.get(CONF_STATE_CLASS) data = CommandSensorData(hass, command, command_timeout) - trigger_entity_config = { - CONF_UNIQUE_ID: unique_id, - CONF_NAME: Template(name, hass), - CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), - } + trigger_entity_config = {CONF_NAME: Template(name, hass)} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in sensor_config: + continue + trigger_entity_config[key] = sensor_config[key] async_add_entities( [ CommandSensor( data, trigger_entity_config, - unit, - state_class, value_template, json_attributes, scan_interval, @@ -119,7 +127,7 @@ async def async_setup_platform( ) -class CommandSensor(ManualTriggerEntity, SensorEntity): +class CommandSensor(ManualTriggerSensorEntity): """Representation of a sensor that is using shell commands.""" _attr_should_poll = False @@ -128,8 +136,6 @@ class CommandSensor(ManualTriggerEntity, SensorEntity): self, data: CommandSensorData, config: ConfigType, - unit_of_measurement: str | None, - state_class: SensorStateClass | None, value_template: Template | None, json_attributes: list[str] | None, scan_interval: timedelta, @@ -141,8 +147,6 @@ class CommandSensor(ManualTriggerEntity, SensorEntity): self._json_attributes = json_attributes self._attr_native_value = None self._value_template = value_template - self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_state_class = state_class self._scan_interval = scan_interval self._process_updates: asyncio.Lock | None = None diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index f7743a853ad..63a9d6f210c 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorDeviceClass, - SensorEntity, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( @@ -118,7 +117,7 @@ async def async_setup_platform( ) -class RestSensor(ManualTriggerSensorEntity, RestEntity, SensorEntity): +class RestSensor(ManualTriggerSensorEntity, RestEntity): """Implementation of a REST sensor.""" def __init__( diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 7cd7e2197ab..2763d034804 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -6,11 +6,7 @@ from typing import Any, cast import voluptuous as vol -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - SensorDeviceClass, - SensorEntity, -) +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorDeviceClass from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -32,6 +28,7 @@ from homeassistant.helpers.template_entity import ( CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, ManualTriggerEntity, + ManualTriggerSensorEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -41,6 +38,16 @@ from .coordinator import ScrapeCoordinator _LOGGER = logging.getLogger(__name__) +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + async def async_setup_platform( hass: HomeAssistant, @@ -63,25 +70,17 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass - trigger_entity_config = { - CONF_NAME: sensor_config[CONF_NAME], - CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), - CONF_UNIQUE_ID: sensor_config.get(CONF_UNIQUE_ID), - } - if available := sensor_config.get(CONF_AVAILABILITY): - trigger_entity_config[CONF_AVAILABILITY] = available - if icon := sensor_config.get(CONF_ICON): - trigger_entity_config[CONF_ICON] = icon - if picture := sensor_config.get(CONF_PICTURE): - trigger_entity_config[CONF_PICTURE] = picture + trigger_entity_config = {CONF_NAME: sensor_config[CONF_NAME]} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in sensor_config: + continue + trigger_entity_config[key] = sensor_config[key] entities.append( ScrapeSensor( hass, coordinator, trigger_entity_config, - sensor_config.get(CONF_UNIT_OF_MEASUREMENT), - sensor_config.get(CONF_STATE_CLASS), sensor_config[CONF_SELECT], sensor_config.get(CONF_ATTRIBUTE), sensor_config[CONF_INDEX], @@ -113,19 +112,17 @@ async def async_setup_entry( Template(value_string, hass) if value_string is not None else None ) - trigger_entity_config = { - CONF_NAME: name, - CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), - CONF_UNIQUE_ID: sensor_config[CONF_UNIQUE_ID], - } + trigger_entity_config = {CONF_NAME: name} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in sensor_config: + continue + trigger_entity_config[key] = sensor_config[key] entities.append( ScrapeSensor( hass, coordinator, trigger_entity_config, - sensor_config.get(CONF_UNIT_OF_MEASUREMENT), - sensor_config.get(CONF_STATE_CLASS), sensor_config[CONF_SELECT], sensor_config.get(CONF_ATTRIBUTE), sensor_config[CONF_INDEX], @@ -137,9 +134,7 @@ async def async_setup_entry( async_add_entities(entities) -class ScrapeSensor( - CoordinatorEntity[ScrapeCoordinator], ManualTriggerEntity, SensorEntity -): +class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEntity): """Representation of a web scrape sensor.""" def __init__( @@ -147,8 +142,6 @@ class ScrapeSensor( hass: HomeAssistant, coordinator: ScrapeCoordinator, trigger_entity_config: ConfigType, - unit_of_measurement: str | None, - state_class: str | None, select: str, attr: str | None, index: int, @@ -157,9 +150,7 @@ class ScrapeSensor( ) -> None: """Initialize a web scrape sensor.""" CoordinatorEntity.__init__(self, coordinator) - ManualTriggerEntity.__init__(self, hass, trigger_entity_config) - self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_state_class = state_class + ManualTriggerSensorEntity.__init__(self, hass, trigger_entity_config) self._select = select self._attr = attr self._index = index diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index f750b364106..0b32b10f972 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -19,12 +19,7 @@ from homeassistant.components.recorder import ( SupportedDialect, get_instance, ) -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -44,7 +39,7 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, - ManualTriggerEntity, + ManualTriggerSensorEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -56,6 +51,16 @@ _LOGGER = logging.getLogger(__name__) _SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + async def async_setup_platform( hass: HomeAssistant, @@ -69,43 +74,29 @@ async def async_setup_platform( name: Template = conf[CONF_NAME] query_str: str = conf[CONF_QUERY] - unit: str | None = conf.get(CONF_UNIT_OF_MEASUREMENT) value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE) column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL)) - device_class: SensorDeviceClass | None = conf.get(CONF_DEVICE_CLASS) - state_class: SensorStateClass | None = conf.get(CONF_STATE_CLASS) - availability: Template | None = conf.get(CONF_AVAILABILITY) - icon: Template | None = conf.get(CONF_ICON) - picture: Template | None = conf.get(CONF_PICTURE) if value_template is not None: value_template.hass = hass - trigger_entity_config = { - CONF_NAME: name, - CONF_DEVICE_CLASS: device_class, - CONF_UNIQUE_ID: unique_id, - } - if availability: - trigger_entity_config[CONF_AVAILABILITY] = availability - if icon: - trigger_entity_config[CONF_ICON] = icon - if picture: - trigger_entity_config[CONF_PICTURE] = picture + trigger_entity_config = {CONF_NAME: name} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in conf: + continue + trigger_entity_config[key] = conf[key] await async_setup_sensor( hass, trigger_entity_config, query_str, column_name, - unit, value_template, unique_id, db_url, True, - state_class, async_add_entities, ) @@ -118,11 +109,8 @@ async def async_setup_entry( db_url: str = resolve_db_url(hass, entry.options.get(CONF_DB_URL)) name: str = entry.options[CONF_NAME] query_str: str = entry.options[CONF_QUERY] - unit: str | None = entry.options.get(CONF_UNIT_OF_MEASUREMENT) template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) column_name: str = entry.options[CONF_COLUMN_NAME] - device_class: SensorDeviceClass | None = entry.options.get(CONF_DEVICE_CLASS, None) - state_class: SensorStateClass | None = entry.options.get(CONF_STATE_CLASS, None) value_template: Template | None = None if template is not None: @@ -135,23 +123,21 @@ async def async_setup_entry( value_template.hass = hass name_template = Template(name, hass) - trigger_entity_config = { - CONF_NAME: name_template, - CONF_DEVICE_CLASS: device_class, - CONF_UNIQUE_ID: entry.entry_id, - } + trigger_entity_config = {CONF_NAME: name_template} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in entry.options: + continue + trigger_entity_config[key] = entry.options[key] await async_setup_sensor( hass, trigger_entity_config, query_str, column_name, - unit, value_template, entry.entry_id, db_url, False, - state_class, async_add_entities, ) @@ -191,12 +177,10 @@ async def async_setup_sensor( trigger_entity_config: ConfigType, query_str: str, column_name: str, - unit: str | None, value_template: Template | None, unique_id: str | None, db_url: str, yaml: bool, - state_class: SensorStateClass | None, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" @@ -274,10 +258,8 @@ async def async_setup_sensor( sessmaker, query_str, column_name, - unit, value_template, yaml, - state_class, use_database_executor, ) ], @@ -317,7 +299,7 @@ def _generate_lambda_stmt(query: str) -> StatementLambdaElement: return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) -class SQLSensor(ManualTriggerEntity, SensorEntity): +class SQLSensor(ManualTriggerSensorEntity): """Representation of an SQL sensor.""" def __init__( @@ -326,17 +308,13 @@ class SQLSensor(ManualTriggerEntity, SensorEntity): sessmaker: scoped_session, query: str, column: str, - unit: str | None, value_template: Template | None, yaml: bool, - state_class: SensorStateClass | None, use_database_executor: bool, ) -> None: """Initialize the SQL sensor.""" super().__init__(self.hass, trigger_entity_config) self._query = query - self._attr_native_unit_of_measurement = unit - self._attr_state_class = state_class self._template = value_template self._column_name = column self.sessionmaker = sessmaker diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index 07e68152d64..70a0ee1d16c 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -486,7 +486,7 @@ class TriggerBaseEntity(Entity): def __init__( self, hass: HomeAssistant, - config: dict, + config: ConfigType, ) -> None: """Initialize the entity.""" self.hass = hass @@ -623,7 +623,7 @@ class ManualTriggerEntity(TriggerBaseEntity): def __init__( self, hass: HomeAssistant, - config: dict, + config: ConfigType, ) -> None: """Initialize the entity.""" TriggerBaseEntity.__init__(self, hass, config) @@ -655,13 +655,13 @@ class ManualTriggerEntity(TriggerBaseEntity): self._render_templates(variables) -class ManualTriggerSensorEntity(ManualTriggerEntity): +class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity): """Template entity based on manual trigger data for sensor.""" def __init__( self, hass: HomeAssistant, - config: dict, + config: ConfigType, ) -> None: """Initialize the sensor entity.""" ManualTriggerEntity.__init__(self, hass, config) From f96446cb241aa103accb6eeb196f268da63d4c1a Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 18 Aug 2023 19:45:12 +0100 Subject: [PATCH 175/180] Clean up integration sensor (#98552) always update --- .../components/integration/sensor.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 6daecc6a305..ba17a448477 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -298,18 +298,14 @@ class IntegrationSensor(RestoreSensor): old_state = event.data["old_state"] new_state = event.data["new_state"] - # We may want to update our state before an early return, - # based on the source sensor's unit_of_measurement - # or device_class. - update_state = False - if ( source_state := self.hass.states.get(self._sensor_source_id) ) is None or source_state.state == STATE_UNAVAILABLE: self._attr_available = False - update_state = True - else: - self._attr_available = True + self.async_write_ha_state() + return + + self._attr_available = True if old_state is None or new_state is None: # we can't calculate the elapsed time, so we can't calculate the integral @@ -317,10 +313,7 @@ class IntegrationSensor(RestoreSensor): unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if unit is not None: - new_unit_of_measurement = self._unit(unit) - if self._unit_of_measurement != new_unit_of_measurement: - self._unit_of_measurement = new_unit_of_measurement - update_state = True + self._unit_of_measurement = self._unit(unit) if ( self.device_class is None @@ -329,10 +322,8 @@ class IntegrationSensor(RestoreSensor): ): self._attr_device_class = SensorDeviceClass.ENERGY self._attr_icon = None - update_state = True - if update_state: - self.async_write_ha_state() + self.async_write_ha_state() try: # integration as the Riemann integral of previous measures. From 7827f9ccaea272c37186fdd6630d6ab848bf07a1 Mon Sep 17 00:00:00 2001 From: Arkadii Yakovets Date: Fri, 18 Aug 2023 12:20:04 -0700 Subject: [PATCH 176/180] Update country `province` validation (#84463) * Update country `province` validation. * Run pre-commit. * Add tests * Mod config flow --------- Co-authored-by: G Johansson --- .../components/workday/binary_sensor.py | 26 +++++++++++-------- .../components/workday/config_flow.py | 15 ++++++----- tests/components/workday/__init__.py | 9 +++++++ .../components/workday/test_binary_sensor.py | 16 ++++++++++++ 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index d1666fa9097..4c383543125 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -4,8 +4,13 @@ from __future__ import annotations from datetime import date, timedelta from typing import Any -import holidays -from holidays import DateLike, HolidayBase +from holidays import ( + DateLike, + HolidayBase, + __version__ as python_holidays_version, + country_holidays, + list_supported_countries, +) import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -43,7 +48,6 @@ from .const import ( def valid_country(value: Any) -> str: """Validate that the given country is supported.""" value = cv.string(value) - all_supported_countries = holidays.list_supported_countries() try: raw_value = value.encode("utf-8") @@ -53,7 +57,7 @@ def valid_country(value: Any) -> str: ) from err if not raw_value: raise vol.Invalid("Country name or the abbreviation must not be empty.") - if value not in all_supported_countries: + if value not in list_supported_countries(): raise vol.Invalid("Country is not supported.") return value @@ -123,17 +127,17 @@ async def async_setup_entry( province: str | None = entry.options.get(CONF_PROVINCE) sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] - - cls: HolidayBase = getattr(holidays, country) year: int = (dt_util.now() + timedelta(days=days_offset)).year - if province and province not in cls.subdivisions: + if country and country not in list_supported_countries(): + LOGGER.error("There is no country %s", country) + return + + if province and province not in list_supported_countries()[country]: LOGGER.error("There is no subdivision %s in country %s", province, country) return - obj_holidays = cls( - subdiv=province, years=year, language=cls.default_language - ) # type: ignore[operator] + obj_holidays: HolidayBase = country_holidays(country, subdiv=province, years=year) # Add custom holidays try: @@ -209,7 +213,7 @@ class IsWorkdaySensor(BinarySensorEntity): entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, manufacturer="python-holidays", - model=holidays.__version__, + model=python_holidays_version, name=name, ) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 15e04ffca93..54c6196b75b 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -3,8 +3,7 @@ from __future__ import annotations from typing import Any -import holidays -from holidays import HolidayBase, list_supported_countries +from holidays import HolidayBase, country_holidays, list_supported_countries import voluptuous as vol from homeassistant.config_entries import ( @@ -77,12 +76,14 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: if dt_util.parse_date(add_date) is None: raise AddDatesError("Incorrect date") - cls: HolidayBase = getattr(holidays, user_input[CONF_COUNTRY]) + cls: HolidayBase = country_holidays(user_input[CONF_COUNTRY]) year: int = dt_util.now().year - - obj_holidays = cls( - subdiv=user_input.get(CONF_PROVINCE), years=year, language=cls.default_language - ) # type: ignore[operator] + obj_holidays: HolidayBase = country_holidays( + user_input[CONF_COUNTRY], + subdiv=user_input.get(CONF_PROVINCE), + years=year, + language=cls.default_language, + ) for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: if dt_util.parse_date(remove_date) is None: diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index 005a63397d9..f87328998e1 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -50,6 +50,15 @@ TEST_CONFIG_WITH_PROVINCE = { "add_holidays": [], "remove_holidays": [], } +TEST_CONFIG_INCORRECT_COUNTRY = { + "name": DEFAULT_NAME, + "country": "ZZ", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} TEST_CONFIG_INCORRECT_PROVINCE = { "name": DEFAULT_NAME, "country": "DE", diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 71dd23c19a3..a8cea01a864 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -17,6 +17,7 @@ from . import ( TEST_CONFIG_EXAMPLE_2, TEST_CONFIG_INCLUDE_HOLIDAY, TEST_CONFIG_INCORRECT_ADD_REMOVE, + TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, TEST_CONFIG_NO_PROVINCE, TEST_CONFIG_NO_STATE, @@ -187,6 +188,21 @@ async def test_setup_day_after_tomorrow( assert state.state == "off" +async def test_setup_faulty_country( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with faulty province.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is None + + assert "There is no country" in caplog.text + + async def test_setup_faulty_province( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From 9e42451934d0513eb53fbe84dfe5576f7727d695 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 18 Aug 2023 22:44:59 +0200 Subject: [PATCH 177/180] UniFi refactor using site data (#98549) * Clean up * Simplify admin verification * Streamline using sites in config_flow * Bump aiounifi --- homeassistant/components/unifi/button.py | 2 +- homeassistant/components/unifi/config_flow.py | 29 +++++++--------- homeassistant/components/unifi/controller.py | 32 +++-------------- homeassistant/components/unifi/diagnostics.py | 2 +- homeassistant/components/unifi/image.py | 2 +- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/switch.py | 2 +- homeassistant/components/unifi/update.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_controller.py | 30 +++++++++------- tests/components/unifi/test_diagnostics.py | 2 +- tests/components/unifi/test_init.py | 2 +- tests/components/unifi/test_switch.py | 34 +++++++++---------- tests/components/unifi/test_update.py | 11 +++--- 15 files changed, 65 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 6b0660325f0..0235f6156cc 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -89,7 +89,7 @@ async def async_setup_entry( """Set up button platform for UniFi Network integration.""" controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - if controller.site_role != "admin": + if not controller.is_admin: return controller.register_platform_add_entities( diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 12f2d49e416..8c0696463c5 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -13,6 +13,7 @@ from types import MappingProxyType from typing import Any from urllib.parse import urlparse +from aiounifi.interfaces.sites import Sites import voluptuous as vol from homeassistant import config_entries @@ -63,6 +64,8 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): VERSION = 1 + sites: Sites + @staticmethod @callback def async_get_options_flow( @@ -74,8 +77,6 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): def __init__(self) -> None: """Initialize the UniFi Network flow.""" self.config: dict[str, Any] = {} - self.site_ids: dict[str, str] = {} - self.site_names: dict[str, str] = {} self.reauth_config_entry: config_entries.ConfigEntry | None = None self.reauth_schema: dict[vol.Marker, Any] = {} @@ -99,7 +100,8 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): controller = await get_unifi_controller( self.hass, MappingProxyType(self.config) ) - sites = await controller.sites() + await controller.sites.update() + self.sites = controller.sites except AuthenticationRequired: errors["base"] = "faulty_credentials" @@ -108,12 +110,10 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): errors["base"] = "service_unavailable" else: - self.site_ids = {site["_id"]: site["name"] for site in sites.values()} - self.site_names = {site["_id"]: site["desc"] for site in sites.values()} - if ( self.reauth_config_entry - and self.reauth_config_entry.unique_id in self.site_names + and self.reauth_config_entry.unique_id is not None + and self.reauth_config_entry.unique_id in self.sites ): return await self.async_step_site( {CONF_SITE_ID: self.reauth_config_entry.unique_id} @@ -148,7 +148,7 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): """Select site to control.""" if user_input is not None: unique_id = user_input[CONF_SITE_ID] - self.config[CONF_SITE_ID] = self.site_ids[unique_id] + self.config[CONF_SITE_ID] = self.sites[unique_id].name config_entry = await self.async_set_unique_id(unique_id) abort_reason = "configuration_updated" @@ -171,19 +171,16 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): await self.hass.config_entries.async_reload(config_entry.entry_id) return self.async_abort(reason=abort_reason) - site_nice_name = self.site_names[unique_id] + site_nice_name = self.sites[unique_id].description return self.async_create_entry(title=site_nice_name, data=self.config) - if len(self.site_names) == 1: - return await self.async_step_site( - {CONF_SITE_ID: next(iter(self.site_names))} - ) + if len(self.sites.values()) == 1: + return await self.async_step_site({CONF_SITE_ID: next(iter(self.sites))}) + site_names = {site.site_id: site.description for site in self.sites.values()} return self.async_show_form( step_id="site", - data_schema=vol.Schema( - {vol.Required(CONF_SITE_ID): vol.In(self.site_names)} - ), + data_schema=vol.Schema({vol.Required(CONF_SITE_ID): vol.In(site_names)}), ) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 649d7c30fdb..c1ffa0aa57d 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -87,9 +87,8 @@ class UniFiController: self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] - self.site_id: str = "" - self._site_name: str | None = None - self._site_role: str | None = None + self.site = config_entry.data[CONF_SITE_ID] + self.is_admin = False self._cancel_heartbeat_check: CALLBACK_TYPE | None = None self._heartbeat_time: dict[str, datetime] = {} @@ -154,22 +153,6 @@ class UniFiController: host: str = self.config_entry.data[CONF_HOST] return host - @property - def site(self) -> str: - """Return the site of this config entry.""" - site_id: str = self.config_entry.data[CONF_SITE_ID] - return site_id - - @property - def site_name(self) -> str | None: - """Return the nice name of site.""" - return self._site_name - - @property - def site_role(self) -> str | None: - """Return the site user role of this controller.""" - return self._site_role - @property def mac(self) -> str | None: """Return the mac address of this controller.""" @@ -264,15 +247,8 @@ class UniFiController: """Set up a UniFi Network instance.""" await self.api.initialize() - sites = await self.api.sites() - for site in sites.values(): - if self.site == site["name"]: - self.site_id = site["_id"] - self._site_name = site["desc"] - break - - description = await self.api.site_description() - self._site_role = description[0]["site_role"] + assert self.config_entry.unique_id is not None + self.is_admin = self.api.sites[self.config_entry.unique_id].role == "admin" # Restore clients that are not a part of active clients list. entity_registry = er.async_get(self.hass) diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py index 3c72c06d6f2..c01dc193078 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -94,7 +94,7 @@ async def async_get_config_entry_diagnostics( diag["config"] = async_redact_data( async_replace_dict_data(config_entry.as_dict(), macs_to_redact), REDACT_CONFIG ) - diag["site_role"] = controller.site_role + diag["role_is_admin"] = controller.is_admin diag["clients"] = { macs_to_redact[k]: async_redact_data( async_replace_dict_data(v.raw, macs_to_redact), REDACT_CLIENTS diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 3ff893838c9..8231b87ee85 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -85,7 +85,7 @@ async def async_setup_entry( """Set up image platform for UniFi Network integration.""" controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - if controller.site_role != "admin": + if not controller.is_admin: return controller.register_platform_add_entities( diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 8f27263b288..579e64c5862 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==53"], + "requirements": ["aiounifi==55"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index a82b9e35d45..e2b4dda3912 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -274,7 +274,7 @@ async def async_setup_entry( """Set up switches for UniFi Network integration.""" controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - if controller.site_role != "admin": + if not controller.is_admin: return for mac in controller.option_block_clients: diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 661a9016bdc..6526a02da83 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -103,7 +103,7 @@ class UnifiDeviceUpdateEntity(UnifiEntity[_HandlerT, _DataT], UpdateEntity): def async_initiate_state(self) -> None: """Initiate entity state.""" self._attr_supported_features = UpdateEntityFeature.PROGRESS - if self.controller.site_role == "admin": + if self.controller.is_admin: self._attr_supported_features |= UpdateEntityFeature.INSTALL self.async_update_state(ItemEvent.ADDED, self._obj_id) diff --git a/requirements_all.txt b/requirements_all.txt index 1afbd67743c..aa636c42372 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -360,7 +360,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==53 +aiounifi==55 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d0f59d7bca..aa34d430dba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,7 +335,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==53 +aiounifi==55 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 2d28240a90d..a2be388af4c 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -80,7 +80,6 @@ ENTRY_OPTIONS = {} CONFIGURATION = [] SITE = [{"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"}] -DESCRIPTION = [{"name": "username", "site_name": "site_id", "site_role": "admin"}] def mock_default_unifi_requests( @@ -88,12 +87,13 @@ def mock_default_unifi_requests( host, site_id, sites=None, - description=None, clients_response=None, clients_all_response=None, devices_response=None, dpiapp_response=None, dpigroup_response=None, + port_forward_response=None, + system_information_response=None, wlans_response=None, ): """Mock default UniFi requests responses.""" @@ -111,12 +111,6 @@ def mock_default_unifi_requests( headers={"content-type": CONTENT_TYPE_JSON}, ) - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/self", - json={"data": description or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( f"https://{host}:1234/api/s/{site_id}/stat/sta", json={"data": clients_response or [], "meta": {"rc": "ok"}}, @@ -142,6 +136,16 @@ def mock_default_unifi_requests( json={"data": dpigroup_response or [], "meta": {"rc": "ok"}}, headers={"content-type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/rest/portforward", + json={"data": port_forward_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/stat/sysinfo", + json={"data": system_information_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) aioclient_mock.get( f"https://{host}:1234/api/s/{site_id}/rest/wlanconf", json={"data": wlans_response or [], "meta": {"rc": "ok"}}, @@ -156,12 +160,13 @@ async def setup_unifi_integration( config=ENTRY_CONFIG, options=ENTRY_OPTIONS, sites=SITE, - site_description=DESCRIPTION, clients_response=None, clients_all_response=None, devices_response=None, dpiapp_response=None, dpigroup_response=None, + port_forward_response=None, + system_information_response=None, wlans_response=None, known_wireless_clients=None, controllers=None, @@ -192,12 +197,13 @@ async def setup_unifi_integration( host=config_entry.data[CONF_HOST], site_id=config_entry.data[CONF_SITE_ID], sites=sites, - description=site_description, clients_response=clients_response, clients_all_response=clients_all_response, devices_response=devices_response, dpiapp_response=dpiapp_response, dpigroup_response=dpigroup_response, + port_forward_response=port_forward_response, + system_information_response=system_information_response, wlans_response=wlans_response, ) @@ -230,9 +236,7 @@ async def test_controller_setup( assert forward_entry_setup.mock_calls[4][1] == (entry, SWITCH_DOMAIN) assert controller.host == ENTRY_CONFIG[CONF_HOST] - assert controller.site == ENTRY_CONFIG[CONF_SITE_ID] - assert controller.site_name == SITE[0]["desc"] - assert controller.site_role == SITE[0]["role"] + assert controller.is_admin == (SITE[0]["role"] == "admin") assert controller.option_allow_bandwidth_sensors == DEFAULT_ALLOW_BANDWIDTH_SENSORS assert controller.option_allow_uptime_sensors == DEFAULT_ALLOW_UPTIME_SENSORS diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 5248836c08a..638e79ae649 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -141,7 +141,7 @@ async def test_entry_diagnostics( "unique_id": "1", "version": 1, }, - "site_role": "admin", + "role_is_admin": True, "clients": { "00:00:00:00:00:00": { "blocked": False, diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index cce26ac84cc..a1b817d67e2 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -24,7 +24,7 @@ async def test_successful_config_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that configured options for a host are loaded via config entry.""" - await setup_unifi_integration(hass, aioclient_mock, unique_id=None) + await setup_unifi_integration(hass, aioclient_mock) assert hass.data[UNIFI_DOMAIN] diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 5344ac901b7..c091fc5cc59 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -36,8 +36,8 @@ from homeassistant.util import dt as dt_util from .test_controller import ( CONTROLLER_HOST, - DESCRIPTION, ENTRY_CONFIG, + SITE, setup_unifi_integration, ) @@ -778,7 +778,7 @@ async def test_no_clients( }, ) - assert aioclient_mock.call_count == 10 + assert aioclient_mock.call_count == 11 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -803,13 +803,13 @@ async def test_not_admin( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that switch platform only work on an admin account.""" - description = deepcopy(DESCRIPTION) - description[0]["site_role"] = "not admin" + site = deepcopy(SITE) + site[0]["role"] = "not admin" await setup_unifi_integration( hass, aioclient_mock, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, - site_description=description, + sites=site, clients_response=[CLIENT_1], devices_response=[DEVICE_1], ) @@ -867,8 +867,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 11 - assert aioclient_mock.mock_calls[10][2] == { + assert aioclient_mock.call_count == 12 + assert aioclient_mock.mock_calls[11][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -876,8 +876,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } @@ -894,8 +894,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == {"enabled": False} + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[13][2] == {"enabled": False} await hass.services.async_call( SWITCH_DOMAIN, @@ -903,8 +903,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[13][2] == {"enabled": True} + assert aioclient_mock.call_count == 15 + assert aioclient_mock.mock_calls[14][2] == {"enabled": True} async def test_remove_switches( @@ -990,8 +990,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 11 - assert aioclient_mock.mock_calls[10][2] == { + assert aioclient_mock.call_count == 12 + assert aioclient_mock.mock_calls[11][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -999,8 +999,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index 7cf8495b9db..e59eca371d6 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_controller import DESCRIPTION, setup_unifi_integration +from .test_controller import SITE, setup_unifi_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -136,14 +136,11 @@ async def test_not_admin( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that the INSTALL feature is not available on a non-admin account.""" - description = deepcopy(DESCRIPTION) - description[0]["site_role"] = "not admin" + site = deepcopy(SITE) + site[0]["role"] = "not admin" await setup_unifi_integration( - hass, - aioclient_mock, - site_description=description, - devices_response=[DEVICE_1], + hass, aioclient_mock, sites=site, devices_response=[DEVICE_1] ) assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 1 From a39af8aff9e123ffdac0f69906aa6070596c1b32 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 18 Aug 2023 23:03:56 +0200 Subject: [PATCH 178/180] Fix rest debug logging (#98649) Correct rest debug logging --- homeassistant/components/rest/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 1f331651165..61c88a14400 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -81,7 +81,7 @@ class RestData: "REST xml result could not be parsed and converted to JSON" ) else: - _LOGGER.debug("JSON converted from XML: %s", self.data) + _LOGGER.debug("JSON converted from XML: %s", value) return value async def async_update(self, log_errors: bool = True) -> None: From 1a032cebddca0a9a2bc41acafbc77ed5b6f3f7a1 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 18 Aug 2023 23:18:55 +0200 Subject: [PATCH 179/180] modbus: slave is allowed with custom (#98644) --- homeassistant/components/modbus/validators.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index f3336e5cb0c..b2e33a0f1f1 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -65,7 +65,6 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: name = config[CONF_NAME] structure = config.get(CONF_STRUCTURE) slave_count = config.get(CONF_SLAVE_COUNT, 0) + 1 - slave = config.get(CONF_SLAVE, 0) swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) if ( slave_count > 1 @@ -79,7 +78,7 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: error = f"{name} structure: cannot be mixed with {data_type}" if config[CONF_DATA_TYPE] == DataType.CUSTOM: - if slave or slave_count > 1: + if slave_count > 1: error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SLAVE_COUNT}` / `{CONF_SLAVE}`" raise vol.Invalid(error) if swap_type != CONF_SWAP_NONE: From c1fb97f26b7ce512df589823dd653cfd98e777bc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 19 Aug 2023 02:28:27 +0200 Subject: [PATCH 180/180] Fix aiohttp DeprecationWarning (#98626) --- tests/components/cloud/test_subscription.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/cloud/test_subscription.py b/tests/components/cloud/test_subscription.py index bc5d149e914..9207c1fef2c 100644 --- a/tests/components/cloud/test_subscription.py +++ b/tests/components/cloud/test_subscription.py @@ -16,7 +16,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture(name="mocked_cloud") -def mocked_cloud_object(hass: HomeAssistant) -> Cloud: +async def mocked_cloud_object(hass: HomeAssistant) -> Cloud: """Mock cloud object.""" return Mock( accounts_server="accounts.nabucasa.com",