From 269429aa0c6dcd0ffa1fe5f2c87f08a542fac31b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Apr 2024 13:38:28 -0500 Subject: [PATCH 001/107] Only calculate the tplink emeter values once per update cycle (#115587) The sensor platform has to read the native_value multiple times during the state write cycle which means the integration calculated the value multiple times. Switch to using _attr_native_value to ensure the calculations in the library are only done once per state write. To demonstrate this issue, + _LOGGER.warning("Fetch name value for %s", self.entity_id) was added to `def native_value`: ``` 2024-04-14 06:58:52.506 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_current_consumption 2024-04-14 06:58:52.506 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_total_consumption 2024-04-14 06:58:52.507 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_today_s_consumption 2024-04-14 06:58:52.507 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_voltage 2024-04-14 06:58:52.508 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_current 2024-04-14 06:58:52.509 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_current_consumption 2024-04-14 06:58:52.510 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_total_consumption 2024-04-14 06:58:52.510 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_today_s_consumption 2024-04-14 06:58:52.510 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_voltage 2024-04-14 06:58:52.510 WARNING (MainThread) [homeassistant.components.tplink.sensor] Fetch name value for sensor.kasa_smart_plug_542b_0_kasa_smart_plug_542b_0_current ``` --- homeassistant/components/tplink/sensor.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 1f6b07365b5..d7563dd0401 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfPower, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import legacy_device_id @@ -171,8 +171,17 @@ class SmartPlugSensor(CoordinatedTPLinkEntity, SensorEntity): else: assert description.device_class self._attr_translation_key = f"{description.device_class.value}_child" + self._async_update_attrs() - @property - def native_value(self) -> float | None: - """Return the sensors state.""" - return async_emeter_from_device(self.device, self.entity_description) + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_native_value = async_emeter_from_device( + self.device, self.entity_description + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() From e08301f362fde2e7daa781cd4c87f7ae48d76c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Sun, 14 Apr 2024 23:11:42 +0200 Subject: [PATCH 002/107] Move Alexa entity id generation into abstract config class (#115593) This makes it possible to change the id schema in implementations. --- homeassistant/components/alexa/config.py | 5 +++++ homeassistant/components/alexa/entities.py | 7 +------ homeassistant/components/alexa/state_report.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index fb589dde566..0801a32a607 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -13,6 +13,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.storage import Store from .const import DOMAIN +from .entities import TRANSLATION_TABLE from .state_report import async_enable_proactive_mode STORE_AUTHORIZED = "authorized" @@ -101,6 +102,10 @@ class AbstractConfig(ABC): """If an entity should be exposed.""" return False + def generate_alexa_id(self, entity_id: str) -> str: + """Return the alexa ID for an entity ID.""" + return entity_id.replace(".", "#").translate(TRANSLATION_TABLE) + @callback def async_invalidate_access_token(self) -> None: """Invalidate access token.""" diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 240f676b5f3..ca7b78f7ff5 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -259,11 +259,6 @@ class DisplayCategory: WEARABLE = "WEARABLE" -def generate_alexa_id(entity_id: str) -> str: - """Return the alexa ID for an entity ID.""" - return entity_id.replace(".", "#").translate(TRANSLATION_TABLE) - - class AlexaEntity: """An adaptation of an entity, expressed in Alexa's terms. @@ -298,7 +293,7 @@ class AlexaEntity: def alexa_id(self) -> str: """Return the Alexa API entity id.""" - return generate_alexa_id(self.entity.entity_id) + return self.config.generate_alexa_id(self.entity.entity_id) def display_categories(self) -> list[str] | None: """Return a list of display categories.""" diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 24d750e7cb7..dc6c8ee3186 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -41,7 +41,7 @@ from .const import ( Cause, ) from .diagnostics import async_redact_auth_data -from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id +from .entities import ENTITY_ADAPTERS, AlexaEntity from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink if TYPE_CHECKING: @@ -492,7 +492,7 @@ async def async_send_delete_message( if domain not in ENTITY_ADAPTERS: continue - endpoints.append({"endpointId": generate_alexa_id(entity_id)}) + endpoints.append({"endpointId": config.generate_alexa_id(entity_id)}) payload: dict[str, Any] = { "endpoints": endpoints, From 6422bc4c19250397a41d1592cc3339c9568aec7c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Apr 2024 00:26:06 +0200 Subject: [PATCH 003/107] Set follow_imports to normal [mypy] (#115521) --- mypy.ini | 2 +- script/hassfest/mypy_config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mypy.ini b/mypy.ini index 3e0419be269..0ce41821f51 100644 --- a/mypy.ini +++ b/mypy.ini @@ -6,7 +6,7 @@ python_version = 3.12 plugins = pydantic.mypy show_error_codes = true -follow_imports = silent +follow_imports = normal local_partial_types = true strict_equality = true no_implicit_optional = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 76fe47837e4..40d2c9718d6 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -34,7 +34,7 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), "plugins": "pydantic.mypy", "show_error_codes": "true", - "follow_imports": "silent", + "follow_imports": "normal", # Enable some checks globally. "local_partial_types": "true", "strict_equality": "true", From d40fc613aa7e9462abd4832d1ee2e8f8f2b9398d Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 14 Apr 2024 19:39:07 -0400 Subject: [PATCH 004/107] Bump soco to 0.30.3 (#115607) bump soco to 0.30.3 --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index b6375eb7f16..ec5ef90a0c1 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.30.2", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.3", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index 672fac5cff9..1c9bc045a3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2572,7 +2572,7 @@ smhi-pkg==1.0.16 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.2 +soco==0.30.3 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb12f451178..b663d78085f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ smhi-pkg==1.0.16 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.2 +soco==0.30.3 # homeassistant.components.solaredge solaredge==0.0.2 From 5e1de6842da9602d651d4eb492e132deac2f208b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 15 Apr 2024 09:48:22 +1000 Subject: [PATCH 005/107] Fix Teslemetry sensor values (#115571) --- homeassistant/components/teslemetry/sensor.py | 5 + .../teslemetry/snapshots/test_sensor.ambr | 100 +++++++++--------- 2 files changed, 55 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 6284a0e5368..cced1090e2a 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -449,6 +449,11 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Initialize the sensor.""" super().__init__(vehicle, description.key) + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self._value + class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Base class for Teslemetry vehicle metric sensors.""" diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index fad04d341c9..81142e40901 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -757,7 +757,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '77', }) # --- # name: test_sensors[sensor.test_battery_level-statealt] @@ -770,7 +770,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '77', }) # --- # name: test_sensors[sensor.test_battery_range-entry] @@ -816,7 +816,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '266.87', }) # --- # name: test_sensors[sensor.test_battery_range-statealt] @@ -829,7 +829,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '266.87', }) # --- # name: test_sensors[sensor.test_charge_cable-entry] @@ -875,7 +875,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'IEC', }) # --- # name: test_sensors[sensor.test_charge_cable-statealt] @@ -888,7 +888,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'IEC', }) # --- # name: test_sensors[sensor.test_charge_energy_added-entry] @@ -934,7 +934,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charge_energy_added-statealt] @@ -947,7 +947,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charge_rate-entry] @@ -993,7 +993,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charge_rate-statealt] @@ -1006,7 +1006,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_current-entry] @@ -1052,7 +1052,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_current-statealt] @@ -1065,7 +1065,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_power-entry] @@ -1111,7 +1111,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_power-statealt] @@ -1124,7 +1124,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_voltage-entry] @@ -1170,7 +1170,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2', }) # --- # name: test_sensors[sensor.test_charger_voltage-statealt] @@ -1183,7 +1183,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2', }) # --- # name: test_sensors[sensor.test_charging-entry] @@ -1229,7 +1229,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'Stopped', }) # --- # name: test_sensors[sensor.test_charging-statealt] @@ -1242,7 +1242,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'Stopped', }) # --- # name: test_sensors[sensor.test_distance_to_arrival-entry] @@ -1288,7 +1288,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.039491', }) # --- # name: test_sensors[sensor.test_distance_to_arrival-statealt] @@ -1301,7 +1301,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_driver_temperature_setting-entry] @@ -1347,7 +1347,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '22', }) # --- # name: test_sensors[sensor.test_driver_temperature_setting-statealt] @@ -1360,7 +1360,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '22', }) # --- # name: test_sensors[sensor.test_estimate_battery_range-entry] @@ -1406,7 +1406,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '275.04', }) # --- # name: test_sensors[sensor.test_estimate_battery_range-statealt] @@ -1419,7 +1419,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '275.04', }) # --- # name: test_sensors[sensor.test_fast_charger_type-entry] @@ -1465,7 +1465,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'ACSingleWireCAN', }) # --- # name: test_sensors[sensor.test_fast_charger_type-statealt] @@ -1478,7 +1478,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'ACSingleWireCAN', }) # --- # name: test_sensors[sensor.test_ideal_battery_range-entry] @@ -1524,7 +1524,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '266.87', }) # --- # name: test_sensors[sensor.test_ideal_battery_range-statealt] @@ -1537,7 +1537,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '266.87', }) # --- # name: test_sensors[sensor.test_inside_temperature-entry] @@ -1583,7 +1583,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '29.8', }) # --- # name: test_sensors[sensor.test_inside_temperature-statealt] @@ -1596,7 +1596,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '29.8', }) # --- # name: test_sensors[sensor.test_odometer-entry] @@ -1642,7 +1642,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '6481.019282', }) # --- # name: test_sensors[sensor.test_odometer-statealt] @@ -1655,7 +1655,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '6481.019282', }) # --- # name: test_sensors[sensor.test_outside_temperature-entry] @@ -1701,7 +1701,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '30', }) # --- # name: test_sensors[sensor.test_outside_temperature-statealt] @@ -1714,7 +1714,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '30', }) # --- # name: test_sensors[sensor.test_passenger_temperature_setting-entry] @@ -1760,7 +1760,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '22', }) # --- # name: test_sensors[sensor.test_passenger_temperature_setting-statealt] @@ -1773,7 +1773,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '22', }) # --- # name: test_sensors[sensor.test_power-entry] @@ -1819,7 +1819,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '-7', }) # --- # name: test_sensors[sensor.test_power-statealt] @@ -1832,7 +1832,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '-7', }) # --- # name: test_sensors[sensor.test_shift_state-entry] @@ -2177,7 +2177,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_front_left-statealt] @@ -2190,7 +2190,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_front_right-entry] @@ -2236,7 +2236,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.8', }) # --- # name: test_sensors[sensor.test_tire_pressure_front_right-statealt] @@ -2249,7 +2249,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.8', }) # --- # name: test_sensors[sensor.test_tire_pressure_rear_left-entry] @@ -2295,7 +2295,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_rear_left-statealt] @@ -2308,7 +2308,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_rear_right-entry] @@ -2354,7 +2354,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_rear_right-statealt] @@ -2367,7 +2367,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_traffic_delay-entry] @@ -2413,7 +2413,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_traffic_delay-statealt] @@ -2426,7 +2426,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_usable_battery_level-entry] @@ -2472,7 +2472,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '77', }) # --- # name: test_sensors[sensor.test_usable_battery_level-statealt] @@ -2485,7 +2485,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '77', }) # --- # name: test_sensors[sensor.wall_connector_fault_state_code-entry] From b1bd9dc22cde0af241d217c2a36a773442fcb6b8 Mon Sep 17 00:00:00 2001 From: Shawn Weeks Date: Sun, 14 Apr 2024 21:43:11 -0500 Subject: [PATCH 006/107] Bump emulated-roku to 0.3.0 to fix Sofabaton Support (#115452) --- homeassistant/components/emulated_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/emulated_roku/manifest.json b/homeassistant/components/emulated_roku/manifest.json index 739f3b04ec0..214658b7c0e 100644 --- a/homeassistant/components/emulated_roku/manifest.json +++ b/homeassistant/components/emulated_roku/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_roku", "iot_class": "local_push", "loggers": ["emulated_roku"], - "requirements": ["emulated-roku==0.2.1"] + "requirements": ["emulated-roku==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1c9bc045a3c..5a1ad65fd8b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -795,7 +795,7 @@ elvia==0.1.0 emoji==2.8.0 # homeassistant.components.emulated_roku -emulated-roku==0.2.1 +emulated-roku==0.3.0 # homeassistant.components.huisbaasje energyflip-client==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b663d78085f..5cfbf1e80a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -652,7 +652,7 @@ elmax-api==0.0.4 elvia==0.1.0 # homeassistant.components.emulated_roku -emulated-roku==0.2.1 +emulated-roku==0.3.0 # homeassistant.components.huisbaasje energyflip-client==0.2.2 From 15ecd3ae31a150ea47a2b22d0ecc21a9146d1d10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Apr 2024 06:12:09 -0500 Subject: [PATCH 007/107] Fix flaky zwave update entity delay test (#115552) The test assumed the node updates would happen in a specific order but they can switch order based on timing. Adjust to check to make sure all the nodes are called but make it order independent --- tests/components/zwave_js/test_update.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index c5cfba18569..338d1511fc3 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -650,20 +650,25 @@ async def test_update_entity_delay( assert len(client.async_send_command.call_args_list) == 2 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + + nodes: set[int] = set() assert len(client.async_send_command.call_args_list) == 3 args = client.async_send_command.call_args_list[2][0][0] assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == ge_in_wall_dimmer_switch.node_id + nodes.add(args["nodeId"]) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(client.async_send_command.call_args_list) == 4 args = client.async_send_command.call_args_list[3][0][0] assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == zen_31.node_id + nodes.add(args["nodeId"]) + + assert len(nodes) == 2 + assert nodes == {ge_in_wall_dimmer_switch.node_id, zen_31.node_id} async def test_update_entity_partial_restore_data( From 3963b3994b14ccf5aa37bd9755e88fed7355ba72 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Apr 2024 06:42:28 -0500 Subject: [PATCH 008/107] Small cleanups to the rate limit helper (#115621) --- homeassistant/helpers/ratelimit.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py index 516d4134f76..020c7c3a0d3 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -30,7 +30,7 @@ class KeyedRateLimit: @callback def async_has_timer(self, key: Hashable) -> bool: """Check if a rate limit timer is running.""" - return bool(self._rate_limit_timers and key in self._rate_limit_timers) + return key in self._rate_limit_timers @callback def async_triggered(self, key: Hashable, now: float | None = None) -> None: @@ -41,10 +41,8 @@ class KeyedRateLimit: @callback def async_cancel_timer(self, key: Hashable) -> None: """Cancel a rate limit time that will call the action.""" - if not self._rate_limit_timers or key not in self._rate_limit_timers: - return - - self._rate_limit_timers.pop(key).cancel() + if handle := self._rate_limit_timers.pop(key, None): + handle.cancel() @callback def async_remove(self) -> None: From 881e201a152a114bf23f39c29cad6f90ab6a9838 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:12:26 +0200 Subject: [PATCH 009/107] Set platform for mypy (#115638) --- mypy.ini | 1 + script/hassfest/mypy_config.py | 1 + 2 files changed, 2 insertions(+) diff --git a/mypy.ini b/mypy.ini index 0ce41821f51..546ae52f972 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4,6 +4,7 @@ [mypy] python_version = 3.12 +platform = linux plugins = pydantic.mypy show_error_codes = true follow_imports = normal diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 40d2c9718d6..fab3d5fcd7f 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -32,6 +32,7 @@ HEADER: Final = """ GENERAL_SETTINGS: Final[dict[str, str]] = { "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), + "platform": "linux", "plugins": "pydantic.mypy", "show_error_codes": "true", "follow_imports": "normal", From 9f852c6a5811df1e59d25fea28c10be4f8fd1c2e Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 15 Apr 2024 10:06:44 -0400 Subject: [PATCH 010/107] Bump vacuum-map-parser-roborock to 0.1.2 (#115579) bump to 0.1.2 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index d03aa68f1a6..0646f8ee083 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -8,6 +8,6 @@ "loggers": ["roborock"], "requirements": [ "python-roborock==2.0.0", - "vacuum-map-parser-roborock==0.1.1" + "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 5a1ad65fd8b..67d731ab587 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2796,7 +2796,7 @@ url-normalize==1.4.3 uvcclient==0.11.0 # homeassistant.components.roborock -vacuum-map-parser-roborock==0.1.1 +vacuum-map-parser-roborock==0.1.2 # homeassistant.components.vallox vallox-websocket-api==5.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5cfbf1e80a3..5de1f7f5164 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2155,7 +2155,7 @@ url-normalize==1.4.3 uvcclient==0.11.0 # homeassistant.components.roborock -vacuum-map-parser-roborock==0.1.1 +vacuum-map-parser-roborock==0.1.2 # homeassistant.components.vallox vallox-websocket-api==5.1.1 From dbc5109fd839269b84b5a6c841bebae62420268d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Apr 2024 10:42:18 -0500 Subject: [PATCH 011/107] Avoid update calls in state writes when attributes are empty (#115624) --- homeassistant/helpers/entity.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index fb071d438b1..20948a7130a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1052,8 +1052,10 @@ class Entity( available = self.available # only call self.available once per update cycle state = self._stringify_state(available) if available: - attr.update(self.state_attributes or {}) - attr.update(self.extra_state_attributes or {}) + if state_attributes := self.state_attributes: + attr.update(state_attributes) + if extra_state_attributes := self.extra_state_attributes: + attr.update(extra_state_attributes) if (unit_of_measurement := self.unit_of_measurement) is not None: attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement From 2ec588d2a6e391089687bffce307d64c07e2aaa6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Apr 2024 13:40:31 -0500 Subject: [PATCH 012/107] Migrate websocket_api sensor to use shorthand attrs (#115620) --- .../components/websocket_api/sensor.py | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index 7d668466bc2..4d874bca74e 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -30,9 +30,12 @@ async def async_setup_platform( class APICount(SensorEntity): """Entity to represent how many people are connected to the stream API.""" + _attr_name = "Connected clients" + _attr_native_unit_of_measurement = "clients" + def __init__(self) -> None: """Initialize the API count.""" - self.count = 0 + self._attr_native_value = 0 async def async_added_to_hass(self) -> None: """Handle addition to hass.""" @@ -47,22 +50,7 @@ class APICount(SensorEntity): ) ) - @property - def name(self) -> str: - """Return name of entity.""" - return "Connected clients" - - @property - def native_value(self) -> int: - """Return current API count.""" - return self.count - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement.""" - return "clients" - @callback def _update_count(self) -> None: - self.count = self.hass.data.get(DATA_CONNECTIONS, 0) + self._attr_native_value = self.hass.data.get(DATA_CONNECTIONS, 0) self.async_write_ha_state() From a6a47c0b4426b034c79bcafe34f3a9ce89fd7933 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Apr 2024 14:06:59 -0500 Subject: [PATCH 013/107] Make aiohttp_cors a top level import (#115563) * Make aiohttp_cors a top level import This was moved to a late import in #27935 but there is no longer any need to import it late in the event loop as aiohttp_cors is listed in pyproject.toml so it will always be available * drop requirements as they are all top level now * drop requirements as they are all top level now * adjust --- homeassistant/components/emulated_hue/manifest.json | 3 +-- homeassistant/components/http/cors.py | 6 +----- homeassistant/components/http/manifest.json | 7 +------ requirements_all.txt | 10 ---------- requirements_test_all.txt | 10 ---------- tests/test_requirements.py | 9 ++++----- 6 files changed, 7 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index ff3591e0066..14baa5b5d04 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -6,6 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "iot_class": "local_push", - "quality_scale": "internal", - "requirements": ["aiohttp_cors==0.7.0"] + "quality_scale": "internal" } diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index ebae2480589..d97ac9922a2 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -13,6 +13,7 @@ from aiohttp.web_urldispatcher import ( ResourceRoute, StaticResource, ) +import aiohttp_cors from homeassistant.const import HTTP_HEADER_X_REQUESTED_WITH from homeassistant.core import callback @@ -35,11 +36,6 @@ VALID_CORS_TYPES: Final = (Resource, ResourceRoute, StaticResource) @callback def setup_cors(app: Application, origins: list[str]) -> None: """Set up CORS.""" - # This import should remain here. That way the HTTP integration can always - # be imported by other integrations without it's requirements being installed. - # pylint: disable-next=import-outside-toplevel - import aiohttp_cors - cors = aiohttp_cors.setup( app, defaults={ diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index 647b7e42a3a..fb804251edc 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -5,10 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/http", "integration_type": "system", "iot_class": "local_push", - "quality_scale": "internal", - "requirements": [ - "aiohttp_cors==0.7.0", - "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.3.1" - ] + "quality_scale": "internal" } diff --git a/requirements_all.txt b/requirements_all.txt index 67d731ab587..be4e0d16451 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -262,16 +262,6 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.1.5 -# homeassistant.components.http -aiohttp-fast-url-dispatcher==0.3.0 - -# homeassistant.components.http -aiohttp-zlib-ng==0.3.1 - -# homeassistant.components.emulated_hue -# homeassistant.components.http -aiohttp_cors==0.7.0 - # homeassistant.components.hue aiohue==4.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5de1f7f5164..5f471c4d7a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -238,16 +238,6 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.1.5 -# homeassistant.components.http -aiohttp-fast-url-dispatcher==0.3.0 - -# homeassistant.components.http -aiohttp-zlib-ng==0.3.1 - -# homeassistant.components.emulated_hue -# homeassistant.components.http -aiohttp_cors==0.7.0 - # homeassistant.components.hue aiohue==4.7.1 diff --git a/tests/test_requirements.py b/tests/test_requirements.py index ed04ef8649b..73f3f54c3c4 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -591,7 +591,7 @@ async def test_discovery_requirements_mqtt(hass: HomeAssistant) -> None: ) as mock_process: await async_get_integration_with_requirements(hass, "mqtt_comp") - assert len(mock_process.mock_calls) == 2 # mqtt also depends on http + assert len(mock_process.mock_calls) == 1 assert mock_process.mock_calls[0][1][1] == mqtt.requirements @@ -608,13 +608,12 @@ async def test_discovery_requirements_ssdp(hass: HomeAssistant) -> None: ) as mock_process: await async_get_integration_with_requirements(hass, "ssdp_comp") - assert len(mock_process.mock_calls) == 4 + assert len(mock_process.mock_calls) == 3 assert mock_process.mock_calls[0][1][1] == ssdp.requirements assert { mock_process.mock_calls[1][1][0], mock_process.mock_calls[2][1][0], - mock_process.mock_calls[3][1][0], - } == {"http", "network", "recorder"} + } == {"network", "recorder"} @pytest.mark.parametrize( @@ -638,7 +637,7 @@ async def test_discovery_requirements_zeroconf( ) as mock_process: await async_get_integration_with_requirements(hass, "comp") - assert len(mock_process.mock_calls) == 4 # zeroconf also depends on http + assert len(mock_process.mock_calls) == 3 assert mock_process.mock_calls[0][1][1] == zeroconf.requirements From 2c6ec506adf219df62005d6270bd260cf488bcc0 Mon Sep 17 00:00:00 2001 From: Heiko Carrasco <4395770+miterion@users.noreply.github.com> Date: Mon, 15 Apr 2024 15:09:34 -0400 Subject: [PATCH 014/107] Update switchbot_api to 2.1.0 (#115529) --- homeassistant/components/switchbot_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index cb651e5c84f..2b50f39925f 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==2.0.0"] + "requirements": ["switchbot-api==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index be4e0d16451..8090db881bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2643,7 +2643,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.0.0 +switchbot-api==2.1.0 # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f471c4d7a0..2782ae4e9bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2044,7 +2044,7 @@ sunweg==2.1.1 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.0.0 +switchbot-api==2.1.0 # homeassistant.components.system_bridge systembridgeconnector==4.0.3 From c7e6f3696f16fc3a5626387cec5fac7e29d2b32f Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Mon, 15 Apr 2024 15:22:44 -0400 Subject: [PATCH 015/107] Create base class for Rachio smart hose timer entities (#115475) --- homeassistant/components/rachio/device.py | 4 +- homeassistant/components/rachio/entity.py | 57 ++++++++++++++++++++++- homeassistant/components/rachio/switch.py | 50 ++++---------------- 3 files changed, 67 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index c018d7e6f86..09f7eaf1b06 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -350,11 +350,9 @@ class RachioBaseStation: def __init__( self, rachio: Rachio, data: dict[str, Any], coordinator: RachioUpdateCoordinator ) -> None: - """Initialize a hose time base station.""" + """Initialize a smart hose timer base station.""" self.rachio = rachio self._id = data[KEY_ID] - self.serial_number = data[KEY_SERIAL_NUMBER] - self.mac_address = data[KEY_MAC_ADDRESS] self.coordinator = coordinator def start_watering(self, valve_id: str, duration: int) -> None: diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index fc0dc1f1aae..27564f1caca 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -1,10 +1,24 @@ """Adapter to wrap the rachiopy api for home assistant.""" +from abc import abstractmethod +from typing import Any + +from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN +from .const import ( + DEFAULT_NAME, + DOMAIN, + KEY_CONNECTED, + KEY_ID, + KEY_NAME, + KEY_REPORTED_STATE, + KEY_STATE, +) +from .coordinator import RachioUpdateCoordinator from .device import RachioIro @@ -35,3 +49,44 @@ class RachioDevice(Entity): manufacturer=DEFAULT_NAME, configuration_url="https://app.rach.io", ) + + +class RachioHoseTimerEntity(CoordinatorEntity[RachioUpdateCoordinator]): + """Base class for smart hose timer entities.""" + + _attr_has_entity_name = True + + def __init__( + self, data: dict[str, Any], coordinator: RachioUpdateCoordinator + ) -> None: + """Initialize a Rachio smart hose timer entity.""" + super().__init__(coordinator) + self.id = data[KEY_ID] + self._name = data[KEY_NAME] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.id)}, + model="Smart Hose Timer", + name=self._name, + manufacturer=DEFAULT_NAME, + configuration_url="https://app.rach.io", + ) + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return ( + super().available + and self.coordinator.data[self.id][KEY_STATE][KEY_REPORTED_STATE][ + KEY_CONNECTED + ] + ) + + @abstractmethod + def _update_attr(self) -> None: + """Update the state and attributes.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + super()._handle_coordinator_update() diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index fe3d455df3c..0f696baad3a 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -13,25 +13,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_platform, -) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp from .const import ( CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, - DEFAULT_NAME, DOMAIN as DOMAIN_RACHIO, - KEY_CONNECTED, KEY_CURRENT_STATUS, KEY_CUSTOM_CROP, KEY_CUSTOM_SHADE, @@ -67,9 +59,8 @@ from .const import ( SLOPE_SLIGHT, SLOPE_STEEP, ) -from .coordinator import RachioUpdateCoordinator from .device import RachioPerson -from .entity import RachioDevice +from .entity import RachioDevice, RachioHoseTimerEntity from .webhooks import ( SUBTYPE_RAIN_DELAY_OFF, SUBTYPE_RAIN_DELAY_ON, @@ -546,39 +537,19 @@ class RachioSchedule(RachioSwitch): ) -class RachioValve(CoordinatorEntity[RachioUpdateCoordinator], SwitchEntity): +class RachioValve(RachioHoseTimerEntity, SwitchEntity): """Representation of one smart hose timer valve.""" - def __init__( - self, person, base, data, coordinator: RachioUpdateCoordinator - ) -> None: + _attr_name = None + + def __init__(self, person, base, data, coordinator) -> None: """Initialize a new smart hose valve.""" - super().__init__(coordinator) + super().__init__(data, coordinator) self._person = person self._base = base - self.id = data[KEY_ID] - self._attr_name = data[KEY_NAME] self._attr_unique_id = f"{self.id}-valve" self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs - self._attr_device_info = DeviceInfo( - identifiers={ - ( - DOMAIN_RACHIO, - self.id, - ) - }, - connections={(dr.CONNECTION_NETWORK_MAC, self._base.mac_address)}, - manufacturer=DEFAULT_NAME, - model="Smart Hose Timer", - name=self._attr_name, - configuration_url="https://app.rach.io", - ) - - @property - def available(self) -> bool: - """Return if the valve is available.""" - return super().available and self._static_attrs[KEY_CONNECTED] def turn_on(self, **kwargs: Any) -> None: """Turn on this valve.""" @@ -594,20 +565,19 @@ class RachioValve(CoordinatorEntity[RachioUpdateCoordinator], SwitchEntity): self._base.start_watering(self.id, manual_run_time.seconds) self._attr_is_on = True self.schedule_update_ha_state(force_refresh=True) - _LOGGER.debug("Starting valve %s for %s", self.name, str(manual_run_time)) + _LOGGER.debug("Starting valve %s for %s", self._name, str(manual_run_time)) def turn_off(self, **kwargs: Any) -> None: """Turn off this valve.""" self._base.stop_watering(self.id) self._attr_is_on = False self.schedule_update_ha_state(force_refresh=True) - _LOGGER.debug("Stopping watering on valve %s", self.name) + _LOGGER.debug("Stopping watering on valve %s", self._name) @callback - def _handle_coordinator_update(self) -> None: + def _update_attr(self) -> None: """Handle updated coordinator data.""" data = self.coordinator.data[self.id] self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs - super()._handle_coordinator_update() From 5f055a64bbd895e7c595e0e64bec9856c40ebae6 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 15 Apr 2024 22:25:09 +0200 Subject: [PATCH 016/107] Enable Ruff B017 (#115335) --- pyproject.toml | 1 + tests/components/bluetooth/test_wrappers.py | 7 ++++--- tests/components/iaqualink/test_utils.py | 5 +++-- tests/components/repairs/test_init.py | 3 ++- tests/components/smartthings/test_init.py | 8 ++++---- tests/test_setup.py | 3 ++- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8701d67c930..3db19fe6851 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -668,6 +668,7 @@ select = [ "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. + "B017", # pytest.raises(BaseException) should be considered evil "B018", # Found useless attribute access. Either assign it to a variable or remove it. "B023", # Function definition does not bind loop variable {name} "B026", # Star-arg unpacking after a keyword argument is strongly discouraged diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index c14fb8a58c1..2acc2b0ddfc 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -107,7 +107,7 @@ class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): async def connect(self, *args, **kwargs): """Connect.""" - raise Exception("Test exception") + raise ConnectionError("Test exception") def _generate_ble_device_and_adv_data( @@ -304,8 +304,9 @@ async def test_release_slot_on_connect_exception( ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) - with pytest.raises(Exception): - assert await client.connect() is False + with pytest.raises(ConnectionError) as exc_info: + await client.connect() + assert str(exc_info.value) == "Test exception" assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 diff --git a/tests/components/iaqualink/test_utils.py b/tests/components/iaqualink/test_utils.py index c803fb48b09..b9aba93523c 100644 --- a/tests/components/iaqualink/test_utils.py +++ b/tests/components/iaqualink/test_utils.py @@ -15,9 +15,10 @@ async def test_await_or_reraise(hass: HomeAssistant) -> None: async_noop = async_returns(None) await await_or_reraise(async_noop()) - with pytest.raises(Exception): - async_ex = async_raises(Exception) + with pytest.raises(Exception) as exc_info: + async_ex = async_raises(Exception("Test exception")) await await_or_reraise(async_ex()) + assert str(exc_info.value) == "Test exception" with pytest.raises(HomeAssistantError): async_ex = async_raises(AqualinkServiceException) diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index ec34409eb74..75088f6c370 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, Mock +from awesomeversion.exceptions import AwesomeVersionStrategyException from freezegun.api import FrozenDateTimeFactory import pytest @@ -145,7 +146,7 @@ async def test_create_issue_invalid_version( "translation_placeholders": {"abc": "123"}, } - with pytest.raises(Exception): + with pytest.raises(AwesomeVersionStrategyException): async_create_issue( hass, issue["domain"], diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 6ff640e012a..ae8a288e3a5 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -370,9 +370,9 @@ async def test_remove_entry_installedapp_unknown_error( ) -> None: """Test raises exceptions removing the installed app.""" # Arrange - smartthings_mock.delete_installed_app.side_effect = Exception + smartthings_mock.delete_installed_app.side_effect = ValueError # Act - with pytest.raises(Exception): + with pytest.raises(ValueError): await smartthings.async_remove_entry(hass, config_entry) # Assert assert smartthings_mock.delete_installed_app.call_count == 1 @@ -403,9 +403,9 @@ async def test_remove_entry_app_unknown_error( ) -> None: """Test raises exceptions removing the app.""" # Arrange - smartthings_mock.delete_app.side_effect = Exception + smartthings_mock.delete_app.side_effect = ValueError # Act - with pytest.raises(Exception): + with pytest.raises(ValueError): await smartthings.async_remove_entry(hass, config_entry) # Assert assert smartthings_mock.delete_installed_app.call_count == 1 diff --git a/tests/test_setup.py b/tests/test_setup.py index e3d9a322862..65472643adb 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -346,8 +346,9 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: mock_integration(hass, MockModule("comp", setup=exception_setup)) - with pytest.raises(BaseException): + with pytest.raises(BaseException) as exc_info: await setup.async_setup_component(hass, "comp", {}) + assert str(exc_info.value) == "fail!" assert "comp" not in hass.config.components From a16d98854ae0a396e1e2c929b4f94bc3f6b4a9af Mon Sep 17 00:00:00 2001 From: John Luetke Date: Mon, 15 Apr 2024 13:32:14 -0700 Subject: [PATCH 017/107] Remove pihole codeowner (#110384) * Update codeowners Removing myself from codeowners as I have been unable to dedicate time to this * Update CODEOWNERS --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 4 ++-- homeassistant/components/pi_hole/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6e7b7e6f8f4..919777391ab 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1023,8 +1023,8 @@ build.json @home-assistant/supervisor /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/philips_js/ @elupus /tests/components/philips_js/ @elupus -/homeassistant/components/pi_hole/ @johnluetke @shenxn -/tests/components/pi_hole/ @johnluetke @shenxn +/homeassistant/components/pi_hole/ @shenxn +/tests/components/pi_hole/ @shenxn /homeassistant/components/picnic/ @corneyl /tests/components/picnic/ @corneyl /homeassistant/components/pilight/ @trekky12 diff --git a/homeassistant/components/pi_hole/manifest.json b/homeassistant/components/pi_hole/manifest.json index 99439ba3a17..975d8a1494c 100644 --- a/homeassistant/components/pi_hole/manifest.json +++ b/homeassistant/components/pi_hole/manifest.json @@ -1,7 +1,7 @@ { "domain": "pi_hole", "name": "Pi-hole", - "codeowners": ["@johnluetke", "@shenxn"], + "codeowners": ["@shenxn"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/pi_hole", "iot_class": "local_polling", From 4aab073bd207e02ecbd89f5a1c3c4e9a724ae3c5 Mon Sep 17 00:00:00 2001 From: Collin Fair Date: Mon, 15 Apr 2024 15:42:33 -0500 Subject: [PATCH 018/107] Remove cloud dependency from `islamic-prayer-times` (#115146) Co-authored-by: J. Nick Koston --- CODEOWNERS | 4 +- .../islamic_prayer_times/config_flow.py | 42 ++-------- .../islamic_prayer_times/coordinator.py | 15 +--- .../islamic_prayer_times/manifest.json | 6 +- homeassistant/generated/integrations.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../islamic_prayer_times/__init__.py | 10 --- .../islamic_prayer_times/test_config_flow.py | 46 +---------- .../islamic_prayer_times/test_init.py | 81 ++----------------- .../islamic_prayer_times/test_sensor.py | 2 +- 11 files changed, 33 insertions(+), 179 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 919777391ab..39fa804314d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -683,8 +683,8 @@ build.json @home-assistant/supervisor /homeassistant/components/iqvia/ @bachya /tests/components/iqvia/ @bachya /homeassistant/components/irish_rail_transport/ @ttroy50 -/homeassistant/components/islamic_prayer_times/ @engrbm87 -/tests/components/islamic_prayer_times/ @engrbm87 +/homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair +/tests/components/islamic_prayer_times/ @engrbm87 @cpfair /homeassistant/components/iss/ @DurgNomis-drol /tests/components/iss/ @DurgNomis-drol /homeassistant/components/isy994/ @bdraco @shbatm diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 12730c9be08..2db89183499 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import Any -from prayer_times_calculator import InvalidResponseError, PrayerTimesCalculator -from requests.exceptions import ConnectionError as ConnError import voluptuous as vol from homeassistant.config_entries import ( @@ -15,7 +13,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers.selector import ( LocationSelector, SelectSelector, @@ -23,7 +21,6 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, TextSelector, ) -import homeassistant.util.dt as dt_util from .const import ( CALC_METHODS, @@ -43,26 +40,6 @@ from .const import ( ) -async def async_validate_location( - hass: HomeAssistant, lat: float, lon: float -) -> dict[str, str]: - """Check if the selected location is valid.""" - errors = {} - calc = PrayerTimesCalculator( - latitude=lat, - longitude=lon, - calculation_method=DEFAULT_CALC_METHOD, - date=str(dt_util.now().date()), - ) - try: - await hass.async_add_executor_job(calc.fetch_prayer_times) - except InvalidResponseError: - errors["base"] = "invalid_location" - except ConnError: - errors["base"] = "conn_error" - return errors - - class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle the Islamic Prayer config flow.""" @@ -81,7 +58,6 @@ class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors = {} if user_input is not None: lat: float = user_input[CONF_LOCATION][CONF_LATITUDE] @@ -89,14 +65,13 @@ class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{lat}-{lon}") self._abort_if_unique_id_configured() - if not (errors := await async_validate_location(self.hass, lat, lon)): - return self.async_create_entry( - title=user_input[CONF_NAME], - data={ - CONF_LATITUDE: lat, - CONF_LONGITUDE: lon, - }, - ) + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + }, + ) home_location = { CONF_LATITUDE: self.hass.config.latitude, @@ -112,7 +87,6 @@ class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN): ): LocationSelector(), } ), - errors=errors, ) diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index d70d0e2f4fe..2785f69534c 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -6,14 +6,13 @@ from datetime import datetime, timedelta import logging from typing import Any, cast -from prayer_times_calculator import PrayerTimesCalculator, exceptions -from requests.exceptions import ConnectionError as ConnError +from prayer_times_calculator_offline import PrayerTimesCalculator from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.event import async_call_later, async_track_point_in_time -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator import homeassistant.util.dt as dt_util from .const import ( @@ -142,13 +141,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim async def _async_update_data(self) -> dict[str, datetime]: """Update sensors with new prayer times.""" - try: - prayer_times = await self.hass.async_add_executor_job( - self.get_new_prayer_times - ) - except (exceptions.InvalidResponseError, ConnError) as err: - async_call_later(self.hass, 60, self.async_request_update) - raise UpdateFailed from err + prayer_times = self.get_new_prayer_times() # introduced in prayer-times-calculator 0.0.8 prayer_times.pop("date", None) diff --git a/homeassistant/components/islamic_prayer_times/manifest.json b/homeassistant/components/islamic_prayer_times/manifest.json index 5f7e52dd3db..cae3d31feb2 100644 --- a/homeassistant/components/islamic_prayer_times/manifest.json +++ b/homeassistant/components/islamic_prayer_times/manifest.json @@ -1,10 +1,10 @@ { "domain": "islamic_prayer_times", "name": "Islamic Prayer Times", - "codeowners": ["@engrbm87"], + "codeowners": ["@engrbm87", "@cpfair"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/islamic_prayer_times", - "iot_class": "cloud_polling", + "iot_class": "calculated", "loggers": ["prayer_times_calculator"], - "requirements": ["prayer-times-calculator==0.0.12"] + "requirements": ["prayer-times-calculator-offline==1.0.3"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 20fbc883207..340be50978d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2879,7 +2879,7 @@ "islamic_prayer_times": { "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "calculated" }, "ismartwindow": { "name": "iSmartWindow", diff --git a/requirements_all.txt b/requirements_all.txt index 8090db881bc..82d1e50a479 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1556,7 +1556,7 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer-times-calculator==0.0.12 +prayer-times-calculator-offline==1.0.3 # homeassistant.components.proliphix proliphix==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2782ae4e9bd..a85c110477b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1227,7 +1227,7 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer-times-calculator==0.0.12 +prayer-times-calculator-offline==1.0.3 # homeassistant.components.prometheus prometheus-client==0.17.1 diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 4df733a93fc..1e6d6815921 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -22,14 +22,4 @@ PRAYER_TIMES = { "Midnight": "2020-01-01T00:45:00+00:00", } -NEW_PRAYER_TIMES = { - "Fajr": "2020-01-02T06:00:00+00:00", - "Sunrise": "2020-01-02T07:25:00+00:00", - "Dhuhr": "2020-01-02T12:30:00+00:00", - "Asr": "2020-01-02T15:32:00+00:00", - "Maghrib": "2020-01-02T17:45:00+00:00", - "Isha": "2020-01-02T18:53:00+00:00", - "Midnight": "2020-01-02T00:43:00+00:00", -} - NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index be8eca210d3..cb37a6b147d 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,10 +1,6 @@ """Tests for Islamic Prayer Times config flow.""" -from unittest.mock import patch - -from prayer_times_calculator import InvalidResponseError import pytest -from requests.exceptions import ConnectionError as ConnError from homeassistant import config_entries from homeassistant.components import islamic_prayer_times @@ -33,49 +29,15 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.islamic_prayer_times.config_flow.async_validate_location", - return_value={}, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_INPUT - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home" -@pytest.mark.parametrize( - ("exception", "error"), - [ - (InvalidResponseError, "invalid_location"), - (ConnError, "conn_error"), - ], -) -async def test_flow_error( - hass: HomeAssistant, exception: Exception, error: str -) -> None: - """Test flow errors.""" - result = await hass.config_entries.flow.async_init( - islamic_prayer_times.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - with patch( - "homeassistant.components.islamic_prayer_times.config_flow.PrayerTimesCalculator.fetch_prayer_times", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_INPUT - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["errors"]["base"] == error - - async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" entry = MockConfigEntry( diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index aa865ee05a4..c5d4933e24a 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -1,24 +1,21 @@ """Tests for Islamic Prayer Times init.""" -from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time -from prayer_times_calculator.exceptions import InvalidResponseError import pytest from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, STATE_UNAVAILABLE +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util -from . import NEW_PRAYER_TIMES, NOW, PRAYER_TIMES +from . import NOW, PRAYER_TIMES -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry @pytest.fixture(autouse=True) @@ -37,7 +34,7 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ): await hass.config_entries.async_setup(entry.entry_id) @@ -46,25 +43,6 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED -async def test_setup_failed(hass: HomeAssistant) -> None: - """Test Islamic Prayer Times failed due to an error.""" - - entry = MockConfigEntry( - domain=islamic_prayer_times.DOMAIN, - data={}, - ) - entry.add_to_hass(hass) - - # test request error raising ConfigEntryNotReady - with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", - side_effect=InvalidResponseError(), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY - - async def test_unload_entry(hass: HomeAssistant) -> None: """Test removing Islamic Prayer Times.""" entry = MockConfigEntry( @@ -74,7 +52,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ): await hass.config_entries.async_setup(entry.entry_id) @@ -91,7 +69,7 @@ async def test_options_listener(hass: HomeAssistant) -> None: with ( patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ) as mock_fetch_prayer_times, freeze_time(NOW), @@ -107,49 +85,6 @@ async def test_options_listener(hass: HomeAssistant) -> None: assert mock_fetch_prayer_times.call_count == 2 -async def test_update_failed(hass: HomeAssistant) -> None: - """Test integrations tries to update after 1 min if update fails.""" - entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) - entry.add_to_hass(hass) - - with ( - patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, - ), - freeze_time(NOW), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.LOADED - - with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times" - ) as FetchPrayerTimes: - FetchPrayerTimes.side_effect = [ - InvalidResponseError, - NEW_PRAYER_TIMES, - ] - midnight_time = dt_util.parse_datetime(PRAYER_TIMES["Midnight"]) - assert midnight_time - future = midnight_time + timedelta(days=1, minutes=1) - with freeze_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("sensor.islamic_prayer_times_fajr_prayer") - assert state.state == STATE_UNAVAILABLE - - # coordinator tries to update after 1 minute - future = future + timedelta(minutes=1) - with freeze_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - state = hass.states.get("sensor.islamic_prayer_times_fajr_prayer") - assert state.state == "2020-01-02T06:00:00+00:00" - - @pytest.mark.parametrize( ("object_id", "old_unique_id"), [ @@ -184,7 +119,7 @@ async def test_migrate_unique_id( with ( patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ), freeze_time(NOW), @@ -207,7 +142,7 @@ async def test_migration_from_1_1_to_1_2(hass: HomeAssistant) -> None: with ( patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ), freeze_time(NOW), diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 22629819e05..1f8d28dfb6f 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -40,7 +40,7 @@ async def test_islamic_prayer_times_sensors( with ( patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ), freeze_time(NOW), From 11ff00f6377325f3ad5a1f7abcaa6ce701fc3c93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Apr 2024 17:30:05 -0500 Subject: [PATCH 019/107] Small speed up to async_prepare_setup_platform (#115662) --- homeassistant/setup.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 643bb8983b8..5772fce6955 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -487,14 +487,6 @@ async def async_prepare_setup_platform( log_error("Integration not found") return None - # Process deps and reqs as soon as possible, so that requirements are - # available when we import the platform. - try: - await async_process_deps_reqs(hass, hass_config, integration) - except HomeAssistantError as err: - log_error(str(err)) - return None - # Platforms cannot exist on their own, they are part of their integration. # If the integration is not set up yet, and can be set up, set it up. # @@ -502,6 +494,16 @@ async def async_prepare_setup_platform( # where the top level component is. # if load_top_level_component := integration.domain not in hass.config.components: + # Process deps and reqs as soon as possible, so that requirements are + # available when we import the platform. We only do this if the integration + # is not in hass.config.components yet, as we already processed them in + # async_setup_component if it is. + try: + await async_process_deps_reqs(hass, hass_config, integration) + except HomeAssistantError as err: + log_error(str(err)) + return None + try: component = await integration.async_get_component() except ImportError as exc: From 6a7a44c9987598fec19adcce194548b949c5104c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 16 Apr 2024 08:02:27 +0200 Subject: [PATCH 020/107] Add dataclass to store AdGuard data (#115668) * Add dataclass to store AdGuard data * Unify version call --- homeassistant/components/adguard/__init__.py | 17 +++++++++--- homeassistant/components/adguard/const.py | 3 -- homeassistant/components/adguard/entity.py | 14 +++++----- homeassistant/components/adguard/sensor.py | 25 ++++++----------- homeassistant/components/adguard/switch.py | 29 ++++++++++---------- 5 files changed, 43 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index b3cbb3300bf..874a4cae963 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import dataclass + from adguardhome import AdGuardHome, AdGuardHomeConnectionError import voluptuous as vol @@ -24,7 +26,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_FORCE, - DATA_ADGUARD_CLIENT, DOMAIN, SERVICE_ADD_URL, SERVICE_DISABLE_URL, @@ -44,6 +45,14 @@ SERVICE_REFRESH_SCHEMA = vol.Schema( PLATFORMS = [Platform.SENSOR, Platform.SWITCH] +@dataclass +class AdGuardData: + """Adguard data type.""" + + client: AdGuardHome + version: str + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AdGuard Home from a config entry.""" session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) @@ -57,13 +66,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=session, ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_ADGUARD_CLIENT: adguard} - try: - await adguard.version() + version = await adguard.version() except AdGuardHomeConnectionError as exception: raise ConfigEntryNotReady from exception + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AdGuardData(adguard, version) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def add_url(call: ServiceCall) -> None: diff --git a/homeassistant/components/adguard/const.py b/homeassistant/components/adguard/const.py index 7b6827c19d4..5af739a8f0b 100644 --- a/homeassistant/components/adguard/const.py +++ b/homeassistant/components/adguard/const.py @@ -6,9 +6,6 @@ DOMAIN = "adguard" LOGGER = logging.getLogger(__package__) -DATA_ADGUARD_CLIENT = "adguard_client" -DATA_ADGUARD_VERSION = "adguard_version" - CONF_FORCE = "force" SERVICE_ADD_URL = "add_url" diff --git a/homeassistant/components/adguard/entity.py b/homeassistant/components/adguard/entity.py index 8cb71a861e8..a4e16f1b995 100644 --- a/homeassistant/components/adguard/entity.py +++ b/homeassistant/components/adguard/entity.py @@ -2,13 +2,14 @@ from __future__ import annotations -from adguardhome import AdGuardHome, AdGuardHomeError +from adguardhome import AdGuardHomeError from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DATA_ADGUARD_VERSION, DOMAIN, LOGGER +from . import AdGuardData +from .const import DOMAIN, LOGGER class AdGuardHomeEntity(Entity): @@ -19,12 +20,13 @@ class AdGuardHomeEntity(Entity): def __init__( self, - adguard: AdGuardHome, + data: AdGuardData, entry: ConfigEntry, ) -> None: """Initialize the AdGuard Home entity.""" self._entry = entry - self.adguard = adguard + self.data = data + self.adguard = data.client async def async_update(self) -> None: """Update AdGuard Home entity.""" @@ -68,8 +70,6 @@ class AdGuardHomeEntity(Entity): }, manufacturer="AdGuard Team", name="AdGuard Home", - sw_version=self.hass.data[DOMAIN][self._entry.entry_id].get( - DATA_ADGUARD_VERSION - ), + sw_version=self.data.version, configuration_url=config_url, ) diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 1e95a07bffa..ce112f49531 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -7,16 +7,16 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -from adguardhome import AdGuardHome, AdGuardHomeConnectionError +from adguardhome import AdGuardHome from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERSION, DOMAIN +from . import AdGuardData +from .const import DOMAIN from .entity import AdGuardHomeEntity SCAN_INTERVAL = timedelta(seconds=300) @@ -89,17 +89,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdGuard Home sensor based on a config entry.""" - adguard = hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_CLIENT] - - try: - version = await adguard.version() - except AdGuardHomeConnectionError as exception: - raise PlatformNotReady from exception - - hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_VERSION] = version + data: AdGuardData = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [AdGuardHomeSensor(adguard, entry, description) for description in SENSORS], + [AdGuardHomeSensor(data, entry, description) for description in SENSORS], True, ) @@ -111,18 +104,18 @@ class AdGuardHomeSensor(AdGuardHomeEntity, SensorEntity): def __init__( self, - adguard: AdGuardHome, + data: AdGuardData, entry: ConfigEntry, description: AdGuardHomeEntityDescription, ) -> None: """Initialize AdGuard Home sensor.""" - super().__init__(adguard, entry) + super().__init__(data, entry) self.entity_description = description self._attr_unique_id = "_".join( [ DOMAIN, - adguard.host, - str(adguard.port), + self.adguard.host, + str(self.adguard.port), "sensor", description.key, ] diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index ae4bee85d23..e084ed2f349 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -7,15 +7,15 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -from adguardhome import AdGuardHome, AdGuardHomeConnectionError, AdGuardHomeError +from adguardhome import AdGuardHome, AdGuardHomeError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERSION, DOMAIN, LOGGER +from . import AdGuardData +from .const import DOMAIN, LOGGER from .entity import AdGuardHomeEntity SCAN_INTERVAL = timedelta(seconds=10) @@ -83,17 +83,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdGuard Home switch based on a config entry.""" - adguard = hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_CLIENT] - - try: - version = await adguard.version() - except AdGuardHomeConnectionError as exception: - raise PlatformNotReady from exception - - hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_VERSION] = version + data: AdGuardData = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [AdGuardHomeSwitch(adguard, entry, description) for description in SWITCHES], + [AdGuardHomeSwitch(data, entry, description) for description in SWITCHES], True, ) @@ -105,15 +98,21 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity): def __init__( self, - adguard: AdGuardHome, + data: AdGuardData, entry: ConfigEntry, description: AdGuardHomeSwitchEntityDescription, ) -> None: """Initialize AdGuard Home switch.""" - super().__init__(adguard, entry) + super().__init__(data, entry) self.entity_description = description self._attr_unique_id = "_".join( - [DOMAIN, adguard.host, str(adguard.port), "switch", description.key] + [ + DOMAIN, + self.adguard.host, + str(self.adguard.port), + "switch", + description.key, + ] ) async def async_turn_off(self, **kwargs: Any) -> None: From 7e35fcf11a6c863d0cab429f9e02d28e91c12021 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Apr 2024 01:30:51 -0500 Subject: [PATCH 021/107] Bump sqlparse to 0.5.0 (#115681) fixes https://github.com/home-assistant/core/security/dependabot/54 fixes https://github.com/home-assistant/core/security/dependabot/55 --- homeassistant/components/sql/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index dd44af89237..30d071f25af 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.29", "sqlparse==0.4.4"] + "requirements": ["SQLAlchemy==2.0.29", "sqlparse==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 82d1e50a479..7a9a99c2628 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2592,7 +2592,7 @@ spiderpy==1.6.1 spotipy==2.23.0 # homeassistant.components.sql -sqlparse==0.4.4 +sqlparse==0.5.0 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a85c110477b..6172bb97007 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1999,7 +1999,7 @@ spiderpy==1.6.1 spotipy==2.23.0 # homeassistant.components.sql -sqlparse==0.4.4 +sqlparse==0.5.0 # homeassistant.components.srp_energy srpenergy==1.3.6 From 1dfabf34c4adb9f3de114aa4d2687dc249de7bea Mon Sep 17 00:00:00 2001 From: theminer3746 Date: Tue, 16 Apr 2024 14:04:00 +0700 Subject: [PATCH 022/107] Fix typo in modbus integration strings.json (#115685) --- homeassistant/components/modbus/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index fd93185b891..72d7a3ec5f1 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -88,7 +88,7 @@ }, "duplicate_entity_entry": { "title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.", - "description": "An address can only be associated with on entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "duplicate_entity_name": { "title": "Modbus {sub_1} is duplicate, second entry not loaded.", From c5c407b3bb30dba6c24583988359c064457d5dc5 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 16 Apr 2024 03:10:32 -0400 Subject: [PATCH 023/107] Move Insteon configuration panel to config entry (#105581) * Move Insteon panel to the config menu * Bump pyinsteon to 1.5.3 * Undo devcontainer.json changes * Bump Insteon frontend * Update config_flow.py * Code cleanup * Code review changes * Fix failing tests * Fix format * Remove unnecessary exception * codecov * Remove return from try * Fix merge mistake --------- Co-authored-by: Erik Montnemery --- homeassistant/components/insteon/__init__.py | 11 +- .../components/insteon/api/__init__.py | 20 +- .../components/insteon/api/config.py | 272 ++++++++++++ .../components/insteon/api/device.py | 69 +++ .../components/insteon/config_flow.py | 221 +--------- homeassistant/components/insteon/const.py | 2 + .../components/insteon/insteon_entity.py | 1 + .../components/insteon/manifest.json | 2 +- homeassistant/components/insteon/schemas.py | 96 +---- homeassistant/components/insteon/utils.py | 25 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/insteon/mock_setup.py | 44 ++ tests/components/insteon/test_api_config.py | 391 +++++++++++++++++ tests/components/insteon/test_api_device.py | 169 ++++++-- tests/components/insteon/test_config_flow.py | 404 +----------------- tests/components/insteon/test_init.py | 23 +- 17 files changed, 988 insertions(+), 766 deletions(-) create mode 100644 homeassistant/components/insteon/api/config.py create mode 100644 tests/components/insteon/mock_setup.py create mode 100644 tests/components/insteon/test_api_config.py diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 529ac20df52..0ec2434bc82 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers.typing import ConfigType from . import api from .const import ( CONF_CAT, + CONF_DEV_PATH, CONF_DIM_STEPS, CONF_HOUSECODE, CONF_OVERRIDE, @@ -84,6 +85,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Insteon entry.""" + if dev_path := entry.options.get(CONF_DEV_PATH): + hass.data[DOMAIN] = {} + hass.data[DOMAIN][CONF_DEV_PATH] = dev_path + + api.async_load_api(hass) + await api.async_register_insteon_frontend(hass) + if not devices.modem: try: await async_connect(**entry.data) @@ -149,9 +157,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: create_insteon_device(hass, devices.modem, entry.entry_id) - api.async_load_api(hass) - await api.async_register_insteon_frontend(hass) - entry.async_create_background_task( hass, async_get_device_config(hass, entry), "insteon-get-device-config" ) diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py index fa006c6a6d9..1f671aa1343 100644 --- a/homeassistant/components/insteon/api/__init__.py +++ b/homeassistant/components/insteon/api/__init__.py @@ -16,10 +16,19 @@ from .aldb import ( websocket_reset_aldb, websocket_write_aldb, ) +from .config import ( + websocket_add_device_override, + websocket_get_config, + websocket_get_modem_schema, + websocket_remove_device_override, + websocket_update_modem_config, +) from .device import ( websocket_add_device, + websocket_add_x10_device, websocket_cancel_add_device, websocket_get_device, + websocket_remove_device, ) from .properties import ( websocket_change_properties_record, @@ -58,6 +67,8 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_reset_aldb) websocket_api.async_register_command(hass, websocket_add_default_links) websocket_api.async_register_command(hass, websocket_notify_on_aldb_status) + websocket_api.async_register_command(hass, websocket_add_x10_device) + websocket_api.async_register_command(hass, websocket_remove_device) websocket_api.async_register_command(hass, websocket_get_properties) websocket_api.async_register_command(hass, websocket_change_properties_record) @@ -65,6 +76,12 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_load_properties) websocket_api.async_register_command(hass, websocket_reset_properties) + websocket_api.async_register_command(hass, websocket_get_config) + websocket_api.async_register_command(hass, websocket_get_modem_schema) + websocket_api.async_register_command(hass, websocket_update_modem_config) + websocket_api.async_register_command(hass, websocket_add_device_override) + websocket_api.async_register_command(hass, websocket_remove_device_override) + async def async_register_insteon_frontend(hass: HomeAssistant): """Register the Insteon frontend configuration panel.""" @@ -80,8 +97,7 @@ async def async_register_insteon_frontend(hass: HomeAssistant): hass=hass, frontend_url_path=DOMAIN, webcomponent_name="insteon-frontend", - sidebar_title=DOMAIN.capitalize(), - sidebar_icon="mdi:power", + config_panel_domain=DOMAIN, module_url=f"{URL_BASE}/entrypoint-{build_id}.js", embed_iframe=True, require_admin=True, diff --git a/homeassistant/components/insteon/api/config.py b/homeassistant/components/insteon/api/config.py new file mode 100644 index 00000000000..8a617911d1e --- /dev/null +++ b/homeassistant/components/insteon/api/config.py @@ -0,0 +1,272 @@ +"""API calls to manage Insteon configuration changes.""" + +from __future__ import annotations + +from typing import Any, TypedDict + +from pyinsteon import async_close, async_connect, devices +from pyinsteon.address import Address +import voluptuous as vol +import voluptuous_serialize + +from homeassistant.components import websocket_api +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_DEVICE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from ..const import ( + CONF_HOUSECODE, + CONF_OVERRIDE, + CONF_UNITCODE, + CONF_X10, + DEVICE_ADDRESS, + DOMAIN, + ID, + SIGNAL_ADD_DEVICE_OVERRIDE, + SIGNAL_ADD_X10_DEVICE, + SIGNAL_REMOVE_DEVICE_OVERRIDE, + TYPE, +) +from ..schemas import ( + build_device_override_schema, + build_hub_schema, + build_plm_manual_schema, + build_plm_schema, +) +from ..utils import async_get_usb_ports + +HUB_V1_SCHEMA = build_hub_schema(hub_version=1) +HUB_V2_SCHEMA = build_hub_schema(hub_version=2) +PLM_SCHEMA = build_plm_manual_schema() +DEVICE_OVERRIDE_SCHEMA = build_device_override_schema() +OVERRIDE = "override" + + +class X10DeviceConfig(TypedDict): + """X10 Device Configuration Definition.""" + + housecode: str + unitcode: int + platform: str + dim_steps: int + + +class DeviceOverride(TypedDict): + """X10 Device Configuration Definition.""" + + address: Address | str + cat: int + subcat: str + + +def get_insteon_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Return the Insteon configuration entry.""" + return hass.config_entries.async_entries(DOMAIN)[0] + + +def add_x10_device(hass: HomeAssistant, x10_device: X10DeviceConfig): + """Add an X10 device to the Insteon integration.""" + + config_entry = get_insteon_config_entry(hass) + x10_config = config_entry.options.get(CONF_X10, []) + if any( + device[CONF_HOUSECODE] == x10_device["housecode"] + and device[CONF_UNITCODE] == x10_device["unitcode"] + for device in x10_config + ): + raise ValueError("Duplicate X10 device") + + hass.config_entries.async_update_entry( + entry=config_entry, + options=config_entry.options | {CONF_X10: [*x10_config, x10_device]}, + ) + async_dispatcher_send(hass, SIGNAL_ADD_X10_DEVICE, x10_device) + + +def remove_x10_device(hass: HomeAssistant, housecode: str, unitcode: int): + """Remove an X10 device from the config.""" + + config_entry = get_insteon_config_entry(hass) + new_options = {**config_entry.options} + new_x10 = [ + existing_device + for existing_device in config_entry.options.get(CONF_X10, []) + if existing_device[CONF_HOUSECODE].lower() != housecode.lower() + or existing_device[CONF_UNITCODE] != unitcode + ] + + new_options[CONF_X10] = new_x10 + hass.config_entries.async_update_entry(entry=config_entry, options=new_options) + + +def add_device_overide(hass: HomeAssistant, override: DeviceOverride): + """Add an Insteon device override.""" + + config_entry = get_insteon_config_entry(hass) + override_config = config_entry.options.get(CONF_OVERRIDE, []) + address = Address(override[CONF_ADDRESS]) + if any( + Address(existing_override[CONF_ADDRESS]) == address + for existing_override in override_config + ): + raise ValueError("Duplicate override") + + hass.config_entries.async_update_entry( + entry=config_entry, + options=config_entry.options | {CONF_OVERRIDE: [*override_config, override]}, + ) + async_dispatcher_send(hass, SIGNAL_ADD_DEVICE_OVERRIDE, override) + + +def remove_device_override(hass: HomeAssistant, address: Address): + """Remove a device override from config.""" + + config_entry = get_insteon_config_entry(hass) + new_options = {**config_entry.options} + + new_overrides = [ + existing_override + for existing_override in config_entry.options.get(CONF_OVERRIDE, []) + if Address(existing_override[CONF_ADDRESS]) != address + ] + new_options[CONF_OVERRIDE] = new_overrides + hass.config_entries.async_update_entry(entry=config_entry, options=new_options) + + +async def _async_connect(**kwargs): + """Connect to the Insteon modem.""" + if devices.modem: + await async_close() + try: + await async_connect(**kwargs) + except ConnectionError: + return False + return True + + +@websocket_api.websocket_command({vol.Required(TYPE): "insteon/config/get"}) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_config( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get Insteon configuration.""" + config_entry = get_insteon_config_entry(hass) + modem_config = config_entry.data + options_config = config_entry.options + x10_config = options_config.get(CONF_X10) + override_config = options_config.get(CONF_OVERRIDE) + connection.send_result( + msg[ID], + { + "modem_config": {**modem_config}, + "x10_config": x10_config, + "override_config": override_config, + }, + ) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/config/get_modem_schema", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_modem_schema( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the modem configuration.""" + config_entry = get_insteon_config_entry(hass) + config_data = config_entry.data + if device := config_data.get(CONF_DEVICE): + ports = await async_get_usb_ports(hass=hass) + plm_schema = voluptuous_serialize.convert( + build_plm_schema(ports=ports, device=device) + ) + connection.send_result(msg[ID], plm_schema) + else: + hub_schema = voluptuous_serialize.convert(build_hub_schema(**config_data)) + connection.send_result(msg[ID], hub_schema) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/config/update_modem_config", + vol.Required("config"): vol.Any(PLM_SCHEMA, HUB_V2_SCHEMA, HUB_V1_SCHEMA), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_update_modem_config( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the modem configuration.""" + config = msg["config"] + config_entry = get_insteon_config_entry(hass) + is_connected = devices.modem.connected + + if not await _async_connect(**config): + connection.send_error( + msg_id=msg[ID], code="connection_failed", message="Connection failed" + ) + # Try to reconnect using old info + if is_connected: + await _async_connect(**config_entry.data) + return + + hass.config_entries.async_update_entry( + entry=config_entry, + data=config, + ) + connection.send_result(msg[ID], {"status": "success"}) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/config/device_override/add", + vol.Required(OVERRIDE): DEVICE_OVERRIDE_SCHEMA, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_add_device_override( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the modem configuration.""" + override = msg[OVERRIDE] + try: + add_device_overide(hass, override) + except ValueError: + connection.send_error(msg[ID], "duplicate", "Duplicate device address") + + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/config/device_override/remove", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_remove_device_override( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the modem configuration.""" + address = Address(msg[DEVICE_ADDRESS]) + remove_device_override(hass, address) + async_dispatcher_send(hass, SIGNAL_REMOVE_DEVICE_OVERRIDE, address) + connection.send_result(msg[ID]) diff --git a/homeassistant/components/insteon/api/device.py b/homeassistant/components/insteon/api/device.py index d48d87fa347..e8bd08bc4ee 100644 --- a/homeassistant/components/insteon/api/device.py +++ b/homeassistant/components/insteon/api/device.py @@ -3,12 +3,14 @@ from typing import Any from pyinsteon import devices +from pyinsteon.address import Address from pyinsteon.constants import DeviceAction import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send from ..const import ( DEVICE_ADDRESS, @@ -18,8 +20,17 @@ from ..const import ( ID, INSTEON_DEVICE_NOT_FOUND, MULTIPLE, + SIGNAL_REMOVE_HA_DEVICE, + SIGNAL_REMOVE_INSTEON_DEVICE, + SIGNAL_REMOVE_X10_DEVICE, TYPE, ) +from ..schemas import build_x10_schema +from .config import add_x10_device, remove_device_override, remove_x10_device + +X10_DEVICE = "x10_device" +X10_DEVICE_SCHEMA = build_x10_schema() +REMOVE_ALL_REFS = "remove_all_refs" def compute_device_name(ha_device): @@ -139,3 +150,61 @@ async def websocket_cancel_add_device( """Cancel the Insteon all-linking process.""" await devices.async_cancel_all_linking() connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/device/remove", + vol.Required(DEVICE_ADDRESS): str, + vol.Required(REMOVE_ALL_REFS): bool, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_remove_device( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Remove an Insteon device.""" + + address = msg[DEVICE_ADDRESS] + remove_all_refs = msg[REMOVE_ALL_REFS] + if address.startswith("X10"): + _, housecode, unitcode = address.split(".") + unitcode = int(unitcode) + async_dispatcher_send(hass, SIGNAL_REMOVE_X10_DEVICE, housecode, unitcode) + remove_x10_device(hass, housecode, unitcode) + else: + address = Address(address) + remove_device_override(hass, address) + async_dispatcher_send(hass, SIGNAL_REMOVE_HA_DEVICE, address) + async_dispatcher_send( + hass, SIGNAL_REMOVE_INSTEON_DEVICE, address, remove_all_refs + ) + + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/device/add_x10", + vol.Required(X10_DEVICE): X10_DEVICE_SCHEMA, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_add_x10_device( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the X10 devices configuration.""" + x10_device = msg[X10_DEVICE] + try: + add_x10_device(hass, x10_device) + except ValueError: + connection.send_error(msg[ID], code="duplicate", message="Duplicate X10 device") + return + + connection.send_result(msg[ID]) diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 44aa1e18646..baf06b13860 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -4,52 +4,19 @@ from __future__ import annotations import logging -from pyinsteon import async_close, async_connect, devices +from pyinsteon import async_connect from homeassistant.components import dhcp, usb from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, - ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, ) -from homeassistant.const import ( - CONF_ADDRESS, - CONF_DEVICE, - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, -) -from homeassistant.core import callback +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ( - CONF_HOUSECODE, - CONF_HUB_VERSION, - CONF_OVERRIDE, - CONF_UNITCODE, - CONF_X10, - DOMAIN, - SIGNAL_ADD_DEVICE_OVERRIDE, - SIGNAL_ADD_X10_DEVICE, - SIGNAL_REMOVE_DEVICE_OVERRIDE, - SIGNAL_REMOVE_X10_DEVICE, -) -from .schemas import ( - add_device_override, - add_x10_device, - build_device_override_schema, - build_hub_schema, - build_plm_manual_schema, - build_plm_schema, - build_remove_override_schema, - build_remove_x10_schema, - build_x10_schema, -) +from .const import CONF_HUB_VERSION, DOMAIN +from .schemas import build_hub_schema, build_plm_manual_schema, build_plm_schema from .utils import async_get_usb_ports STEP_PLM = "plm" @@ -80,41 +47,6 @@ async def _async_connect(**kwargs): return True -def _remove_override(address, options): - """Remove a device override from config.""" - new_options = {} - if options.get(CONF_X10): - new_options[CONF_X10] = options.get(CONF_X10) - new_overrides = [ - override - for override in options[CONF_OVERRIDE] - if override[CONF_ADDRESS] != address - ] - if new_overrides: - new_options[CONF_OVERRIDE] = new_overrides - return new_options - - -def _remove_x10(device, options): - """Remove an X10 device from the config.""" - housecode = device[11].lower() - unitcode = int(device[24:]) - new_options = {} - if options.get(CONF_OVERRIDE): - new_options[CONF_OVERRIDE] = options.get(CONF_OVERRIDE) - new_x10 = [ - existing_device - for existing_device in options[CONF_X10] - if ( - existing_device[CONF_HOUSECODE].lower() != housecode - or existing_device[CONF_UNITCODE] != unitcode - ) - ] - if new_x10: - new_options[CONF_X10] = new_x10 - return new_options, housecode, unitcode - - class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): """Insteon config flow handler.""" @@ -122,14 +54,6 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): _device_name: str | None = None discovered_conf: dict[str, str] = {} - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> InsteonOptionsFlowHandler: - """Define the config flow to handle options.""" - return InsteonOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): """Init the config flow.""" if self._async_current_entries(): @@ -237,140 +161,3 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): } await self.async_set_unique_id(format_mac(discovery_info.macaddress)) return await self.async_step_user() - - -class InsteonOptionsFlowHandler(OptionsFlow): - """Handle an Insteon options flow.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Init the InsteonOptionsFlowHandler class.""" - self.config_entry = config_entry - - async def async_step_init(self, user_input=None) -> ConfigFlowResult: - """Init the options config flow.""" - menu_options = [STEP_ADD_OVERRIDE, STEP_ADD_X10] - - if self.config_entry.data.get(CONF_HOST): - menu_options.append(STEP_CHANGE_HUB_CONFIG) - else: - menu_options.append(STEP_CHANGE_PLM_CONFIG) - - options = {**self.config_entry.options} - if options.get(CONF_OVERRIDE): - menu_options.append(STEP_REMOVE_OVERRIDE) - if options.get(CONF_X10): - menu_options.append(STEP_REMOVE_X10) - - return self.async_show_menu(step_id="init", menu_options=menu_options) - - async def async_step_change_hub_config(self, user_input=None) -> ConfigFlowResult: - """Change the Hub configuration.""" - errors = {} - if user_input is not None: - data = { - **self.config_entry.data, - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - } - if self.config_entry.data[CONF_HUB_VERSION] == 2: - data[CONF_USERNAME] = user_input[CONF_USERNAME] - data[CONF_PASSWORD] = user_input[CONF_PASSWORD] - if devices.modem: - await async_close() - - if await _async_connect(**data): - self.hass.config_entries.async_update_entry( - self.config_entry, data=data - ) - return self.async_create_entry(data={**self.config_entry.options}) - errors["base"] = "cannot_connect" - data_schema = build_hub_schema(**self.config_entry.data) - return self.async_show_form( - step_id=STEP_CHANGE_HUB_CONFIG, data_schema=data_schema, errors=errors - ) - - async def async_step_change_plm_config(self, user_input=None) -> ConfigFlowResult: - """Change the PLM configuration.""" - errors = {} - if user_input is not None: - data = { - **self.config_entry.data, - CONF_DEVICE: user_input[CONF_DEVICE], - } - if devices.modem: - await async_close() - if await _async_connect(**data): - self.hass.config_entries.async_update_entry( - self.config_entry, data=data - ) - return self.async_create_entry(data={**self.config_entry.options}) - errors["base"] = "cannot_connect" - - ports = await async_get_usb_ports(self.hass) - data_schema = build_plm_schema(ports, **self.config_entry.data) - return self.async_show_form( - step_id=STEP_CHANGE_PLM_CONFIG, data_schema=data_schema, errors=errors - ) - - async def async_step_add_override(self, user_input=None) -> ConfigFlowResult: - """Add a device override.""" - errors = {} - if user_input is not None: - try: - data = add_device_override({**self.config_entry.options}, user_input) - async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE_OVERRIDE, user_input) - return self.async_create_entry(data=data) - except ValueError: - errors["base"] = "input_error" - schema_defaults = user_input if user_input is not None else {} - data_schema = build_device_override_schema(**schema_defaults) - return self.async_show_form( - step_id=STEP_ADD_OVERRIDE, data_schema=data_schema, errors=errors - ) - - async def async_step_add_x10(self, user_input=None) -> ConfigFlowResult: - """Add an X10 device.""" - errors: dict[str, str] = {} - if user_input is not None: - options = add_x10_device({**self.config_entry.options}, user_input) - async_dispatcher_send(self.hass, SIGNAL_ADD_X10_DEVICE, user_input) - return self.async_create_entry(data=options) - schema_defaults: dict[str, str] = user_input if user_input is not None else {} - data_schema = build_x10_schema(**schema_defaults) - return self.async_show_form( - step_id=STEP_ADD_X10, data_schema=data_schema, errors=errors - ) - - async def async_step_remove_override(self, user_input=None) -> ConfigFlowResult: - """Remove a device override.""" - errors: dict[str, str] = {} - options = self.config_entry.options - if user_input is not None: - options = _remove_override(user_input[CONF_ADDRESS], options) - async_dispatcher_send( - self.hass, - SIGNAL_REMOVE_DEVICE_OVERRIDE, - user_input[CONF_ADDRESS], - ) - return self.async_create_entry(data=options) - - data_schema = build_remove_override_schema(options[CONF_OVERRIDE]) - return self.async_show_form( - step_id=STEP_REMOVE_OVERRIDE, data_schema=data_schema, errors=errors - ) - - async def async_step_remove_x10(self, user_input=None) -> ConfigFlowResult: - """Remove an X10 device.""" - errors: dict[str, str] = {} - options = self.config_entry.options - if user_input is not None: - options, housecode, unitcode = _remove_x10(user_input[CONF_DEVICE], options) - async_dispatcher_send( - self.hass, SIGNAL_REMOVE_X10_DEVICE, housecode, unitcode - ) - return self.async_create_entry(data=options) - - data_schema = build_remove_x10_schema(options[CONF_X10]) - return self.async_show_form( - step_id=STEP_REMOVE_X10, data_schema=data_schema, errors=errors - ) diff --git a/homeassistant/components/insteon/const.py b/homeassistant/components/insteon/const.py index b7e6e6055e1..11e1943aa73 100644 --- a/homeassistant/components/insteon/const.py +++ b/homeassistant/components/insteon/const.py @@ -101,6 +101,8 @@ SIGNAL_SAVE_DEVICES = "save_devices" SIGNAL_ADD_ENTITIES = "insteon_add_entities" SIGNAL_ADD_DEFAULT_LINKS = "add_default_links" SIGNAL_ADD_DEVICE_OVERRIDE = "add_device_override" +SIGNAL_REMOVE_HA_DEVICE = "insteon_remove_ha_device" +SIGNAL_REMOVE_INSTEON_DEVICE = "insteon_remove_insteon_device" SIGNAL_REMOVE_DEVICE_OVERRIDE = "insteon_remove_device_override" SIGNAL_REMOVE_ENTITY = "insteon_remove_entity" SIGNAL_ADD_X10_DEVICE = "insteon_add_x10_device" diff --git a/homeassistant/components/insteon/insteon_entity.py b/homeassistant/components/insteon/insteon_entity.py index f81298dfe48..79e5c18a934 100644 --- a/homeassistant/components/insteon/insteon_entity.py +++ b/homeassistant/components/insteon/insteon_entity.py @@ -95,6 +95,7 @@ class InsteonEntity(Entity): f" {self._insteon_device.engine_version}" ), via_device=(DOMAIN, str(devices.modem.address)), + configuration_url=f"homeassistant://insteon/device/config/{self._insteon_device.id}", ) @callback diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index cf210963841..7d12436d0fb 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -18,7 +18,7 @@ "loggers": ["pyinsteon", "pypubsub"], "requirements": [ "pyinsteon==1.5.3", - "insteon-frontend-home-assistant==0.4.0" + "insteon-frontend-home-assistant==0.5.0" ], "usb": [ { diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index e277281c240..837c6224014 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -2,9 +2,6 @@ from __future__ import annotations -from binascii import Error as HexError, unhexlify - -from pyinsteon.address import Address from pyinsteon.constants import HC_LOOKUP import voluptuous as vol @@ -25,10 +22,8 @@ from .const import ( CONF_CAT, CONF_DIM_STEPS, CONF_HOUSECODE, - CONF_OVERRIDE, CONF_SUBCAT, CONF_UNITCODE, - CONF_X10, HOUSECODES, PORT_HUB_V1, PORT_HUB_V2, @@ -76,76 +71,6 @@ TRIGGER_SCENE_SCHEMA = vol.Schema( ADD_DEFAULT_LINKS_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) -def normalize_byte_entry_to_int(entry: int | bytes | str): - """Format a hex entry value.""" - if isinstance(entry, int): - if entry in range(256): - return entry - raise ValueError("Must be single byte") - if isinstance(entry, str): - if entry[0:2].lower() == "0x": - entry = entry[2:] - if len(entry) != 2: - raise ValueError("Not a valid hex code") - try: - entry = unhexlify(entry) - except HexError as err: - raise ValueError("Not a valid hex code") from err - return int.from_bytes(entry, byteorder="big") - - -def add_device_override(config_data, new_override): - """Add a new device override.""" - try: - address = str(Address(new_override[CONF_ADDRESS])) - cat = normalize_byte_entry_to_int(new_override[CONF_CAT]) - subcat = normalize_byte_entry_to_int(new_override[CONF_SUBCAT]) - except ValueError as err: - raise ValueError("Incorrect values") from err - - overrides = [ - override - for override in config_data.get(CONF_OVERRIDE, []) - if override[CONF_ADDRESS] != address - ] - overrides.append( - { - CONF_ADDRESS: address, - CONF_CAT: cat, - CONF_SUBCAT: subcat, - } - ) - - new_config = {} - if config_data.get(CONF_X10): - new_config[CONF_X10] = config_data[CONF_X10] - new_config[CONF_OVERRIDE] = overrides - return new_config - - -def add_x10_device(config_data, new_x10): - """Add a new X10 device to X10 device list.""" - x10_devices = [ - x10_device - for x10_device in config_data.get(CONF_X10, []) - if x10_device[CONF_HOUSECODE] != new_x10[CONF_HOUSECODE] - or x10_device[CONF_UNITCODE] != new_x10[CONF_UNITCODE] - ] - x10_devices.append( - { - CONF_HOUSECODE: new_x10[CONF_HOUSECODE], - CONF_UNITCODE: new_x10[CONF_UNITCODE], - CONF_PLATFORM: new_x10[CONF_PLATFORM], - CONF_DIM_STEPS: new_x10[CONF_DIM_STEPS], - } - ) - new_config = {} - if config_data.get(CONF_OVERRIDE): - new_config[CONF_OVERRIDE] = config_data[CONF_OVERRIDE] - new_config[CONF_X10] = x10_devices - return new_config - - def build_device_override_schema( address=vol.UNDEFINED, cat=vol.UNDEFINED, @@ -169,12 +94,16 @@ def build_x10_schema( dim_steps=22, ): """Build the X10 schema for config flow.""" + if platform == "light": + dim_steps_schema = vol.Required(CONF_DIM_STEPS, default=dim_steps) + else: + dim_steps_schema = vol.Optional(CONF_DIM_STEPS, default=dim_steps) return vol.Schema( { vol.Required(CONF_HOUSECODE, default=housecode): vol.In(HC_LOOKUP.keys()), vol.Required(CONF_UNITCODE, default=unitcode): vol.In(range(1, 17)), vol.Required(CONF_PLATFORM, default=platform): vol.In(X10_PLATFORMS), - vol.Optional(CONF_DIM_STEPS, default=dim_steps): vol.In(range(1, 255)), + dim_steps_schema: vol.Range(min=0, max=255), } ) @@ -219,18 +148,3 @@ def build_hub_schema( schema[vol.Required(CONF_USERNAME, default=username)] = str schema[vol.Required(CONF_PASSWORD, default=password)] = str return vol.Schema(schema) - - -def build_remove_override_schema(data): - """Build the schema to remove device overrides in config flow options.""" - selection = [override[CONF_ADDRESS] for override in data] - return vol.Schema({vol.Required(CONF_ADDRESS): vol.In(selection)}) - - -def build_remove_x10_schema(data): - """Build the schema to remove an X10 device in config flow options.""" - selection = [ - f"Housecode: {device[CONF_HOUSECODE].upper()}, Unitcode: {device[CONF_UNITCODE]}" - for device in data - ] - return vol.Schema({vol.Required(CONF_DEVICE): vol.In(selection)}) diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 272018ea507..db25d8c97a9 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -65,6 +65,8 @@ from .const import ( SIGNAL_PRINT_ALDB, SIGNAL_REMOVE_DEVICE_OVERRIDE, SIGNAL_REMOVE_ENTITY, + SIGNAL_REMOVE_HA_DEVICE, + SIGNAL_REMOVE_INSTEON_DEVICE, SIGNAL_REMOVE_X10_DEVICE, SIGNAL_SAVE_DEVICES, SRV_ADD_ALL_LINK, @@ -179,7 +181,7 @@ def register_new_device_callback(hass): @callback -def async_register_services(hass): +def async_register_services(hass): # noqa: C901 """Register services used by insteon component.""" save_lock = asyncio.Lock() @@ -270,14 +272,14 @@ def async_register_services(hass): async def async_add_device_override(override): """Remove an Insten device and associated entities.""" address = Address(override[CONF_ADDRESS]) - await async_remove_device(address) + await async_remove_ha_device(address) devices.set_id(address, override[CONF_CAT], override[CONF_SUBCAT], 0) await async_srv_save_devices() async def async_remove_device_override(address): """Remove an Insten device and associated entities.""" address = Address(address) - await async_remove_device(address) + await async_remove_ha_device(address) devices.set_id(address, None, None, None) await devices.async_identify_device(address) await async_srv_save_devices() @@ -304,9 +306,9 @@ def async_register_services(hass): """Remove an X10 device and associated entities.""" address = create_x10_address(housecode, unitcode) devices.pop(address) - await async_remove_device(address) + await async_remove_ha_device(address) - async def async_remove_device(address): + async def async_remove_ha_device(address: Address, remove_all_refs: bool = False): """Remove the device and all entities from hass.""" signal = f"{address.id}_{SIGNAL_REMOVE_ENTITY}" async_dispatcher_send(hass, signal) @@ -315,6 +317,15 @@ def async_register_services(hass): if device: dev_registry.async_remove_device(device.id) + async def async_remove_insteon_device( + address: Address, remove_all_refs: bool = False + ): + """Remove the underlying Insteon device from the network.""" + await devices.async_remove_device( + address=address, force=False, remove_all_refs=remove_all_refs + ) + await async_srv_save_devices() + hass.services.async_register( DOMAIN, SRV_ADD_ALL_LINK, async_srv_add_all_link, schema=ADD_ALL_LINK_SCHEMA ) @@ -368,6 +379,10 @@ def async_register_services(hass): ) async_dispatcher_connect(hass, SIGNAL_ADD_X10_DEVICE, async_add_x10_device) async_dispatcher_connect(hass, SIGNAL_REMOVE_X10_DEVICE, async_remove_x10_device) + async_dispatcher_connect(hass, SIGNAL_REMOVE_HA_DEVICE, async_remove_ha_device) + async_dispatcher_connect( + hass, SIGNAL_REMOVE_INSTEON_DEVICE, async_remove_insteon_device + ) _LOGGER.debug("Insteon Services registered") diff --git a/requirements_all.txt b/requirements_all.txt index 7a9a99c2628..653e481d2fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1142,7 +1142,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.4.0 +insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6172bb97007..0decf82fe0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -926,7 +926,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.4.0 +insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==2.2.2 diff --git a/tests/components/insteon/mock_setup.py b/tests/components/insteon/mock_setup.py new file mode 100644 index 00000000000..c0d90509a50 --- /dev/null +++ b/tests/components/insteon/mock_setup.py @@ -0,0 +1,44 @@ +"""Utility to setup the Insteon integration.""" + +from homeassistant.components.insteon.api import async_load_api +from homeassistant.components.insteon.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import MOCK_USER_INPUT_PLM +from .mock_devices import MockDevices + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def async_mock_setup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + config_data: dict | None = None, + config_options: dict | None = None, +): + """Set up for tests.""" + config_data = MOCK_USER_INPUT_PLM if config_data is None else config_data + config_options = {} if config_options is None else config_options + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data=config_data, + options=config_options, + ) + config_entry.add_to_hass(hass) + async_load_api(hass) + + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + + dev_reg = dr.async_get(hass) + # Create device registry entry for mock node + ha_device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "11.11.11")}, + name="Device 11.11.11", + ) + return ws_client, devices, ha_device, dev_reg diff --git a/tests/components/insteon/test_api_config.py b/tests/components/insteon/test_api_config.py new file mode 100644 index 00000000000..7c922338638 --- /dev/null +++ b/tests/components/insteon/test_api_config.py @@ -0,0 +1,391 @@ +"""Test the Insteon APIs for configuring the integration.""" + +from unittest.mock import patch + +from homeassistant.components.insteon.api.device import ID, TYPE +from homeassistant.components.insteon.const import ( + CONF_HUB_VERSION, + CONF_OVERRIDE, + CONF_X10, +) +from homeassistant.core import HomeAssistant + +from .const import ( + MOCK_DEVICE, + MOCK_HOSTNAME, + MOCK_USER_INPUT_HUB_V1, + MOCK_USER_INPUT_HUB_V2, + MOCK_USER_INPUT_PLM, +) +from .mock_connection import mock_failed_connection, mock_successful_connection +from .mock_setup import async_mock_setup + +from tests.typing import WebSocketGenerator + + +class MockProtocol: + """A mock Insteon protocol object.""" + + connected = True + + +async def test_get_config( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon configuration.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json({ID: 2, TYPE: "insteon/config/get"}) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["modem_config"] == {"device": MOCK_DEVICE} + + +async def test_get_modem_schema_plm( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon PLM modem configuration schema.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json({ID: 2, TYPE: "insteon/config/get_modem_schema"}) + msg = await ws_client.receive_json() + result = msg["result"][0] + + assert result["default"] == MOCK_DEVICE + assert result["name"] == "device" + assert result["required"] + + +async def test_get_modem_schema_hub( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon PLM modem configuration schema.""" + + ws_client, devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + config_data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, + ) + await ws_client.send_json({ID: 2, TYPE: "insteon/config/get_modem_schema"}) + msg = await ws_client.receive_json() + result = msg["result"][0] + + assert result["default"] == MOCK_HOSTNAME + assert result["name"] == "host" + assert result["required"] + + +async def test_update_modem_config_plm( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon PLM modem configuration schema.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_successful_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_PLM, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["status"] == "success" + + +async def test_update_modem_config_hub_v2( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon HubV2 modem configuration schema.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + config_data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, + config_options={"dev_path": "/some/path"}, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_successful_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_HUB_V2, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["status"] == "success" + + +async def test_update_modem_config_hub_v1( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon HubV1 modem configuration schema.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + config_data={**MOCK_USER_INPUT_HUB_V1, CONF_HUB_VERSION: 1}, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_successful_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_HUB_V1, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["status"] == "success" + + +async def test_update_modem_config_bad( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test updating the Insteon modem configuration with bad connection information.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_failed_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_PLM, + } + ) + msg = await ws_client.receive_json() + result = msg["error"] + assert result["code"] == "connection_failed" + + +async def test_update_modem_config_bad_reconnect( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test updating the Insteon modem configuration with bad connection information so reconnect to old.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_failed_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + mock_devices.modem.protocol = MockProtocol() + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_PLM, + } + ) + msg = await ws_client.receive_json() + result = msg["error"] + assert result["code"] == "connection_failed" + + +async def test_add_device_override( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding a device configuration override.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + await ws_client.send_json( + {ID: 2, TYPE: "insteon/config/device_override/add", "override": override} + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_OVERRIDE]) == 1 + assert config_entry.options[CONF_OVERRIDE][0]["address"] == "99.99.99" + + +async def test_add_device_override_duplicate( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding a duplicate device configuration override.""" + + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_OVERRIDE: [override]} + ) + await ws_client.send_json( + {ID: 2, TYPE: "insteon/config/device_override/add", "override": override} + ) + msg = await ws_client.receive_json() + assert msg["error"] + + +async def test_remove_device_override( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing a device configuration override.""" + + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + overrides = [ + override, + { + "address": "88.88.88", + "cat": "0x02", + "subcat": "0x05", + }, + ] + + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_OVERRIDE: overrides} + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/device_override/remove", + "device_address": "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_OVERRIDE]) == 1 + assert config_entry.options[CONF_OVERRIDE][0]["address"] == "88.88.88" + + +async def test_add_device_override_with_x10( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding a device configuration override when X10 configuration exists.""" + + x10_device = {"housecode": "a", "unitcode": 1, "platform": "switch"} + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_X10: [x10_device]} + ) + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + await ws_client.send_json( + {ID: 2, TYPE: "insteon/config/device_override/add", "override": override} + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_X10]) == 1 + + +async def test_remove_device_override_with_x10( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing a device configuration override when X10 configuration exists.""" + + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + overrides = [ + override, + { + "address": "88.88.88", + "cat": "0x02", + "subcat": "0x05", + }, + ] + x10_device = {"housecode": "a", "unitcode": 1, "platform": "switch"} + + ws_client, _, _, _ = await async_mock_setup( + hass, + hass_ws_client, + config_options={CONF_OVERRIDE: overrides, CONF_X10: [x10_device]}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/device_override/remove", + "device_address": "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_X10]) == 1 + + +async def test_remove_device_override_no_overrides( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing a device override when no overrides are configured.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/device_override/remove", + "device_address": "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert not config_entry.options.get(CONF_OVERRIDE) diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py index f3c67d479d0..29d601eb3ef 100644 --- a/tests/components/insteon/test_api_device.py +++ b/tests/components/insteon/test_api_device.py @@ -18,48 +18,29 @@ from homeassistant.components.insteon.api.device import ( TYPE, async_device_name, ) -from homeassistant.components.insteon.const import DOMAIN, MULTIPLE +from homeassistant.components.insteon.const import ( + CONF_OVERRIDE, + CONF_X10, + DOMAIN, + MULTIPLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .const import MOCK_USER_INPUT_PLM from .mock_devices import MockDevices +from .mock_setup import async_mock_setup from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator -async def _async_setup(hass, hass_ws_client): - """Set up for tests.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data=MOCK_USER_INPUT_PLM, - options={}, - ) - config_entry.add_to_hass(hass) - async_load_api(hass) - - ws_client = await hass_ws_client(hass) - devices = MockDevices() - await devices.async_load() - - dev_reg = dr.async_get(hass) - # Create device registry entry for mock node - ha_device = dev_reg.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, "11.11.11")}, - name="Device 11.11.11", - ) - return ws_client, devices, ha_device, dev_reg - - -async def test_get_device_api( +async def test_get_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test getting an Insteon device.""" - ws_client, devices, ha_device, _ = await _async_setup(hass, hass_ws_client) + ws_client, devices, ha_device, _ = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.device, "devices", devices): await ws_client.send_json( {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: ha_device.id} @@ -76,7 +57,7 @@ async def test_no_ha_device( ) -> None: """Test response when no HA device exists.""" - ws_client, devices, _, _ = await _async_setup(hass, hass_ws_client) + ws_client, devices, _, _ = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.device, "devices", devices): await ws_client.send_json( {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: "not_a_device"} @@ -141,7 +122,7 @@ async def test_get_ha_device_name( ) -> None: """Test getting the HA device name from an Insteon address.""" - _, devices, _, device_reg = await _async_setup(hass, hass_ws_client) + _, devices, _, device_reg = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.device, "devices", devices): # Test a real HA and Insteon device @@ -164,7 +145,7 @@ async def test_add_device_api( ) -> None: """Test adding an Insteon device.""" - ws_client, devices, _, _ = await _async_setup(hass, hass_ws_client) + ws_client, devices, _, _ = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.device, "devices", devices): await ws_client.send_json({ID: 2, TYPE: "insteon/device/add", MULTIPLE: True}) @@ -194,7 +175,7 @@ async def test_cancel_add_device( ) -> None: """Test cancelling adding of a new device.""" - ws_client, devices, _, _ = await _async_setup(hass, hass_ws_client) + ws_client, devices, _, _ = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.aldb, "devices", devices): await ws_client.send_json( @@ -205,3 +186,127 @@ async def test_cancel_add_device( ) msg = await ws_client.receive_json() assert msg["success"] + + +async def test_add_x10_device( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding an X10 device.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + x10_device = {"housecode": "a", "unitcode": 1, "platform": "switch"} + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/add_x10", "x10_device": x10_device} + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_X10]) == 1 + assert config_entry.options[CONF_X10][0]["housecode"] == "a" + assert config_entry.options[CONF_X10][0]["unitcode"] == 1 + assert config_entry.options[CONF_X10][0]["platform"] == "switch" + + +async def test_add_x10_device_duplicate( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding a duplicate X10 device.""" + + x10_device = {"housecode": "a", "unitcode": 1, "platform": "switch"} + + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_X10: [x10_device]} + ) + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/add_x10", "x10_device": x10_device} + ) + msg = await ws_client.receive_json() + assert msg["error"] + assert msg["error"]["code"] == "duplicate" + + +async def test_remove_device( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing an Insteon device.""" + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/device/remove", + "device_address": "11.22.33", + "remove_all_refs": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + +async def test_remove_x10_device( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing an X10 device.""" + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/device/remove", + "device_address": "X10.A.01", + "remove_all_refs": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + +async def test_remove_one_x10_device( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test one X10 device without removing others.""" + x10_device = {"housecode": "a", "unitcode": 1, "platform": "light", "dim_steps": 22} + x10_devices = [ + x10_device, + {"housecode": "a", "unitcode": 2, "platform": "switch"}, + ] + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_X10: x10_devices} + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/device/remove", + "device_address": "X10.A.01", + "remove_all_refs": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_X10]) == 1 + assert config_entry.options[CONF_X10][0]["housecode"] == "a" + assert config_entry.options[CONF_X10][0]["unitcode"] == 2 + + +async def test_remove_device_with_overload( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing an Insteon device that has a device overload.""" + overload = {"address": "99.99.99", "cat": 1, "subcat": 3} + overloads = {CONF_OVERRIDE: [overload]} + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options=overloads + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/device/remove", + "device_address": "99.99.99", + "remove_all_refs": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert not config_entry.options.get(CONF_OVERRIDE) diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 7cc0eefc0b5..4d3fb815463 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -8,38 +8,14 @@ from voluptuous_serialize import convert from homeassistant import config_entries from homeassistant.components import dhcp, usb from homeassistant.components.insteon.config_flow import ( - STEP_ADD_OVERRIDE, - STEP_ADD_X10, - STEP_CHANGE_HUB_CONFIG, - STEP_CHANGE_PLM_CONFIG, STEP_HUB_V1, STEP_HUB_V2, STEP_PLM, STEP_PLM_MANUALLY, - STEP_REMOVE_OVERRIDE, - STEP_REMOVE_X10, -) -from homeassistant.components.insteon.const import ( - CONF_CAT, - CONF_DIM_STEPS, - CONF_HOUSECODE, - CONF_HUB_VERSION, - CONF_OVERRIDE, - CONF_SUBCAT, - CONF_UNITCODE, - CONF_X10, - DOMAIN, ) +from homeassistant.components.insteon.const import CONF_HUB_VERSION, DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_ADDRESS, - CONF_DEVICE, - CONF_HOST, - CONF_PASSWORD, - CONF_PLATFORM, - CONF_PORT, - CONF_USERNAME, -) +from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -52,11 +28,8 @@ from .const import ( PATCH_ASYNC_SETUP, PATCH_ASYNC_SETUP_ENTRY, PATCH_CONNECTION, - PATCH_CONNECTION_CLOSE, - PATCH_DEVICES, PATCH_USB_LIST, ) -from .mock_devices import MockDevices from tests.common import MockConfigEntry @@ -294,379 +267,6 @@ async def test_failed_connection_hub(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def _options_init_form(hass, entry_id, step): - """Run the init options form.""" - with patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True): - result = await hass.config_entries.options.async_init(entry_id) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "init" - - return await hass.config_entries.options.async_configure( - result["flow_id"], - {"next_step_id": step}, - ) - - -async def _options_form( - hass, flow_id, user_input, connection=mock_successful_connection -): - """Test an options form.""" - mock_devices = MockDevices(connected=True) - await mock_devices.async_load() - mock_devices.modem = mock_devices["AA.AA.AA"] - with ( - patch(PATCH_CONNECTION, new=connection), - patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True) as mock_setup_entry, - patch(PATCH_DEVICES, mock_devices), - patch(PATCH_CONNECTION_CLOSE), - ): - result = await hass.config_entries.options.async_configure(flow_id, user_input) - return result, mock_setup_entry - - -async def test_options_change_hub_config(hass: HomeAssistant) -> None: - """Test changing Hub v2 config.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form( - hass, config_entry.entry_id, STEP_CHANGE_HUB_CONFIG - ) - - user_input = { - CONF_HOST: "2.3.4.5", - CONF_PORT: 9999, - CONF_USERNAME: "new username", - CONF_PASSWORD: "new password", - } - result, _ = await _options_form(hass, result["flow_id"], user_input) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {} - assert config_entry.data == {**user_input, CONF_HUB_VERSION: 2} - - -async def test_options_change_hub_bad_config(hass: HomeAssistant) -> None: - """Test changing Hub v2 with bad config.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form( - hass, config_entry.entry_id, STEP_CHANGE_HUB_CONFIG - ) - - user_input = { - CONF_HOST: "2.3.4.5", - CONF_PORT: 9999, - CONF_USERNAME: "new username", - CONF_PASSWORD: "new password", - } - result, _ = await _options_form( - hass, result["flow_id"], user_input, mock_failed_connection - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"]["base"] == "cannot_connect" - - -async def test_options_change_plm_config(hass: HomeAssistant) -> None: - """Test changing PLM config.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data=MOCK_USER_INPUT_PLM, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form( - hass, config_entry.entry_id, STEP_CHANGE_PLM_CONFIG - ) - - user_input = {CONF_DEVICE: "/dev/ttyUSB0"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {} - assert config_entry.data == user_input - - -async def test_options_change_plm_bad_config(hass: HomeAssistant) -> None: - """Test changing PLM config.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data=MOCK_USER_INPUT_PLM, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form( - hass, config_entry.entry_id, STEP_CHANGE_PLM_CONFIG - ) - - user_input = {CONF_DEVICE: "/dev/ttyUSB0"} - result, _ = await _options_form( - hass, result["flow_id"], user_input, mock_failed_connection - ) - - assert result["type"] is FlowResultType.FORM - - assert result["type"] is FlowResultType.FORM - assert result["errors"]["base"] == "cannot_connect" - - -async def test_options_add_device_override(hass: HomeAssistant) -> None: - """Test adding a device override.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_OVERRIDE) - - user_input = { - CONF_ADDRESS: "1a2b3c", - CONF_CAT: "0x04", - CONF_SUBCAT: "0xaa", - } - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_OVERRIDE]) == 1 - assert config_entry.options[CONF_OVERRIDE][0][CONF_ADDRESS] == "1A.2B.3C" - assert config_entry.options[CONF_OVERRIDE][0][CONF_CAT] == 4 - assert config_entry.options[CONF_OVERRIDE][0][CONF_SUBCAT] == 170 - - result2 = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_OVERRIDE) - - user_input = { - CONF_ADDRESS: "4d5e6f", - CONF_CAT: "05", - CONF_SUBCAT: "bb", - } - result3, _ = await _options_form(hass, result2["flow_id"], user_input) - - assert len(config_entry.options[CONF_OVERRIDE]) == 2 - assert config_entry.options[CONF_OVERRIDE][1][CONF_ADDRESS] == "4D.5E.6F" - assert config_entry.options[CONF_OVERRIDE][1][CONF_CAT] == 5 - assert config_entry.options[CONF_OVERRIDE][1][CONF_SUBCAT] == 187 - - # If result1 eq result2 the changes will not save - assert result["data"] != result3["data"] - - -async def test_options_remove_device_override(hass: HomeAssistant) -> None: - """Test removing a device override.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={ - CONF_OVERRIDE: [ - {CONF_ADDRESS: "1A.2B.3C", CONF_CAT: 6, CONF_SUBCAT: 100}, - {CONF_ADDRESS: "4D.5E.6F", CONF_CAT: 7, CONF_SUBCAT: 200}, - ] - }, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_OVERRIDE) - - user_input = {CONF_ADDRESS: "1A.2B.3C"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_OVERRIDE]) == 1 - - -async def test_options_remove_device_override_with_x10(hass: HomeAssistant) -> None: - """Test removing a device override when an X10 device is configured.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={ - CONF_OVERRIDE: [ - {CONF_ADDRESS: "1A.2B.3C", CONF_CAT: 6, CONF_SUBCAT: 100}, - {CONF_ADDRESS: "4D.5E.6F", CONF_CAT: 7, CONF_SUBCAT: 200}, - ], - CONF_X10: [ - { - CONF_HOUSECODE: "d", - CONF_UNITCODE: 5, - CONF_PLATFORM: "light", - CONF_DIM_STEPS: 22, - } - ], - }, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_OVERRIDE) - - user_input = {CONF_ADDRESS: "1A.2B.3C"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_OVERRIDE]) == 1 - assert len(config_entry.options[CONF_X10]) == 1 - - -async def test_options_add_x10_device(hass: HomeAssistant) -> None: - """Test adding an X10 device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_X10) - - user_input = { - CONF_HOUSECODE: "c", - CONF_UNITCODE: 12, - CONF_PLATFORM: "light", - CONF_DIM_STEPS: 18, - } - result2, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_X10]) == 1 - assert config_entry.options[CONF_X10][0][CONF_HOUSECODE] == "c" - assert config_entry.options[CONF_X10][0][CONF_UNITCODE] == 12 - assert config_entry.options[CONF_X10][0][CONF_PLATFORM] == "light" - assert config_entry.options[CONF_X10][0][CONF_DIM_STEPS] == 18 - - result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_X10) - user_input = { - CONF_HOUSECODE: "d", - CONF_UNITCODE: 10, - CONF_PLATFORM: "binary_sensor", - CONF_DIM_STEPS: 15, - } - result3, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_X10]) == 2 - assert config_entry.options[CONF_X10][1][CONF_HOUSECODE] == "d" - assert config_entry.options[CONF_X10][1][CONF_UNITCODE] == 10 - assert config_entry.options[CONF_X10][1][CONF_PLATFORM] == "binary_sensor" - assert config_entry.options[CONF_X10][1][CONF_DIM_STEPS] == 15 - - # If result2 eq result3 the changes will not save - assert result2["data"] != result3["data"] - - -async def test_options_remove_x10_device(hass: HomeAssistant) -> None: - """Test removing an X10 device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={ - CONF_X10: [ - { - CONF_HOUSECODE: "C", - CONF_UNITCODE: 4, - CONF_PLATFORM: "light", - CONF_DIM_STEPS: 18, - }, - { - CONF_HOUSECODE: "D", - CONF_UNITCODE: 10, - CONF_PLATFORM: "binary_sensor", - CONF_DIM_STEPS: 15, - }, - ] - }, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_X10) - - user_input = {CONF_DEVICE: "Housecode: C, Unitcode: 4"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_X10]) == 1 - - -async def test_options_remove_x10_device_with_override(hass: HomeAssistant) -> None: - """Test removing an X10 device when a device override is configured.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={ - CONF_X10: [ - { - CONF_HOUSECODE: "C", - CONF_UNITCODE: 4, - CONF_PLATFORM: "light", - CONF_DIM_STEPS: 18, - }, - { - CONF_HOUSECODE: "D", - CONF_UNITCODE: 10, - CONF_PLATFORM: "binary_sensor", - CONF_DIM_STEPS: 15, - }, - ], - CONF_OVERRIDE: [{CONF_ADDRESS: "1A.2B.3C", CONF_CAT: 1, CONF_SUBCAT: 18}], - }, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_X10) - - user_input = {CONF_DEVICE: "Housecode: C, Unitcode: 4"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_X10]) == 1 - assert len(config_entry.options[CONF_OVERRIDE]) == 1 - - -async def test_options_override_bad_data(hass: HomeAssistant) -> None: - """Test for bad data in a device override.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_OVERRIDE) - - user_input = { - CONF_ADDRESS: "zzzzzz", - CONF_CAT: "bad", - CONF_SUBCAT: "data", - } - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "input_error"} - - async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow.""" discovery_info = usb.UsbServiceInfo( diff --git a/tests/components/insteon/test_init.py b/tests/components/insteon/test_init.py index a4e8da03345..c5524ff1919 100644 --- a/tests/components/insteon/test_init.py +++ b/tests/components/insteon/test_init.py @@ -1,6 +1,5 @@ """Test the init file for the Insteon component.""" -import asyncio from unittest.mock import patch import pytest @@ -11,7 +10,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import MOCK_USER_INPUT_PLM, PATCH_CONNECTION +from .const import MOCK_USER_INPUT_PLM from .mock_devices import MockDevices from tests.common import MockConfigEntry @@ -70,22 +69,24 @@ async def test_setup_entry_failed_connection( async def test_import_frontend_dev_url(hass: HomeAssistant) -> None: """Test importing a dev_url config entry.""" - config = {} - config[DOMAIN] = {CONF_DEV_PATH: "/some/path"} + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_USER_INPUT_PLM, options={CONF_DEV_PATH: "/some/path"} + ) + config_entry.add_to_hass(hass) with ( patch.object(insteon, "async_connect", new=mock_successful_connection), - patch.object(insteon, "close_insteon_connection"), + patch.object(insteon, "async_close") as mock_close, patch.object(insteon, "devices", new=MockDevices()), - patch( - PATCH_CONNECTION, - new=mock_successful_connection, - ), ): assert await async_setup_component( hass, insteon.DOMAIN, - config, + {}, ) await hass.async_block_till_done() - await asyncio.sleep(0.01) + assert hass.data[DOMAIN][CONF_DEV_PATH] == "/some/path" + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert insteon.devices.async_save.call_count == 1 + assert mock_close.called From a99ecb024eef0ded73a467f71af9d672f31c60ae Mon Sep 17 00:00:00 2001 From: brave0d <138725265+brave0d@users.noreply.github.com> Date: Tue, 16 Apr 2024 08:24:32 +0100 Subject: [PATCH 024/107] New BMW sensor for climate activity (#110287) * add sensor with climate activity status * Update strings.json * use icon translation and is_available for sensor * use enum with translations * Return None if value is UNKNOWN * fix getting the value: x.value * fix getting the value: x instead of x.value * Fix tests and pre-commit --------- Co-authored-by: Richard --- .../components/bmw_connected_drive/const.py | 7 +++ .../components/bmw_connected_drive/icons.json | 3 + .../components/bmw_connected_drive/sensor.py | 11 +++- .../bmw_connected_drive/strings.json | 9 +++ .../snapshots/test_sensor.ambr | 57 +++++++++++++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 49990977f71..5374b52e684 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -28,3 +28,10 @@ SCAN_INTERVALS = { "north_america": 600, "rest_of_world": 300, } + +CLIMATE_ACTIVITY_STATE: list[str] = [ + "cooling", + "heating", + "inactive", + "standby", +] diff --git a/homeassistant/components/bmw_connected_drive/icons.json b/homeassistant/components/bmw_connected_drive/icons.json index a4eb37b369a..fc30b87ed3f 100644 --- a/homeassistant/components/bmw_connected_drive/icons.json +++ b/homeassistant/components/bmw_connected_drive/icons.json @@ -85,6 +85,9 @@ }, "remaining_fuel_percent": { "default": "mdi:gas-station" + }, + "climate_status": { + "default": "mdi:fan" } }, "switch": { diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index e1ed398cfec..d3366543c55 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import BMWBaseEntity -from .const import DOMAIN, UNIT_MAP +from .const import CLIMATE_ACTIVITY_STATE, DOMAIN, UNIT_MAP from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -153,6 +153,15 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), + BMWSensorEntityDescription( + key="activity", + translation_key="climate_status", + key_class="climate", + device_class=SensorDeviceClass.ENUM, + options=CLIMATE_ACTIVITY_STATE, + value=lambda x, _: x.lower() if x != "UNKNOWN" else None, + is_available=lambda v: v.is_remote_climate_stop_enabled, + ), ] diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 69abd97ddfe..539c281a1a5 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -122,6 +122,15 @@ }, "remaining_fuel_percent": { "name": "Remaining fuel percent" + }, + "climate_status": { + "name": "Climate status", + "state": { + "cooling": "Cooling", + "heating": "Heating", + "inactive": "Inactive", + "standby": "Standby" + } } }, "switch": { diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr index c9dd4e3ddb8..dcf68622fdc 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -96,6 +96,25 @@ 'last_updated': , 'state': '340', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'iX xDrive50 Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'inactive', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', @@ -191,6 +210,25 @@ 'last_updated': , 'state': '472', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'i4 eDrive40 Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', @@ -261,6 +299,25 @@ 'last_updated': , 'state': '80', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'M340i xDrive Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'inactive', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', From f62fb76765513a5f1b0f96701fac29c1a35d636f Mon Sep 17 00:00:00 2001 From: Stephen Alderman Date: Tue, 16 Apr 2024 08:29:02 +0100 Subject: [PATCH 025/107] Add Config Flow to LG Netcast (#104913) * Add Config Flow to lg_netcast * Add YAML import to Lg Netcast ConfigFlow Deprecates YAML config support * Add LG Netcast Device triggers for turn_on action * Add myself to LG Netcast codeowners * Remove unnecessary user_input validation check. * Move netcast discovery logic to the backend * Use FlowResultType Enum for tests * Mock pylgnetcast.query_device_info instead of _send_to_tv * Refactor lg_netcast client discovery, simplify YAML import * Simplify CONF_NAME to use friendly name Fix: Use Friendly name for Name * Expose model to DeviceInfo * Add test for testing YAML import when not TV not online * Switch to entity_name for LGTVDevice * Add data_description to host field in user step * Wrap try only around _get_session_id * Send regular request for access_token to ensure it display on the TV * Stop displaying access token when flow is aborted * Remove config_flow only consts and minor fixups * Simplify media_player logic & raise new migration issue * Add async_unload_entry * Create issues when import config flow fails, and raise only a single yaml deprecation issue type * Remove single use trigger helpers * Bump issue deprecation breakage version * Lint --------- Co-authored-by: Erik Montnemery --- CODEOWNERS | 3 +- .../components/lg_netcast/__init__.py | 32 +++ .../components/lg_netcast/config_flow.py | 217 +++++++++++++++ homeassistant/components/lg_netcast/const.py | 6 + .../components/lg_netcast/device_trigger.py | 88 ++++++ .../components/lg_netcast/helpers.py | 59 ++++ .../components/lg_netcast/manifest.json | 7 +- .../components/lg_netcast/media_player.py | 92 +++++-- .../components/lg_netcast/strings.json | 46 ++++ .../components/lg_netcast/trigger.py | 49 ++++ .../lg_netcast/triggers/__init__.py | 1 + .../components/lg_netcast/triggers/turn_on.py | 115 ++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/lg_netcast/__init__.py | 116 ++++++++ tests/components/lg_netcast/conftest.py | 11 + .../components/lg_netcast/test_config_flow.py | 252 ++++++++++++++++++ .../lg_netcast/test_device_trigger.py | 148 ++++++++++ tests/components/lg_netcast/test_trigger.py | 189 +++++++++++++ 21 files changed, 1411 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/lg_netcast/config_flow.py create mode 100644 homeassistant/components/lg_netcast/device_trigger.py create mode 100644 homeassistant/components/lg_netcast/helpers.py create mode 100644 homeassistant/components/lg_netcast/strings.json create mode 100644 homeassistant/components/lg_netcast/trigger.py create mode 100644 homeassistant/components/lg_netcast/triggers/__init__.py create mode 100644 homeassistant/components/lg_netcast/triggers/turn_on.py create mode 100644 tests/components/lg_netcast/__init__.py create mode 100644 tests/components/lg_netcast/conftest.py create mode 100644 tests/components/lg_netcast/test_config_flow.py create mode 100644 tests/components/lg_netcast/test_device_trigger.py create mode 100644 tests/components/lg_netcast/test_trigger.py diff --git a/CODEOWNERS b/CODEOWNERS index 39fa804314d..d93a8f6b9d3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -753,7 +753,8 @@ build.json @home-assistant/supervisor /tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco -/homeassistant/components/lg_netcast/ @Drafteed +/homeassistant/components/lg_netcast/ @Drafteed @splinter98 +/tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob /homeassistant/components/light/ @home-assistant/core diff --git a/homeassistant/components/lg_netcast/__init__.py b/homeassistant/components/lg_netcast/__init__.py index 232d7bd10b8..f6fb834ab11 100644 --- a/homeassistant/components/lg_netcast/__init__.py +++ b/homeassistant/components/lg_netcast/__init__.py @@ -1 +1,33 @@ """The lg_netcast component.""" + +from typing import Final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + + return unload_ok diff --git a/homeassistant/components/lg_netcast/config_flow.py b/homeassistant/components/lg_netcast/config_flow.py new file mode 100644 index 00000000000..3c1d3d73e0f --- /dev/null +++ b/homeassistant/components/lg_netcast/config_flow.py @@ -0,0 +1,217 @@ +"""Config flow to configure the LG Netcast TV integration.""" + +from __future__ import annotations + +import contextlib +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +from pylgnetcast import AccessTokenError, LgNetCastClient, SessionIdError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, +) +from homeassistant.core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, callback +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.util.network import is_host_valid + +from .const import DEFAULT_NAME, DOMAIN +from .helpers import LGNetCastDetailDiscoveryError, async_discover_netcast_details + +DISPLAY_ACCESS_TOKEN_INTERVAL = timedelta(seconds=1) + + +class LGNetCast(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for LG Netcast TV integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + self.client: LgNetCastClient | None = None + self.device_config: dict[str, Any] = {} + self._discovered_devices: dict[str, Any] = {} + self._track_interval: CALLBACK_TYPE | None = None + + def create_client(self) -> None: + """Create LG Netcast client from config.""" + host = self.device_config[CONF_HOST] + access_token = self.device_config.get(CONF_ACCESS_TOKEN) + self.client = LgNetCastClient(host, access_token) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + if is_host_valid(host): + self.device_config[CONF_HOST] = host + return await self.async_step_authorize() + + errors[CONF_HOST] = "invalid_host" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> ConfigFlowResult: + """Import configuration from yaml.""" + self.device_config = { + CONF_HOST: config[CONF_HOST], + CONF_NAME: config[CONF_NAME], + } + + def _create_issue(): + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LG Netcast", + }, + ) + + try: + result: ConfigFlowResult = await self.async_step_authorize(config) + except AbortFlow as err: + if err.reason != "already_configured": + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_{err.reason}", + breaks_in_ha_version="2024.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{err.reason}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LG Netcast", + "error_type": err.reason, + }, + ) + else: + _create_issue() + raise + + _create_issue() + + return result + + async def async_discover_client(self): + """Handle Discovery step.""" + self.create_client() + + if TYPE_CHECKING: + assert self.client is not None + + if self.device_config.get(CONF_ID): + return + + try: + details = await async_discover_netcast_details(self.hass, self.client) + except LGNetCastDetailDiscoveryError as err: + raise AbortFlow("cannot_connect") from err + + if (unique_id := details["uuid"]) is None: + raise AbortFlow("invalid_host") + + self.device_config[CONF_ID] = unique_id + self.device_config[CONF_MODEL] = details["model_name"] + + if CONF_NAME not in self.device_config: + self.device_config[CONF_NAME] = details["friendly_name"] or DEFAULT_NAME + + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle Authorize step.""" + errors: dict[str, str] = {} + self.async_stop_display_access_token() + + if user_input is not None and user_input.get(CONF_ACCESS_TOKEN) is not None: + self.device_config[CONF_ACCESS_TOKEN] = user_input[CONF_ACCESS_TOKEN] + + await self.async_discover_client() + assert self.client is not None + + await self.async_set_unique_id(self.device_config[CONF_ID]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.device_config[CONF_HOST]} + ) + + try: + await self.hass.async_add_executor_job( + self.client._get_session_id # pylint: disable=protected-access + ) + except AccessTokenError: + if user_input is not None: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + except SessionIdError: + errors["base"] = "cannot_connect" + else: + return await self.async_create_device() + + self._track_interval = async_track_time_interval( + self.hass, + self.async_display_access_token, + DISPLAY_ACCESS_TOKEN_INTERVAL, + cancel_on_shutdown=True, + ) + + return self.async_show_form( + step_id="authorize", + data_schema=vol.Schema( + { + vol.Optional(CONF_ACCESS_TOKEN): vol.All(str, vol.Length(max=6)), + } + ), + errors=errors, + ) + + async def async_display_access_token(self, _: datetime | None = None): + """Display access token on screen.""" + assert self.client is not None + with contextlib.suppress(AccessTokenError, SessionIdError): + await self.hass.async_add_executor_job( + self.client._get_session_id # pylint: disable=protected-access + ) + + @callback + def async_remove(self): + """Terminate Access token display if flow is removed.""" + self.async_stop_display_access_token() + + def async_stop_display_access_token(self): + """Stop Access token request if running.""" + if self._track_interval is not None: + self._track_interval() + self._track_interval = None + + async def async_create_device(self) -> ConfigFlowResult: + """Create LG Netcast TV Device from config.""" + assert self.client + + return self.async_create_entry( + title=self.device_config[CONF_NAME], data=self.device_config + ) diff --git a/homeassistant/components/lg_netcast/const.py b/homeassistant/components/lg_netcast/const.py index 0344ad6f177..aca01c9b870 100644 --- a/homeassistant/components/lg_netcast/const.py +++ b/homeassistant/components/lg_netcast/const.py @@ -1,3 +1,9 @@ """Constants for the lg_netcast component.""" +from typing import Final + +ATTR_MANUFACTURER: Final = "LG" + +DEFAULT_NAME: Final = "LG Netcast TV" + DOMAIN = "lg_netcast" diff --git a/homeassistant/components/lg_netcast/device_trigger.py b/homeassistant/components/lg_netcast/device_trigger.py new file mode 100644 index 00000000000..51c5ec53004 --- /dev/null +++ b/homeassistant/components/lg_netcast/device_trigger.py @@ -0,0 +1,88 @@ +"""Provides device triggers for LG Netcast.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import trigger +from .const import DOMAIN +from .helpers import async_get_device_entry_by_device_id +from .triggers.turn_on import ( + PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE, + async_get_turn_on_trigger, +) + +TRIGGER_TYPES = {TURN_ON_PLATFORM_TYPE} + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] == TURN_ON_PLATFORM_TYPE: + device_id = config[CONF_DEVICE_ID] + + try: + device = async_get_device_entry_by_device_id(hass, device_id) + except ValueError as err: + raise InvalidDeviceAutomationConfig(err) from err + + if DOMAIN in hass.data: + for config_entry_id in device.config_entries: + if hass.data[DOMAIN].get(config_entry_id): + break + else: + raise InvalidDeviceAutomationConfig( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) + + return config + + +async def async_get_triggers( + _hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for LG Netcast devices.""" + return [async_get_turn_on_trigger(device_id)] + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + if (trigger_type := config[CONF_TYPE]) == TURN_ON_PLATFORM_TYPE: + trigger_config = { + CONF_PLATFORM: trigger_type, + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + } + trigger_config = await trigger.async_validate_trigger_config( + hass, trigger_config + ) + return await trigger.async_attach_trigger( + hass, trigger_config, action, trigger_info + ) + + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") diff --git a/homeassistant/components/lg_netcast/helpers.py b/homeassistant/components/lg_netcast/helpers.py new file mode 100644 index 00000000000..7cfc0d50271 --- /dev/null +++ b/homeassistant/components/lg_netcast/helpers.py @@ -0,0 +1,59 @@ +"""Helper functions for LG Netcast TV.""" + +from typing import TypedDict +import xml.etree.ElementTree as ET + +from pylgnetcast import LgNetCastClient +from requests import RequestException + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN + + +class LGNetCastDetailDiscoveryError(Exception): + """Unable to retrieve details from Netcast TV.""" + + +class NetcastDetails(TypedDict): + """Netcast TV Details.""" + + uuid: str + model_name: str + friendly_name: str + + +async def async_discover_netcast_details( + hass: HomeAssistant, client: LgNetCastClient +) -> NetcastDetails: + """Discover UUID and Model Name from Netcast Tv.""" + try: + resp = await hass.async_add_executor_job(client.query_device_info) + except RequestException as err: + raise LGNetCastDetailDiscoveryError( + f"Error in connecting to {client.url}" + ) from err + except ET.ParseError as err: + raise LGNetCastDetailDiscoveryError("Invalid XML") from err + + if resp is None: + raise LGNetCastDetailDiscoveryError("Empty response received") + + return resp + + +@callback +def async_get_device_entry_by_device_id( + hass: HomeAssistant, device_id: str +) -> DeviceEntry: + """Get Device Entry from Device Registry by device ID. + + Raises ValueError if device ID is invalid. + """ + device_reg = dr.async_get(hass) + if (device := device_reg.async_get(device_id)) is None: + raise ValueError(f"Device {device_id} is not a valid {DOMAIN} device.") + + return device diff --git a/homeassistant/components/lg_netcast/manifest.json b/homeassistant/components/lg_netcast/manifest.json index 8a63e064b41..cf91374feb7 100644 --- a/homeassistant/components/lg_netcast/manifest.json +++ b/homeassistant/components/lg_netcast/manifest.json @@ -1,9 +1,12 @@ { "domain": "lg_netcast", "name": "LG Netcast", - "codeowners": ["@Drafteed"], + "codeowners": ["@Drafteed", "@splinter98"], + "config_flow": true, + "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/lg_netcast", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pylgnetcast"], - "requirements": ["pylgnetcast==0.3.7"] + "requirements": ["pylgnetcast==0.3.9"] } diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 9f6e88dc614..3fc07cab12b 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from pylgnetcast import LG_COMMAND, LgNetCastClient, LgNetCastError from requests import RequestException @@ -17,14 +17,19 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script +from homeassistant.helpers.trigger import PluggableAction from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import ATTR_MANUFACTURER, DOMAIN +from .triggers.turn_on import async_get_turn_on_trigger DEFAULT_NAME = "LG TV Remote" @@ -54,23 +59,45 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a LG Netcast Media Player from a config_entry.""" + + host = config_entry.data[CONF_HOST] + access_token = config_entry.data[CONF_ACCESS_TOKEN] + unique_id = config_entry.unique_id + name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) + model = config_entry.data[CONF_MODEL] + + client = LgNetCastClient(host, access_token) + + hass.data[DOMAIN][config_entry.entry_id] = client + + async_add_entities([LgTVDevice(client, name, model, unique_id=unique_id)]) + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the LG TV platform.""" host = config.get(CONF_HOST) - access_token = config.get(CONF_ACCESS_TOKEN) - name = config[CONF_NAME] - on_action = config.get(CONF_ON_ACTION) - client = LgNetCastClient(host, access_token) - on_action_script = Script(hass, on_action, name, DOMAIN) if on_action else None + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) - add_entities([LgTVDevice(client, name, on_action_script)], True) + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") == "cannot_connect" + ): + raise PlatformNotReady(f"Connection error while connecting to {host}") class LgTVDevice(MediaPlayerEntity): @@ -79,19 +106,42 @@ class LgTVDevice(MediaPlayerEntity): _attr_assumed_state = True _attr_device_class = MediaPlayerDeviceClass.TV _attr_media_content_type = MediaType.CHANNEL + _attr_has_entity_name = True + _attr_name = None - def __init__(self, client, name, on_action_script): + def __init__(self, client, name, model, unique_id): """Initialize the LG TV device.""" self._client = client - self._name = name self._muted = False - self._on_action_script = on_action_script + self._turn_on = PluggableAction(self.async_write_ha_state) self._volume = 0 self._channel_id = None self._channel_name = "" self._program_name = "" self._sources = {} self._source_names = [] + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=ATTR_MANUFACTURER, + name=name, + model=model, + ) + + async def async_added_to_hass(self) -> None: + """Connect and subscribe to dispatcher signals and state updates.""" + await super().async_added_to_hass() + + entry = self.registry_entry + + if TYPE_CHECKING: + assert entry is not None and entry.device_id is not None + + self.async_on_remove( + self._turn_on.async_register( + self.hass, async_get_turn_on_trigger(entry.device_id) + ) + ) def send_command(self, command): """Send remote control commands to the TV.""" @@ -151,11 +201,6 @@ class LgTVDevice(MediaPlayerEntity): self._volume = volume self._muted = muted - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def is_volume_muted(self): """Boolean if volume is currently muted.""" @@ -194,7 +239,7 @@ class LgTVDevice(MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" - if self._on_action_script: + if self._turn_on: return SUPPORT_LGTV | MediaPlayerEntityFeature.TURN_ON return SUPPORT_LGTV @@ -209,10 +254,9 @@ class LgTVDevice(MediaPlayerEntity): """Turn off media player.""" self.send_command(LG_COMMAND.POWER) - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn on the media player.""" - if self._on_action_script: - self._on_action_script.run(context=self._context) + await self._turn_on.async_run(self.hass, self._context) def volume_up(self) -> None: """Volume up the media player.""" diff --git a/homeassistant/components/lg_netcast/strings.json b/homeassistant/components/lg_netcast/strings.json new file mode 100644 index 00000000000..77003f60f43 --- /dev/null +++ b/homeassistant/components/lg_netcast/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "step": { + "user": { + "description": "Ensure that your TV is turned on before trying to set it up.\nIf you leave the host empty, discovery will be used to find devices.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the LG Netcast TV to control." + } + }, + "authorize": { + "title": "Authorize LG Netcast TV", + "description": "Enter the Pairing Key displayed on the TV", + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The {integration_title} is not online for YAML migration to complete", + "description": "Migrating {integration_title} from YAML cannot complete until the TV is online.\n\nPlease turn on your TV for migration to complete." + }, + "deprecated_yaml_import_issue_invalid_host": { + "title": "The {integration_title} YAML configuration has an invalid host.", + "description": "Configuring {integration_title} using YAML is being removed but the device returned an invalid response.\n\nPlease check or manually remove the YAML configuration." + } + }, + "device_automation": { + "trigger_type": { + "lg_netcast.turn_on": "Device is requested to turn on" + } + } +} diff --git a/homeassistant/components/lg_netcast/trigger.py b/homeassistant/components/lg_netcast/trigger.py new file mode 100644 index 00000000000..8dfbe309e03 --- /dev/null +++ b/homeassistant/components/lg_netcast/trigger.py @@ -0,0 +1,49 @@ +"""LG Netcast TV trigger dispatcher.""" + +from __future__ import annotations + +from typing import cast + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) +from homeassistant.helpers.typing import ConfigType + +from .triggers import turn_on + +TRIGGERS = { + "turn_on": turn_on, +} + + +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: + """Return trigger platform.""" + platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) + if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: + raise ValueError( + f"Unknown LG Netcast TV trigger platform {config[CONF_PLATFORM]}" + ) + return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + platform = _get_trigger_platform(config) + return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach trigger of specified platform.""" + platform = _get_trigger_platform(config) + return await platform.async_attach_trigger(hass, config, action, trigger_info) diff --git a/homeassistant/components/lg_netcast/triggers/__init__.py b/homeassistant/components/lg_netcast/triggers/__init__.py new file mode 100644 index 00000000000..d352620118e --- /dev/null +++ b/homeassistant/components/lg_netcast/triggers/__init__.py @@ -0,0 +1 @@ +"""LG Netcast triggers.""" diff --git a/homeassistant/components/lg_netcast/triggers/turn_on.py b/homeassistant/components/lg_netcast/triggers/turn_on.py new file mode 100644 index 00000000000..118ed89797e --- /dev/null +++ b/homeassistant/components/lg_netcast/triggers/turn_on.py @@ -0,0 +1,115 @@ +"""LG Netcast TV device turn on trigger.""" + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.trigger import ( + PluggableAction, + TriggerActionType, + TriggerInfo, +) +from homeassistant.helpers.typing import ConfigType + +from ..const import DOMAIN +from ..helpers import async_get_device_entry_by_device_id + +PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" + +TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + }, + ), + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), +) + + +def async_get_turn_on_trigger(device_id: str) -> dict[str, str]: + """Return data for a turn on trigger.""" + + return { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: PLATFORM_TYPE, + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, + *, + platform_type: str = PLATFORM_TYPE, +) -> CALLBACK_TYPE | None: + """Attach a trigger.""" + device_ids = set() + if ATTR_DEVICE_ID in config: + device_ids.update(config.get(ATTR_DEVICE_ID, [])) + + if ATTR_ENTITY_ID in config: + ent_reg = er.async_get(hass) + + def _get_device_id_from_entity_id(entity_id): + entity_entry = ent_reg.async_get(entity_id) + + if ( + entity_entry is None + or entity_entry.device_id is None + or entity_entry.platform != DOMAIN + ): + raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + + return entity_entry.device_id + + device_ids.update( + { + _get_device_id_from_entity_id(entity_id) + for entity_id in config.get(ATTR_ENTITY_ID, []) + } + ) + + trigger_data = trigger_info["trigger_data"] + + unsubs = [] + + for device_id in device_ids: + device = async_get_device_entry_by_device_id(hass, device_id) + device_name = device.name_by_user or device.name + + variables = { + **trigger_data, + CONF_PLATFORM: platform_type, + ATTR_DEVICE_ID: device_id, + "description": f"lg netcast turn on trigger for {device_name}", + } + + turn_on_trigger = async_get_turn_on_trigger(device_id) + + unsubs.append( + PluggableAction.async_attach_trigger( + hass, turn_on_trigger, action, {"trigger": variables} + ) + ) + + @callback + def async_remove() -> None: + """Remove state listeners async.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + return async_remove diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 125f02df3b5..d1fe540c1b4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -285,6 +285,7 @@ FLOWS = { "ld2410_ble", "leaone", "led_ble", + "lg_netcast", "lg_soundbar", "lidarr", "lifx", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 340be50978d..1b964ceae34 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3177,8 +3177,8 @@ "name": "LG", "integrations": { "lg_netcast": { - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_polling", "name": "LG Netcast" }, diff --git a/requirements_all.txt b/requirements_all.txt index 653e481d2fb..92c2533dc4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1931,7 +1931,7 @@ pylast==5.1.0 pylaunches==1.4.0 # homeassistant.components.lg_netcast -pylgnetcast==0.3.7 +pylgnetcast==0.3.9 # homeassistant.components.forked_daapd pylibrespot-java==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0decf82fe0c..216edd0c5da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1502,6 +1502,9 @@ pylast==5.1.0 # homeassistant.components.launch_library pylaunches==1.4.0 +# homeassistant.components.lg_netcast +pylgnetcast==0.3.9 + # homeassistant.components.forked_daapd pylibrespot-java==0.1.1 diff --git a/tests/components/lg_netcast/__init__.py b/tests/components/lg_netcast/__init__.py new file mode 100644 index 00000000000..ce3e09aeb65 --- /dev/null +++ b/tests/components/lg_netcast/__init__.py @@ -0,0 +1,116 @@ +"""Tests for LG Netcast TV.""" + +from unittest.mock import patch +from xml.etree import ElementTree + +from pylgnetcast import AccessTokenError, LgNetCastClient, SessionIdError +import requests + +from homeassistant.components.lg_netcast import DOMAIN +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +FAIL_TO_BIND_IP = "1.2.3.4" + +IP_ADDRESS = "192.168.1.239" +DEVICE_TYPE = "TV" +MODEL_NAME = "MockLGModelName" +FRIENDLY_NAME = "LG Smart TV" +UNIQUE_ID = "1234" +ENTITY_ID = f"{MP_DOMAIN}.{MODEL_NAME.lower()}" + +FAKE_SESSION_ID = "987654321" +FAKE_PIN = "123456" + + +def _patched_lgnetcast_client( + *args, + session_error=False, + fail_connection: bool = True, + invalid_details: bool = False, + always_404: bool = False, + no_unique_id: bool = False, + **kwargs, +): + client = LgNetCastClient(*args, **kwargs) + + def _get_fake_session_id(): + if not client.access_token: + raise AccessTokenError("Fake Access Token Requested") + if session_error: + raise SessionIdError("Can not get session id from TV.") + return FAKE_SESSION_ID + + def _get_fake_query_device_info(): + if fail_connection: + raise requests.exceptions.ConnectTimeout("Mocked Failed Connection") + if always_404: + return None + if invalid_details: + raise ElementTree.ParseError("Mocked Parsed Error") + return { + "uuid": UNIQUE_ID if not no_unique_id else None, + "model_name": MODEL_NAME, + "friendly_name": FRIENDLY_NAME, + } + + client._get_session_id = _get_fake_session_id + client.query_device_info = _get_fake_query_device_info + + return client + + +def _patch_lg_netcast( + *, + session_error: bool = False, + fail_connection: bool = False, + invalid_details: bool = False, + always_404: bool = False, + no_unique_id: bool = False, +): + def _generate_fake_lgnetcast_client(*args, **kwargs): + return _patched_lgnetcast_client( + *args, + session_error=session_error, + fail_connection=fail_connection, + invalid_details=invalid_details, + always_404=always_404, + no_unique_id=no_unique_id, + **kwargs, + ) + + return patch( + "homeassistant.components.lg_netcast.config_flow.LgNetCastClient", + new=_generate_fake_lgnetcast_client, + ) + + +async def setup_lgnetcast(hass: HomeAssistant, unique_id: str = UNIQUE_ID): + """Initialize lg netcast and media_player for tests.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + CONF_MODEL: MODEL_NAME, + CONF_ID: unique_id, + }, + title=MODEL_NAME, + unique_id=unique_id, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/lg_netcast/conftest.py b/tests/components/lg_netcast/conftest.py new file mode 100644 index 00000000000..4faee2c6f06 --- /dev/null +++ b/tests/components/lg_netcast/conftest.py @@ -0,0 +1,11 @@ +"""Common fixtures and objects for the LG Netcast integration tests.""" + +import pytest + +from tests.common import async_mock_service + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") diff --git a/tests/components/lg_netcast/test_config_flow.py b/tests/components/lg_netcast/test_config_flow.py new file mode 100644 index 00000000000..c159b8fb9d2 --- /dev/null +++ b/tests/components/lg_netcast/test_config_flow.py @@ -0,0 +1,252 @@ +"""Define tests for the LG Netcast config flow.""" + +from datetime import timedelta +from unittest.mock import DEFAULT, patch + +from homeassistant import data_entry_flow +from homeassistant.components.lg_netcast.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, +) +from homeassistant.core import HomeAssistant + +from . import ( + FAKE_PIN, + FRIENDLY_NAME, + IP_ADDRESS, + MODEL_NAME, + UNIQUE_ID, + _patch_lg_netcast, +) + +from tests.common import MockConfigEntry + + +async def test_show_form(hass: HomeAssistant) -> None: + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_user_invalid_host(hass: HomeAssistant) -> None: + """Test that errors are shown when the host is invalid.""" + with _patch_lg_netcast(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "invalid/host"} + ) + + assert result["errors"] == {CONF_HOST: "invalid_host"} + + +async def test_manual_host(hass: HomeAssistant) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "authorize" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "authorize" + assert result2["errors"] is not None + assert result2["errors"][CONF_ACCESS_TOKEN] == "invalid_access_token" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: FAKE_PIN} + ) + + assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result3["title"] == FRIENDLY_NAME + assert result3["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: FRIENDLY_NAME, + CONF_MODEL: MODEL_NAME, + CONF_ID: UNIQUE_ID, + } + + +async def test_manual_host_no_connection_during_authorize(hass: HomeAssistant) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(fail_connection=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_manual_host_invalid_details_during_authorize( + hass: HomeAssistant, +) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(invalid_details=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_manual_host_unsuccessful_details_response(hass: HomeAssistant) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(always_404=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_manual_host_no_unique_id_response(hass: HomeAssistant) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(no_unique_id=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "invalid_host" + + +async def test_invalid_session_id(hass: HomeAssistant) -> None: + """Test Invalid Session ID.""" + with _patch_lg_netcast(session_error=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "authorize" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: FAKE_PIN} + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "authorize" + assert result2["errors"] is not None + assert result2["errors"]["base"] == "cannot_connect" + + +async def test_import(hass: HomeAssistant) -> None: + """Test that the import works.""" + with _patch_lg_netcast(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == UNIQUE_ID + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + CONF_MODEL: MODEL_NAME, + CONF_ID: UNIQUE_ID, + } + + +async def test_import_not_online(hass: HomeAssistant) -> None: + """Test that the import works.""" + with _patch_lg_netcast(fail_connection=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_duplicate_error(hass): + """Test that errors are shown when duplicates are added during import.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + CONF_ID: UNIQUE_ID, + }, + ) + config_entry.add_to_hass(hass) + + with _patch_lg_netcast(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + CONF_ID: UNIQUE_ID, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_display_access_token_aborted(hass: HomeAssistant): + """Test Access token display is cancelled.""" + + def _async_track_time_interval( + hass: HomeAssistant, + action, + interval: timedelta, + *, + name=None, + cancel_on_shutdown=None, + ): + hass.async_create_task(action()) + return DEFAULT + + with ( + _patch_lg_netcast(session_error=True), + patch( + "homeassistant.components.lg_netcast.config_flow.async_track_time_interval" + ) as mock_interval, + ): + mock_interval.side_effect = _async_track_time_interval + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "authorize" + assert not result["errors"] + + assert mock_interval.called + + hass.config_entries.flow.async_abort(result["flow_id"]) + assert mock_interval.return_value.called diff --git a/tests/components/lg_netcast/test_device_trigger.py b/tests/components/lg_netcast/test_device_trigger.py new file mode 100644 index 00000000000..05911acc41d --- /dev/null +++ b/tests/components/lg_netcast/test_device_trigger.py @@ -0,0 +1,148 @@ +"""The tests for LG NEtcast device triggers.""" + +import pytest + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.lg_netcast import DOMAIN, device_trigger +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import ENTITY_ID, UNIQUE_ID, setup_lgnetcast + +from tests.common import MockConfigEntry, async_get_device_automations + + +async def test_get_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test we get the expected triggers.""" + await setup_lgnetcast(hass) + + device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)}) + assert device is not None + + turn_on_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "lg_netcast.turn_on", + "device_id": device.id, + "metadata": {}, + } + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert turn_on_trigger in triggers + + +async def test_if_fires_on_turn_on_request( + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry +) -> None: + """Test for turn_on triggers firing.""" + await setup_lgnetcast(hass) + + device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)}) + assert device is not None + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "lg_netcast.turn_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.device_id }}", + "id": "{{ trigger.id }}", + }, + }, + }, + { + "trigger": { + "platform": "lg_netcast.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + assert calls[1].data["some"] == ENTITY_ID + assert calls[1].data["id"] == 0 + + +async def test_failure_scenarios( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test failure scenarios.""" + await setup_lgnetcast(hass) + + # Test wrong trigger platform type + with pytest.raises(HomeAssistantError): + await device_trigger.async_attach_trigger( + hass, {"type": "wrong.type", "device_id": "invalid_device_id"}, None, {} + ) + + # Test invalid device id + with pytest.raises(HomeAssistantError): + await device_trigger.async_validate_trigger_config( + hass, + { + "platform": "device", + "domain": DOMAIN, + "type": "lg_netcast.turn_on", + "device_id": "invalid_device_id", + }, + ) + + entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("fake", "fake")} + ) + + config = { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "lg_netcast.turn_on", + } + + # Test that device id from non lg_netcast domain raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config(hass, config) + + # Test that only valid triggers are attached diff --git a/tests/components/lg_netcast/test_trigger.py b/tests/components/lg_netcast/test_trigger.py new file mode 100644 index 00000000000..e75dac501c3 --- /dev/null +++ b/tests/components/lg_netcast/test_trigger.py @@ -0,0 +1,189 @@ +"""The tests for LG Netcast device triggers.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.components.lg_netcast import DOMAIN +from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import ENTITY_ID, UNIQUE_ID, setup_lgnetcast + +from tests.common import MockEntity, MockEntityPlatform + + +async def test_lg_netcast_turn_on_trigger_device_id( + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry +) -> None: + """Test for turn_on trigger by device_id firing.""" + await setup_lgnetcast(hass) + + device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)}) + assert device, repr(device_registry.devices) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "lg_netcast.turn_on", + "device_id": device.id, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": device.id, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + + with patch("homeassistant.config.load_yaml_dict", return_value={}): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + + calls.clear() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_lg_netcast_turn_on_trigger_entity_id(hass: HomeAssistant, calls): + """Test for turn_on triggers by entity firing.""" + await setup_lgnetcast(hass) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "lg_netcast.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == ENTITY_ID + assert calls[0].data["id"] == 0 + + +async def test_wrong_trigger_platform_type( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test wrong trigger platform type.""" + await setup_lgnetcast(hass) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "lg_netcast.wrong_type", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + assert ( + "ValueError: Unknown LG Netcast TV trigger platform lg_netcast.wrong_type" + in caplog.text + ) + + +async def test_trigger_invalid_entity_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test turn on trigger using invalid entity_id.""" + await setup_lgnetcast(hass) + + platform = MockEntityPlatform(hass) + + invalid_entity = f"{DOMAIN}.invalid" + await platform.async_add_entities([MockEntity(name=invalid_entity)]) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "lg_netcast.turn_on", + "entity_id": invalid_entity, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + } + ], + }, + ) + + assert ( + f"ValueError: Entity {invalid_entity} is not a valid lg_netcast entity" + in caplog.text + ) From 0dd8ffd1f541574ddf7835af89d63b934a1dc224 Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Tue, 16 Apr 2024 00:46:15 -0700 Subject: [PATCH 026/107] Add a new "Ambient Weather Network" integration (#105779) * Adding a new "Ambient Weather Network" integration. * Rebase and update code coverage. * Addressed some reviewer comments. * Remove mnemonics and replace with station names. * Remove climate-utils * Remove support for virtual stations. * Rebase * Address feedback * Remove redundant errors * Reviewer feedback * Add icons.json * More icons * Reviewer feedback * Fix test * Make sensor tests more robust * Make coordinator more robust * Change update coordinator to raise UpdateFailed * Recover from no station found error * Dynamically set device name * Address feedback * Disable some sensors by default * Reviewer feedback * Change from hub to service * Rebase * Address reviewer feedback * Reviewer feedback * Manually rerun ruff on all files --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/ambient_network/__init__.py | 35 + .../components/ambient_network/config_flow.py | 152 ++++ .../components/ambient_network/const.py | 16 + .../components/ambient_network/coordinator.py | 65 ++ .../components/ambient_network/entity.py | 50 + .../components/ambient_network/helper.py | 31 + .../components/ambient_network/icons.json | 21 + .../components/ambient_network/manifest.json | 11 + .../components/ambient_network/sensor.py | 315 +++++++ .../components/ambient_network/strings.json | 87 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/ambient_network/__init__.py | 1 + tests/components/ambient_network/conftest.py | 91 ++ .../fixtures/device_details_response_a.json | 34 + .../fixtures/device_details_response_b.json | 7 + .../fixtures/device_details_response_c.json | 33 + .../devices_by_location_response.json | 364 ++++++++ .../snapshots/test_sensor.ambr | 856 ++++++++++++++++++ .../ambient_network/test_config_flow.py | 85 ++ .../components/ambient_network/test_sensor.py | 123 +++ 26 files changed, 2399 insertions(+) create mode 100644 homeassistant/components/ambient_network/__init__.py create mode 100644 homeassistant/components/ambient_network/config_flow.py create mode 100644 homeassistant/components/ambient_network/const.py create mode 100644 homeassistant/components/ambient_network/coordinator.py create mode 100644 homeassistant/components/ambient_network/entity.py create mode 100644 homeassistant/components/ambient_network/helper.py create mode 100644 homeassistant/components/ambient_network/icons.json create mode 100644 homeassistant/components/ambient_network/manifest.json create mode 100644 homeassistant/components/ambient_network/sensor.py create mode 100644 homeassistant/components/ambient_network/strings.json create mode 100644 tests/components/ambient_network/__init__.py create mode 100644 tests/components/ambient_network/conftest.py create mode 100644 tests/components/ambient_network/fixtures/device_details_response_a.json create mode 100644 tests/components/ambient_network/fixtures/device_details_response_b.json create mode 100644 tests/components/ambient_network/fixtures/device_details_response_c.json create mode 100644 tests/components/ambient_network/fixtures/devices_by_location_response.json create mode 100644 tests/components/ambient_network/snapshots/test_sensor.ambr create mode 100644 tests/components/ambient_network/test_config_flow.py create mode 100644 tests/components/ambient_network/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 63a867e9c50..5985938885f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -66,6 +66,7 @@ homeassistant.components.alpha_vantage.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* homeassistant.components.ambiclimate.* +homeassistant.components.ambient_network.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* diff --git a/CODEOWNERS b/CODEOWNERS index d93a8f6b9d3..83d5539a15c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -90,6 +90,8 @@ build.json @home-assistant/supervisor /tests/components/amberelectric/ @madpilot /homeassistant/components/ambiclimate/ @danielhiversen /tests/components/ambiclimate/ @danielhiversen +/homeassistant/components/ambient_network/ @thomaskistler +/tests/components/ambient_network/ @thomaskistler /homeassistant/components/ambient_station/ @bachya /tests/components/ambient_station/ @bachya /homeassistant/components/amcrest/ @flacjacket diff --git a/homeassistant/components/ambient_network/__init__.py b/homeassistant/components/ambient_network/__init__.py new file mode 100644 index 00000000000..b286fb7fbc9 --- /dev/null +++ b/homeassistant/components/ambient_network/__init__.py @@ -0,0 +1,35 @@ +"""The Ambient Weather Network integration.""" + +from __future__ import annotations + +from aioambient.open_api import OpenAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import AmbientNetworkDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Ambient Weather Network from a config entry.""" + + api = OpenAPI() + coordinator = AmbientNetworkDataUpdateCoordinator(hass, api) + 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): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ambient_network/config_flow.py b/homeassistant/components/ambient_network/config_flow.py new file mode 100644 index 00000000000..d29134db1c9 --- /dev/null +++ b/homeassistant/components/ambient_network/config_flow.py @@ -0,0 +1,152 @@ +"""Config flow for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from typing import Any + +from aioambient import OpenAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_MAC, + CONF_RADIUS, + UnitOfLength, +) +from homeassistant.helpers.selector import ( + LocationSelector, + LocationSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import API_STATION_INDOOR, API_STATION_INFO, API_STATION_MAC_ADDRESS, DOMAIN +from .helper import get_station_name + +CONF_USER = "user" +CONF_STATION = "station" + +# One mile +CONF_RADIUS_DEFAULT = 1609.34 + + +class AmbientNetworkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for the Ambient Weather Network integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Construct the config flow.""" + + self._longitude = 0.0 + self._latitude = 0.0 + self._radius = 0.0 + self._stations: dict[str, dict[str, Any]] = {} + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the initial step to select the location.""" + + errors: dict[str, str] | None = None + if user_input: + self._latitude = user_input[CONF_LOCATION][CONF_LATITUDE] + self._longitude = user_input[CONF_LOCATION][CONF_LONGITUDE] + self._radius = user_input[CONF_LOCATION][CONF_RADIUS] + + client: OpenAPI = OpenAPI() + self._stations = { + x[API_STATION_MAC_ADDRESS]: x + for x in await client.get_devices_by_location( + self._latitude, + self._longitude, + radius=DistanceConverter.convert( + self._radius, + UnitOfLength.METERS, + UnitOfLength.MILES, + ), + ) + } + + # Filter out indoor stations + self._stations = dict( + filter( + lambda item: not item[1] + .get(API_STATION_INFO, {}) + .get(API_STATION_INDOOR, False), + self._stations.items(), + ) + ) + + if self._stations: + return await self.async_step_station() + + errors = {"base": "no_stations_found"} + + schema: vol.Schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required( + CONF_LOCATION, + ): LocationSelector(LocationSelectorConfig(radius=True)), + } + ), + { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_RADIUS: CONF_RADIUS_DEFAULT, + } + if not errors + else { + CONF_LATITUDE: self._latitude, + CONF_LONGITUDE: self._longitude, + CONF_RADIUS: self._radius, + } + }, + ) + + return self.async_show_form( + step_id=CONF_USER, data_schema=schema, errors=errors if errors else {} + ) + + async def async_step_station( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the second step to select the station.""" + + if user_input: + mac_address = user_input[CONF_STATION] + await self.async_set_unique_id(mac_address) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=get_station_name(self._stations[mac_address]), + data={CONF_MAC: mac_address}, + ) + + options: list[SelectOptionDict] = [ + SelectOptionDict( + label=get_station_name(station), + value=mac_address, + ) + for mac_address, station in self._stations.items() + ] + + schema: vol.Schema = vol.Schema( + { + vol.Required(CONF_STATION): SelectSelector( + SelectSelectorConfig(options=options, multiple=False, sort=True), + ) + } + ) + + return self.async_show_form( + step_id=CONF_STATION, + data_schema=schema, + ) diff --git a/homeassistant/components/ambient_network/const.py b/homeassistant/components/ambient_network/const.py new file mode 100644 index 00000000000..402e5f81097 --- /dev/null +++ b/homeassistant/components/ambient_network/const.py @@ -0,0 +1,16 @@ +"""Constants for the Ambient Weather Network integration.""" + +import logging + +DOMAIN = "ambient_network" + +API_LAST_DATA = "lastData" +API_STATION_COORDS = "coords" +API_STATION_INDOOR = "indoor" +API_STATION_INFO = "info" +API_STATION_LOCATION = "location" +API_STATION_NAME = "name" +API_STATION_MAC_ADDRESS = "macAddress" +API_STATION_TYPE = "stationtype" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/ambient_network/coordinator.py b/homeassistant/components/ambient_network/coordinator.py new file mode 100644 index 00000000000..f26ddd47b24 --- /dev/null +++ b/homeassistant/components/ambient_network/coordinator.py @@ -0,0 +1,65 @@ +"""DataUpdateCoordinator for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, cast + +from aioambient import OpenAPI +from aioambient.errors import RequestError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_LAST_DATA, DOMAIN, LOGGER +from .helper import get_station_name + +SCAN_INTERVAL = timedelta(minutes=5) + + +class AmbientNetworkDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """The Ambient Network Data Update Coordinator.""" + + config_entry: ConfigEntry + station_name: str + + def __init__(self, hass: HomeAssistant, api: OpenAPI) -> None: + """Initialize the coordinator.""" + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch the latest data from the Ambient Network.""" + + try: + response = await self.api.get_device_details( + self.config_entry.data[CONF_MAC] + ) + except RequestError as ex: + raise UpdateFailed("Cannot connect to Ambient Network") from ex + + self.station_name = get_station_name(response) + + if (last_data := response.get(API_LAST_DATA)) is None: + raise UpdateFailed( + f"Station '{self.config_entry.title}' did not report any data" + ) + + # Eliminate data if the station hasn't been updated for a while. + if (created_at := last_data.get("created_at")) is None: + raise UpdateFailed( + f"Station '{self.config_entry.title}' did not report a time stamp" + ) + + # Eliminate data that has been generated more than an hour ago. The station is + # probably offline. + if int(created_at / 1000) < int( + (datetime.now() - timedelta(hours=1)).timestamp() + ): + raise UpdateFailed( + f"Station '{self.config_entry.title}' reported stale data" + ) + + return cast(dict[str, Any], last_data) diff --git a/homeassistant/components/ambient_network/entity.py b/homeassistant/components/ambient_network/entity.py new file mode 100644 index 00000000000..ad0241ea3de --- /dev/null +++ b/homeassistant/components/ambient_network/entity.py @@ -0,0 +1,50 @@ +"""Base entity class for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from abc import abstractmethod + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AmbientNetworkDataUpdateCoordinator + + +class AmbientNetworkEntity(CoordinatorEntity[AmbientNetworkDataUpdateCoordinator]): + """Entity class for Ambient network devices.""" + + _attr_attribution = "Data provided by ambientnetwork.net" + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AmbientNetworkDataUpdateCoordinator, + description: EntityDescription, + mac_address: str, + ) -> None: + """Initialize the Ambient network entity.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{mac_address}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.station_name, + identifiers={(DOMAIN, mac_address)}, + manufacturer="Ambient Weather", + ) + self._update_attrs() + + @abstractmethod + def _update_attrs(self) -> None: + """Update state attributes.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and updates the state.""" + + self._update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/ambient_network/helper.py b/homeassistant/components/ambient_network/helper.py new file mode 100644 index 00000000000..fbde45ee756 --- /dev/null +++ b/homeassistant/components/ambient_network/helper.py @@ -0,0 +1,31 @@ +"""Helper class for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from typing import Any + +from .const import ( + API_LAST_DATA, + API_STATION_COORDS, + API_STATION_INFO, + API_STATION_LOCATION, + API_STATION_NAME, + API_STATION_TYPE, +) + + +def get_station_name(station: dict[str, Any]) -> str: + """Pick a station name. + + Station names can be empty, in which case we construct the name from + the location and device type. + """ + if name := station.get(API_STATION_INFO, {}).get(API_STATION_NAME): + return str(name) + location = ( + station.get(API_STATION_INFO, {}) + .get(API_STATION_COORDS, {}) + .get(API_STATION_LOCATION) + ) + station_type = station.get(API_LAST_DATA, {}).get(API_STATION_TYPE) + return f"{location}{'' if location is None or station_type is None else ' '}{station_type}" diff --git a/homeassistant/components/ambient_network/icons.json b/homeassistant/components/ambient_network/icons.json new file mode 100644 index 00000000000..a7abebce187 --- /dev/null +++ b/homeassistant/components/ambient_network/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "last_rain": { + "default": "mdi:water" + }, + "lightning_strikes_per_day": { + "default": "mdi:lightning-bolt" + }, + "lightning_strikes_per_hour": { + "default": "mdi:lightning-bolt" + }, + "lightning_distance": { + "default": "mdi:lightning-bolt" + }, + "wind_direction": { + "default": "mdi:compass-outline" + } + } + } +} diff --git a/homeassistant/components/ambient_network/manifest.json b/homeassistant/components/ambient_network/manifest.json new file mode 100644 index 00000000000..553adb240b0 --- /dev/null +++ b/homeassistant/components/ambient_network/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ambient_network", + "name": "Ambient Weather Network", + "codeowners": ["@thomaskistler"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ambient_network", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["aioambient"], + "requirements": ["aioambient==2024.01.0"] +} diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py new file mode 100644 index 00000000000..c28b69229d8 --- /dev/null +++ b/homeassistant/components/ambient_network/sensor.py @@ -0,0 +1,315 @@ +"""Support for Ambient Weather Network sensors.""" + +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + CONF_MAC, + DEGREE, + PERCENTAGE, + UnitOfIrradiance, + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolumetricFlux, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import AmbientNetworkDataUpdateCoordinator +from .entity import AmbientNetworkEntity + +TYPE_AQI_PM25 = "aqi_pm25" +TYPE_AQI_PM25_24H = "aqi_pm25_24h" +TYPE_BAROMABSIN = "baromabsin" +TYPE_BAROMRELIN = "baromrelin" +TYPE_CO2 = "co2" +TYPE_DAILYRAININ = "dailyrainin" +TYPE_DEWPOINT = "dewPoint" +TYPE_EVENTRAININ = "eventrainin" +TYPE_FEELSLIKE = "feelsLike" +TYPE_HOURLYRAININ = "hourlyrainin" +TYPE_HUMIDITY = "humidity" +TYPE_LASTRAIN = "lastRain" +TYPE_LIGHTNING_DISTANCE = "lightning_distance" +TYPE_LIGHTNING_PER_DAY = "lightning_day" +TYPE_LIGHTNING_PER_HOUR = "lightning_hour" +TYPE_MAXDAILYGUST = "maxdailygust" +TYPE_MONTHLYRAININ = "monthlyrainin" +TYPE_PM25 = "pm25" +TYPE_PM25_24H = "pm25_24h" +TYPE_SOLARRADIATION = "solarradiation" +TYPE_TEMPF = "tempf" +TYPE_UV = "uv" +TYPE_WEEKLYRAININ = "weeklyrainin" +TYPE_WINDDIR = "winddir" +TYPE_WINDGUSTMPH = "windgustmph" +TYPE_WINDSPEEDMPH = "windspeedmph" +TYPE_YEARLYRAININ = "yearlyrainin" + + +SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_AQI_PM25, + translation_key="pm25_aqi", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + SensorEntityDescription( + key=TYPE_AQI_PM25_24H, + translation_key="pm25_aqi_24h_average", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_BAROMABSIN, + translation_key="absolute_pressure", + native_unit_of_measurement=UnitOfPressure.INHG, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_BAROMRELIN, + translation_key="relative_pressure", + native_unit_of_measurement=UnitOfPressure.INHG, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key=TYPE_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_DAILYRAININ, + translation_key="daily_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key=TYPE_DEWPOINT, + translation_key="dew_point", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_FEELSLIKE, + translation_key="feels_like", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_HOURLYRAININ, + translation_key="hourly_rain", + native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + suggested_display_precision=2, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_LASTRAIN, + translation_key="last_rain", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_LIGHTNING_PER_DAY, + translation_key="lightning_strikes_per_day", + native_unit_of_measurement="strikes", + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_LIGHTNING_PER_HOUR, + translation_key="lightning_strikes_per_hour", + native_unit_of_measurement="strikes/hour", + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_LIGHTNING_DISTANCE, + translation_key="lightning_distance", + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_MAXDAILYGUST, + translation_key="max_daily_gust", + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_MONTHLYRAININ, + translation_key="monthly_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_PM25_24H, + translation_key="pm25_24h_average", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_SOLARRADIATION, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_TEMPF, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_UV, + translation_key="uv_index", + native_unit_of_measurement="index", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_WEEKLYRAININ, + translation_key="weekly_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_WINDDIR, + translation_key="wind_direction", + native_unit_of_measurement=DEGREE, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_WINDGUSTMPH, + translation_key="wind_gust", + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_WINDSPEEDMPH, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_YEARLYRAININ, + translation_key="yearly_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Ambient Network sensor entities.""" + + coordinator: AmbientNetworkDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + if coordinator.config_entry is not None: + async_add_entities( + AmbientNetworkSensor( + coordinator, + description, + coordinator.config_entry.data[CONF_MAC], + ) + for description in SENSOR_DESCRIPTIONS + if coordinator.data.get(description.key) is not None + ) + + +class AmbientNetworkSensor(AmbientNetworkEntity, SensorEntity): + """A sensor implementation for an Ambient Weather Network sensor.""" + + def __init__( + self, + coordinator: AmbientNetworkDataUpdateCoordinator, + description: SensorEntityDescription, + mac_address: str, + ) -> None: + """Initialize a sensor object.""" + + super().__init__(coordinator, description, mac_address) + + def _update_attrs(self) -> None: + """Update sensor attributes.""" + + value = self.coordinator.data.get(self.entity_description.key) + + # Treatments for special units. + if value is not None and self.device_class == SensorDeviceClass.TIMESTAMP: + value = datetime.fromtimestamp(value / 1000, tz=dt_util.DEFAULT_TIME_ZONE) + + self._attr_available = value is not None + self._attr_native_value = value diff --git a/homeassistant/components/ambient_network/strings.json b/homeassistant/components/ambient_network/strings.json new file mode 100644 index 00000000000..7d18c40d902 --- /dev/null +++ b/homeassistant/components/ambient_network/strings.json @@ -0,0 +1,87 @@ +{ + "config": { + "step": { + "user": { + "title": "Select region", + "description": "Choose the region you want to survey in order to locate Ambient personal weather stations." + }, + "station": { + "title": "Select station", + "description": "Select the weather station you want to add to Home Assistant.", + "data": { + "station": "Station" + } + } + }, + "error": { + "no_stations_found": "Did not find any stations in the selected region." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "pm25_24h_average": { + "name": "PM2.5 (24 hour average)" + }, + "pm25_aqi": { + "name": "PM2.5 AQI" + }, + "pm25_aqi_24h_average": { + "name": "PM2.5 AQI (24 hour average)" + }, + "absolute_pressure": { + "name": "Absolute pressure" + }, + "relative_pressure": { + "name": "Relative pressure" + }, + "daily_rain": { + "name": "Daily rain" + }, + "dew_point": { + "name": "Dew point" + }, + "feels_like": { + "name": "Feels like" + }, + "hourly_rain": { + "name": "Hourly rain" + }, + "last_rain": { + "name": "Last rain" + }, + "lightning_strikes_per_day": { + "name": "Lightning strikes per day" + }, + "lightning_strikes_per_hour": { + "name": "Lightning strikes per hour" + }, + "lightning_distance": { + "name": "Lightning distance" + }, + "max_daily_gust": { + "name": "Max daily gust" + }, + "monthly_rain": { + "name": "Monthly rain" + }, + "uv_index": { + "name": "UV index" + }, + "weekly_rain": { + "name": "Weekly rain" + }, + "wind_direction": { + "name": "Wind direction" + }, + "wind_gust": { + "name": "Wind gust" + }, + "yearly_rain": { + "name": "Yearly rain" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d1fe540c1b4..30d580ad1ea 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -42,6 +42,7 @@ FLOWS = { "alarmdecoder", "amberelectric", "ambiclimate", + "ambient_network", "ambient_station", "analytics_insights", "android_ip_webcam", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1b964ceae34..fa2cec4d77a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -244,6 +244,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ambient_network": { + "name": "Ambient Weather Network", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ambient_station": { "name": "Ambient Weather Station", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 546ae52f972..216d43322a4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -421,6 +421,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ambient_network.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ambient_station.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 92c2533dc4d..64d67ada712 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,6 +190,7 @@ aioairzone-cloud==0.5.1 # homeassistant.components.airzone aioairzone==0.7.6 +# homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.01.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 216edd0c5da..d9fd0586fa7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,6 +169,7 @@ aioairzone-cloud==0.5.1 # homeassistant.components.airzone aioairzone==0.7.6 +# homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.01.0 diff --git a/tests/components/ambient_network/__init__.py b/tests/components/ambient_network/__init__.py new file mode 100644 index 00000000000..2971b77ddd8 --- /dev/null +++ b/tests/components/ambient_network/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ambient Weather Network integration.""" diff --git a/tests/components/ambient_network/conftest.py b/tests/components/ambient_network/conftest.py new file mode 100644 index 00000000000..3afadbfa722 --- /dev/null +++ b/tests/components/ambient_network/conftest.py @@ -0,0 +1,91 @@ +"""Common fixtures for the Ambient Weather Network integration tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from aioambient import OpenAPI +import pytest + +from homeassistant.components import ambient_network +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ambient_network.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="devices_by_location", scope="package") +def devices_by_location_fixture() -> list[dict[str, Any]]: + """Return result of OpenAPI get_devices_by_location() call.""" + return load_json_array_fixture( + "devices_by_location_response.json", "ambient_network" + ) + + +def mock_device_details_callable(mac_address: str) -> dict[str, Any]: + """Return result of OpenAPI get_device_details() call.""" + return load_json_object_fixture( + f"device_details_response_{mac_address[0].lower()}.json", "ambient_network" + ) + + +@pytest.fixture(name="open_api") +def mock_open_api() -> OpenAPI: + """Mock OpenAPI object.""" + return Mock( + get_device_details=AsyncMock(side_effect=mock_device_details_callable), + ) + + +@pytest.fixture(name="aioambient") +async def mock_aioambient(open_api: OpenAPI): + """Mock aioambient library.""" + with ( + patch( + "homeassistant.components.ambient_network.config_flow.OpenAPI", + return_value=open_api, + ), + patch( + "homeassistant.components.ambient_network.OpenAPI", + return_value=open_api, + ), + ): + yield + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(request) -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=ambient_network.DOMAIN, + title=f"Station {request.param[0]}", + data={"mac": request.param}, + ) + + +async def setup_platform( + expected_outcome: bool, + hass: HomeAssistant, + config_entry: MockConfigEntry, +): + """Load the Ambient Network integration with the provided OpenAPI and config entry.""" + + config_entry.add_to_hass(hass) + assert ( + await hass.config_entries.async_setup(config_entry.entry_id) == expected_outcome + ) + await hass.async_block_till_done() + + return diff --git a/tests/components/ambient_network/fixtures/device_details_response_a.json b/tests/components/ambient_network/fixtures/device_details_response_a.json new file mode 100644 index 00000000000..40491e2631c --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_a.json @@ -0,0 +1,34 @@ +{ + "_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "macAddress": "AA:AA:AA:AA:AA:AA", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "dateutc": 1699474320000, + "tempf": 82.9, + "dewPoint": 82.0, + "feelsLike": 85.0, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "created_at": 1699474320914, + "dateutc5": 1699474200000, + "lastRain": 1698659100000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station A" + } +} diff --git a/tests/components/ambient_network/fixtures/device_details_response_b.json b/tests/components/ambient_network/fixtures/device_details_response_b.json new file mode 100644 index 00000000000..8249f6f0c30 --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_b.json @@ -0,0 +1,7 @@ +{ + "_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "macAddress": "BB:BB:BB:BB:BB:BB", + "info": { + "name": "Station B" + } +} diff --git a/tests/components/ambient_network/fixtures/device_details_response_c.json b/tests/components/ambient_network/fixtures/device_details_response_c.json new file mode 100644 index 00000000000..8e171f35374 --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_c.json @@ -0,0 +1,33 @@ +{ + "_id": "cccccccccccccccccccccccccccccccc", + "macAddress": "CC:CC:CC:CC:CC:CC", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "dateutc": 1699474320000, + "tempf": 82.9, + "dewPoint": 82.0, + "feelsLike": 85.0, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "dateutc5": 1699474200000, + "lastRain": 1698659100000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station C" + } +} diff --git a/tests/components/ambient_network/fixtures/devices_by_location_response.json b/tests/components/ambient_network/fixtures/devices_by_location_response.json new file mode 100644 index 00000000000..848ba0a7b87 --- /dev/null +++ b/tests/components/ambient_network/fixtures/devices_by_location_response.json @@ -0,0 +1,364 @@ +[ + { + "_id": "aaaaaaaaaaaaaaaaaaaaaaaa", + "macAddress": "AA:AA:AA:AA:AA:AA", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "dateutc": 1699474320000, + "tempf": 82.9, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "created_at": 1699474320914, + "dateutc5": 1699474200000, + "lastRain": 1698659100000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station A", + "coords": { + "geo": { + "coordinates": [-97.0, 32.0], + "type": "Point" + }, + "elevation": 237.0, + "location": "Location A", + "coords": { + "lon": -97.0, + "lat": 32.0 + } + }, + "indoor": false, + "slug": "aaaaaaaaaaaaaaaaaaaaaaaa" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "bbbbbbbbbbbbbbbbbbbbbbbb", + "macAddress": "BB:BB:BB:BB:BB:BB", + "lastData": { + "stationtype": "AMBWeatherV4.2.6", + "dateutc": 1700716980000, + "baromrelin": 29.342, + "baromabsin": 29.342, + "tempf": 35.8, + "humidity": 88, + "winddir": 237, + "winddir_avg10m": 221, + "windspeedmph": 0, + "windspdmph_avg10m": 0, + "windgustmph": 1.3, + "maxdailygust": 12.3, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0.024, + "monthlyrainin": 0.331, + "yearlyrainin": 12.382, + "solarradiation": 0, + "uv": 0, + "soilhum2": 0, + "type": "weather-data", + "created_at": 1700717004020, + "dateutc5": 1700716800000, + "lastRain": 1700445000000, + "discreets": { + "humidity1": [41, 42, 43] + }, + "tz": "America/Chicago" + }, + "info": { + "name": "Station B", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "location": "Location B", + "elevation": 226.0, + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "bbbbbbbbbbbbbbbbbbbbbbbb" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "cccccccccccccccccccccccc", + "macAddress": "CC:CC:CC:CC:CC:CC", + "lastData": {}, + "info": { + "name": "Station C", + "coords": { + "geo": { + "coordinates": [-97.0, 32.0], + "type": "Point" + }, + "elevation": 242.0, + "location": "Location C", + "coords": { + "lon": -97.0, + "lat": 32.0 + } + }, + "indoor": false, + "slug": "cccccccccccccccccccccccc" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "dddddddddddddddddddddddd", + "macAddress": "DD:DD:DD:DD:DD:DD", + "lastData": { + "stationtype": "AMBWeatherPro_V5.1.3", + "dateutc": 1700716920000, + "tempf": 38.1, + "humidity": 85, + "windspeedmph": 0, + "windgustmph": 0, + "maxdailygust": 0, + "winddir": 89, + "uv": 0, + "solarradiation": 0, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0.028, + "monthlyrainin": 0.327, + "yearlyrainin": 12.76, + "totalrainin": 12.76, + "baromrelin": 29.731, + "baromabsin": 29.338, + "type": "weather-data", + "created_at": 1700716969446, + "dateutc5": 1700716800000, + "lastRain": 1700449500000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station D", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "address": "", + "location": "Location D", + "elevation": 221.0, + "address_components": [ + { + "long_name": "1234", + "short_name": "1234", + "types": ["street_number"] + }, + { + "long_name": "D Street", + "short_name": "D St.", + "types": ["route"] + }, + { + "long_name": "D Town", + "short_name": "D Town", + "types": ["locality", "political"] + }, + { + "long_name": "D County", + "short_name": "D County", + "types": ["administrative_area_level_2", "political"] + }, + { + "long_name": "Delaware", + "short_name": "DE", + "types": ["administrative_area_level_1", "political"] + }, + { + "long_name": "United States", + "short_name": "US", + "types": ["country", "political"] + }, + { + "long_name": "12345", + "short_name": "12345", + "types": ["postal_code"] + } + ], + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "dddddddddddddddddddddddd" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "eeeeeeeeeeeeeeeeeeeeeeee", + "macAddress": "EE:EE:EE:EE:EE:EE", + "lastData": { + "stationtype": "AMBWeatherV4.3.4", + "dateutc": 1700716920000, + "baromrelin": 29.238, + "baromabsin": 29.238, + "tempf": 45, + "humidity": 55, + "winddir": 98, + "winddir_avg10m": 185, + "windspeedmph": 1.1, + "windspdmph_avg10m": 1.3, + "windgustmph": 3.4, + "maxdailygust": 12.5, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0.059, + "monthlyrainin": 0.39, + "yearlyrainin": 31.268, + "lightning_day": 1, + "lightning_time": 1700700515000, + "lightning_distance": 8.7, + "batt_lightning": 0, + "solarradiation": 0, + "uv": 0, + "batt_co2": 1, + "type": "weather-data", + "created_at": 1700716954726, + "dateutc5": 1700716800000, + "lastRain": 1700445300000, + "lightnings": [ + [1700713320000, 0], + [1700713380000, 0], + [1700713440000, 0], + [1700713500000, 0], + [1700713560000, 0], + [1700713620000, 0], + [1700713680000, 0], + [1700713740000, 0], + [1700713800000, 0], + [1700713860000, 0], + [1700713920000, 0], + [1700713980000, 0], + [1700714040000, 0], + [1700714100000, 0], + [1700714160000, 0], + [1700714220000, 0], + [1700714280000, 0], + [1700714340000, 0], + [1700714400000, 0], + [1700714460000, 0], + [1700714520000, 0], + [1700714580000, 0], + [1700714640000, 0], + [1700714700000, 0], + [1700714760000, 0], + [1700714820000, 0], + [1700714880000, 0], + [1700714940000, 0], + [1700715000000, 0], + [1700715060000, 0], + [1700715120000, 0], + [1700715180000, 0], + [1700715240000, 0], + [1700715300000, 0], + [1700715360000, 0], + [1700715420000, 0], + [1700715480000, 0], + [1700715540000, 0], + [1700715600000, 0], + [1700715660000, 0], + [1700715720000, 0], + [1700715780000, 0], + [1700715840000, 0], + [1700715900000, 0], + [1700715960000, 0], + [1700716020000, 0], + [1700716080000, 0], + [1700716140000, 0], + [1700716200000, 0], + [1700716260000, 0], + [1700716320000, 0], + [1700716380000, 0], + [1700716440000, 0], + [1700716500000, 0], + [1700716560000, 0], + [1700716620000, 0], + [1700716680000, 0], + [1700716740000, 0], + [1700716800000, 0], + [1700716860000, 0], + [1700716920000, 0] + ], + "lightning_hour": 0, + "tz": "America/Chicago" + }, + "info": { + "name": "Station E", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "location": "Location E", + "elevation": 236.0, + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "eeeeeeeeeeeeeeeeeeeeeeee" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "ffffffffffffffffffffffff", + "macAddress": "FF:FF:FF:FF:FF:FF", + "lastData": {}, + "info": { + "name": "", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "location": "Location F", + "elevation": 242.0, + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "ffffffffffffffffffffffff" + }, + "tz": { + "name": "America/Chicago" + } + } +] diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..377018c54be --- /dev/null +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -0,0 +1,856 @@ +# serializer version: 1 +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_absolute_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_absolute_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Absolute pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'absolute_pressure', + 'unique_id': 'AA:AA:AA:AA:AA:AA_baromabsin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_absolute_pressure-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_daily_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_daily_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_dailyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_daily_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation', + 'friendly_name': 'Station A Daily rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_daily_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dew_point', + 'unique_id': 'AA:AA:AA:AA:AA:AA_dewPoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station A Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.7777777777778', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_feels_like-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_feels_like', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Feels like', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'feels_like', + 'unique_id': 'AA:AA:AA:AA:AA:AA_feelsLike', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_feels_like-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station A Feels like', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_feels_like', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.4444444444444', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_hourly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_hourly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hourly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hourly_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_hourlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_hourly_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'Station A Hourly rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_hourly_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'humidity', + 'friendly_name': 'Station A Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.station_a_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_irradiance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_irradiance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irradiance', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_solarradiation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_irradiance-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_last_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_last_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_lastRain', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_last_rain-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_max_daily_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_max_daily_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max daily gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_daily_gust', + 'unique_id': 'AA:AA:AA:AA:AA:AA_maxdailygust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_max_daily_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station A Max daily gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_max_daily_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.72523008', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_monthly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_monthly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monthly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_monthlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_monthly_rain-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_relative_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_relative_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relative pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_pressure', + 'unique_id': 'AA:AA:AA:AA:AA:AA_baromrelin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_relative_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'pressure', + 'friendly_name': 'Station A Relative pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_relative_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1001.89694313129', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_tempf', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station A Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.2777777777778', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV index', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': 'AA:AA:AA:AA:AA:AA_uv', + 'unit_of_measurement': 'index', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'friendly_name': 'Station A UV index', + 'state_class': , + 'unit_of_measurement': 'index', + }), + 'context': , + 'entity_id': 'sensor.station_a_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_weekly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_weekly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weekly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'weekly_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_weeklyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_weekly_rain-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_direction', + 'unique_id': 'AA:AA:AA:AA:AA:AA_winddir', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_direction-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust', + 'unique_id': 'AA:AA:AA:AA:AA:AA_windgustmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station A Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.75768448', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_windspeedmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station A Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.03347968', + }) +# --- diff --git a/tests/components/ambient_network/test_config_flow.py b/tests/components/ambient_network/test_config_flow.py new file mode 100644 index 00000000000..d9093de7234 --- /dev/null +++ b/tests/components/ambient_network/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the Ambient Weather Network config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aioambient import OpenAPI +import pytest + +from homeassistant.components.ambient_network.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_happy_path( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + open_api: OpenAPI, + aioambient: AsyncMock, + devices_by_location: list[dict[str, Any]], + config_entry: ConfigEntry, +) -> None: + """Test the happy path.""" + + setup_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert setup_result["type"] == FlowResultType.FORM + assert setup_result["step_id"] == "user" + + with patch.object( + open_api, + "get_devices_by_location", + AsyncMock(return_value=devices_by_location), + ): + user_result = await hass.config_entries.flow.async_configure( + setup_result["flow_id"], + {"location": {"latitude": 10.0, "longitude": 20.0, "radius": 1.0}}, + ) + + assert user_result["type"] == FlowResultType.FORM + assert user_result["step_id"] == "station" + + stations_result = await hass.config_entries.flow.async_configure( + user_result["flow_id"], + { + "station": "AA:AA:AA:AA:AA:AA", + }, + ) + + assert stations_result["type"] == FlowResultType.CREATE_ENTRY + assert stations_result["title"] == config_entry.title + assert stations_result["data"] == config_entry.data + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_no_station_found( + hass: HomeAssistant, + aioambient: AsyncMock, + open_api: OpenAPI, +) -> None: + """Test that we abort when we cannot find a station in the area.""" + + setup_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert setup_result["type"] == FlowResultType.FORM + assert setup_result["step_id"] == "user" + + with patch.object( + open_api, + "get_devices_by_location", + AsyncMock(return_value=[]), + ): + user_result = await hass.config_entries.flow.async_configure( + setup_result["flow_id"], + {"location": {"latitude": 10.0, "longitude": 20.0, "radius": 1.0}}, + ) + + assert user_result["type"] == FlowResultType.FORM + assert user_result["step_id"] == "user" + assert user_result["errors"] == {"base": "no_stations_found"} diff --git a/tests/components/ambient_network/test_sensor.py b/tests/components/ambient_network/test_sensor.py new file mode 100644 index 00000000000..b556c0c9c7c --- /dev/null +++ b/tests/components/ambient_network/test_sensor.py @@ -0,0 +1,123 @@ +"""Test Ambient Weather Network sensors.""" + +from datetime import datetime, timedelta +from unittest.mock import patch + +from aioambient import OpenAPI +from aioambient.errors import RequestError +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_platform + +from tests.common import async_fire_time_changed + + +@freeze_time("2023-11-08") +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_sensors( + hass: HomeAssistant, + open_api: OpenAPI, + aioambient, + config_entry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all sensors under normal operation.""" + await setup_platform(True, hass, config_entry) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + + +@freeze_time("2023-11-09") +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_sensors_with_stale_data( + hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry +) -> None: + """Test that the sensors are not populated if the data is stale.""" + await setup_platform(False, hass, config_entry) + + sensor = hass.states.get("sensor.station_a_absolute_pressure") + assert sensor is None + + +@freeze_time("2023-11-08") +@pytest.mark.parametrize("config_entry", ["BB:BB:BB:BB:BB:BB"], indirect=True) +async def test_sensors_with_no_data( + hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry +) -> None: + """Test that the sensors are not populated if the last data is absent.""" + await setup_platform(False, hass, config_entry) + + sensor = hass.states.get("sensor.station_b_absolute_pressure") + assert sensor is None + + +@freeze_time("2023-11-08") +@pytest.mark.parametrize("config_entry", ["CC:CC:CC:CC:CC:CC"], indirect=True) +async def test_sensors_with_no_update_time( + hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry +) -> None: + """Test that the sensors are not populated if the update time is missing.""" + await setup_platform(False, hass, config_entry) + + sensor = hass.states.get("sensor.station_c_absolute_pressure") + assert sensor is None + + +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_sensors_disappearing( + hass: HomeAssistant, + open_api: OpenAPI, + aioambient, + config_entry, + caplog, +) -> None: + """Test that we log errors properly.""" + + initial_datetime = datetime(year=2023, month=11, day=8) + with freeze_time(initial_datetime) as frozen_datetime: + # Normal state, sensor is available. + await setup_platform(True, hass, config_entry) + sensor = hass.states.get("sensor.station_a_relative_pressure") + assert sensor is not None + assert float(sensor.state) == pytest.approx(1001.89694313129) + + # Sensor becomes unavailable if the network is unavailable. Log message + # should only show up once. + for _ in range(5): + with patch.object( + open_api, "get_device_details", side_effect=RequestError() + ): + frozen_datetime.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.station_a_relative_pressure") + assert sensor is not None + assert sensor.state == "unavailable" + assert caplog.text.count("Cannot connect to Ambient Network") == 1 + + # Network comes back. Sensor should start reporting again. Log message + # should only show up once. + for _ in range(5): + frozen_datetime.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + sensor = hass.states.get("sensor.station_a_relative_pressure") + assert sensor is not None + assert float(sensor.state) == pytest.approx(1001.89694313129) + assert caplog.text.count("Fetching ambient_network data recovered") == 1 From 093aee672cb103e30a7dedc5088d29c5f718cafa Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 16 Apr 2024 10:19:23 +0200 Subject: [PATCH 027/107] Fix ambient network test linting (#115691) --- tests/components/ambient_network/conftest.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/components/ambient_network/conftest.py b/tests/components/ambient_network/conftest.py index 3afadbfa722..ede44b5d92f 100644 --- a/tests/components/ambient_network/conftest.py +++ b/tests/components/ambient_network/conftest.py @@ -79,7 +79,7 @@ async def setup_platform( expected_outcome: bool, hass: HomeAssistant, config_entry: MockConfigEntry, -): +) -> None: """Load the Ambient Network integration with the provided OpenAPI and config entry.""" config_entry.add_to_hass(hass) @@ -87,5 +87,3 @@ async def setup_platform( await hass.config_entries.async_setup(config_entry.entry_id) == expected_outcome ) await hass.async_block_till_done() - - return From d1ed8d817c514630114e1849808365f4dad8151a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 16 Apr 2024 10:50:51 +0200 Subject: [PATCH 028/107] Remove Adafruit-BBIO from commented requirements (#115689) --- script/gen_requirements_all.py | 1 - 1 file changed, 1 deletion(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d8fffac1a06..3f96e41a8ef 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -18,7 +18,6 @@ from homeassistant.util.yaml.loader import load_yaml from script.hassfest.model import Integration COMMENT_REQUIREMENTS = ( - "Adafruit-BBIO", "atenpdu", # depends on pysnmp which is not maintained at this time "avea", # depends on bluepy "avion", From 679752ceb8d96df9387191906bc0dcb89e5d912a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:33:10 +0200 Subject: [PATCH 029/107] Bump github/codeql-action from 3.24.10 to 3.25.0 (#115686) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 475c0bd352f..9dba09557e3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.1.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.24.10 + uses: github/codeql-action/init@v3.25.0 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.24.10 + uses: github/codeql-action/analyze@v3.25.0 with: category: "/language:python" From 7cd0fe3c5f1c4dad49f5c05ebd755c2b86d63fa4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 16 Apr 2024 15:58:57 +0200 Subject: [PATCH 030/107] Don't reload other automations when saving an automation (#80254) * Only reload modified automation * Correct check for existing automation * Add tests * Remove the new service, improve ReloadServiceHelper * Revert unneeded changes * Update tests * Address review comments * Improve test coverage * Address review comments * Tweak reloader code + add a targetted test * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Explain the tests + add more variations * Fix copy-paste mistake in test * Rephrase explanation of expected test outcome --------- Co-authored-by: Martin Hjelmare --- .../components/automation/__init__.py | 56 +++- homeassistant/components/config/automation.py | 4 +- homeassistant/helpers/service.py | 53 +++- tests/components/automation/test_init.py | 253 +++++++++++++++++- tests/components/config/test_automation.py | 8 +- tests/helpers/test_service.py | 136 ++++++++++ 6 files changed, 484 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index afc8f9aba10..89a2817e236 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -331,17 +331,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_get_blueprints(hass).async_reset_cache() if (conf := await component.async_prepare_reload(skip_reset=True)) is None: return - await _async_process_config(hass, conf, component) + if automation_id := service_call.data.get(CONF_ID): + await _async_process_single_config(hass, conf, component, automation_id) + else: + await _async_process_config(hass, conf, component) hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context) - reload_helper = ReloadServiceHelper(reload_service_handler) + def reload_targets(service_call: ServiceCall) -> set[str | None]: + if automation_id := service_call.data.get(CONF_ID): + return {automation_id} + return {automation.unique_id for automation in component.entities} + + reload_helper = ReloadServiceHelper(reload_service_handler, reload_targets) async_register_admin_service( hass, DOMAIN, SERVICE_RELOAD, reload_helper.execute_service, - schema=vol.Schema({}), + schema=vol.Schema({vol.Optional(CONF_ID): str}), ) websocket_api.async_register_command(hass, websocket_config) @@ -859,6 +867,7 @@ class AutomationEntityConfig: async def _prepare_automation_config( hass: HomeAssistant, config: ConfigType, + wanted_automation_id: str | None, ) -> list[AutomationEntityConfig]: """Parse configuration and prepare automation entity configuration.""" automation_configs: list[AutomationEntityConfig] = [] @@ -866,6 +875,10 @@ async def _prepare_automation_config( conf: list[ConfigType] = config[DOMAIN] for list_no, config_block in enumerate(conf): + automation_id: str | None = config_block.get(CONF_ID) + if wanted_automation_id is not None and automation_id != wanted_automation_id: + continue + raw_config = cast(AutomationConfig, config_block).raw_config raw_blueprint_inputs = cast(AutomationConfig, config_block).raw_blueprint_inputs validation_failed = cast(AutomationConfig, config_block).validation_failed @@ -1025,7 +1038,7 @@ async def _async_process_config( return automation_matches, config_matches - automation_configs = await _prepare_automation_config(hass, config) + automation_configs = await _prepare_automation_config(hass, config, None) automations: list[BaseAutomationEntity] = list(component.entities) # Find automations and configurations which have matches @@ -1049,6 +1062,41 @@ async def _async_process_config( await component.async_add_entities(entities) +def _automation_matches_config( + automation: BaseAutomationEntity | None, config: AutomationEntityConfig | None +) -> bool: + """Return False if an automation's config has been changed.""" + if not automation: + return False + if not config: + return False + name = _automation_name(config) + return automation.name == name and automation.raw_config == config.raw_config + + +async def _async_process_single_config( + hass: HomeAssistant, + config: dict[str, Any], + component: EntityComponent[BaseAutomationEntity], + automation_id: str, +) -> None: + """Process config and add a single automation.""" + + automation_configs = await _prepare_automation_config(hass, config, automation_id) + automation = next( + (x for x in component.entities if x.unique_id == automation_id), None + ) + automation_config = automation_configs[0] if automation_configs else None + + if _automation_matches_config(automation, automation_config): + return + + if automation: + await automation.async_remove() + entities = await _create_automation_entities(hass, automation_configs) + await component.async_add_entities(entities) + + async def _async_process_if( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> IfAction | None: diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index a5a010c00a6..ccc36dc4430 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -26,7 +26,9 @@ def async_setup(hass: HomeAssistant) -> bool: async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads automations.""" if action != ACTION_DELETE: - await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + await hass.services.async_call( + DOMAIN, SERVICE_RELOAD, {CONF_ID: config_key} + ) return ent_reg = er.async_get(hass) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3947bc9cbf8..66c9f7db3e6 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -77,6 +77,8 @@ _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" ALL_SERVICE_DESCRIPTIONS_CACHE = "all_service_descriptions_cache" +_T = TypeVar("_T") + @cache def _base_components() -> dict[str, ModuleType]: @@ -1154,40 +1156,67 @@ def verify_domain_control( class ReloadServiceHelper: - """Helper for reload services to minimize unnecessary reloads.""" + """Helper for reload services. - def __init__(self, service_func: Callable[[ServiceCall], Awaitable]) -> None: + The helper has the following purposes: + - Make sure reloads do not happen in parallel + - Avoid redundant reloads of the same target + """ + + def __init__( + self, + service_func: Callable[[ServiceCall], Awaitable], + reload_targets_func: Callable[[ServiceCall], set[_T]], + ) -> None: """Initialize ReloadServiceHelper.""" self._service_func = service_func self._service_running = False self._service_condition = asyncio.Condition() + self._pending_reload_targets: set[_T] = set() + self._reload_targets_func = reload_targets_func async def execute_service(self, service_call: ServiceCall) -> None: """Execute the service. - If a previous reload task if currently in progress, wait for it to finish first. + If a previous reload task is currently in progress, wait for it to finish first. Once the previous reload task has finished, one of the waiting tasks will be - assigned to execute the reload, the others will wait for the reload to finish. + assigned to execute the reload of the targets it is assigned to reload. The + other tasks will wait if they should reload the same target, otherwise they + will wait for the next round. """ do_reload = False + reload_targets = None async with self._service_condition: if self._service_running: - # A previous reload task is already in progress, wait for it to finish + # A previous reload task is already in progress, wait for it to finish, + # because that task may be reloading a stale version of the resource. await self._service_condition.wait() - async with self._service_condition: - if not self._service_running: - # This task will do the reload - self._service_running = True - do_reload = True - else: - # Another task will perform the reload, wait for it to finish + while True: + async with self._service_condition: + # Once we've passed this point, we assume the version of the resource is + # the one our task was assigned to reload, or a newer one. Regardless of + # which, our task is happy as long as the target is reloaded at least + # once. + if reload_targets is None: + reload_targets = self._reload_targets_func(service_call) + self._pending_reload_targets |= reload_targets + if not self._service_running: + # This task will do a reload + self._service_running = True + do_reload = True + break + # Another task will perform a reload, wait for it to finish await self._service_condition.wait() + # Check if the reload this task is waiting for has been completed + if reload_targets.isdisjoint(self._pending_reload_targets): + break if do_reload: # Reload, then notify other tasks await self._service_func(service_call) async with self._service_condition: self._service_running = False + self._pending_reload_targets -= reload_targets self._service_condition.notify_all() diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 5b3fc2a723e..61e6d0e4660 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, + CONF_ID, EVENT_HOMEASSISTANT_STARTED, SERVICE_RELOAD, SERVICE_TOGGLE, @@ -692,7 +693,9 @@ async def test_reload_config_handles_load_fails(hass: HomeAssistant, calls) -> N assert len(calls) == 2 -@pytest.mark.parametrize("service", ["turn_off_stop", "turn_off_no_stop", "reload"]) +@pytest.mark.parametrize( + "service", ["turn_off_stop", "turn_off_no_stop", "reload", "reload_single"] +) async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: """Test that turning off / reloading stops any running actions as appropriate.""" entity_id = "automation.hello" @@ -700,6 +703,7 @@ async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: config = { automation.DOMAIN: { + "id": "sun", "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event"}, "action": [ @@ -737,7 +741,7 @@ async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: {ATTR_ENTITY_ID: entity_id, automation.CONF_STOP_ACTIONS: False}, blocking=True, ) - else: + elif service == "reload": config[automation.DOMAIN]["alias"] = "goodbye" with patch( "homeassistant.config.load_yaml_config_file", @@ -747,6 +751,19 @@ async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: await hass.services.async_call( automation.DOMAIN, SERVICE_RELOAD, blocking=True ) + else: # service == "reload_single" + config[automation.DOMAIN]["alias"] = "goodbye" + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=True, + ) hass.states.async_set(test_entity, "goodbye") await hass.async_block_till_done() @@ -801,6 +818,238 @@ async def test_reload_unchanged_does_not_stop( assert len(calls) == 1 +async def test_reload_single_unchanged_does_not_stop( + hass: HomeAssistant, calls +) -> None: + """Test that reloading stops any running actions as appropriate.""" + test_entity = "test.entity" + + config = { + automation.DOMAIN: { + "id": "sun", + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "running"}, + {"wait_template": "{{ is_state('test.entity', 'goodbye') }}"}, + {"service": "test.automation"}, + ], + } + } + assert await async_setup_component(hass, automation.DOMAIN, config) + + running = asyncio.Event() + + @callback + def running_cb(event): + running.set() + + hass.bus.async_listen_once("running", running_cb) + hass.states.async_set(test_entity, "hello") + + hass.bus.async_fire("test_event") + await running.wait() + assert len(calls) == 0 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=True, + ) + + hass.states.async_set(test_entity, "goodbye") + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_reload_single_add_automation(hass: HomeAssistant, calls) -> None: + """Test that reloading a single automation.""" + config1 = {automation.DOMAIN: {}} + config2 = { + automation.DOMAIN: { + "id": "sun", + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + } + } + assert await async_setup_component(hass, automation.DOMAIN, config1) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 0 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config2, + ): + await hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=True, + ) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_reload_single_parallel_calls(hass: HomeAssistant, calls) -> None: + """Test reloading single automations in parallel.""" + config1 = {automation.DOMAIN: {}} + config2 = { + automation.DOMAIN: [ + { + "id": "sun", + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event_sun"}, + "action": [{"service": "test.automation"}], + }, + { + "id": "moon", + "alias": "goodbye", + "trigger": {"platform": "event", "event_type": "test_event_moon"}, + "action": [{"service": "test.automation"}], + }, + { + "id": "mars", + "alias": "goodbye", + "trigger": {"platform": "event", "event_type": "test_event_mars"}, + "action": [{"service": "test.automation"}], + }, + { + "id": "venus", + "alias": "goodbye", + "trigger": {"platform": "event", "event_type": "test_event_venus"}, + "action": [{"service": "test.automation"}], + }, + ] + } + assert await async_setup_component(hass, automation.DOMAIN, config1) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 0 + + # Trigger multiple reload service calls, each automation is reloaded twice. + # This tests the logic in the `ReloadServiceHelper` which avoids redundant + # reloads of the same target automation. + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config2, + ): + tasks = [ + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "moon"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "mars"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "venus"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "moon"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "mars"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "venus"}, + blocking=False, + ), + ] + await asyncio.gather(*tasks) + await hass.async_block_till_done() + + # Sanity check to ensure all automations are correctly setup + hass.bus.async_fire("test_event_sun") + await hass.async_block_till_done() + assert len(calls) == 1 + hass.bus.async_fire("test_event_moon") + await hass.async_block_till_done() + assert len(calls) == 2 + hass.bus.async_fire("test_event_mars") + await hass.async_block_till_done() + assert len(calls) == 3 + hass.bus.async_fire("test_event_venus") + await hass.async_block_till_done() + assert len(calls) == 4 + + +async def test_reload_single_remove_automation(hass: HomeAssistant, calls) -> None: + """Test that reloading a single automation.""" + config1 = { + automation.DOMAIN: { + "id": "sun", + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + } + } + config2 = {automation.DOMAIN: {}} + assert await async_setup_component(hass, automation.DOMAIN, config1) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config2, + ): + await hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=True, + ) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_reload_moved_automation_without_alias( hass: HomeAssistant, calls ) -> None: diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 80f68b96fe1..b17face10d9 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -10,7 +10,7 @@ import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components import config from homeassistant.components.config import automation -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import yaml @@ -82,10 +82,8 @@ async def test_update_automation_config( ) await hass.async_block_till_done() assert sorted(hass.states.async_entity_ids("automation")) == [ - "automation.automation_0", "automation.automation_1", ] - assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE assert hass.states.get("automation.automation_1").state == STATE_ON assert resp.status == HTTPStatus.OK @@ -260,10 +258,8 @@ async def test_update_remove_key_automation_config( ) await hass.async_block_till_done() assert sorted(hass.states.async_entity_ids("automation")) == [ - "automation.automation_0", "automation.automation_1", ] - assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE assert hass.states.get("automation.automation_1").state == STATE_ON assert resp.status == HTTPStatus.OK @@ -305,10 +301,8 @@ async def test_bad_formatted_automations( ) await hass.async_block_till_done() assert sorted(hass.states.async_entity_ids("automation")) == [ - "automation.automation_0", "automation.automation_1", ] - assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE assert hass.states.get("automation.automation_1").state == STATE_ON assert resp.status == HTTPStatus.OK diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index b5e71f4c9d8..e32768ee33e 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1852,3 +1852,139 @@ async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: ) assert await service.async_extract_config_entry_ids(hass, call) == {"abc"} + + +async def test_reload_service_helper(hass: HomeAssistant) -> None: + """Test the reload service helper.""" + + active_reload_calls = 0 + reloaded = [] + + async def reload_service_handler(service_call: ServiceCall) -> None: + """Remove all automations and load new ones from config.""" + nonlocal active_reload_calls + # Assert the reload helper prevents parallel reloads + assert not active_reload_calls + active_reload_calls += 1 + if not (target := service_call.data.get("target")): + reloaded.append("all") + else: + reloaded.append(target) + await asyncio.sleep(0.01) + active_reload_calls -= 1 + + def reload_targets(service_call: ServiceCall) -> set[str | None]: + if target_id := service_call.data.get("target"): + return {target_id} + return {"target1", "target2", "target3", "target4"} + + # Test redundant reload of single targets + reloader = service.ReloadServiceHelper(reload_service_handler, reload_targets) + tasks = [ + # This reload task will start executing first, (target1) + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + # These reload tasks will be deduplicated to (target2, target3, target4, target1) + # while the first task is reloaded, note that target1 can't be deduplicated + # because it's already being reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered( + ["target1", "target2", "target3", "target4", "target1"] + ) + + # Test redundant reload of multiple targets + single target + reloaded.clear() + tasks = [ + # This reload task will start executing first, (target1) + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + # These reload tasks will be deduplicated to (target2, target3, target4, all) + # while the first task is reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test")), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered(["target1", "target2", "target3", "target4", "all"]) + + # Test redundant reload of multiple targets + single target + reloaded.clear() + tasks = [ + # This reload task will start executing first, (all) + reloader.execute_service(ServiceCall("test", "test")), + # These reload tasks will be deduplicated to (target1, target2, target3, target4) + # while the first task is reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) + + # Test redundant reload of single targets + reloaded.clear() + tasks = [ + # This reload task will start executing first, (target1) + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + # These reload tasks will be deduplicated to (target2, target3, target4, target1) + # while the first task is reloaded, note that target1 can't be deduplicated + # because it's already being reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered( + ["target1", "target2", "target3", "target4", "target1"] + ) + + # Test redundant reload of multiple targets + single target + reloaded.clear() + tasks = [ + # This reload task will start executing first, (target1) + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + # These reload tasks will be deduplicated to (target2, target3, target4, all) + # while the first task is reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service(ServiceCall("test", "test")), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered(["target1", "target2", "target3", "target4", "all"]) + + # Test redundant reload of multiple targets + single target + reloaded.clear() + tasks = [ + # This reload task will start executing first, (all) + reloader.execute_service(ServiceCall("test", "test")), + # These reload tasks will be deduplicated to (target1, target2, target3, target4) + # while the first task is reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) From e9894f8e91495ebe113f0145a3e78d7edb8a28c5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 16 Apr 2024 16:13:03 +0200 Subject: [PATCH 031/107] Add extract media url service to media extractor (#100780) * Exclude manifest files from youtube media extraction * Add media_extractor service to extract media * Fix snapshot * Run ytdlp async * Add icon * Fix * Fix --- .../components/media_extractor/__init__.py | 71 +- .../components/media_extractor/const.py | 9 + .../components/media_extractor/icons.json | 3 +- .../components/media_extractor/services.yaml | 11 + .../components/media_extractor/strings.json | 14 + tests/components/media_extractor/__init__.py | 6 +- .../media_extractor/fixtures/no_formats.json | 87 + .../media_extractor/fixtures/soundcloud.json | 114 ++ .../media_extractor/fixtures/youtube_1.json | 1430 +++++++++++++++++ .../fixtures/youtube_empty_playlist.json | 49 + .../fixtures/youtube_playlist.json | 179 +++ .../media_extractor/snapshots/test_init.ambr | 20 + tests/components/media_extractor/test_init.py | 59 +- 13 files changed, 2045 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/media_extractor/const.py create mode 100644 tests/components/media_extractor/fixtures/no_formats.json create mode 100644 tests/components/media_extractor/fixtures/soundcloud.json create mode 100644 tests/components/media_extractor/fixtures/youtube_1.json create mode 100644 tests/components/media_extractor/fixtures/youtube_empty_playlist.json create mode 100644 tests/components/media_extractor/fixtures/youtube_playlist.json diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 888265e8d3c..228a012a04f 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -17,18 +17,29 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, ) from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from .const import ( + ATTR_FORMAT_QUERY, + ATTR_URL, + DEFAULT_STREAM_QUERY, + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, +) + _LOGGER = logging.getLogger(__name__) CONF_CUSTOMIZE_ENTITIES = "customize" CONF_DEFAULT_STREAM_QUERY = "default_query" -DEFAULT_STREAM_QUERY = "best" -DOMAIN = "media_extractor" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -47,10 +58,62 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media extractor service.""" + async def extract_media_url(call: ServiceCall) -> ServiceResponse: + """Extract media url.""" + youtube_dl = YoutubeDL( + {"quiet": True, "logger": _LOGGER, "format": call.data[ATTR_FORMAT_QUERY]} + ) + + def extract_info() -> dict[str, Any]: + return cast( + dict[str, Any], + youtube_dl.extract_info( + call.data[ATTR_URL], download=False, process=False + ), + ) + + result = await hass.async_add_executor_job(extract_info) + if "entries" in result: + _LOGGER.warning("Playlists are not supported, looking for the first video") + entries = list(result["entries"]) + if entries: + selected_media = entries[0] + else: + raise HomeAssistantError("Playlist is empty") + else: + selected_media = result + if "formats" in selected_media: + if selected_media["extractor"] == "youtube": + url = get_best_stream_youtube(selected_media["formats"]) + else: + url = get_best_stream(selected_media["formats"]) + else: + url = cast(str, selected_media["url"]) + return {"url": url} + def play_media(call: ServiceCall) -> None: """Get stream URL and send it to the play_media service.""" MediaExtractor(hass, config[DOMAIN], call.data).extract_and_send() + default_format_query = config.get(DOMAIN, {}).get( + CONF_DEFAULT_STREAM_QUERY, DEFAULT_STREAM_QUERY + ) + + hass.services.async_register( + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, + extract_media_url, + schema=vol.Schema( + { + vol.Required(ATTR_URL): cv.string, + vol.Optional( + ATTR_FORMAT_QUERY, default=default_format_query + ): cv.string, + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.register( DOMAIN, SERVICE_PLAY_MEDIA, diff --git a/homeassistant/components/media_extractor/const.py b/homeassistant/components/media_extractor/const.py new file mode 100644 index 00000000000..009ab37602c --- /dev/null +++ b/homeassistant/components/media_extractor/const.py @@ -0,0 +1,9 @@ +"""Constants for media_extractor.""" + +DEFAULT_STREAM_QUERY = "best" +DOMAIN = "media_extractor" + +ATTR_URL = "url" +ATTR_FORMAT_QUERY = "format_query" + +SERVICE_EXTRACT_MEDIA_URL = "extract_media_url" diff --git a/homeassistant/components/media_extractor/icons.json b/homeassistant/components/media_extractor/icons.json index 71b65e7c4a6..7abc4410b19 100644 --- a/homeassistant/components/media_extractor/icons.json +++ b/homeassistant/components/media_extractor/icons.json @@ -1,5 +1,6 @@ { "services": { - "play_media": "mdi:play" + "play_media": "mdi:play", + "extract_media_url": "mdi:link" } } diff --git a/homeassistant/components/media_extractor/services.yaml b/homeassistant/components/media_extractor/services.yaml index 8af2d12d0e9..abfe52dc4f5 100644 --- a/homeassistant/components/media_extractor/services.yaml +++ b/homeassistant/components/media_extractor/services.yaml @@ -19,3 +19,14 @@ play_media: - "MUSIC" - "TVSHOW" - "VIDEO" +extract_media_url: + fields: + url: + required: true + example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + selector: + text: + format_query: + example: "best" + selector: + text: diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json index 0cdffd5d508..1af84b5b8c8 100644 --- a/homeassistant/components/media_extractor/strings.json +++ b/homeassistant/components/media_extractor/strings.json @@ -13,6 +13,20 @@ "description": "The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC." } } + }, + "extract_media_url": { + "name": "Get Media URL", + "description": "Extract media url from a service.", + "fields": { + "url": { + "name": "Media URL", + "description": "URL where the media can be found." + }, + "format_query": { + "name": "Format query", + "description": "Youtube-dl query to select the quality of the result." + } + } } } } diff --git a/tests/components/media_extractor/__init__.py b/tests/components/media_extractor/__init__.py index 7aac726501b..79130f1ea4b 100644 --- a/tests/components/media_extractor/__init__.py +++ b/tests/components/media_extractor/__init__.py @@ -36,9 +36,13 @@ class MockYoutubeDL: """Initialize mock object for YoutubeDL.""" self.params = params - def extract_info(self, url: str, *, process: bool = False) -> dict[str, Any]: + def extract_info( + self, url: str, *, download: bool = True, process: bool = False + ) -> dict[str, Any]: """Return info.""" self._fixture = _get_base_fixture(url) + if not download: + return load_json_object_fixture(f"media_extractor/{self._fixture}.json") return load_json_object_fixture(f"media_extractor/{self._fixture}_info.json") def process_ie_result( diff --git a/tests/components/media_extractor/fixtures/no_formats.json b/tests/components/media_extractor/fixtures/no_formats.json new file mode 100644 index 00000000000..aefb1525738 --- /dev/null +++ b/tests/components/media_extractor/fixtures/no_formats.json @@ -0,0 +1,87 @@ +{ + "id": "223644256", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16 + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20 + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32 + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47 + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67 + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100 + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300 + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400 + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500 + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 291779, + "like_count": 3347, + "comment_count": 14, + "repost_count": 59, + "genre": "Brutto", + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/soundcloud.json b/tests/components/media_extractor/fixtures/soundcloud.json new file mode 100644 index 00000000000..ee430e43982 --- /dev/null +++ b/tests/components/media_extractor/fixtures/soundcloud.json @@ -0,0 +1,114 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16 + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20 + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32 + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47 + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67 + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100 + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300 + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400 + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500 + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 291779, + "like_count": 3347, + "comment_count": 14, + "repost_count": 59, + "genre": "Brutto", + "formats": [ + { + "url": "https://cf-hls-media.sndcdn.com/playlist/50remGX1OqRY.128.mp3/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1tZWRpYS5zbmRjZG4uY29tL3BsYXlsaXN0LzUwcmVtR1gxT3FSWS4xMjgubXAzL3BsYXlsaXN0Lm0zdTgqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk1NTAzOTYzfX19XX0_&Signature=eNeIoSTgRZL89YBJYXpmRg0AVGk3M0gV4E4rYPYbFw6pTePHO4o8Mv6HwdK85FOMsaUHZvYgzc35uWPhAr1SUqqjnm--xwN8VUrDkCPgdv97Vrs9qJ9QElHKnlWhK2-BDs3Y7sDcAurA00L2uReB-vjI-4K65WBApYBTaUGnOACimoVAOWHmtigO0Ap5DxlEh7fqqwi88enEvVDE-98v5uX9FcV9lq9AfVwEtfqbPsjVJyh6WbWAB3PJDJElvV13RgKmzVvbFluLElYlDud9WMsHjztdWhdaRzGOj1AfcQcwkQbQlBRiAKMtqrRlzAAXnBfLvMF3DOvdYWeCwJeCXA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "hls_mp3_128", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none" + }, + { + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk1NTAzOTYzfX19XX0_&Signature=JAG~zJ~2NOWOgiuHLCSYWwdjUVuYWR2fBvmxPGSnLzMgX2xqu5~WfOk-gOyRUbHhnKnybUbP70cr6~t~Qx0KEU5mwIy2H0YhOXDHFX5RJVQlj1iCVuko-hAFJc7RtZuKTP5oCWOM-R2a6HfYN88YAIqgwWbGvTKin1CAgHaICeoM2p5O50n-kp05KgCw3RKcRutkYT-RVcWkmXtY4D4Jtw~LuBERDNyErseTHzmruDCkaYkVNlTcaIdgygQjgxVlgZiIRj-p0vRNO0qv5Bc0LfNMBzYm9fTAr86c~TzxyvQRhwHOPYp-DCXcs1K6i9x4BVvHWLOSHr0Dhd3X4fe5kw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none" + }, + { + "url": "https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTU1MDM5NjR9fX1dfQ__&Signature=HqyeKoK4gx1Bd3jId5v1-9ltDY2SN7fzGp6M7tW3kWluv8Ns0SgANPKG3~Tzo8hUrQzAYVvwMbO2F75b6NBeBr4xx0SRCeKotZdArzBFT4Wtrz4HtEddLyPjp12vWYCDuOshd1sTjrvaBAd9TAFTZuwyal1OKpLMsqK0QN-KFH-5GsmLDZTPWcoVkMqC7XBmNWTq0G1mtVeP57TN~9T7qEYqRLFIDXdm2HKSPSR4BB2gjaZUK22pBUPSGVr-ziBSpNVHvNISy2QdObjS5zjuAe8bl-npQ3PlFatYECDI3Gc~wjCNIJPdTpCbHUsX36SSR4dnKlgW1nYGx~eED7dppA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "opus", + "abr": 64, + "format_id": "hls_opus_64", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none" + } + ], + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/youtube_1.json b/tests/components/media_extractor/fixtures/youtube_1.json new file mode 100644 index 00000000000..e1283274f63 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_1.json @@ -0,0 +1,1430 @@ +{ + "id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "formats": [ + { + "asr": null, + "filesize": 80166145, + "format_id": "137", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 3024.566, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=137&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=80166145&dur=212.040&lmt=1694045208995966&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJvqkzrOZjbqQLPANU-Q0Ti57XZCS5MLEZMrme2Vqqj2AiEAoU5oDVbWI-82LxhSDuTtTvpgKEspgfrw7aPzQ8Di40w%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 40874930, + "format_id": "248", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1542.159, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgM6ztX8BqXVEkyq4FTukRfb0mlWfDdll8wN_8iZvFoDMCIQDvRawrloLUqZWDjgf2ZZKkQPPX2NZQm5mUcIHjX04bWA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 16780212, + "format_id": "136", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 633.096, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=136&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=16780212&dur=212.040&lmt=1694045071129751&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgG69VUvtxkC7_DuWzobsIDSBoAq9K8NfzCDI1BRkqC4ICIQD-G-4SOmZuQKmSkka0p8USe-GX_RzmuxsNPZj89r-9WA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 15359727, + "format_id": "247", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 579.502, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=247&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=15359727&dur=212.040&lmt=1694043486219683&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgclGPS3eSxjSMzpc-gOTA8Vsr4yfK6UCVyG5LUot-jMkCIQCdkrhr5s2GjZH5i8d_WciMXSN6kjqG9A6BCMzxqpeuRw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 8683274, + "format_id": "135", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 327.608, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=135&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=8683274&dur=212.040&lmt=1694045045723793&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhANvnqVNT-trAFyACiWh8EllyhTzAuStHpLlDrTan7LxXAiEAy_Yajm6EEJUwcAVnEBRukcxc5-CB8UTY5BjB9oR1TeM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 10927666, + "format_id": "244", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 412.286, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=244&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=10927666&dur=212.040&lmt=1694043369037289&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKBxnJIQLH7cWZFfZuMs5yhQ66jdt35KMdmqi5nmGIgnAiAzZ28nc8BNIKhKlhKBr5w6gWmvz-vm8E-PnNWigmhwgA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 5678772, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 214.252, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=134&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5678772&dur=212.040&lmt=1694045013473544&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAOHb4lBYlXkxFj1ZMXhNDcw1CQPWiB2c6Y6vOTGevdqJAiEAt644Dv84Eqzc6yfe1GG3sDMwYeLRUKA_KYHbSeJeKIo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 6902164, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 260.409, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=243&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=6902164&dur=212.040&lmt=1694043349554753&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAM3xj4hJ22Ur3eTOxA0LselI9THQg1Qb2gryxihUmPFLAiEAuQYROAwdEs6XdFszg8SRgCgojRUr1y9VS3096aQXnjc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 3019976, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 113.939, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=133&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3019976&dur=212.040&lmt=1694045014258984&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgWmsCuyZEqz2NashfdLp92iJqaqRtA8bYJJhohjGFxzgCIQD3aQZ90zKGHu-JXiJZMViWuCb0UeZ-MesxOGi_gMWHxA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 4021173, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 151.713, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=242&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=4021173&dur=212.040&lmt=1694043379783601&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgAVwANVM5s1vtgkPKId_2b9bw2d_Lhbvkvm2J2OJM-fUCIQDwcC5FLMxOF3g6nZq1vpf0d7dyKnp0plE1Niy3rZ6Cdg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 1863601, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 70.311, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=160&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1863601&dur=212.040&lmt=1694045032286738&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAL_zAjcV7CL3hke-z49D6nQ7k5dCTVweXQdj4_cVHIc2AiB9bkIVgy7GYGFUGo36PYjnlN_8KNnyxiNhh0M76Fjjgw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 2404581, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 90.721, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=278&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=2404581&dur=212.040&lmt=1694043382822868&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAOetHAZolpx87k21SDKePQP6gZHC3CWiQ_DtEQd1bDRvAiEA8GGA-2C5lIFuucuPqdnS4FZiGdKYgWUTlJ-9yQEnSR0%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 1294944, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 48.823, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=139&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=1294944&dur=212.183&lmt=1694042119353699&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMKE8c3kivZrqSOCOcLzUCa1erqAaOj6K7SWFAcCJyCXAiBOFkaL_lvsXhZeLwyOUP98LBTGxUHEurO_IWZOeRCkAQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": 3433514, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.51, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=140&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=3433514&dur=212.091&lmt=1694042124987733&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKw2jXdoZhn8sjUu-1MSxfV1p0TzRyqjuooRwoQohtOwAiANfeuxDTlTpi_f9scAC0n01xOejhRLD0m-6pl3oo7wIQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 2086732, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 6, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 78.693, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=17&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=2086732&dur=212.137&lmt=1694042486266781&mt=1695502401&fvip=3&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAIOMPqxbtJwYRIrAYmr0I9rEovBipWNTTg9AMju1ehECAiEA7vjnz-TCwh2zQQm4vmXW0nGpft4nX42Ql_hwHHCA-Yk%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 176, + "language": "en", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 343.32, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=18&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045104514388&mt=1695502401&fvip=3&fexp=24007246&beids=24350018&c=ANDROID&txp=4538434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFhTg7TavCE2HBWS9I3agqj3CG2RqrvxLJt6JgHtN4O4CIF-IDHhEPLlkGP2QtwJ19sSumUVPqVElVXjrM-qqYIae&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 640, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": null, + "format_id": "22", + "format_note": "720p", + "source_preference": -5, + "fps": 25, + "audio_channels": 2, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 762.182, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1695502401&fvip=3&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgHVwajP0J2fiJ1ERoAonpxghXGzDmEXh3rvJ399UEMWECIFdBjiVUOk7QdiFBxQ4QqojJd8p_PfL25TV_8TBrp_Kb&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 1280, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.64001F", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 31265835, + "format_id": "399", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1179.62, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=399&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=31265835&dur=212.040&lmt=1694042163788395&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMbD0sObTPrf1j0GESI-SRztzhMi98xn1XBMfFsnMjLFAiEAnMCImljVChi4G_wjA9UE2EN9xQHJ7LhuEO9HeNlR334%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.08M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 17466721, + "format_id": "398", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 658.997, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=398&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=17466721&dur=212.040&lmt=1694042319819525&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgfJ1sWUmxG82ls65giBWKTwsH7UP4ItT0soOPZSEtKg4CIQC_GFYhkfiktXrWOoKWW2j50GkQX7dE7mfWzvjh-XnIXA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.05M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 9434981, + "format_id": "397", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 355.969, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=397&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=9434981&dur=212.040&lmt=1694042458043976&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFFbe3pmUWAFqJCycmW7hGbeJuC8dfEax2p6v9J-y9GQCIB-2d1ss2yBL3yhesngue7dM5AsJqLNOMLnCD51o-1zW&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.04M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 5438397, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 205.183, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=396&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5438397&dur=212.040&lmt=1694042190822892&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgNr6BZ03kG8p2KTAv7gOZ01pEcCWwnkWvjOdxmGn1X4ACICrqnbbGqLvm0jpEqXYOXMISHcPt7vQVzwohM84tfeYb&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 3024455, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 114.108, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=395&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3024455&dur=212.040&lmt=1694042297309821&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAM7Up-h9A3SMwIYMo6V5t4oM7BpkjnEIcO_s7BTR1hfzAiA72-vEcn4y21NtpQkzpTZI_BdjCeCUez43ohuzw4MJsA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 1416915, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 53.458, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=394&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1416915&dur=212.040&lmt=1694042192787352&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgLYhy9j4feWSuTyTnJr2MF4xiEpALLDeez2_BwF__Qq8CIQDBTg9R-8YOcUtA4-R-Gu8A7o_66wGf69Vky62ZE-T0Zw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 847252, + "format_id": "597", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 31.959, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=597&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=847252&dur=212.080&lmt=1694042194934376&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgH6Tk52MhzNtfm-6d1XHdQfIh12aqbEohhH-ffBZP9z0CIQDJwPFA7eTz6LdcZaBlfnlogft7pgtrXHvm6DIHWCODUg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4d400b", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 643306, + "format_id": "598", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 24.266, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=598&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=643306&dur=212.080&lmt=1694042224218554&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgHwZWhUJuuFMMAva_Katkrgk3FGNcBlHCwBVwV1jGz4ACIQCerrScqjke9mtPVPwYZraaCp4u7VkFz1hIzx-Fl_7HzQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp9", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 1232413, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 46.492, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=249&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1232413&dur=212.061&lmt=1694040798737498&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgD3BhwkfqYjk1FEud7AdjzD9RJImYWaebeN9Ip7HuX3ICIQDCzT7tMYmDyb_fz4TB4GPwroXqO55NV5h7Ao-IoPq0Kw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 1630086, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 61.494, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=250&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1630086&dur=212.061&lmt=1694040798724510&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgJ1nALE0kFCC4pg2mj0LpB_ZwivihtQo6ugYw-AzKJAsCIQD5q8PFpJtloWUmuK2A80NC7c2hr_9OUldFCXOCLyPN3Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPSOofxlLab49bVcmvVP8wmIHVWvqDyOd11oJdP1RPFfAiAOCKp1VodP12z6FqdWxp_2xYcS2J949BbgFWqlfGkHjA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 817805, + "format_id": "599", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 30.833, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=599&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=817805&dur=212.183&lmt=1694040788792847&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAKEpb371lM9F_wlPf3i5D6zreL34as0UmzOJxw5TXqlrAiEAjNU124xhGmkotlkRSCWdF15IBB-frezyqNQY1AV-l4M%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 832823, + "format_id": "600", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 31.418, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=600&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=832823&dur=212.061&lmt=1694040798740210&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgRqdkURprOblK7GurvCvDxSunECdm96nzQwzwHuDvUKcCIQCmeMMeDP816FMH0GgpugoQhe4z4X6nDYoY3PtQhShtWA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D1294944%3Bdur%3D212.183%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1694042119353699/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRgIhANECzBL2bCCpseaXL3qapc_gEQkpP-2eTqyPCspG44PXAiEA4J6i9mS_2vJB4TtIwHjg7-f-KCyiYSs-kL5dcEkToRg%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgUS67qZ67NkB4J8491hAMcCKs400sACGWUhNcrXWefx4CIB64Ny0g5mTEFnTryntu2vexyLjfXtNHmvEAYgosB_9w/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1 + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D3433514%3Bdur%3D212.091%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1694042124987733/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRgIhAM_Q8sGDeFsxPWBEUPdlx6Mul7XMV1uCmH_5jdrqR06cAiEAqpNga8OlFdW_uqNwYxIL70Ki64lw0hKT700Z8dZPTtA%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgM8VqbzvtcXd5PhUv9cffclj4q86NQYiKEJw9CuwxlbYCIQDntLRgkHqq_HrUvzSiwWLph8lvrnAgSps0aAilpdDKfg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1 + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D3019976%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1694045014258984/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgDXYLvO8hsKnxOFI4QK0KR6_bH3y963ahqlBfJqHHOBgCIQDlYY2nTWarn59nFcVOD35IVk5obSssFUeidm3u4n6FBw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAKnlpIqSLjxTjoejuijzzpuB-Di1I2eiDdzTDDWNboffAiBbdWGc04XUqoe4imJg9kVp3fWTDOFXFVnhAXfqp3Qp6w%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 225.675, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D5678772%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1694045013473544/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAIQvJojwVvM5frF6Na5JJ5zkd1VdUfPLdqPW5Tht2eNkAiEA3437jLFLon8Rpbsp5krc66qddvGP4rj8sbwJd4rlHmU%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAIL_XGGyZVNILzOXnviLoYEMYJfAngPM1eBZGgN6wEr-AiAInhTuU6hkWCkpZGr9dPeXYSfa3brLjZNivRNbpJB8QA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 478.155, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "231", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/231/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D8683274%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D135%3Blmt%3D1694045045723793/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgdgnN5tIO26lbTUty02k4r8k9-oaX_7m4LsLXMRBE7n8CIEb2DJRXZ0dGH_ZDbtYAapQKCiCxqht8Bznh0LmS83cw/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgfNxyHiNcbQeIRKSwnBRTymUYFDqdhNZqEdRJ5-dl_uICIQD6XyM3ReaIZkg4DsK6ys3VjdMroKJUBHPx4pZGYbqJ0Q%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 660.067, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "232", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/232/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D16780212%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D136%3Blmt%3D1694045071129751/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAKklfVW5UAGwbMKG5dyOkjEW9RqlFeZXuS9RKazS7hgHAiEAluSFc2bFqy_0nb32n7mR-SOR0gCmAdFwl35gbDldf3w%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAOqcypFYB4AyrOahc7gihR0-jqv-Gzc8JHdRtQEn3r9wAiEA2cqI-R7Cjr-UaDu-B9miweYpBXWzDeC8PoxK_0bkm5c%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 1130.986, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D1863601%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1694045032286738/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAOI4zFdpx6CuAJUVfQO8mSPKS-WskVy5lco9PRAL-TfDAiABtG5PX_rqg5Vr77L9IKeZgKU4Mbt-YLWmvxQos9prAg%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAJA2-IQoPA4HYhx5ehZh9_b91jlS-QLvYO8xOp8HXN2uAiAUYQXpYWShcC4WGSKU0_MMNwdKqeQgdYPtqIXTVTKZuA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 156.229, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "270", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/270/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D80166145%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1694045208995966/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgbzf0MRvdmSh-0Na1BR8xckB4DoMcL2nNJl3vDRew0AICIANhvJC9Q1hAqDjjLubtM7DoNaY-PtJpVlbfaL81F3l8/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAOya7lZyTdnNmoyCacVPgOcsyLDSKevmW3xFt_afVsWfAiB-hASkkk9GrfTuT-6adP2aXYrMXkiB-Y8nuX-wrWUmSg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 4901.412, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "602", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/602/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D643306%3Bdur%3D212.080%3Bgir%3Dyes%3Bitag%3D598%3Blmt%3D1694042224218554/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAJ5BHwzV7tAkb9pRDEsdmzFTJOrsuq-IZSmBaa8ZgWU4AiBoqSh-knrE3feDNHwFm_0fAM_qNFn3xvV98kmX_-pYPA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIQe5bcuBu9He-VtMYGRHkjZuDoUvmnuIbyjxf6sncbKAiEA9iegdULdUppfIh2N3Lz4Kt0PwtdV-c5G1gRDaO-U7t0%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 80.559, + "ext": "mp4", + "fps": 13.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.10.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D2404581%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1694043382822868/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgW4kL5-Bqq0OV_FyB5Df0QcqkyUTYid2eN4BUzn8sp98CIFqLxBBSz7H3PaXJ4NycNae2P0--5ri0HHMItBr8PKIP/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAJ6-KuK2swgLKLyCSnGkgsoVy2VR9SuNpx6Crrz9mc9GAiEAqbUS5dWqCZkA7oSKAONrBYKbsjgiXwT1EV6Uxj8ToOU%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 153.593, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D4021173%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1694043379783601/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAIW_PHJodC3iY33S6s7ju5X9_6oByqQFda5vPWR8jwrgAiB_csQrznhta4iTLmj6Xzybwgfe5CRA6TFV1KbQ21QJZw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgHGms27SWPkQSOt2slvmBWboDwV_BrqW_RoRlpdqD5rACIQCVpBzzlQxE44nHEJ4hoYD2QUvIm732saxlZ2fLjfljJQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 287.523, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D6902164%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1694043349554753/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgJd3aB7R2t5a0XNGTqDIuYSimhFpK2hEvDD1-itRftKkCIQCqe4F0OhI5PSp0tSYEXlngrmJgfTGIuVZUMH8saPZlnQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgQveRyDrh7DOwnJgI7dzbB3XLvqrPvKwutQI7ZjCtIs0CIHRxPzpMlfC9QmQMTu2SIGs7QP8bP1Nn65JxYGRecFCt/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 566.25, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "606", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/606/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D10927666%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D244%3Blmt%3D1694043369037289/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAIIZhHj_PxMIRxj2AvOoouUWwZPnPs3-autC7-_Qu1dnAiAtQJp9ZV1TVQXd02g1viHWghB6tKSD5_jcRHzLPHIAeA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgXJh3M-PAlfv0g5H_brRmCBl1Z0w0b5y9mqIdEsZSp-0CIQD4j4piciikRuQI3KX-HFizmq-dPxMc-aqVBFYw43-NRA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 733.359, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "609", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/609/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D15359727%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D247%3Blmt%3D1694043486219683/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhANJRh709Wuh23a9Wj2UUKFE9qjHRMscBHd3fQjuNjK5zAiB4gh40D5HmwOx3JuqptUi44o5EtkdzK0IQEunFmwOPiA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgRcLcLuy1kw90oGfrplalJdXQ4t9tjEQNH-bp7lGNsCICIQDLlCQYGjyHjnkZONzlaYidWV7-_stKKzzkhz3xEsOP4w%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 1179.472, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "614", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/614/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D40874930%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D248%3Blmt%3D1694044655610179/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIga8rGnkxIK7d-77rdKN1yHtRP9NyUJGXfRyVba5rKVRoCIQD7mJ1LOowgdfuJQuXTvarIbd54VwB6hM5O05zpPdFJDQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgGwgwIro32WyMRnb_Ccp6z_iH1YZLIwF2D8nnhQOoyJwCIGvOkZvz50XkJPrLReF_rHyHcsgE9PM_hcpudysB6YN9/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 2831.123, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAPTG-DKQd4Rtv1dvvExqPNGfPU_wBRbsGSIYRqJ3UCDEAiBBYBgPR_gAJAiCr2eHvR3hu6uWUEUCvEN5pr5Dm2_5gA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIzhyfPERJMLLzgSld4XG3lYTJKhsmpOrVD2v_siZfEgCIQCPOKf2Or4aqJhe--d_2Qh_ljI39BS6JH7x6BPXC7f_NA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": 99, + "format_note": "Premium" + }, + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "width": 160, + "height": 90, + "fps": 0.5094339622641509, + "rows": 5, + "columns": 5, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M2.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M3.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M4.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 15.703703703703724 + } + ] + }, + { + "format_id": "sb1", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "width": 80, + "height": 45, + "fps": 0.5094339622641509, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 196.29629629629628 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 15.703703703703724 + } + ] + }, + { + "format_id": "sb2", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "width": 48, + "height": 27, + "fps": 0.4716981132075472, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "duration": 212.0 + } + ] + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sddefault.jpg", + "height": 480, + "width": 640, + "preference": -5 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/default.webp", + "height": 90, + "width": 120, + "preference": -12 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDd2KtelLHaNSXrI9_5K-NvTscKNw", + "height": 94, + "width": 168, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBUpEOOWUXWkNyijQuZ4UPzp2BE-w", + "height": 110, + "width": 196, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBCyhr8AqpJ1SxKVU6SyK5ODJ_IpA", + "height": 138, + "width": 246, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_p0PncTtkrhaNDZtntrE3gKkoYw", + "height": 188, + "width": 336, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq720.webp", + "preference": -2 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq720.jpg", + "preference": -3 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/0.webp", + "preference": -8 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/0.jpg", + "preference": -9 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg", + "preference": -13 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd1.webp", + "preference": -14 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd1.jpg", + "preference": -15 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd2.webp", + "preference": -16 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd2.jpg", + "preference": -17 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd3.webp", + "preference": -18 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd3.jpg", + "preference": -19 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq1.webp", + "preference": -20 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq1.jpg", + "preference": -21 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq2.webp", + "preference": -22 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq2.jpg", + "preference": -23 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq3.webp", + "preference": -24 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq3.jpg", + "preference": -25 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq1.webp", + "preference": -26 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq1.jpg", + "preference": -27 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq2.webp", + "preference": -28 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq2.jpg", + "preference": -29 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq3.webp", + "preference": -30 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq3.jpg", + "preference": -31 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/1.webp", + "preference": -32 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/1.jpg", + "preference": -33 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/2.webp", + "preference": -34 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/2.jpg", + "preference": -35 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/3.webp", + "preference": -36 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/3.jpg", + "preference": -37 + } + ], + "thumbnail": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "description": "The official video for \u201cNever Gonna Give You Up\u201d by Rick Astley\n\n\u2018Hold Me In Your Arms\u2019 \u2013 deluxe blue vinyl, 2CD and digital deluxe out 12th May 2023 Pre-order here \u2013 https://rick-astley.lnk.to/HMIYA2023ID\n\n\u201cNever Gonna Give You Up\u201d was a global smash on its release in July 1987, topping the charts in 25 countries including Rick\u2019s native UK and the US Billboard Hot 100. It also won the Brit Award for Best single in 1988. Stock Aitken and Waterman wrote and produced the track which was the lead-off single and lead track from Rick\u2019s debut LP \u201cWhenever You Need Somebody\u201d. The album was itself a UK number one and would go on to sell over 15 million copies worldwide.\n\nThe legendary video was directed by Simon West \u2013 who later went on to make Hollywood blockbusters such as Con Air, Lara Croft \u2013 Tomb Raider and The Expendables 2. The video passed the 1bn YouTube views milestone on 28 July 2021.\n\nSubscribe to the official Rick Astley YouTube channel: https://RickAstley.lnk.to/YTSubID\n\nFollow Rick Astley:\nFacebook: https://RickAstley.lnk.to/FBFollowID \nTwitter: https://RickAstley.lnk.to/TwitterID \nInstagram: https://RickAstley.lnk.to/InstagramID \nWebsite: https://RickAstley.lnk.to/storeID \nTikTok: https://RickAstley.lnk.to/TikTokID\n\nListen to Rick Astley:\nSpotify: https://RickAstley.lnk.to/SpotifyID \nApple Music: https://RickAstley.lnk.to/AppleMusicID \nAmazon Music: https://RickAstley.lnk.to/AmazonMusicID \nDeezer: https://RickAstley.lnk.to/DeezerID \n\nLyrics:\nWe\u2019re no strangers to love\nYou know the rules and so do I\nA full commitment\u2019s what I\u2019m thinking of\nYou wouldn\u2019t get this from any other guy\n\nI just wanna tell you how I\u2019m feeling\nGotta make you understand\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\nWe\u2019ve known each other for so long\nYour heart\u2019s been aching but you\u2019re too shy to say it\nInside we both know what\u2019s been going on\nWe know the game and we\u2019re gonna play it\n\nAnd if you ask me how I\u2019m feeling\nDon\u2019t tell me you\u2019re too blind to see\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\n#RickAstley #NeverGonnaGiveYouUp #WheneverYouNeedSomebody #OfficialMusicVideo", + "channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw", + "channel_url": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw", + "duration": 212, + "view_count": 1450567564, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "categories": ["Music"], + "tags": [ + "rick astley", + "Never Gonna Give You Up", + "nggyu", + "never gonna give you up lyrics", + "rick rolled", + "Rick Roll", + "rick astley official", + "rickrolled", + "Fortnite song", + "Fortnite event", + "Fortnite dance", + "fortnite never gonna give you up", + "rick roll", + "rickrolling", + "rick rolling", + "never gonna give you up", + "80s music", + "rick astley new", + "animated video", + "rickroll", + "meme songs", + "never gonna give u up lyrics", + "Rick Astley 2022", + "never gonna let you down", + "animated", + "rick rolls 2022", + "never gonna give you up karaoke" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 2300000, + "chapters": null, + "heatmap": [], + "like_count": 16869622, + "channel": "Rick Astley", + "channel_follower_count": 3890000, + "channel_is_verified": true, + "uploader": "Rick Astley", + "uploader_id": "@RickAstleyYT", + "uploader_url": "https://www.youtube.com/@RickAstleyYT", + "upload_date": "20091025", + "availability": "public", + "__post_extractor": null, + "original_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube" +} diff --git a/tests/components/media_extractor/fixtures/youtube_empty_playlist.json b/tests/components/media_extractor/fixtures/youtube_empty_playlist.json new file mode 100644 index 00000000000..37f22693528 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_empty_playlist.json @@ -0,0 +1,49 @@ +{ + "id": "PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "title": "Very important videos", + "availability": "public", + "channel_follower_count": null, + "description": "Not original", + "tags": [], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCKgBEF5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLDBCH5IQ0obogxXhAzIH8pE0d7r1Q", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCMQBEG5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLAybhgn-CoPMjBE-0VfBDqvy0jyOQ", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCPYBEIoBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLDutIdjr5zTE9G78eWf83-mGXYnUA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCNACELwBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLD2884fHuvAv8ysHA48LD3uArB6bA", + "height": 188, + "width": 336 + } + ], + "modified_date": "20230813", + "view_count": 5770834, + "playlist_count": 3, + "channel": "ZulTarx", + "channel_id": "UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_id": "@Armand314", + "uploader": "ZulTarx", + "channel_url": "https://www.youtube.com/channel/UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_url": "https://www.youtube.com/@Armand314", + "_type": "playlist", + "entries": [], + "extractor_key": "YoutubeTab", + "extractor": "youtube:tab", + "webpage_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "original_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "webpage_url_basename": "playlist", + "webpage_url_domain": "youtube.com", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/youtube_playlist.json b/tests/components/media_extractor/fixtures/youtube_playlist.json new file mode 100644 index 00000000000..053b243a1be --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_playlist.json @@ -0,0 +1,179 @@ +{ + "id": "PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "title": "Very important videos", + "availability": "public", + "channel_follower_count": null, + "description": "Not original", + "tags": [], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCKgBEF5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLDBCH5IQ0obogxXhAzIH8pE0d7r1Q", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCMQBEG5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLAybhgn-CoPMjBE-0VfBDqvy0jyOQ", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCPYBEIoBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLDutIdjr5zTE9G78eWf83-mGXYnUA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCNACELwBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLD2884fHuvAv8ysHA48LD3uArB6bA", + "height": 188, + "width": 336 + } + ], + "modified_date": "20230813", + "view_count": 5770834, + "playlist_count": 3, + "channel": "ZulTarx", + "channel_id": "UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_id": "@Armand314", + "uploader": "ZulTarx", + "channel_url": "https://www.youtube.com/channel/UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_url": "https://www.youtube.com/@Armand314", + "_type": "playlist", + "entries": [ + { + "_type": "url", + "ie_key": "Youtube", + "id": "q6EoRBvdVPQ", + "url": "https://www.youtube.com/watch?v=q6EoRBvdVPQ", + "title": "Yee", + "description": null, + "duration": 10, + "channel_id": "UC-fD_qwTEQQ1L-MUWx_mNvg", + "channel": "revergo", + "channel_url": "https://www.youtube.com/channel/UC-fD_qwTEQQ1L-MUWx_mNvg", + "uploader": "revergo", + "uploader_id": "@revergo", + "uploader_url": "https://www.youtube.com/@revergo", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAJYg16HMBdEsv9lYBJyNqA5G3anQ", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAgCNP9UuQas-D59hHHM-RqkUvA6g", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCTWaY5897XxhcpRyVtGQQNuMHfTg", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCeS6NC75yTYvyP4DsehZ3oXNuxMQ", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 96000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "8YWl7tDGUPA", + "url": "https://www.youtube.com/watch?v=8YWl7tDGUPA", + "title": "color red", + "description": null, + "duration": 17, + "channel_id": "UCbYMTn6xKV0IKshL4pRCV3g", + "channel": "Alex Jimenez", + "channel_url": "https://www.youtube.com/channel/UCbYMTn6xKV0IKshL4pRCV3g", + "uploader": "Alex Jimenez", + "uploader_id": "@alexjimenez1237", + "uploader_url": "https://www.youtube.com/@alexjimenez1237", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AG2BIACwAKKAgwIABABGGUgXShUMA8=&rs=AOn4CLBqzngIx-4i_HFvqloetUfeN8yrYw", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AG2BIACwAKKAgwIABABGGUgXShUMA8=&rs=AOn4CLB7mWPQmdL2QBLxTHhrgbFj2jFaCg", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgBtgSAAsACigIMCAAQARhlIF0oVDAP&rs=AOn4CLA9YAIO3g_DnClsuc5LjMQn4O9ZQQ", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgBtgSAAsACigIMCAAQARhlIF0oVDAP&rs=AOn4CLDPHY6aG08hlTJMlc-LJt9ywtpWEg", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 30000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "6bnanI9jXps", + "url": "https://www.youtube.com/watch?v=6bnanI9jXps", + "title": "Terrible Mall Commercial", + "description": null, + "duration": 31, + "channel_id": "UCLmnB20wsih9F5N0o5K0tig", + "channel": "quantim", + "channel_url": "https://www.youtube.com/channel/UCLmnB20wsih9F5N0o5K0tig", + "uploader": "quantim", + "uploader_id": "@Potatoflesh", + "uploader_url": "https://www.youtube.com/@Potatoflesh", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAsyI0ZJA9STG8vlSdRkKk55ls5Dg", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD2bZ9S8AB4UGsZlx_8TjBoL72enA", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCKNlgvl_7lKoFq8vyDYZRtTs4woA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBeZv8F8IyICmKD9qjo9pTMJmM8ug", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 26000000, + "live_status": null, + "channel_is_verified": null + } + ], + "extractor_key": "YoutubeTab", + "extractor": "youtube:tab", + "webpage_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "original_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "webpage_url_basename": "playlist", + "webpage_url_domain": "youtube.com", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/snapshots/test_init.ambr b/tests/components/media_extractor/snapshots/test_init.ambr index d70c370b60c..ed56f40af73 100644 --- a/tests/components/media_extractor/snapshots/test_init.ambr +++ b/tests/components/media_extractor/snapshots/test_init.ambr @@ -1,4 +1,24 @@ # serializer version: 1 +# name: test_extract_media_service[https://soundcloud.com/bruttoband/brutto-11] + dict({ + 'url': 'https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTU1MDM5NjR9fX1dfQ__&Signature=HqyeKoK4gx1Bd3jId5v1-9ltDY2SN7fzGp6M7tW3kWluv8Ns0SgANPKG3~Tzo8hUrQzAYVvwMbO2F75b6NBeBr4xx0SRCeKotZdArzBFT4Wtrz4HtEddLyPjp12vWYCDuOshd1sTjrvaBAd9TAFTZuwyal1OKpLMsqK0QN-KFH-5GsmLDZTPWcoVkMqC7XBmNWTq0G1mtVeP57TN~9T7qEYqRLFIDXdm2HKSPSR4BB2gjaZUK22pBUPSGVr-ziBSpNVHvNISy2QdObjS5zjuAe8bl-npQ3PlFatYECDI3Gc~wjCNIJPdTpCbHUsX36SSR4dnKlgW1nYGx~eED7dppA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + }) +# --- +# name: test_extract_media_service[https://test.com/abc] + dict({ + 'url': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + }) +# --- +# name: test_extract_media_service[https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP] + dict({ + 'url': 'https://www.youtube.com/watch?v=q6EoRBvdVPQ', + }) +# --- +# name: test_extract_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ] + dict({ + 'url': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1695502401&fvip=3&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgHVwajP0J2fiJ1ERoAonpxghXGzDmEXh3rvJ399UEMWECIFdBjiVUOk7QdiFBxQ4QqojJd8p_PfL25TV_8TBrp_Kb&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D', + }) +# --- # name: test_no_target_entity ReadOnlyDict({ 'device_id': list([ diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py index e47f0ae1470..388ea3be1fd 100644 --- a/tests/components/media_extractor/test_init.py +++ b/tests/components/media_extractor/test_init.py @@ -9,9 +9,14 @@ import pytest from syrupy import SnapshotAssertion from yt_dlp import DownloadError -from homeassistant.components.media_extractor import DOMAIN +from homeassistant.components.media_extractor.const import ( + ATTR_URL, + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, +) from homeassistant.components.media_player import SERVICE_PLAY_MEDIA from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import load_json_object_fixture @@ -30,6 +35,58 @@ async def test_play_media_service_is_registered(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, SERVICE_PLAY_MEDIA) + assert hass.services.has_service(DOMAIN, SERVICE_EXTRACT_MEDIA_URL) + + +@pytest.mark.parametrize( + "url", + [ + YOUTUBE_VIDEO, + SOUNDCLOUD_TRACK, + NO_FORMATS_RESPONSE, + YOUTUBE_PLAYLIST, + ], +) +async def test_extract_media_service( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + snapshot: SnapshotAssertion, + empty_media_extractor_config: dict[str, Any], + url: str, +) -> None: + """Test play media service is registered.""" + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + assert ( + await hass.services.async_call( + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, + {ATTR_URL: url}, + blocking=True, + return_response=True, + ) + == snapshot + ) + + +async def test_extracting_playlist_no_entries( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], +) -> None: + """Test extracting a playlist without entries.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, + {ATTR_URL: YOUTUBE_EMPTY_PLAYLIST}, + blocking=True, + return_response=True, + ) @pytest.mark.parametrize( From 18ac9a7ba5d26a49785066e8fd581659a045208c Mon Sep 17 00:00:00 2001 From: myMartek Date: Tue, 16 Apr 2024 16:16:32 +0200 Subject: [PATCH 032/107] Add select hold to AppleTVs remote entity as possible command (#105764) * Fixed home hold and added select hold * Fixed home hold and added select hold * Removed select_hold for now * Fixed wrong import block sorting * Fixed unit tests for AppleTV * Added select hold command to AppleTV integration * Removed home_hold and added hold_secs option for remote commands * Added DEFAULT_HOLD_SECS --------- Co-authored-by: Erik Montnemery --- homeassistant/components/apple_tv/remote.py | 13 +++++++-- tests/components/apple_tv/test_remote.py | 32 ++++++++++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 822a9c3306a..aed2c0ae3f0 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -5,10 +5,14 @@ from collections.abc import Iterable import logging from typing import Any +from pyatv.const import InputAction + from homeassistant.components.remote import ( ATTR_DELAY_SECS, + ATTR_HOLD_SECS, ATTR_NUM_REPEATS, DEFAULT_DELAY_SECS, + DEFAULT_HOLD_SECS, RemoteEntity, ) from homeassistant.config_entries import ConfigEntry @@ -29,7 +33,6 @@ COMMAND_TO_ATTRIBUTE = { "turn_off": ("power", "turn_off"), "volume_up": ("audio", "volume_up"), "volume_down": ("audio", "volume_down"), - "home_hold": ("remote_control", "home"), } @@ -66,6 +69,7 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): """Send a command to one device.""" num_repeats = kwargs[ATTR_NUM_REPEATS] delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS) if not self.atv: _LOGGER.error("Unable to send commands, not connected to %s", self.name) @@ -84,5 +88,10 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): raise ValueError("Command not found. Exiting sequence") _LOGGER.info("Sending command %s", single_command) - await attr_value() + + if hold_secs >= 1: + await attr_value(action=InputAction.Hold) + else: + await attr_value() + await asyncio.sleep(delay) diff --git a/tests/components/apple_tv/test_remote.py b/tests/components/apple_tv/test_remote.py index f831518d75a..bc8a0e6a2dd 100644 --- a/tests/components/apple_tv/test_remote.py +++ b/tests/components/apple_tv/test_remote.py @@ -5,25 +5,37 @@ from unittest.mock import AsyncMock import pytest from homeassistant.components.apple_tv.remote import AppleTVRemote -from homeassistant.components.remote import ATTR_DELAY_SECS, ATTR_NUM_REPEATS +from homeassistant.components.remote import ( + ATTR_DELAY_SECS, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, +) @pytest.mark.parametrize( - ("command", "method"), + ("command", "method", "hold_secs"), [ - ("up", "remote_control.up"), - ("wakeup", "power.turn_on"), - ("volume_up", "audio.volume_up"), - ("home_hold", "remote_control.home"), + ("up", "remote_control.up", 0.0), + ("wakeup", "power.turn_on", 0.0), + ("volume_up", "audio.volume_up", 0.0), + ("home", "remote_control.home", 1.0), + ("select", "remote_control.select", 1.0), ], - ids=["up", "wakeup", "volume_up", "home_hold"], + ids=["up", "wakeup", "volume_up", "home", "select"], ) -async def test_send_command(command: str, method: str) -> None: +async def test_send_command(command: str, method: str, hold_secs: float) -> None: """Test "send_command" method.""" remote = AppleTVRemote("test", "test", None) remote.atv = AsyncMock() await remote.async_send_command( - [command], **{ATTR_NUM_REPEATS: 1, ATTR_DELAY_SECS: 0} + [command], + **{ATTR_NUM_REPEATS: 1, ATTR_DELAY_SECS: 0, ATTR_HOLD_SECS: hold_secs}, ) assert len(remote.atv.method_calls) == 1 - assert str(remote.atv.method_calls[0]) == f"call.{method}()" + if hold_secs >= 1: + assert ( + str(remote.atv.method_calls[0]) + == f"call.{method}(action=)" + ) + else: + assert str(remote.atv.method_calls[0]) == f"call.{method}()" From 63c9aef71dd110e3588701314725372087147282 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 16 Apr 2024 16:28:25 +0200 Subject: [PATCH 033/107] Correct spelling of "Wi-Fi" in devolo_home_network (#106167) * Use Wi-Fi in devolo_home_network * Recreate snapshots --------- Co-authored-by: Erik Montnemery --- .../devolo_home_network/strings.json | 8 +- .../snapshots/test_button.ambr | 172 ------------------ .../snapshots/test_image.ambr | 4 +- .../snapshots/test_sensor.ambr | 24 +-- .../snapshots/test_switch.ambr | 94 +--------- .../devolo_home_network/test_image.py | 6 +- .../devolo_home_network/test_sensor.py | 10 +- .../devolo_home_network/test_switch.py | 8 +- 8 files changed, 35 insertions(+), 291 deletions(-) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 9d86b127d77..97348c5c43c 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -50,7 +50,7 @@ }, "image": { "image_guest_wifi": { - "name": "Guest Wifi credentials as QR code" + "name": "Guest Wi-Fi credentials as QR code" } }, "sensor": { @@ -58,10 +58,10 @@ "name": "Connected PLC devices" }, "connected_wifi_clients": { - "name": "Connected Wifi clients" + "name": "Connected Wi-Fi clients" }, "neighboring_wifi_networks": { - "name": "Neighboring Wifi networks" + "name": "Neighboring Wi-Fi networks" }, "plc_rx_rate": { "name": "PLC downlink PHY rate" @@ -72,7 +72,7 @@ }, "switch": { "switch_guest_wifi": { - "name": "Enable guest Wifi" + "name": "Enable guest Wi-Fi" }, "switch_leds": { "name": "Enable LEDs" diff --git a/tests/components/devolo_home_network/snapshots/test_button.ambr b/tests/components/devolo_home_network/snapshots/test_button.ambr index 3e8e4ae2bb3..126ac4e7cdb 100644 --- a/tests/components/devolo_home_network/snapshots/test_button.ambr +++ b/tests/components/devolo_home_network/snapshots/test_button.ambr @@ -1,47 +1,4 @@ # serializer version: 1 -# name: test_button[identify_device_with_a_blinking_led-async_identify_device_start] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Identify device with a blinking LED', - 'icon': 'mdi:led-on', - }), - 'context': , - 'entity_id': 'button.mock_title_identify_device_with_a_blinking_led', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_button[identify_device_with_a_blinking_led-async_identify_device_start].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_title_identify_device_with_a_blinking_led', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:led-on', - 'original_name': 'Identify device with a blinking LED', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': 'identify', - 'unique_id': '1234567890_identify', - 'unit_of_measurement': None, - }) -# --- # name: test_button[identify_device_with_a_blinking_led-plcnet-async_identify_device_start] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -89,49 +46,6 @@ 'unit_of_measurement': None, }) # --- -# name: test_button[restart_device-async_restart] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'restart', - 'friendly_name': 'Mock Title Restart device', - }), - 'context': , - 'entity_id': 'button.mock_title_restart_device', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_button[restart_device-async_restart].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_title_restart_device', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Restart device', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': 'restart', - 'unique_id': '1234567890_restart', - 'unit_of_measurement': None, - }) -# --- # name: test_button[restart_device-device-async_restart] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -179,49 +93,6 @@ 'unit_of_measurement': None, }) # --- -# name: test_button[start_plc_pairing-async_pair_device] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Start PLC pairing', - 'icon': 'mdi:plus-network-outline', - }), - 'context': , - 'entity_id': 'button.mock_title_start_plc_pairing', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_button[start_plc_pairing-async_pair_device].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.mock_title_start_plc_pairing', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:plus-network-outline', - 'original_name': 'Start PLC pairing', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': 'pairing', - 'unique_id': '1234567890_pairing', - 'unit_of_measurement': None, - }) -# --- # name: test_button[start_plc_pairing-plcnet-async_pair_device] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -268,49 +139,6 @@ 'unit_of_measurement': None, }) # --- -# name: test_button[start_wps-async_start_wps] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Start WPS', - 'icon': 'mdi:wifi-plus', - }), - 'context': , - 'entity_id': 'button.mock_title_start_wps', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_button[start_wps-async_start_wps].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.mock_title_start_wps', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi-plus', - 'original_name': 'Start WPS', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': 'start_wps', - 'unique_id': '1234567890_start_wps', - 'unit_of_measurement': None, - }) -# --- # name: test_button[start_wps-device-async_start_wps] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/devolo_home_network/snapshots/test_image.ambr b/tests/components/devolo_home_network/snapshots/test_image.ambr index ad8ccf43c55..b3924a508cf 100644 --- a/tests/components/devolo_home_network/snapshots/test_image.ambr +++ b/tests/components/devolo_home_network/snapshots/test_image.ambr @@ -11,7 +11,7 @@ 'disabled_by': None, 'domain': 'image', 'entity_category': , - 'entity_id': 'image.mock_title_guest_wifi_credentials_as_qr_code', + 'entity_id': 'image.mock_title_guest_wi_fi_credentials_as_qr_code', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,7 +23,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Guest Wifi credentials as QR code', + 'original_name': 'Guest Wi-Fi credentials as QR code', 'platform': 'devolo_home_network', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index fc173da8294..d985ac35495 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -45,21 +45,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[connected_wifi_clients-async_get_wifi_connected_station-interval0] +# name: test_sensor[connected_wi_fi_clients-async_get_wifi_connected_station-interval0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Connected Wifi clients', + 'friendly_name': 'Mock Title Connected Wi-Fi clients', 'state_class': , }), 'context': , - 'entity_id': 'sensor.mock_title_connected_wifi_clients', + 'entity_id': 'sensor.mock_title_connected_wi_fi_clients', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_sensor[connected_wifi_clients-async_get_wifi_connected_station-interval0].1 +# name: test_sensor[connected_wi_fi_clients-async_get_wifi_connected_station-interval0].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -73,7 +73,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.mock_title_connected_wifi_clients', + 'entity_id': 'sensor.mock_title_connected_wi_fi_clients', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -85,7 +85,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Connected Wifi clients', + 'original_name': 'Connected Wi-Fi clients', 'platform': 'devolo_home_network', 'previous_unique_id': None, 'supported_features': 0, @@ -94,20 +94,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[neighboring_wifi_networks-async_get_wifi_neighbor_access_points-interval1] +# name: test_sensor[neighboring_wi_fi_networks-async_get_wifi_neighbor_access_points-interval1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Neighboring Wifi networks', + 'friendly_name': 'Mock Title Neighboring Wi-Fi networks', }), 'context': , - 'entity_id': 'sensor.mock_title_neighboring_wifi_networks', + 'entity_id': 'sensor.mock_title_neighboring_wi_fi_networks', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_sensor[neighboring_wifi_networks-async_get_wifi_neighbor_access_points-interval1].1 +# name: test_sensor[neighboring_wi_fi_networks-async_get_wifi_neighbor_access_points-interval1].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,7 +119,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.mock_title_neighboring_wifi_networks', + 'entity_id': 'sensor.mock_title_neighboring_wi_fi_networks', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -131,7 +131,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Neighboring Wifi networks', + 'original_name': 'Neighboring Wi-Fi networks', 'platform': 'devolo_home_network', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/devolo_home_network/snapshots/test_switch.ambr b/tests/components/devolo_home_network/snapshots/test_switch.ambr index 09b56efc784..a2df5d2579f 100644 --- a/tests/components/devolo_home_network/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_network/snapshots/test_switch.ambr @@ -1,97 +1,11 @@ # serializer version: 1 -# name: test_switches[enable_guest_wifi-async_get_wifi_guest_access-async_set_wifi_guest_access-interval0] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Enable guest Wifi', - 'icon': 'mdi:wifi', - }), - 'context': , - 'entity_id': 'switch.mock_title_enable_guest_wifi', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_switches[enable_guest_wifi-async_get_wifi_guest_access-async_set_wifi_guest_access-interval0].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.mock_title_enable_guest_wifi', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi', - 'original_name': 'Enable guest Wifi', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234567890_switch_guest_wifi', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[enable_leds-async_get_led_setting-async_set_led_setting-interval1] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Enable LEDs', - 'icon': 'mdi:led-off', - }), - 'context': , - 'entity_id': 'switch.mock_title_enable_leds', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_switches[enable_leds-async_get_led_setting-async_set_led_setting-interval1].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.mock_title_enable_leds', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:led-off', - 'original_name': 'Enable LEDs', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234567890_switch_leds', - 'unit_of_measurement': None, - }) -# --- # name: test_update_enable_guest_wifi StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Enable guest Wifi', + 'friendly_name': 'Mock Title Enable guest Wi-Fi', }), 'context': , - 'entity_id': 'switch.mock_title_enable_guest_wifi', + 'entity_id': 'switch.mock_title_enable_guest_wi_fi', 'last_changed': , 'last_reported': , 'last_updated': , @@ -110,7 +24,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.mock_title_enable_guest_wifi', + 'entity_id': 'switch.mock_title_enable_guest_wi_fi', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -122,7 +36,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Enable guest Wifi', + 'original_name': 'Enable guest Wi-Fi', 'platform': 'devolo_home_network', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index 0ca3936e1ac..80efc4fcc09 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -32,7 +32,7 @@ async def test_image_setup(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert ( - hass.states.get(f"{DOMAIN}.{device_name}_guest_wifi_credentials_as_qr_code") + hass.states.get(f"{DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code") is not None ) @@ -51,13 +51,13 @@ async def test_guest_wifi_qr( """Test showing a QR code of the guest wifi credentials.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{DOMAIN}.{device_name}_guest_wifi_credentials_as_qr_code" + state_key = f"{DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() state = hass.states.get(state_key) - assert state.name == "Mock Title Guest Wifi credentials as QR code" + assert state.name == "Mock Title Guest Wi-Fi credentials as QR code" assert state.state == dt_util.utcnow().isoformat() assert entity_registry.async_get(state_key) == snapshot diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 5b5e05a40d1..efcbaa803df 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -32,9 +32,11 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.{device_name}_connected_wifi_clients") is not None + assert ( + hass.states.get(f"{DOMAIN}.{device_name}_connected_wi_fi_clients") is not None + ) assert hass.states.get(f"{DOMAIN}.{device_name}_connected_plc_devices") is None - assert hass.states.get(f"{DOMAIN}.{device_name}_neighboring_wifi_networks") is None + assert hass.states.get(f"{DOMAIN}.{device_name}_neighboring_wi_fi_networks") is None assert ( hass.states.get( f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" @@ -67,12 +69,12 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: ("name", "get_method", "interval"), [ ( - "connected_wifi_clients", + "connected_wi_fi_clients", "async_get_wifi_connected_station", SHORT_UPDATE_INTERVAL, ), ( - "neighboring_wifi_networks", + "neighboring_wi_fi_networks", "async_get_wifi_neighbor_access_points", LONG_UPDATE_INTERVAL, ), diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 0fe5bea5c52..b96697dc9cc 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -41,7 +41,7 @@ async def test_switch_setup(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(f"{PLATFORM}.{device_name}_enable_guest_wifi") is not None + assert hass.states.get(f"{PLATFORM}.{device_name}_enable_guest_wi_fi") is not None assert hass.states.get(f"{PLATFORM}.{device_name}_enable_leds") is not None await hass.config_entries.async_unload(entry.entry_id) @@ -82,7 +82,7 @@ async def test_update_enable_guest_wifi( """Test state change of a enable_guest_wifi switch device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_enable_guest_wifi" + state_key = f"{PLATFORM}.{device_name}_enable_guest_wi_fi" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -247,7 +247,7 @@ async def test_update_enable_leds( @pytest.mark.parametrize( ("name", "get_method", "update_interval"), [ - ("enable_guest_wifi", "async_get_wifi_guest_access", SHORT_UPDATE_INTERVAL), + ("enable_guest_wi_fi", "async_get_wifi_guest_access", SHORT_UPDATE_INTERVAL), ("enable_leds", "async_get_led_setting", SHORT_UPDATE_INTERVAL), ], ) @@ -284,7 +284,7 @@ async def test_device_failure( @pytest.mark.parametrize( ("name", "set_method"), [ - ("enable_guest_wifi", "async_set_wifi_guest_access"), + ("enable_guest_wi_fi", "async_set_wifi_guest_access"), ("enable_leds", "async_set_led_setting"), ], ) From 135fe26704c2fce94a85d3266919bd762e394028 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Apr 2024 10:13:47 -0500 Subject: [PATCH 034/107] Bump httpcore to 1.0.5 (#115672) Fixes missing handling of EndOfStream errors --- 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 f3ee84392a7..b6f814c9f58 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -109,7 +109,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==4.3.0 h11==0.14.0 -httpcore==1.0.4 +httpcore==1.0.5 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3f96e41a8ef..94147e3932b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -99,7 +99,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==4.3.0 h11==0.14.0 -httpcore==1.0.4 +httpcore==1.0.5 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation From 586d27320ed9e1506c8cba164f5e70a383d4ef86 Mon Sep 17 00:00:00 2001 From: BestPig Date: Tue, 16 Apr 2024 17:45:48 +0200 Subject: [PATCH 035/107] Add Sound Mode selection in soundpal components (#106589) --- .../components/songpal/media_player.py | 70 +++++++++++++++++++ tests/components/songpal/__init__.py | 18 +++++ tests/components/songpal/test_media_player.py | 22 +++++- 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 33dc65d5eaa..d3ce934ec51 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -11,9 +11,11 @@ from songpal import ( ContentChange, Device, PowerChange, + SettingChange, SongpalException, VolumeChange, ) +from songpal.containers import Setting import voluptuous as vol from homeassistant.components.media_player import ( @@ -99,6 +101,7 @@ class SongpalEntity(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) @@ -124,6 +127,8 @@ class SongpalEntity(MediaPlayerEntity): self._active_source = None self._sources = {} + self._active_sound_mode = None + self._sound_modes = {} async def async_added_to_hass(self) -> None: """Run when entity is added to hass.""" @@ -133,6 +138,28 @@ class SongpalEntity(MediaPlayerEntity): """Run when entity will be removed from hass.""" await self._dev.stop_listen_notifications() + async def _get_sound_modes_info(self): + """Get available sound modes and the active one.""" + settings = await self._dev.get_sound_settings("soundField") + if isinstance(settings, Setting): + settings = [settings] + + sound_modes = {} + active_sound_mode = None + for setting in settings: + cur = setting.currentValue + for opt in setting.candidate: + if not opt.isAvailable: + continue + if opt.value == cur: + active_sound_mode = opt.value + sound_modes[opt.value] = opt + + _LOGGER.debug("Got sound modes: %s", sound_modes) + _LOGGER.debug("Active sound mode: %s", active_sound_mode) + + return active_sound_mode, sound_modes + async def async_activate_websocket(self): """Activate websocket for listening if wanted.""" _LOGGER.info("Activating websocket connection") @@ -152,6 +179,16 @@ class SongpalEntity(MediaPlayerEntity): else: _LOGGER.debug("Got non-handled content change: %s", content) + async def _setting_changed(setting: SettingChange): + _LOGGER.debug("Setting changed: %s", setting) + + if setting.target == "soundField": + self._active_sound_mode = setting.currentValue + _LOGGER.debug("New active sound mode: %s", self._active_sound_mode) + self.async_write_ha_state() + else: + _LOGGER.debug("Got non-handled setting change: %s", setting) + async def _power_changed(power: PowerChange): _LOGGER.debug("Power changed: %s", power) self._state = power.status @@ -192,6 +229,7 @@ class SongpalEntity(MediaPlayerEntity): self._dev.on_notification(VolumeChange, _volume_changed) self._dev.on_notification(ContentChange, _source_changed) self._dev.on_notification(PowerChange, _power_changed) + self._dev.on_notification(SettingChange, _setting_changed) self._dev.on_notification(ConnectChange, _try_reconnect) async def handle_stop(event): @@ -271,6 +309,11 @@ class SongpalEntity(MediaPlayerEntity): _LOGGER.debug("Active source: %s", self._active_source) + ( + self._active_sound_mode, + self._sound_modes, + ) = await self._get_sound_modes_info() + self._attr_available = True except SongpalException as ex: @@ -291,6 +334,27 @@ class SongpalEntity(MediaPlayerEntity): """Return list of available sources.""" return [src.title for src in self._sources.values()] + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + for mode in self._sound_modes.values(): + if mode.title == sound_mode: + await self._dev.set_sound_settings("soundField", mode.value) + return + + _LOGGER.error("Unable to find sound mode: %s", sound_mode) + + @property + def sound_mode_list(self) -> list[str] | None: + """Return list of available sound modes. + + When active mode is None it means that sound mode is unavailable on the sound bar. + Can be due to incompatible sound bar or the sound bar is in a mode that does not + support sound mode changes. + """ + if not self._active_sound_mode: + return None + return [sound_mode.title for sound_mode in self._sound_modes.values()] + @property def state(self) -> MediaPlayerState: """Return current state.""" @@ -304,6 +368,12 @@ class SongpalEntity(MediaPlayerEntity): # Avoid a KeyError when _active_source is not (yet) populated return getattr(self._active_source, "title", None) + @property + def sound_mode(self) -> str | None: + """Return currently active sound_mode.""" + active_sound_mode = self._sound_modes.get(self._active_sound_mode) + return active_sound_mode.title if active_sound_mode else None + @property def volume_level(self): """Return volume level.""" diff --git a/tests/components/songpal/__init__.py b/tests/components/songpal/__init__.py index 6ebc2ec5ef4..ab585c5a6d5 100644 --- a/tests/components/songpal/__init__.py +++ b/tests/components/songpal/__init__.py @@ -85,6 +85,24 @@ def _create_mocked_device(throw_exception=False, wired_mac=MAC, wireless_mac=Non input2.active = True type(mocked_device).get_inputs = AsyncMock(return_value=[input1, input2]) + sound_mode1 = MagicMock() + sound_mode1.title = "Sound Mode 1" + sound_mode1.value = "sound_mode1" + sound_mode1.isAvailable = True + sound_mode2 = MagicMock() + sound_mode2.title = "Sound Mode 2" + sound_mode2.value = "sound_mode2" + sound_mode2.isAvailable = True + sound_mode3 = MagicMock() + sound_mode3.title = "Sound Mode 3" + sound_mode3.value = "sound_mode3" + sound_mode3.isAvailable = False + + soundField = MagicMock() + soundField.currentValue = "sound_mode2" + soundField.candidate = [sound_mode1, sound_mode2, sound_mode3] + type(mocked_device).get_sound_settings = AsyncMock(return_value=[soundField]) + type(mocked_device).set_power = AsyncMock() type(mocked_device).set_sound_settings = AsyncMock() type(mocked_device).listen_notifications = AsyncMock() diff --git a/tests/components/songpal/test_media_player.py b/tests/components/songpal/test_media_player.py index 4b1abf8709e..88443bf58b9 100644 --- a/tests/components/songpal/test_media_player.py +++ b/tests/components/songpal/test_media_player.py @@ -12,6 +12,7 @@ from songpal import ( SongpalException, VolumeChange, ) +from songpal.notification import SettingChange from homeassistant.components import media_player, songpal from homeassistant.components.media_player import MediaPlayerEntityFeature @@ -47,6 +48,7 @@ SUPPORT_SONGPAL = ( | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) @@ -138,6 +140,8 @@ async def test_state(hass: HomeAssistant) -> None: assert attributes["is_volume_muted"] is False assert attributes["source_list"] == ["title1", "title2"] assert attributes["source"] == "title2" + assert attributes["sound_mode_list"] == ["Sound Mode 1", "Sound Mode 2"] + assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL device_registry = dr.async_get(hass) @@ -171,6 +175,8 @@ async def test_state_wireless(hass: HomeAssistant) -> None: assert attributes["is_volume_muted"] is False assert attributes["source_list"] == ["title1", "title2"] assert attributes["source"] == "title2" + assert attributes["sound_mode_list"] == ["Sound Mode 1", "Sound Mode 2"] + assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL device_registry = dr.async_get(hass) @@ -206,6 +212,8 @@ async def test_state_both(hass: HomeAssistant) -> None: assert attributes["is_volume_muted"] is False assert attributes["source_list"] == ["title1", "title2"] assert attributes["source"] == "title2" + assert attributes["sound_mode_list"] == ["Sound Mode 1", "Sound Mode 2"] + assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL device_registry = dr.async_get(hass) @@ -303,6 +311,9 @@ async def test_services(hass: HomeAssistant) -> None: mocked_device2.set_sound_settings.assert_called_once_with("name", "value") mocked_device3.set_sound_settings.assert_called_once_with("name", "value") + await _call(hass, media_player.SERVICE_SELECT_SOUND_MODE, sound_mode="Sound Mode 1") + mocked_device.set_sound_settings.assert_called_with("soundField", "sound_mode1") + async def test_websocket_events(hass: HomeAssistant) -> None: """Test websocket events.""" @@ -315,7 +326,7 @@ async def test_websocket_events(hass: HomeAssistant) -> None: await hass.async_block_till_done() mocked_device.listen_notifications.assert_called_once() - assert mocked_device.on_notification.call_count == 4 + assert mocked_device.on_notification.call_count == 5 notification_callbacks = mocked_device.notification_callbacks @@ -336,6 +347,15 @@ async def test_websocket_events(hass: HomeAssistant) -> None: await notification_callbacks[ContentChange](content_change) assert _get_attributes(hass)["source"] == "title1" + sound_mode_change = MagicMock() + sound_mode_change.target = "soundField" + sound_mode_change.currentValue = "sound_mode1" + await notification_callbacks[SettingChange](sound_mode_change) + assert _get_attributes(hass)["sound_mode"] == "Sound Mode 1" + sound_mode_change.currentValue = "sound_mode2" + await notification_callbacks[SettingChange](sound_mode_change) + assert _get_attributes(hass)["sound_mode"] == "Sound Mode 2" + power_change = MagicMock() power_change.status = False await notification_callbacks[PowerChange](power_change) From 249a92d3211b459b773bd63138b176bf5a84db01 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 16 Apr 2024 11:54:49 -0400 Subject: [PATCH 036/107] Unsupported if wrong image used on virtualization (#113882) * Unsupported if wrong image used on virtualization * Language tweaks Co-authored-by: Stefan Agner --------- Co-authored-by: Stefan Agner --- homeassistant/components/hassio/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 63c1da4bfd8..fe026be6633 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -187,6 +187,10 @@ "unsupported_systemd_resolved": { "title": "Unsupported system - Systemd-Resolved issues", "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + }, + "unsupported_virtualization_image": { + "title": "Unsupported system - Incorrect OS image for virtualization", + "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." } }, "entity": { From 4a89e18b7e6ff8a7e06c6b023368b684a8ce81f0 Mon Sep 17 00:00:00 2001 From: Matthew Hallonbacka <79469789+Mallonbacka@users.noreply.github.com> Date: Tue, 16 Apr 2024 22:34:09 +0300 Subject: [PATCH 037/107] Fix check for missing parts on incoming SMS (#105068) * Fix check for missing parts on incoming SMS * Add tests for get_and_delete_all_sms function * Fix CI issues * Install libgammu-dev in CI * Bust the venv cache * Include python-gammu in requirements-all.txt * Adjust install of dependencies --------- Co-authored-by: Erik --- .github/workflows/ci.yaml | 13 ++- CODEOWNERS | 1 + homeassistant/components/sms/gateway.py | 29 +++-- requirements_test_all.txt | 3 + tests/components/sms/__init__.py | 1 + tests/components/sms/const.py | 143 ++++++++++++++++++++++++ tests/components/sms/test_gateway.py | 52 +++++++++ 7 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 tests/components/sms/__init__.py create mode 100644 tests/components/sms/const.py create mode 100644 tests/components/sms/test_gateway.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d619fd8c7dc..c96c6b5e5f2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,7 +33,7 @@ on: type: boolean env: - CACHE_VERSION: 5 + CACHE_VERSION: 7 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 8 HA_SHORT_VERSION: "2024.5" @@ -484,6 +484,7 @@ jobs: libavfilter-dev \ libavformat-dev \ libavutil-dev \ + libgammu-dev \ libswresample-dev \ libswscale-dev \ libudev-dev @@ -496,6 +497,7 @@ jobs: pip install "$(grep '^uv' < requirements_test.txt)" uv pip install -U "pip>=21.3.1" setuptools wheel uv pip install -r requirements_all.txt + uv pip install "$(grep 'python-gammu' < requirements_all.txt | sed -e 's|# python-gammu|python-gammu|g')" uv pip install -r requirements_test.txt uv pip install -e . --config-settings editable_mode=compat @@ -688,7 +690,8 @@ jobs: sudo apt-get update sudo apt-get -y install \ bluez \ - ffmpeg + ffmpeg \ + libgammu-dev - name: Check out code from GitHub uses: actions/checkout@v4.1.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -747,7 +750,8 @@ jobs: sudo apt-get update sudo apt-get -y install \ bluez \ - ffmpeg + ffmpeg \ + libgammu-dev - name: Check out code from GitHub uses: actions/checkout@v4.1.2 - name: Set up Python ${{ matrix.python-version }} @@ -1124,7 +1128,8 @@ jobs: sudo apt-get update sudo apt-get -y install \ bluez \ - ffmpeg + ffmpeg \ + libgammu-dev - name: Check out code from GitHub uses: actions/checkout@v4.1.2 - name: Set up Python ${{ matrix.python-version }} diff --git a/CODEOWNERS b/CODEOWNERS index 83d5539a15c..56d42e5a3f3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1272,6 +1272,7 @@ build.json @home-assistant/supervisor /homeassistant/components/smhi/ @gjohansson-ST /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo +/tests/components/sms/ @ocalvo /homeassistant/components/snapcast/ @luar123 /tests/components/snapcast/ @luar123 /homeassistant/components/snmp/ @nmaggioni diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index e0cbf78dba4..1ed1f66570f 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -92,7 +92,6 @@ class Gateway: start = True entries = [] all_parts = -1 - all_parts_arrived = False _LOGGER.debug("Start remaining:%i", start_remaining) try: @@ -101,33 +100,31 @@ class Gateway: entry = state_machine.GetNextSMS(Folder=0, Start=True) all_parts = entry[0]["UDH"]["AllParts"] part_number = entry[0]["UDH"]["PartNumber"] - is_single_part = all_parts == 0 - is_multi_part = 0 <= all_parts < start_remaining + part_is_missing = all_parts > start_remaining _LOGGER.debug("All parts:%i", all_parts) _LOGGER.debug("Part Number:%i", part_number) _LOGGER.debug("Remaining:%i", remaining) - all_parts_arrived = is_multi_part or is_single_part - _LOGGER.debug("Start all_parts_arrived:%s", all_parts_arrived) + _LOGGER.debug("Start is_part_missing:%s", part_is_missing) start = False else: entry = state_machine.GetNextSMS( Folder=0, Location=entry[0]["Location"] ) - if all_parts_arrived or force: - remaining = remaining - 1 - entries.append(entry) - - # delete retrieved sms - _LOGGER.debug("Deleting message") - try: - state_machine.DeleteSMS(Folder=0, Location=entry[0]["Location"]) - except gammu.ERR_MEMORY_NOT_AVAILABLE: - _LOGGER.error("Error deleting SMS, memory not available") - else: + if part_is_missing and not force: _LOGGER.debug("Not all parts have arrived") break + remaining = remaining - 1 + entries.append(entry) + + # delete retrieved sms + _LOGGER.debug("Deleting message") + try: + state_machine.DeleteSMS(Folder=0, Location=entry[0]["Location"]) + except gammu.ERR_MEMORY_NOT_AVAILABLE: + _LOGGER.error("Error deleting SMS, memory not available") + except gammu.ERR_EMPTY: # error is raised if memory is empty (this induces wrong reported # memory status) diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9fd0586fa7..fd3bad4398b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1723,6 +1723,9 @@ python-ecobee-api==0.2.17 # homeassistant.components.fully_kiosk python-fullykiosk==0.0.12 +# homeassistant.components.sms +# python-gammu==3.2.4 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.6.0 diff --git a/tests/components/sms/__init__.py b/tests/components/sms/__init__.py new file mode 100644 index 00000000000..09b4b0941fb --- /dev/null +++ b/tests/components/sms/__init__.py @@ -0,0 +1 @@ +"""Tests for SMS integration.""" diff --git a/tests/components/sms/const.py b/tests/components/sms/const.py new file mode 100644 index 00000000000..ae875e6d58e --- /dev/null +++ b/tests/components/sms/const.py @@ -0,0 +1,143 @@ +"""Constants for tests of the SMS component.""" + +import datetime + +SMS_STATUS_SINGLE = { + "SIMUnRead": 0, + "SIMUsed": 1, + "SIMSize": 30, + "PhoneUnRead": 0, + "PhoneUsed": 0, + "PhoneSize": 50, + "TemplatesUsed": 0, +} + +NEXT_SMS_SINGLE = [ + { + "SMSC": { + "Location": 0, + "Name": "", + "Format": "Text", + "Validity": "NA", + "Number": "+358444111111", + "DefaultNumber": "", + }, + "UDH": { + "Type": "NoUDH", + "Text": b"", + "ID8bit": 0, + "ID16bit": 0, + "PartNumber": -1, + "AllParts": 0, + }, + "Folder": 1, + "InboxFolder": 1, + "Memory": "SM", + "Location": 1, + "Name": "", + "Number": "+358444222222", + "Text": "Short message", + "Type": "Deliver", + "Coding": "Default_No_Compression", + "DateTime": datetime.datetime(2024, 3, 23, 20, 15, 37), + "SMSCDateTime": datetime.datetime(2024, 3, 23, 20, 15, 41), + "DeliveryStatus": 0, + "ReplyViaSameSMSC": 0, + "State": "UnRead", + "Class": -1, + "MessageReference": 0, + "ReplaceMessage": 0, + "RejectDuplicates": 0, + "Length": 7, + } +] + +SMS_STATUS_MULTIPLE = { + "SIMUnRead": 0, + "SIMUsed": 2, + "SIMSize": 30, + "PhoneUnRead": 0, + "PhoneUsed": 0, + "PhoneSize": 50, + "TemplatesUsed": 0, +} + +NEXT_SMS_MULTIPLE_1 = [ + { + "SMSC": { + "Location": 0, + "Name": "", + "Format": "Text", + "Validity": "NA", + "Number": "+358444111111", + "DefaultNumber": "", + }, + "UDH": { + "Type": "ConcatenatedMessages", + "Text": b"\x05\x00\x03\x00\x02\x01", + "ID8bit": 0, + "ID16bit": -1, + "PartNumber": 1, + "AllParts": 2, + }, + "Folder": 1, + "InboxFolder": 1, + "Memory": "SM", + "Location": 1, + "Name": "", + "Number": "+358444222222", + "Text": "Longer test again: 01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123", + "Type": "Deliver", + "Coding": "Default_No_Compression", + "DateTime": datetime.datetime(2024, 3, 25, 19, 53, 56), + "SMSCDateTime": datetime.datetime(2024, 3, 25, 19, 54, 6), + "DeliveryStatus": 0, + "ReplyViaSameSMSC": 0, + "State": "UnRead", + "Class": -1, + "MessageReference": 0, + "ReplaceMessage": 0, + "RejectDuplicates": 0, + "Length": 153, + } +] + +NEXT_SMS_MULTIPLE_2 = [ + { + "SMSC": { + "Location": 0, + "Name": "", + "Format": "Text", + "Validity": "NA", + "Number": "+358444111111", + "DefaultNumber": "", + }, + "UDH": { + "Type": "ConcatenatedMessages", + "Text": b"\x05\x00\x03\x00\x02\x02", + "ID8bit": 0, + "ID16bit": -1, + "PartNumber": 2, + "AllParts": 2, + }, + "Folder": 1, + "InboxFolder": 1, + "Memory": "SM", + "Location": 2, + "Name": "", + "Number": "+358444222222", + "Text": "4567890123456789012345678901", + "Type": "Deliver", + "Coding": "Default_No_Compression", + "DateTime": datetime.datetime(2024, 3, 25, 19, 53, 56), + "SMSCDateTime": datetime.datetime(2024, 3, 25, 19, 54, 7), + "DeliveryStatus": 0, + "ReplyViaSameSMSC": 0, + "State": "UnRead", + "Class": -1, + "MessageReference": 0, + "ReplaceMessage": 0, + "RejectDuplicates": 0, + "Length": 28, + } +] diff --git a/tests/components/sms/test_gateway.py b/tests/components/sms/test_gateway.py new file mode 100644 index 00000000000..132ba9bc1f3 --- /dev/null +++ b/tests/components/sms/test_gateway.py @@ -0,0 +1,52 @@ +"""Test the SMS Gateway.""" + +from unittest.mock import MagicMock + +from homeassistant.components.sms.gateway import Gateway +from homeassistant.core import HomeAssistant + +from .const import ( + NEXT_SMS_MULTIPLE_1, + NEXT_SMS_MULTIPLE_2, + NEXT_SMS_SINGLE, + SMS_STATUS_MULTIPLE, + SMS_STATUS_SINGLE, +) + + +async def test_get_and_delete_all_sms_single_message(hass: HomeAssistant) -> None: + """Test that a single message produces a list of entries containing the single message.""" + + # Mock the Gammu state_machine + state_machine = MagicMock() + state_machine.GetSMSStatus = MagicMock(return_value=SMS_STATUS_SINGLE) + state_machine.GetNextSMS = MagicMock(return_value=NEXT_SMS_SINGLE) + state_machine.DeleteSMS = MagicMock() + + response = Gateway({"Connection": None}, hass).get_and_delete_all_sms(state_machine) + + # Assert the length of the list + assert len(response) == 1 + assert len(response[0]) == 1 + + # Assert the content of the message + assert response[0][0]["Text"] == "Short message" + + +async def test_get_and_delete_all_sms_two_part_message(hass: HomeAssistant) -> None: + """Test that a two-part message produces a list of entries containing one combined message.""" + + state_machine = MagicMock() + state_machine.GetSMSStatus = MagicMock(return_value=SMS_STATUS_MULTIPLE) + state_machine.GetNextSMS = MagicMock( + side_effect=iter([NEXT_SMS_MULTIPLE_1, NEXT_SMS_MULTIPLE_2]) + ) + state_machine.DeleteSMS = MagicMock() + + response = Gateway({"Connection": None}, hass).get_and_delete_all_sms(state_machine) + + assert len(response) == 1 + assert len(response[0]) == 2 + + assert response[0][0]["Text"] == NEXT_SMS_MULTIPLE_1[0]["Text"] + assert response[0][1]["Text"] == NEXT_SMS_MULTIPLE_2[0]["Text"] From 81036967f04b47e75cf861aa821515e2de6e812f Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 16 Apr 2024 22:35:55 +0200 Subject: [PATCH 038/107] Correct unit for total usage in rfxtrx (#115719) --- homeassistant/components/rfxtrx/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index f421b6da7ef..46a3f021122 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -149,7 +149,7 @@ SENSOR_TYPES = ( translation_key="total_energy_usage", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, ), RfxtrxSensorEntityDescription( key="Voltage", From e7076ac83f80c249309d0a5f245bcbc7a15f42c1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 17 Apr 2024 00:00:16 +0200 Subject: [PATCH 039/107] Use separate data coordinators for AccuWeather observation and forecast (#115628) * Remove forecast option * Update strings * Use separate DataUpdateCoordinator for observation and forecast * Fix tests * Remove unneeded variable * Separate data coordinator classes * Use list comprehension * Separate coordinator clasess to add type annotations * Test the availability of the forecast sensor entity * Add DataUpdateCoordinator types * Use snapshot for test_sensor() --------- Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- .../components/accuweather/__init__.py | 121 +- .../components/accuweather/config_flow.py | 24 +- homeassistant/components/accuweather/const.py | 5 +- .../components/accuweather/coordinator.py | 124 + .../components/accuweather/diagnostics.py | 8 +- .../components/accuweather/sensor.py | 192 +- .../components/accuweather/strings.json | 12 +- .../components/accuweather/weather.py | 90 +- tests/components/accuweather/__init__.py | 9 +- .../snapshots/test_diagnostics.ambr | 4 +- .../accuweather/snapshots/test_sensor.ambr | 6436 +++++++++++++++++ .../accuweather/test_config_flow.py | 53 +- .../accuweather/test_diagnostics.py | 7 - tests/components/accuweather/test_init.py | 35 +- tests/components/accuweather/test_sensor.py | 618 +- tests/components/accuweather/test_weather.py | 27 +- 16 files changed, 6913 insertions(+), 852 deletions(-) create mode 100644 homeassistant/components/accuweather/coordinator.py create mode 100644 tests/components/accuweather/snapshots/test_sensor.ambr diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 26e0c1331be..d52ef5e0ec6 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -2,14 +2,10 @@ from __future__ import annotations -from asyncio import timeout -from datetime import timedelta +from dataclasses import dataclass import logging -from typing import Any -from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError -from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientConnectorError +from accuweather import AccuWeather from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -17,43 +13,70 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER +from .const import DOMAIN, UPDATE_INTERVAL_DAILY_FORECAST, UPDATE_INTERVAL_OBSERVATION +from .coordinator import ( + AccuWeatherDailyForecastDataUpdateCoordinator, + AccuWeatherObservationDataUpdateCoordinator, +) _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.WEATHER] +@dataclass +class AccuWeatherData: + """Data for AccuWeather integration.""" + + coordinator_observation: AccuWeatherObservationDataUpdateCoordinator + coordinator_daily_forecast: AccuWeatherDailyForecastDataUpdateCoordinator + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AccuWeather as config entry.""" api_key: str = entry.data[CONF_API_KEY] name: str = entry.data[CONF_NAME] - assert entry.unique_id is not None - location_key = entry.unique_id - forecast: bool = entry.options.get(CONF_FORECAST, False) - _LOGGER.debug("Using location_key: %s, get forecast: %s", location_key, forecast) + location_key = entry.unique_id + + _LOGGER.debug("Using location_key: %s", location_key) websession = async_get_clientsession(hass) + accuweather = AccuWeather(api_key, websession, location_key=location_key) - coordinator = AccuWeatherDataUpdateCoordinator( - hass, websession, api_key, location_key, forecast, name + coordinator_observation = AccuWeatherObservationDataUpdateCoordinator( + hass, + accuweather, + name, + "observation", + UPDATE_INTERVAL_OBSERVATION, ) - await coordinator.async_config_entry_first_refresh() + + coordinator_daily_forecast = AccuWeatherDailyForecastDataUpdateCoordinator( + hass, + accuweather, + name, + "daily forecast", + UPDATE_INTERVAL_DAILY_FORECAST, + ) + + await coordinator_observation.async_config_entry_first_refresh() + await coordinator_daily_forecast.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AccuWeatherData( + coordinator_observation=coordinator_observation, + coordinator_daily_forecast=coordinator_daily_forecast, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Remove ozone sensors from registry if they exist ent_reg = er.async_get(hass) for day in range(5): - unique_id = f"{coordinator.location_key}-ozone-{day}" + unique_id = f"{location_key}-ozone-{day}" if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id): _LOGGER.debug("Removing ozone sensor entity %s", entity_id) ent_reg.async_remove(entity_id) @@ -74,65 +97,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener.""" await hass.config_entries.async_reload(entry.entry_id) - - -class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching AccuWeather data API.""" - - def __init__( - self, - hass: HomeAssistant, - session: ClientSession, - api_key: str, - location_key: str, - forecast: bool, - name: str, - ) -> None: - """Initialize.""" - self.location_key = location_key - self.forecast = forecast - self.accuweather = AccuWeather(api_key, session, location_key=location_key) - self.device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, location_key)}, - manufacturer=MANUFACTURER, - name=name, - # You don't need to provide specific details for the URL, - # so passing in _ characters is fine if the location key - # is correct - configuration_url=( - "http://accuweather.com/en/" - f"_/_/{location_key}/" - f"weather-forecast/{location_key}/" - ), - ) - - # Enabling the forecast download increases the number of requests per data - # update, we use 40 minutes for current condition only and 80 minutes for - # current condition and forecast as update interval to not exceed allowed number - # of requests. We have 50 requests allowed per day, so we use 36 and leave 14 as - # a reserve for restarting HA. - update_interval = timedelta(minutes=40) - if self.forecast: - update_interval *= 2 - _LOGGER.debug("Data will be update every %s", update_interval) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self) -> dict[str, Any]: - """Update data via library.""" - forecast: list[dict[str, Any]] = [] - try: - async with timeout(10): - current = await self.accuweather.async_get_current_conditions() - if self.forecast: - forecast = await self.accuweather.async_get_daily_forecast() - except ( - ApiError, - ClientConnectorError, - InvalidApiKeyError, - RequestsExceededError, - ) as error: - raise UpdateFailed(error) from error - _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) - return {**current, ATTR_FORECAST: forecast} diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index af7560d963a..71f7de89528 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -10,26 +10,12 @@ from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.schema_config_entry_flow import ( - SchemaFlowFormStep, - SchemaOptionsFlowHandler, -) -from .const import CONF_FORECAST, DOMAIN - -OPTIONS_SCHEMA = vol.Schema( - { - vol.Optional(CONF_FORECAST, default=False): bool, - } -) -OPTIONS_FLOW = { - "init": SchemaFlowFormStep(OPTIONS_SCHEMA), -} +from .const import DOMAIN class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN): @@ -87,9 +73,3 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN): ), errors=errors, ) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: - """Options callback for AccuWeather.""" - return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 31925172d1c..1bbf5a36187 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta from typing import Final from homeassistant.components.weather import ( @@ -27,10 +28,8 @@ ATTR_CATEGORY: Final = "Category" ATTR_DIRECTION: Final = "Direction" ATTR_ENGLISH: Final = "English" ATTR_LEVEL: Final = "level" -ATTR_FORECAST: Final = "forecast" ATTR_SPEED: Final = "Speed" ATTR_VALUE: Final = "Value" -CONF_FORECAST: Final = "forecast" DOMAIN: Final = "accuweather" MANUFACTURER: Final = "AccuWeather, Inc." MAX_FORECAST_DAYS: Final = 4 @@ -56,3 +55,5 @@ CONDITION_MAP = { for cond_ha, cond_codes in CONDITION_CLASSES.items() for cond_code in cond_codes } +UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40) +UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py new file mode 100644 index 00000000000..26fadd6806c --- /dev/null +++ b/homeassistant/components/accuweather/coordinator.py @@ -0,0 +1,124 @@ +"""The AccuWeather coordinator.""" + +from asyncio import timeout +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any + +from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError +from aiohttp.client_exceptions import ClientConnectorError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + TimestampDataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN, MANUFACTURER + +EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError) + +_LOGGER = logging.getLogger(__name__) + + +class AccuWeatherObservationDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, Any]] +): + """Class to manage fetching AccuWeather data API.""" + + def __init__( + self, + hass: HomeAssistant, + accuweather: AccuWeather, + name: str, + coordinator_type: str, + update_interval: timedelta, + ) -> None: + """Initialize.""" + self.accuweather = accuweather + self.location_key = accuweather.location_key + + if TYPE_CHECKING: + assert self.location_key is not None + + self.device_info = _get_device_info(self.location_key, name) + + super().__init__( + hass, + _LOGGER, + name=f"{name} ({coordinator_type})", + update_interval=update_interval, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + async with timeout(10): + result = await self.accuweather.async_get_current_conditions() + except EXCEPTIONS as error: + raise UpdateFailed(error) from error + + _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) + + return result + + +class AccuWeatherDailyForecastDataUpdateCoordinator( + TimestampDataUpdateCoordinator[list[dict[str, Any]]] +): + """Class to manage fetching AccuWeather data API.""" + + def __init__( + self, + hass: HomeAssistant, + accuweather: AccuWeather, + name: str, + coordinator_type: str, + update_interval: timedelta, + ) -> None: + """Initialize.""" + self.accuweather = accuweather + self.location_key = accuweather.location_key + + if TYPE_CHECKING: + assert self.location_key is not None + + self.device_info = _get_device_info(self.location_key, name) + + super().__init__( + hass, + _LOGGER, + name=f"{name} ({coordinator_type})", + update_interval=update_interval, + ) + + async def _async_update_data(self) -> list[dict[str, Any]]: + """Update data via library.""" + try: + async with timeout(10): + result = await self.accuweather.async_get_daily_forecast() + except EXCEPTIONS as error: + raise UpdateFailed(error) from error + + _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) + + return result + + +def _get_device_info(location_key: str, name: str) -> DeviceInfo: + """Get device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, location_key)}, + manufacturer=MANUFACTURER, + name=name, + # You don't need to provide specific details for the URL, + # so passing in _ characters is fine if the location key + # is correct + configuration_url=( + "http://accuweather.com/en/" + f"_/_/{location_key}/weather-forecast/{location_key}/" + ), + ) diff --git a/homeassistant/components/accuweather/diagnostics.py b/homeassistant/components/accuweather/diagnostics.py index c4f04b209cf..810638a1e49 100644 --- a/homeassistant/components/accuweather/diagnostics.py +++ b/homeassistant/components/accuweather/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from . import AccuWeatherDataUpdateCoordinator +from . import AccuWeatherData from .const import DOMAIN TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE} @@ -19,11 +19,9 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + accuweather_data: AccuWeatherData = hass.data[DOMAIN][config_entry.entry_id] return { "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), - "coordinator_data": coordinator.data, + "observation_data": accuweather_data.coordinator_observation.data, } diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 521dfdfbead..95274297828 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -28,13 +28,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AccuWeatherDataUpdateCoordinator +from . import AccuWeatherData from .const import ( API_METRIC, ATTR_CATEGORY, ATTR_DIRECTION, ATTR_ENGLISH, - ATTR_FORECAST, ATTR_LEVEL, ATTR_SPEED, ATTR_VALUE, @@ -42,6 +41,10 @@ from .const import ( DOMAIN, MAX_FORECAST_DAYS, ) +from .coordinator import ( + AccuWeatherDailyForecastDataUpdateCoordinator, + AccuWeatherObservationDataUpdateCoordinator, +) PARALLEL_UPDATES = 1 @@ -52,12 +55,18 @@ class AccuWeatherSensorDescription(SensorEntityDescription): value_fn: Callable[[dict[str, Any]], str | int | float | None] attr_fn: Callable[[dict[str, Any]], dict[str, Any]] = lambda _: {} - day: int | None = None -FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( +@dataclass(frozen=True, kw_only=True) +class AccuWeatherForecastSensorDescription(AccuWeatherSensorDescription): + """Class describing AccuWeather sensor entities.""" + + day: int + + +FORECAST_SENSOR_TYPES: tuple[AccuWeatherForecastSensorDescription, ...] = ( *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="AirQuality", icon="mdi:air-filter", value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), @@ -69,7 +78,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="CloudCoverDay", icon="mdi:weather-cloudy", entity_registry_enabled_default=False, @@ -81,7 +90,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="CloudCoverNight", icon="mdi:weather-cloudy", entity_registry_enabled_default=False, @@ -93,7 +102,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="Grass", icon="mdi:grass", entity_registry_enabled_default=False, @@ -106,7 +115,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="HoursOfSun", icon="mdi:weather-partly-cloudy", native_unit_of_measurement=UnitOfTime.HOURS, @@ -117,7 +126,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="LongPhraseDay", value_fn=lambda data: cast(str, data), translation_key=f"condition_day_{day}d", @@ -126,7 +135,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="LongPhraseNight", value_fn=lambda data: cast(str, data), translation_key=f"condition_night_{day}d", @@ -135,7 +144,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="Mold", icon="mdi:blur", entity_registry_enabled_default=False, @@ -148,7 +157,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="Ragweed", icon="mdi:sprout", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, @@ -161,7 +170,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="RealFeelTemperatureMax", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -172,7 +181,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="RealFeelTemperatureMin", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -183,7 +192,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="RealFeelTemperatureShadeMax", device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, @@ -195,7 +204,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="RealFeelTemperatureShadeMin", device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, @@ -207,7 +216,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="SolarIrradianceDay", icon="mdi:weather-sunny", entity_registry_enabled_default=False, @@ -219,7 +228,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="SolarIrradianceNight", icon="mdi:weather-sunny", entity_registry_enabled_default=False, @@ -231,7 +240,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="ThunderstormProbabilityDay", icon="mdi:weather-lightning", native_unit_of_measurement=PERCENTAGE, @@ -242,7 +251,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="ThunderstormProbabilityNight", icon="mdi:weather-lightning", native_unit_of_measurement=PERCENTAGE, @@ -253,7 +262,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="Tree", icon="mdi:tree-outline", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, @@ -266,7 +275,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="UVIndex", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, @@ -278,7 +287,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="WindGustDay", device_class=SensorDeviceClass.WIND_SPEED, entity_registry_enabled_default=False, @@ -291,7 +300,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="WindGustNight", device_class=SensorDeviceClass.WIND_SPEED, entity_registry_enabled_default=False, @@ -304,7 +313,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="WindDay", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, @@ -316,7 +325,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="WindNight", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, @@ -453,25 +462,33 @@ async def async_setup_entry( ) -> None: """Add AccuWeather entities from a config_entry.""" - coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + accuweather_data: AccuWeatherData = hass.data[DOMAIN][entry.entry_id] - sensors = [ - AccuWeatherSensor(coordinator, description) for description in SENSOR_TYPES + observation_coordinator: AccuWeatherObservationDataUpdateCoordinator = ( + accuweather_data.coordinator_observation + ) + forecast_daily_coordinator: AccuWeatherDailyForecastDataUpdateCoordinator = ( + accuweather_data.coordinator_daily_forecast + ) + + sensors: list[AccuWeatherSensor | AccuWeatherForecastSensor] = [ + AccuWeatherSensor(observation_coordinator, description) + for description in SENSOR_TYPES ] - if coordinator.forecast: - for description in FORECAST_SENSOR_TYPES: - # Some air quality/allergy sensors are only available for certain - # locations. - if description.key not in coordinator.data[ATTR_FORECAST][description.day]: - continue - sensors.append(AccuWeatherSensor(coordinator, description)) + sensors.extend( + [ + AccuWeatherForecastSensor(forecast_daily_coordinator, description) + for description in FORECAST_SENSOR_TYPES + if description.key in forecast_daily_coordinator.data[description.day] + ] + ) async_add_entities(sensors) class AccuWeatherSensor( - CoordinatorEntity[AccuWeatherDataUpdateCoordinator], SensorEntity + CoordinatorEntity[AccuWeatherObservationDataUpdateCoordinator], SensorEntity ): """Define an AccuWeather entity.""" @@ -481,22 +498,15 @@ class AccuWeatherSensor( def __init__( self, - coordinator: AccuWeatherDataUpdateCoordinator, + coordinator: AccuWeatherObservationDataUpdateCoordinator, description: AccuWeatherSensorDescription, ) -> None: """Initialize.""" super().__init__(coordinator) - self.forecast_day = description.day + self.entity_description = description - self._sensor_data = _get_sensor_data( - coordinator.data, description.key, self.forecast_day - ) - if self.forecast_day is not None: - self._attr_unique_id = f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower() - else: - self._attr_unique_id = ( - f"{coordinator.location_key}-{description.key}".lower() - ) + self._sensor_data = self._get_sensor_data(coordinator.data, description.key) + self._attr_unique_id = f"{coordinator.location_key}-{description.key}".lower() self._attr_device_info = coordinator.device_info @property @@ -507,30 +517,78 @@ class AccuWeatherSensor( @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - if self.forecast_day is not None: - return self.entity_description.attr_fn(self._sensor_data) - return self.entity_description.attr_fn(self.coordinator.data) @callback def _handle_coordinator_update(self) -> None: """Handle data update.""" - self._sensor_data = _get_sensor_data( + self._sensor_data = self._get_sensor_data( + self.coordinator.data, self.entity_description.key + ) + self.async_write_ha_state() + + @staticmethod + def _get_sensor_data( + sensors: dict[str, Any], + kind: str, + ) -> Any: + """Get sensor data.""" + if kind == "Precipitation": + return sensors["PrecipitationSummary"]["PastHour"] + + return sensors[kind] + + +class AccuWeatherForecastSensor( + CoordinatorEntity[AccuWeatherDailyForecastDataUpdateCoordinator], SensorEntity +): + """Define an AccuWeather entity.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + entity_description: AccuWeatherForecastSensorDescription + + def __init__( + self, + coordinator: AccuWeatherDailyForecastDataUpdateCoordinator, + description: AccuWeatherForecastSensorDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.forecast_day = description.day + self.entity_description = description + self._sensor_data = self._get_sensor_data( + coordinator.data, description.key, self.forecast_day + ) + self._attr_unique_id = ( + f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower() + ) + self._attr_device_info = coordinator.device_info + + @property + def native_value(self) -> str | int | float | None: + """Return the state.""" + return self.entity_description.value_fn(self._sensor_data) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + return self.entity_description.attr_fn(self._sensor_data) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + self._sensor_data = self._get_sensor_data( self.coordinator.data, self.entity_description.key, self.forecast_day ) self.async_write_ha_state() - -def _get_sensor_data( - sensors: dict[str, Any], - kind: str, - forecast_day: int | None = None, -) -> Any: - """Get sensor data.""" - if forecast_day is not None: - return sensors[ATTR_FORECAST][forecast_day][kind] - - if kind == "Precipitation": - return sensors["PrecipitationSummary"]["PastHour"] - - return sensors[kind] + @staticmethod + def _get_sensor_data( + sensors: list[dict[str, Any]], + kind: str, + forecast_day: int, + ) -> Any: + """Get sensor data.""" + return sensors[forecast_day][kind] diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 718f2da6a75..9d8fce865fd 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -11,7 +11,7 @@ } }, "create_entry": { - "default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration.\nWeather forecast is not enabled by default. You can enable it in the integration options." + "default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -790,16 +790,6 @@ } } }, - "options": { - "step": { - "init": { - "description": "Due to the limitations of the free version of the AccuWeather API key, when you enable weather forecast, data updates will be performed every 80 minutes instead of every 40 minutes.", - "data": { - "forecast": "Weather forecast" - } - } - } - }, "system_health": { "info": { "can_reach_server": "Reach AccuWeather server", diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 1f2e606f6ea..4d248a06ac3 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -17,8 +17,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, + CoordinatorWeatherEntity, Forecast, - SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -31,19 +31,23 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util.dt import utc_from_timestamp -from . import AccuWeatherDataUpdateCoordinator +from . import AccuWeatherData from .const import ( API_METRIC, ATTR_DIRECTION, - ATTR_FORECAST, ATTR_SPEED, ATTR_VALUE, ATTRIBUTION, CONDITION_MAP, DOMAIN, ) +from .coordinator import ( + AccuWeatherDailyForecastDataUpdateCoordinator, + AccuWeatherObservationDataUpdateCoordinator, +) PARALLEL_UPDATES = 1 @@ -52,106 +56,134 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add a AccuWeather weather entity from a config_entry.""" + accuweather_data: AccuWeatherData = hass.data[DOMAIN][entry.entry_id] - coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([AccuWeatherEntity(coordinator)]) + async_add_entities([AccuWeatherEntity(accuweather_data)]) class AccuWeatherEntity( - SingleCoordinatorWeatherEntity[AccuWeatherDataUpdateCoordinator] + CoordinatorWeatherEntity[ + AccuWeatherObservationDataUpdateCoordinator, + AccuWeatherDailyForecastDataUpdateCoordinator, + TimestampDataUpdateCoordinator, + TimestampDataUpdateCoordinator, + ] ): """Define an AccuWeather entity.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator: AccuWeatherDataUpdateCoordinator) -> None: + def __init__(self, accuweather_data: AccuWeatherData) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__( + observation_coordinator=accuweather_data.coordinator_observation, + daily_coordinator=accuweather_data.coordinator_daily_forecast, + ) + self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS self._attr_native_pressure_unit = UnitOfPressure.HPA self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS self._attr_native_visibility_unit = UnitOfLength.KILOMETERS self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - self._attr_unique_id = coordinator.location_key + self._attr_unique_id = accuweather_data.coordinator_observation.location_key self._attr_attribution = ATTRIBUTION - self._attr_device_info = coordinator.device_info - if self.coordinator.forecast: - self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + self._attr_device_info = accuweather_data.coordinator_observation.device_info + self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + + self.observation_coordinator = accuweather_data.coordinator_observation + self.daily_coordinator = accuweather_data.coordinator_daily_forecast @property def condition(self) -> str | None: """Return the current condition.""" - return CONDITION_MAP.get(self.coordinator.data["WeatherIcon"]) + return CONDITION_MAP.get(self.observation_coordinator.data["WeatherIcon"]) @property def cloud_coverage(self) -> float: """Return the Cloud coverage in %.""" - return cast(float, self.coordinator.data["CloudCover"]) + return cast(float, self.observation_coordinator.data["CloudCover"]) @property def native_apparent_temperature(self) -> float: """Return the apparent temperature.""" return cast( - float, self.coordinator.data["ApparentTemperature"][API_METRIC][ATTR_VALUE] + float, + self.observation_coordinator.data["ApparentTemperature"][API_METRIC][ + ATTR_VALUE + ], ) @property def native_temperature(self) -> float: """Return the temperature.""" - return cast(float, self.coordinator.data["Temperature"][API_METRIC][ATTR_VALUE]) + return cast( + float, + self.observation_coordinator.data["Temperature"][API_METRIC][ATTR_VALUE], + ) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast(float, self.coordinator.data["Pressure"][API_METRIC][ATTR_VALUE]) + return cast( + float, self.observation_coordinator.data["Pressure"][API_METRIC][ATTR_VALUE] + ) @property def native_dew_point(self) -> float: """Return the dew point.""" - return cast(float, self.coordinator.data["DewPoint"][API_METRIC][ATTR_VALUE]) + return cast( + float, self.observation_coordinator.data["DewPoint"][API_METRIC][ATTR_VALUE] + ) @property def humidity(self) -> int: """Return the humidity.""" - return cast(int, self.coordinator.data["RelativeHumidity"]) + return cast(int, self.observation_coordinator.data["RelativeHumidity"]) @property def native_wind_gust_speed(self) -> float: """Return the wind gust speed.""" return cast( - float, self.coordinator.data["WindGust"][ATTR_SPEED][API_METRIC][ATTR_VALUE] + float, + self.observation_coordinator.data["WindGust"][ATTR_SPEED][API_METRIC][ + ATTR_VALUE + ], ) @property def native_wind_speed(self) -> float: """Return the wind speed.""" return cast( - float, self.coordinator.data["Wind"][ATTR_SPEED][API_METRIC][ATTR_VALUE] + float, + self.observation_coordinator.data["Wind"][ATTR_SPEED][API_METRIC][ + ATTR_VALUE + ], ) @property def wind_bearing(self) -> int: """Return the wind bearing.""" - return cast(int, self.coordinator.data["Wind"][ATTR_DIRECTION]["Degrees"]) + return cast( + int, self.observation_coordinator.data["Wind"][ATTR_DIRECTION]["Degrees"] + ) @property def native_visibility(self) -> float: """Return the visibility.""" - return cast(float, self.coordinator.data["Visibility"][API_METRIC][ATTR_VALUE]) + return cast( + float, + self.observation_coordinator.data["Visibility"][API_METRIC][ATTR_VALUE], + ) @property def uv_index(self) -> float: """Return the UV index.""" - return cast(float, self.coordinator.data["UVIndex"]) + return cast(float, self.observation_coordinator.data["UVIndex"]) @callback def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" - if not self.coordinator.forecast: - return None - # remap keys from library to keys understood by the weather component return [ { ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), @@ -175,5 +207,5 @@ class AccuWeatherEntity( ATTR_FORECAST_WIND_BEARING: item["WindDay"][ATTR_DIRECTION]["Degrees"], ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["IconDay"]), } - for item in self.coordinator.data[ATTR_FORECAST] + for item in self.daily_coordinator.data ] diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py index afaa5bbef25..a08b894ebb4 100644 --- a/tests/components/accuweather/__init__.py +++ b/tests/components/accuweather/__init__.py @@ -11,14 +11,8 @@ from tests.common import ( ) -async def init_integration( - hass, forecast=False, unsupported_icon=False -) -> MockConfigEntry: +async def init_integration(hass, unsupported_icon=False) -> MockConfigEntry: """Set up the AccuWeather integration in Home Assistant.""" - options = {} - if forecast: - options["forecast"] = True - entry = MockConfigEntry( domain=DOMAIN, title="Home", @@ -29,7 +23,6 @@ async def init_integration( "longitude": 122.12, "name": "Home", }, - options=options, ) current = load_json_object_fixture("accuweather/current_conditions_data.json") diff --git a/tests/components/accuweather/snapshots/test_diagnostics.ambr b/tests/components/accuweather/snapshots/test_diagnostics.ambr index b3c0c1de752..7477602f3a4 100644 --- a/tests/components/accuweather/snapshots/test_diagnostics.ambr +++ b/tests/components/accuweather/snapshots/test_diagnostics.ambr @@ -7,7 +7,7 @@ 'longitude': '**REDACTED**', 'name': 'Home', }), - 'coordinator_data': dict({ + 'observation_data': dict({ 'ApparentTemperature': dict({ 'Imperial': dict({ 'Unit': 'F', @@ -297,8 +297,6 @@ }), }), }), - 'forecast': list([ - ]), }), }) # --- diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..42783f375b0 --- /dev/null +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -0,0 +1,6436 @@ +# serializer version: 1 +# name: test_sensor[sensor.home_air_quality_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_1d', + 'unique_id': '0123456-airquality-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality day 1', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_2d', + 'unique_id': '0123456-airquality-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality day 2', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_3d', + 'unique_id': '0123456-airquality-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality day 3', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_4d', + 'unique_id': '0123456-airquality-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality day 4', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_air_quality_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_0d', + 'unique_id': '0123456-airquality-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality today', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_apparent_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_apparent_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent temperature', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'apparent_temperature', + 'unique_id': '0123456-apparenttemperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_apparent_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home Apparent temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_apparent_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.8', + }) +# --- +# name: test_sensor[sensor.home_cloud_ceiling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_ceiling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:weather-fog', + 'original_name': 'Cloud ceiling', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_ceiling', + 'unique_id': '0123456-ceiling', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_cloud_ceiling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'distance', + 'friendly_name': 'Home Cloud ceiling', + 'icon': 'mdi:weather-fog', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_cloud_ceiling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3200.0', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover', + 'unique_id': '0123456-cloudcover', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover', + 'icon': 'mdi:weather-cloudy', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_1d', + 'unique_id': '0123456-cloudcoverday-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover day 1', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_2d', + 'unique_id': '0123456-cloudcoverday-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover day 2', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_3d', + 'unique_id': '0123456-cloudcoverday-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover day 3', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_4d', + 'unique_id': '0123456-cloudcoverday-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover day 4', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_1d', + 'unique_id': '0123456-cloudcovernight-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover night 1', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '63', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_2d', + 'unique_id': '0123456-cloudcovernight-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover night 2', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_3d', + 'unique_id': '0123456-cloudcovernight-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover night 3', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_4d', + 'unique_id': '0123456-cloudcovernight-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover night 4', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_0d', + 'unique_id': '0123456-cloudcoverday-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover today', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '58', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_0d', + 'unique_id': '0123456-cloudcovernight-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover tonight', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensor[sensor.home_condition_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_1d', + 'unique_id': '0123456-longphraseday-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition day 1', + }), + 'context': , + 'entity_id': 'sensor.home_condition_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Clouds and sun', + }) +# --- +# name: test_sensor[sensor.home_condition_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_2d', + 'unique_id': '0123456-longphraseday-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition day 2', + }), + 'context': , + 'entity_id': 'sensor.home_condition_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Very warm with a blend of sun and clouds', + }) +# --- +# name: test_sensor[sensor.home_condition_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_3d', + 'unique_id': '0123456-longphraseday-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition day 3', + }), + 'context': , + 'entity_id': 'sensor.home_condition_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Cooler with partial sunshine', + }) +# --- +# name: test_sensor[sensor.home_condition_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_4d', + 'unique_id': '0123456-longphraseday-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition day 4', + }), + 'context': , + 'entity_id': 'sensor.home_condition_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Intervals of clouds and sunshine', + }) +# --- +# name: test_sensor[sensor.home_condition_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_1d', + 'unique_id': '0123456-longphrasenight-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition night 1', + }), + 'context': , + 'entity_id': 'sensor.home_condition_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Partly cloudy', + }) +# --- +# name: test_sensor[sensor.home_condition_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_2d', + 'unique_id': '0123456-longphrasenight-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition night 2', + }), + 'context': , + 'entity_id': 'sensor.home_condition_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Partly cloudy', + }) +# --- +# name: test_sensor[sensor.home_condition_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_3d', + 'unique_id': '0123456-longphrasenight-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition night 3', + }), + 'context': , + 'entity_id': 'sensor.home_condition_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Mainly clear', + }) +# --- +# name: test_sensor[sensor.home_condition_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_4d', + 'unique_id': '0123456-longphrasenight-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition night 4', + }), + 'context': , + 'entity_id': 'sensor.home_condition_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Mostly clear', + }) +# --- +# name: test_sensor[sensor.home_condition_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_0d', + 'unique_id': '0123456-longphraseday-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition today', + }), + 'context': , + 'entity_id': 'sensor.home_condition_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon', + }) +# --- +# name: test_sensor[sensor.home_condition_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_0d', + 'unique_id': '0123456-longphrasenight-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition tonight', + }), + 'context': , + 'entity_id': 'sensor.home_condition_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Partly cloudy', + }) +# --- +# name: test_sensor[sensor.home_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dew_point', + 'unique_id': '0123456-dewpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.2', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_1d', + 'unique_id': '0123456-grass-1', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen day 1', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_2d', + 'unique_id': '0123456-grass-2', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen day 2', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_3d', + 'unique_id': '0123456-grass-3', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen day 3', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_4d', + 'unique_id': '0123456-grass-4', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen day 4', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_0d', + 'unique_id': '0123456-grass-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen today', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_1d', + 'unique_id': '0123456-hoursofsun-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun day 1', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.4', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_2d', + 'unique_id': '0123456-hoursofsun-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun day 2', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.7', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_3d', + 'unique_id': '0123456-hoursofsun-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun day 3', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.4', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_4d', + 'unique_id': '0123456-hoursofsun-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun day 4', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.2', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_0d', + 'unique_id': '0123456-hoursofsun-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun today', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.2', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_1d', + 'unique_id': '0123456-mold-1', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen day 1', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_2d', + 'unique_id': '0123456-mold-2', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen day 2', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_3d', + 'unique_id': '0123456-mold-3', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen day 3', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_4d', + 'unique_id': '0123456-mold-4', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen day 4', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_0d', + 'unique_id': '0123456-mold-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen today', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_precipitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_precipitation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation', + 'unique_id': '0123456-precipitation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_precipitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'Home Precipitation', + 'state_class': , + 'type': None, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_precipitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[sensor.home_pressure_tendency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'falling', + 'rising', + 'steady', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_pressure_tendency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Pressure tendency', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pressure_tendency', + 'unique_id': '0123456-pressuretendency', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_pressure_tendency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Pressure tendency', + 'icon': 'mdi:gauge', + 'options': list([ + 'falling', + 'rising', + 'steady', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_pressure_tendency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'falling', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_1d', + 'unique_id': '0123456-ragweed-1', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen day 1', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_2d', + 'unique_id': '0123456-ragweed-2', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen day 2', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_3d', + 'unique_id': '0123456-ragweed-3', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen day 3', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_4d', + 'unique_id': '0123456-ragweed-4', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen day 4', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_0d', + 'unique_id': '0123456-ragweed-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen today', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature', + 'unique_id': '0123456-realfeeltemperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_1d', + 'unique_id': '0123456-realfeeltemperaturemax-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.9', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_2d', + 'unique_id': '0123456-realfeeltemperaturemax-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31.6', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_3d', + 'unique_id': '0123456-realfeeltemperaturemax-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.5', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_4d', + 'unique_id': '0123456-realfeeltemperaturemax-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_0d', + 'unique_id': '0123456-realfeeltemperaturemax-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.8', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_1d', + 'unique_id': '0123456-realfeeltemperaturemin-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.8', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_2d', + 'unique_id': '0123456-realfeeltemperaturemin-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.7', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_3d', + 'unique_id': '0123456-realfeeltemperaturemin-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_4d', + 'unique_id': '0123456-realfeeltemperaturemin-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.3', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_0d', + 'unique_id': '0123456-realfeeltemperaturemin-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade', + 'unique_id': '0123456-realfeeltemperatureshade', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_1d', + 'unique_id': '0123456-realfeeltemperatureshademax-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_2d', + 'unique_id': '0123456-realfeeltemperatureshademax-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_3d', + 'unique_id': '0123456-realfeeltemperatureshademax-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_4d', + 'unique_id': '0123456-realfeeltemperatureshademax-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.5', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_0d', + 'unique_id': '0123456-realfeeltemperatureshademax-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.0', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_1d', + 'unique_id': '0123456-realfeeltemperatureshademin-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.8', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_2d', + 'unique_id': '0123456-realfeeltemperatureshademin-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.7', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_3d', + 'unique_id': '0123456-realfeeltemperatureshademin-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_4d', + 'unique_id': '0123456-realfeeltemperatureshademin-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.3', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_0d', + 'unique_id': '0123456-realfeeltemperatureshademin-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_1d', + 'unique_id': '0123456-solarirradianceday-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance day 1', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_2d', + 'unique_id': '0123456-solarirradianceday-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance day 2', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_3d', + 'unique_id': '0123456-solarirradianceday-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance day 3', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_4d', + 'unique_id': '0123456-solarirradianceday-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance day 4', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_1d', + 'unique_id': '0123456-solarirradiancenight-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance night 1', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '271.6', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_2d', + 'unique_id': '0123456-solarirradiancenight-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance night 2', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '271.6', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_3d', + 'unique_id': '0123456-solarirradiancenight-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance night 3', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_4d', + 'unique_id': '0123456-solarirradiancenight-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance night 4', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '276.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_0d', + 'unique_id': '0123456-solarirradianceday-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance today', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_0d', + 'unique_id': '0123456-solarirradiancenight-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance tonight', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '271.6', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_1d', + 'unique_id': '0123456-thunderstormprobabilityday-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability day 1', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_2d', + 'unique_id': '0123456-thunderstormprobabilityday-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability day 2', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_3d', + 'unique_id': '0123456-thunderstormprobabilityday-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability day 3', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_4d', + 'unique_id': '0123456-thunderstormprobabilityday-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability day 4', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_1d', + 'unique_id': '0123456-thunderstormprobabilitynight-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability night 1', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_2d', + 'unique_id': '0123456-thunderstormprobabilitynight-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability night 2', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_3d', + 'unique_id': '0123456-thunderstormprobabilitynight-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability night 3', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_4d', + 'unique_id': '0123456-thunderstormprobabilitynight-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability night 4', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_0d', + 'unique_id': '0123456-thunderstormprobabilityday-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability today', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_0d', + 'unique_id': '0123456-thunderstormprobabilitynight-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability tonight', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_1d', + 'unique_id': '0123456-tree-1', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen day 1', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_2d', + 'unique_id': '0123456-tree-2', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen day 2', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_3d', + 'unique_id': '0123456-tree-3', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen day 3', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_4d', + 'unique_id': '0123456-tree-4', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen day 4', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_0d', + 'unique_id': '0123456-tree-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen today', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': '0123456-uvindex', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index', + 'icon': 'mdi:weather-sunny', + 'level': 'High', + 'state_class': , + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_1d', + 'unique_id': '0123456-uvindex-1', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index day 1', + 'icon': 'mdi:weather-sunny', + 'level': 'high', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_2d', + 'unique_id': '0123456-uvindex-2', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index day 2', + 'icon': 'mdi:weather-sunny', + 'level': 'high', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_3d', + 'unique_id': '0123456-uvindex-3', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index day 3', + 'icon': 'mdi:weather-sunny', + 'level': 'high', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_4d', + 'unique_id': '0123456-uvindex-4', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index day 4', + 'icon': 'mdi:weather-sunny', + 'level': 'high', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensor[sensor.home_uv_index_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_0d', + 'unique_id': '0123456-uvindex-0', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index today', + 'icon': 'mdi:weather-sunny', + 'level': 'moderate', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor[sensor.home_wet_bulb_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wet_bulb_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wet bulb temperature', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wet_bulb_temperature', + 'unique_id': '0123456-wetbulbtemperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wet_bulb_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home Wet bulb temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wet_bulb_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.6', + }) +# --- +# name: test_sensor[sensor.home_wind_chill_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_chill_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind chill temperature', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_chill_temperature', + 'unique_id': '0123456-windchilltemperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_chill_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home Wind chill temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_chill_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.8', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed', + 'unique_id': '0123456-windgust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'friendly_name': 'Home Wind gust speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.3', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_1d', + 'unique_id': '0123456-windgustday-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'NW', + 'friendly_name': 'Home Wind gust speed day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.8', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_2d', + 'unique_id': '0123456-windgustday-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'SSW', + 'friendly_name': 'Home Wind gust speed day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.1', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_3d', + 'unique_id': '0123456-windgustday-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.1', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_4d', + 'unique_id': '0123456-windgustday-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.8', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_1d', + 'unique_id': '0123456-windgustnight-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'S', + 'friendly_name': 'Home Wind gust speed night 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.8', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_2d', + 'unique_id': '0123456-windgustnight-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed night 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_3d', + 'unique_id': '0123456-windgustnight-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed night 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_4d', + 'unique_id': '0123456-windgustnight-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed night 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_0d', + 'unique_id': '0123456-windgustday-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'S', + 'friendly_name': 'Home Wind gust speed today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.6', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_0d', + 'unique_id': '0123456-windgustnight-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WSW', + 'friendly_name': 'Home Wind gust speed tonight', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_sensor[sensor.home_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed', + 'unique_id': '0123456-wind', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'friendly_name': 'Home Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_1d', + 'unique_id': '0123456-windday-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WNW', + 'friendly_name': 'Home Wind speed day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.3', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_2d', + 'unique_id': '0123456-windday-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'SSW', + 'friendly_name': 'Home Wind speed day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.7', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_3d', + 'unique_id': '0123456-windday-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WNW', + 'friendly_name': 'Home Wind speed day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.0', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_4d', + 'unique_id': '0123456-windday-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind speed day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_1d', + 'unique_id': '0123456-windnight-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'SSE', + 'friendly_name': 'Home Wind speed night 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.4', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_2d', + 'unique_id': '0123456-windnight-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind speed night 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.3', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_3d', + 'unique_id': '0123456-windnight-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind speed night 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.1', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_4d', + 'unique_id': '0123456-windnight-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind speed night 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.3', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_0d', + 'unique_id': '0123456-windday-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'SSE', + 'friendly_name': 'Home Wind speed today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.0', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_0d', + 'unique_id': '0123456-windnight-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WNW', + 'friendly_name': 'Home Wind speed tonight', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.4', + }) +# --- diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index bc75ef17309..07b126e0856 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -1,10 +1,10 @@ """Define tests for the AccuWeather config flow.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import patch from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError -from homeassistant.components.accuweather.const import CONF_FORECAST, DOMAIN +from homeassistant.components.accuweather.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant @@ -140,52 +140,3 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["data"][CONF_LATITUDE] == 55.55 assert result["data"][CONF_LONGITUDE] == 122.12 assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw" - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="123456", - data=VALID_CONFIG, - ) - config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.accuweather.AccuWeather._async_get_data", - return_value=load_json_object_fixture("accuweather/location_data.json"), - ), - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=load_json_object_fixture( - "accuweather/current_conditions_data.json" - ), - ), - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast" - ), - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_FORECAST: True} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {CONF_FORECAST: True} - - await hass.async_block_till_done() - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index ab77fc337d0..593cde0f0a3 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -6,7 +6,6 @@ from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_json_object_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -19,12 +18,6 @@ async def test_entry_diagnostics( """Test config entry diagnostics.""" entry = await init_integration(hass) - coordinator_data = load_json_object_fixture( - "current_conditions_data.json", "accuweather" - ) - - coordinator_data["forecast"] = [] - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == snapshot diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index bb5b67e7918..08ad4a66dec 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -1,11 +1,14 @@ """Test init of AccuWeather integration.""" -from datetime import timedelta from unittest.mock import patch from accuweather import ApiError -from homeassistant.components.accuweather.const import DOMAIN +from homeassistant.components.accuweather.const import ( + DOMAIN, + UPDATE_INTERVAL_DAILY_FORECAST, + UPDATE_INTERVAL_OBSERVATION, +) from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE @@ -76,30 +79,8 @@ async def test_update_interval(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED - current = load_json_object_fixture("accuweather/current_conditions_data.json") - future = utcnow() + timedelta(minutes=40) - - with patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current, - ) as mock_current: - assert mock_current.call_count == 0 - - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert mock_current.call_count == 1 - - -async def test_update_interval_forecast(hass: HomeAssistant) -> None: - """Test correct update interval when forecast is True.""" - entry = await init_integration(hass, forecast=True) - - assert entry.state is ConfigEntryState.LOADED - current = load_json_object_fixture("accuweather/current_conditions_data.json") forecast = load_json_array_fixture("accuweather/forecast_data.json") - future = utcnow() + timedelta(minutes=80) with ( patch( @@ -114,10 +95,14 @@ async def test_update_interval_forecast(hass: HomeAssistant) -> None: assert mock_current.call_count == 0 assert mock_forecast.call_count == 0 - async_fire_time_changed(hass, future) + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_OBSERVATION) await hass.async_block_till_done() assert mock_current.call_count == 1 + + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_DAILY_FORECAST) + await hass.async_block_till_done() + assert mock_forecast.call_count == 1 diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 8e6e01a4578..e79e49db96d 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -3,29 +3,20 @@ from datetime import timedelta from unittest.mock import PropertyMock, patch -from homeassistant.components.accuweather.const import ATTRIBUTION -from homeassistant.components.sensor import ( - ATTR_OPTIONS, - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) +from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError +from aiohttp.client_exceptions import ClientConnectorError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.accuweather.const import UPDATE_INTERVAL_DAILY_FORECAST from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - CONCENTRATION_PARTS_PER_CUBIC_METER, - PERCENTAGE, STATE_UNAVAILABLE, - UV_INDEX, - UnitOfIrradiance, + Platform, UnitOfLength, UnitOfSpeed, UnitOfTemperature, - UnitOfTime, - UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -42,517 +33,23 @@ from tests.common import ( ) -async def test_sensor_without_forecast( +async def test_sensor( hass: HomeAssistant, entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test states of the sensor without forecast.""" - await init_integration(hass) + """Test states of the sensor.""" + with patch("homeassistant.components.accuweather.PLATFORMS", [Platform.SENSOR]): + entry = await init_integration(hass) - state = hass.states.get("sensor.home_cloud_ceiling") - assert state - assert state.state == "3200.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.METERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DISTANCE + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - entry = entity_registry.async_get("sensor.home_cloud_ceiling") - assert entry - assert entry.unique_id == "0123456-ceiling" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_precipitation") - assert state - assert state.state == "0.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR - ) - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get("type") is None - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.PRECIPITATION_INTENSITY - ) - - entry = entity_registry.async_get("sensor.home_precipitation") - assert entry - assert entry.unique_id == "0123456-precipitation" - - state = hass.states.get("sensor.home_pressure_tendency") - assert state - assert state.state == "falling" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:gauge" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_OPTIONS) == ["falling", "rising", "steady"] - - entry = entity_registry.async_get("sensor.home_pressure_tendency") - assert entry - assert entry.unique_id == "0123456-pressuretendency" - assert entry.translation_key == "pressure_tendency" - - state = hass.states.get("sensor.home_realfeel_temperature") - assert state - assert state.state == "25.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_realfeel_temperature") - assert entry - assert entry.unique_id == "0123456-realfeeltemperature" - - state = hass.states.get("sensor.home_uv_index") - assert state - assert state.state == "6" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX - assert state.attributes.get("level") == "High" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_uv_index") - assert entry - assert entry.unique_id == "0123456-uvindex" - - state = hass.states.get("sensor.home_apparent_temperature") - assert state - assert state.state == "22.8" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_apparent_temperature") - assert entry - assert entry.unique_id == "0123456-apparenttemperature" - - state = hass.states.get("sensor.home_cloud_cover") - assert state - assert state.state == "10" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_cloud_cover") - assert entry - assert entry.unique_id == "0123456-cloudcover" - - state = hass.states.get("sensor.home_dew_point") - assert state - assert state.state == "16.2" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_dew_point") - assert entry - assert entry.unique_id == "0123456-dewpoint" - - state = hass.states.get("sensor.home_realfeel_temperature_shade") - assert state - assert state.state == "21.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_realfeel_temperature_shade") - assert entry - assert entry.unique_id == "0123456-realfeeltemperatureshade" - - state = hass.states.get("sensor.home_wet_bulb_temperature") - assert state - assert state.state == "18.6" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_wet_bulb_temperature") - assert entry - assert entry.unique_id == "0123456-wetbulbtemperature" - - state = hass.states.get("sensor.home_wind_chill_temperature") - assert state - assert state.state == "22.8" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_wind_chill_temperature") - assert entry - assert entry.unique_id == "0123456-windchilltemperature" - - state = hass.states.get("sensor.home_wind_gust_speed") - assert state - assert state.state == "20.3" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_gust_speed") - assert entry - assert entry.unique_id == "0123456-windgust" - - state = hass.states.get("sensor.home_wind_speed") - assert state - assert state.state == "14.5" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_speed") - assert entry - assert entry.unique_id == "0123456-wind" - - -async def test_sensor_with_forecast( - hass: HomeAssistant, - entity_registry_enabled_by_default: None, - entity_registry: er.EntityRegistry, -) -> None: - """Test states of the sensor with forecast.""" - await init_integration(hass, forecast=True) - - state = hass.states.get("sensor.home_hours_of_sun_today") - assert state - assert state.state == "7.2" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-partly-cloudy" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.HOURS - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_hours_of_sun_today") - assert entry - assert entry.unique_id == "0123456-hoursofsun-0" - - state = hass.states.get("sensor.home_realfeel_temperature_max_today") - assert state - assert state.state == "29.8" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_realfeel_temperature_max_today") - assert entry - - state = hass.states.get("sensor.home_realfeel_temperature_min_today") - assert state - assert state.state == "15.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_realfeel_temperature_min_today") - assert entry - assert entry.unique_id == "0123456-realfeeltemperaturemin-0" - - state = hass.states.get("sensor.home_thunderstorm_probability_today") - assert state - assert state.state == "40" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_thunderstorm_probability_today") - assert entry - assert entry.unique_id == "0123456-thunderstormprobabilityday-0" - - state = hass.states.get("sensor.home_thunderstorm_probability_tonight") - assert state - assert state.state == "40" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_thunderstorm_probability_tonight") - assert entry - assert entry.unique_id == "0123456-thunderstormprobabilitynight-0" - - state = hass.states.get("sensor.home_uv_index_today") - assert state - assert state.state == "5" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX - assert state.attributes.get("level") == "moderate" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_uv_index_today") - assert entry - assert entry.unique_id == "0123456-uvindex-0" - - state = hass.states.get("sensor.home_air_quality_today") - assert state - assert state.state == "good" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM - assert state.attributes.get(ATTR_OPTIONS) == [ - "good", - "hazardous", - "high", - "low", - "moderate", - "unhealthy", - ] - - state = hass.states.get("sensor.home_cloud_cover_today") - assert state - assert state.state == "58" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_cloud_cover_today") - assert entry - assert entry.unique_id == "0123456-cloudcoverday-0" - - state = hass.states.get("sensor.home_cloud_cover_tonight") - assert state - assert state.state == "65" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_cloud_cover_tonight") - assert entry - - state = hass.states.get("sensor.home_grass_pollen_today") - assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_PARTS_PER_CUBIC_METER - ) - assert state.attributes.get("level") == "low" - assert state.attributes.get(ATTR_ICON) == "mdi:grass" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_grass_pollen_today") - assert entry - assert entry.unique_id == "0123456-grass-0" - - state = hass.states.get("sensor.home_mold_pollen_today") - assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_PARTS_PER_CUBIC_METER - ) - assert state.attributes.get("level") == "low" - assert state.attributes.get(ATTR_ICON) == "mdi:blur" - - entry = entity_registry.async_get("sensor.home_mold_pollen_today") - assert entry - assert entry.unique_id == "0123456-mold-0" - - state = hass.states.get("sensor.home_ragweed_pollen_today") - assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_PARTS_PER_CUBIC_METER - ) - assert state.attributes.get("level") == "low" - assert state.attributes.get(ATTR_ICON) == "mdi:sprout" - - entry = entity_registry.async_get("sensor.home_ragweed_pollen_today") - assert entry - assert entry.unique_id == "0123456-ragweed-0" - - state = hass.states.get("sensor.home_realfeel_temperature_shade_max_today") - assert state - assert state.state == "28.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get( - "sensor.home_realfeel_temperature_shade_max_today" - ) - assert entry - assert entry.unique_id == "0123456-realfeeltemperatureshademax-0" - - state = hass.states.get("sensor.home_realfeel_temperature_shade_min_today") - assert state - assert state.state == "15.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - - entry = entity_registry.async_get( - "sensor.home_realfeel_temperature_shade_min_today" - ) - assert entry - assert entry.unique_id == "0123456-realfeeltemperatureshademin-0" - - state = hass.states.get("sensor.home_tree_pollen_today") - assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_PARTS_PER_CUBIC_METER - ) - assert state.attributes.get("level") == "low" - assert state.attributes.get(ATTR_ICON) == "mdi:tree-outline" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_tree_pollen_today") - assert entry - assert entry.unique_id == "0123456-tree-0" - - state = hass.states.get("sensor.home_wind_speed_today") - assert state - assert state.state == "13.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get("direction") == "SSE" - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_speed_today") - assert entry - assert entry.unique_id == "0123456-windday-0" - - state = hass.states.get("sensor.home_wind_speed_tonight") - assert state - assert state.state == "7.4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get("direction") == "WNW" - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_speed_tonight") - assert entry - assert entry.unique_id == "0123456-windnight-0" - - state = hass.states.get("sensor.home_wind_gust_speed_today") - assert state - assert state.state == "29.6" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get("direction") == "S" - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_gust_speed_today") - assert entry - assert entry.unique_id == "0123456-windgustday-0" - - state = hass.states.get("sensor.home_wind_gust_speed_tonight") - assert state - assert state.state == "18.5" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get("direction") == "WSW" - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_gust_speed_tonight") - assert entry - assert entry.unique_id == "0123456-windgustnight-0" - - entry = entity_registry.async_get("sensor.home_air_quality_today") - assert entry - assert entry.unique_id == "0123456-airquality-0" - - state = hass.states.get("sensor.home_solar_irradiance_today") - assert state - assert state.state == "7447.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfIrradiance.WATTS_PER_SQUARE_METER - ) - - entry = entity_registry.async_get("sensor.home_solar_irradiance_today") - assert entry - assert entry.unique_id == "0123456-solarirradianceday-0" - - state = hass.states.get("sensor.home_solar_irradiance_tonight") - assert state - assert state.state == "271.6" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfIrradiance.WATTS_PER_SQUARE_METER - ) - - entry = entity_registry.async_get("sensor.home_solar_irradiance_tonight") - assert entry - assert entry.unique_id == "0123456-solarirradiancenight-0" - - state = hass.states.get("sensor.home_condition_today") - assert state - assert ( - state.state - == "Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon" - ) - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - - entry = entity_registry.async_get("sensor.home_condition_today") - assert entry - assert entry.unique_id == "0123456-longphraseday-0" - - state = hass.states.get("sensor.home_condition_tonight") - assert state - assert state.state == "Partly cloudy" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - - entry = entity_registry.async_get("sensor.home_condition_tonight") - assert entry - assert entry.unique_id == "0123456-longphrasenight-0" + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") async def test_availability(hass: HomeAssistant) -> None: @@ -599,24 +96,88 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state == "3200.0" +@pytest.mark.parametrize( + "exception", + [ + ApiError, + ConnectionError, + ClientConnectorError, + InvalidApiKeyError, + RequestsExceededError, + ], +) +async def test_availability_forecast(hass: HomeAssistant, exception: Exception) -> None: + """Ensure that we mark the entities unavailable correctly when service is offline.""" + current = load_json_object_fixture("accuweather/current_conditions_data.json") + forecast = load_json_array_fixture("accuweather/forecast_data.json") + entity_id = "sensor.home_hours_of_sun_day_2" + + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "5.7" + + with ( + patch( + "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", + return_value=current, + ), + patch( + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", + side_effect=exception, + ), + patch( + "homeassistant.components.accuweather.AccuWeather.requests_remaining", + new_callable=PropertyMock, + return_value=10, + ), + ): + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_DAILY_FORECAST) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + with ( + patch( + "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", + return_value=current, + ), + patch( + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", + return_value=forecast, + ), + patch( + "homeassistant.components.accuweather.AccuWeather.requests_remaining", + new_callable=PropertyMock, + return_value=10, + ), + ): + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_DAILY_FORECAST * 2) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "5.7" + + async def test_manual_update_entity(hass: HomeAssistant) -> None: """Test manual update entity via service homeassistant/update_entity.""" - await init_integration(hass, forecast=True) + await init_integration(hass) await async_setup_component(hass, "homeassistant", {}) current = load_json_object_fixture("accuweather/current_conditions_data.json") - forecast = load_json_array_fixture("accuweather/forecast_data.json") with ( patch( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", - return_value=forecast, - ) as mock_forecast, patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", new_callable=PropertyMock, @@ -629,8 +190,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: ["sensor.home_cloud_ceiling"]}, blocking=True, ) - assert mock_current.call_count == 1 - assert mock_forecast.call_count == 1 + assert mock_current.call_count == 1 async def test_sensor_imperial_units(hass: HomeAssistant) -> None: diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index 0b9d3e28fb2..b3237ca2958 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -7,7 +7,10 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.accuweather.const import ATTRIBUTION +from homeassistant.components.accuweather.const import ( + ATTRIBUTION, + UPDATE_INTERVAL_DAILY_FORECAST, +) from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_WEATHER_APPARENT_TEMPERATURE, @@ -24,6 +27,7 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, + WeatherEntityFeature, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -65,7 +69,10 @@ async def test_weather(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 assert state.attributes.get(ATTR_WEATHER_UV_INDEX) == 6 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ATTR_SUPPORTED_FEATURES not in state.attributes + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + is WeatherEntityFeature.FORECAST_DAILY + ) entry = entity_registry.async_get("weather.home") assert entry @@ -118,22 +125,17 @@ async def test_availability(hass: HomeAssistant) -> None: async def test_manual_update_entity(hass: HomeAssistant) -> None: """Test manual update entity via service homeassistant/update_entity.""" - await init_integration(hass, forecast=True) + await init_integration(hass) await async_setup_component(hass, "homeassistant", {}) current = load_json_object_fixture("accuweather/current_conditions_data.json") - forecast = load_json_array_fixture("accuweather/forecast_data.json") with ( patch( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", - return_value=forecast, - ) as mock_forecast, patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", new_callable=PropertyMock, @@ -147,12 +149,11 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: blocking=True, ) assert mock_current.call_count == 1 - assert mock_forecast.call_count == 1 async def test_unsupported_condition_icon_data(hass: HomeAssistant) -> None: """Test with unsupported condition icon data.""" - await init_integration(hass, forecast=True, unsupported_icon=True) + await init_integration(hass, unsupported_icon=True) state = hass.states.get("weather.home") assert state.attributes.get(ATTR_FORECAST_CONDITION) is None @@ -171,7 +172,7 @@ async def test_forecast_service( service: str, ) -> None: """Test multiple forecast.""" - await init_integration(hass, forecast=True) + await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, @@ -195,7 +196,7 @@ async def test_forecast_subscription( """Test multiple forecast.""" client = await hass_ws_client(hass) - await init_integration(hass, forecast=True) + await init_integration(hass) await client.send_json_auto_id( { @@ -235,7 +236,7 @@ async def test_forecast_subscription( return_value=10, ), ): - freezer.tick(timedelta(minutes=80) + timedelta(seconds=1)) + freezer.tick(UPDATE_INTERVAL_DAILY_FORECAST + timedelta(seconds=1)) await hass.async_block_till_done() msg = await client.receive_json() From 6dcfe861fdc1907cdd88909d4a88ab2de4029586 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Apr 2024 18:34:57 -0500 Subject: [PATCH 040/107] Bump habluetooth to 2.5.2 (#115721) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_scanner.py | 233 +++++++++--------- 5 files changed, 121 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 58009216464..5939a03cefc 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.4.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.4.2" + "habluetooth==2.5.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b6f814c9f58..92522a69e53 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.2.0 -habluetooth==2.4.2 +habluetooth==2.5.2 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 diff --git a/requirements_all.txt b/requirements_all.txt index 64d67ada712..c4e22294747 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1029,7 +1029,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.4.2 +habluetooth==2.5.2 # homeassistant.components.cloud hass-nabucasa==0.78.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd3bad4398b..be395c42054 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -843,7 +843,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.4.2 +habluetooth==2.5.2 # homeassistant.components.cloud hass-nabucasa==0.78.0 diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 523364e0dfd..5658aea523b 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -22,7 +22,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( - _get_manager, async_setup_with_one_adapter, generate_advertisement_data, generate_ble_device, @@ -183,7 +182,7 @@ async def test_adapter_needs_reset_at_start( with ( patch( "habluetooth.scanner.OriginalBleakScanner.start", - side_effect=[BleakError(error), None], + side_effect=[BleakError(error), BleakError(error), None], ), patch( "habluetooth.util.recover_adapter", return_value=True @@ -239,46 +238,47 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 - start_time_monotonic = time.monotonic() - mock_discovered = [MagicMock()] + start_time_monotonic = time.monotonic() + mock_discovered = [MagicMock()] - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 10, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 10, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # Fire a callback to reset the timer - with patch_bluetooth_time( - start_time_monotonic, - ): - _callback( - generate_ble_device("44:44:33:11:23:42", "any_name"), - generate_advertisement_data(local_name="any_name"), - ) + # Fire a callback to reset the timer + with patch_bluetooth_time( + start_time_monotonic, + ): + _callback( + generate_ble_device("44:44:33:11:23:42", "any_name"), + generate_advertisement_data(local_name="any_name"), + ) - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 20, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 20, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # We hit the timer, so we restart the scanner - with patch_bluetooth_time( - start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, - ): - async_fire_time_changed( - hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20) - ) - await hass.async_block_till_done() + # We hit the timer, so we restart the scanner + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20), + ) + await hass.async_block_till_done() - assert called_start == 2 + assert called_start == 2 async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: @@ -327,43 +327,42 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 - scanner = _get_manager() - mock_discovered = [MagicMock()] + mock_discovered = [MagicMock()] - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 10, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 10, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 20, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 20, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # We hit the timer with no detections, so we reset the adapter and restart the scanner - with ( - patch_bluetooth_time( - start_time_monotonic - + SCANNER_WATCHDOG_TIMEOUT - + SCANNER_WATCHDOG_INTERVAL.total_seconds(), - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # We hit the timer with no detections, so we reset the adapter and restart the scanner + with ( + patch_bluetooth_time( + start_time_monotonic + + SCANNER_WATCHDOG_TIMEOUT + + SCANNER_WATCHDOG_INTERVAL.total_seconds(), + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert len(mock_recover_adapter.mock_calls) == 1 - assert called_start == 2 + assert len(mock_recover_adapter.mock_calls) == 1 + assert called_start == 2 async def test_adapter_scanner_fails_to_start_first_time( @@ -418,61 +417,61 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 - scanner = _get_manager() - mock_discovered = [MagicMock()] + mock_discovered = [MagicMock()] - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 10, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 10, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 20, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 20, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # We hit the timer with no detections, so we reset the adapter and restart the scanner - with ( - patch_bluetooth_time( - start_time_monotonic - + SCANNER_WATCHDOG_TIMEOUT - + SCANNER_WATCHDOG_INTERVAL.total_seconds(), - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # We hit the timer with no detections, so we reset the adapter and restart the scanner + with ( + patch_bluetooth_time( + start_time_monotonic + + SCANNER_WATCHDOG_TIMEOUT + + SCANNER_WATCHDOG_INTERVAL.total_seconds(), + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert len(mock_recover_adapter.mock_calls) == 1 - assert called_start == 3 + assert len(mock_recover_adapter.mock_calls) == 1 + assert called_start == 4 - # We hit the timer again the previous start call failed, make sure - # we try again - with ( - patch_bluetooth_time( - start_time_monotonic - + SCANNER_WATCHDOG_TIMEOUT - + SCANNER_WATCHDOG_INTERVAL.total_seconds(), - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + now_monotonic = time.monotonic() + # We hit the timer again the previous start call failed, make sure + # we try again + with ( + patch_bluetooth_time( + now_monotonic + + SCANNER_WATCHDOG_TIMEOUT * 2 + + SCANNER_WATCHDOG_INTERVAL.total_seconds(), + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert len(mock_recover_adapter.mock_calls) == 1 - assert called_start == 4 + assert len(mock_recover_adapter.mock_calls) == 1 + assert called_start == 5 async def test_adapter_fails_to_start_and_takes_a_bit_to_init( @@ -497,9 +496,11 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( nonlocal called_start called_start += 1 if called_start == 1: - raise BleakError("org.bluez.Error.InProgress") - if called_start == 2: raise BleakError("org.freedesktop.DBus.Error.UnknownObject") + if called_start == 2: + raise BleakError("org.bluez.Error.InProgress") + if called_start == 3: + raise BleakError("org.bluez.Error.InProgress") async def stop(self, *args, **kwargs): """Mock Start.""" @@ -538,7 +539,7 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( ): await async_setup_with_one_adapter(hass) - assert called_start == 3 + assert called_start == 4 assert len(mock_recover_adapter.mock_calls) == 1 assert "Waiting for adapter to initialize" in caplog.text From 94a66fa64896a19ca657b8da9cc238ddcbb6cda6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 17 Apr 2024 15:39:38 +1200 Subject: [PATCH 041/107] Bump aioesphomeapi to 24.1.0 (#115729) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4d5636a6f26..e700dddbb96 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "aioesphomeapi==24.0.0", + "aioesphomeapi==24.1.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.0.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index c4e22294747..5c1df9b1844 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==24.0.0 +aioesphomeapi==24.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be395c42054..0342a10d69c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -222,7 +222,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==24.0.0 +aioesphomeapi==24.1.0 # homeassistant.components.flo aioflo==2021.11.0 From 537b00db9a519f633edfd33c3622dcb090b8f6dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 08:50:33 +0200 Subject: [PATCH 042/107] Fix stale comment in wheels.yml (#115736) --- .github/workflows/wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9f127acb57d..0148e476892 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -172,7 +172,7 @@ jobs: - name: Split requirements all run: | - # We split requirements all into two different files. + # We split requirements all into multiple files. # This is to prevent the build from running out of memory when # resolving packages on 32-bits systems (like armhf, armv7). From 20ff101015c2910c4ba66c066de14efc1da726ec Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 17 Apr 2024 09:06:47 +0200 Subject: [PATCH 043/107] Multiple data disks detected: tweak strings (#115713) * Multiple data disks: tweak strings * Fix typos * Update homeassistant/components/hassio/strings.json Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/hassio/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index fe026be6633..6abf9ca6334 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -52,14 +52,14 @@ "fix_flow": { "step": { "fix_menu": { - "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the 'Rename' option to rename the filesystem to prevent this. Use the 'Adopt' option to make that your data disk and rename the existing one. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system.", + "description": "At `{reference}`, we detected another active data disk (containing a file system `hassos-data` from another Home Assistant installation).\n\nYou need to decide what to do with it. Otherwise Home Assistant might choose the wrong data disk at system reboot.\n\nIf you don't want to use this data disk, unplug it from your system. If you leave it plugged in, choose one of the following options:", "menu_options": { - "system_rename_data_disk": "Rename", - "system_adopt_data_disk": "Adopt" + "system_rename_data_disk": "Mark as inactive data disk (rename file system)", + "system_adopt_data_disk": "Use the detected data disk instead of the current system" } }, "system_adopt_data_disk": { - "description": "This fix will initiate a system reboot which will make Home Assistant and all the Add-ons inaccessible for a brief period. After the reboot `{reference}` will be the data disk of Home Assistant and your existing data disk will be renamed and ignored." + "description": "Select submit to make `{reference}` the active data disk. The one and only.\n\nYou won't have access anymore to the current Home Assistant data (will be marked as inactive data disk). After reboot, your system will be in the state of the Home Assistant data on `{reference}`." } }, "abort": { From 34c3e523b4334e4b1079e90177d17e8cd1a7e0f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 02:10:06 -0500 Subject: [PATCH 044/107] Bump aiohttp to 3.9.5 (#115727) changelog: https://github.com/aio-libs/aiohttp/compare/v3.9.4...v3.9.5 --- 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 92522a69e53..2921a845244 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.0.0 aiodns==3.2.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 -aiohttp==3.9.4 +aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 3db19fe6851..8e521fc35a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ requires-python = ">=3.12.0" dependencies = [ "aiodns==3.2.0", - "aiohttp==3.9.4", + "aiohttp==3.9.5", "aiohttp_cors==0.7.0", "aiohttp_session==2.12.0", "aiohttp-fast-url-dispatcher==0.3.0", diff --git a/requirements.txt b/requirements.txt index 3c2a453b762..440e71d2286 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohttp==3.9.4 +aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 aiohttp-fast-url-dispatcher==0.3.0 From dae56222e90868ce3514012cee9a08ba6b649580 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 02:13:55 -0500 Subject: [PATCH 045/107] Bump orjson to 3.10.1 (#115728) changelog: https://github.com/ijl/orjson/compare/3.9.15...3.10.1 --- 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 2921a845244..318738558e8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ ifaddr==0.2.0 Jinja2==3.1.3 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.9.15 +orjson==3.10.1 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.3.0 diff --git a/pyproject.toml b/pyproject.toml index 8e521fc35a5..2216295d00d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "cryptography==42.0.5", "Pillow==10.3.0", "pyOpenSSL==24.1.0", - "orjson==3.9.15", + "orjson==3.10.1", "packaging>=23.1", "pip>=21.3.1", "psutil-home-assistant==0.0.1", diff --git a/requirements.txt b/requirements.txt index 440e71d2286..650a9bf7554 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ PyJWT==2.8.0 cryptography==42.0.5 Pillow==10.3.0 pyOpenSSL==24.1.0 -orjson==3.9.15 +orjson==3.10.1 packaging>=23.1 pip>=21.3.1 psutil-home-assistant==0.0.1 From ba4731ecb419a17a4d6742d27ea172e38b7686d0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 11:54:53 +0200 Subject: [PATCH 046/107] Remove stale packages from uncommenting when building wheels (#115700) --- .github/workflows/wheels.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0148e476892..3636906c305 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -142,11 +142,9 @@ jobs: run: | requirement_files="requirements_all.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do - sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} sed -i "s|# evdev|evdev|g" ${requirement_file} sed -i "s|# pycups|pycups|g" ${requirement_file} - sed -i "s|# homekit|homekit|g" ${requirement_file} sed -i "s|# decora-wifi|decora-wifi|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} From a3c767da2d407246e7df7adeae2b614f46a0dae2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 12:03:06 +0200 Subject: [PATCH 047/107] Correct normalize_package_name (#115750) --- script/gen_requirements_all.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 94147e3932b..b6a37df9012 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -261,8 +261,8 @@ def normalize_package_name(requirement: str) -> str: if not match: return "" - # pipdeptree needs lowercase and dash instead of underscore as separator - return match.group(1).lower().replace("_", "-") + # pipdeptree needs lowercase and dash instead of underscore or period as separator + return match.group(1).lower().replace("_", "-").replace(".", "-") def comment_requirement(req: str) -> bool: From ff1ac1a5446801845e2637d5651fcb284528d7d0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 12:12:51 +0200 Subject: [PATCH 048/107] Remove useless any in gen_requirements_all.comment_requirement (#115751) --- script/gen_requirements_all.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b6a37df9012..7fc0907e756 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -267,9 +267,7 @@ def normalize_package_name(requirement: str) -> str: def comment_requirement(req: str) -> bool: """Comment out requirement. Some don't install on all systems.""" - return any( - normalize_package_name(req) == ign for ign in COMMENT_REQUIREMENTS_NORMALIZED - ) + return normalize_package_name(req) in COMMENT_REQUIREMENTS_NORMALIZED def gather_modules() -> dict[str, list[str]] | None: From fee1f2833d0ac0f436ff8be8898152eec52003b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 12:27:48 +0200 Subject: [PATCH 049/107] Fix hassfest requirements check (#115744) * Fix hassfest requirements check * Add electrasmart to ignore list --- script/hassfest/requirements.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 18d560f840f..aba7e5819d2 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -32,6 +32,7 @@ IGNORE_VIOLATIONS = { # Still has standard library requirements. "acmeda", "blink", + "electrasmart", "ezviz", "hdmi_cec", "juicenet", @@ -126,7 +127,7 @@ def validate_requirements(integration: Integration) -> None: f"Failed to normalize package name from requirement {req}", ) return - if (package == ign for ign in IGNORE_PACKAGES): + if package in IGNORE_PACKAGES: continue integration_requirements.add(req) integration_packages.add(package) @@ -150,7 +151,7 @@ def validate_requirements(integration: Integration) -> None: # Check for requirements incompatible with standard library. for req in all_integration_requirements: - if req in sys.stlib_module_names: + if req in sys.stdlib_module_names: integration.add_error( "requirements", f"Package {req} is not compatible with the Python standard library", From cb16465539eedd02c75558617b3a4fa37fd42175 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 06:23:20 -0500 Subject: [PATCH 050/107] Keep track of top level components (#115586) * Keep track of top level components Currently we have to do a set comp for icons, translations, and integration platforms every time to split the top level components from the platforms. Keep track of the top level components in a seperate set so avoid having to do the setcomp every time. * remove impossible paths * remove unused code * preen * preen * fix * coverage and fixes * Update homeassistant/core.py * Update homeassistant/core.py * Update tests/test_core.py --- homeassistant/core.py | 44 +++++++++++++++- homeassistant/helpers/icon.py | 50 +++++++------------ homeassistant/helpers/integration_platform.py | 2 +- homeassistant/helpers/translation.py | 11 ++-- tests/helpers/test_icon.py | 4 +- tests/test_core.py | 17 +++++++ 6 files changed, 83 insertions(+), 45 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index d957953b609..69227f793a1 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2670,6 +2670,41 @@ class ServiceRegistry: return await self._hass.async_add_executor_job(target, service_call) +class _ComponentSet(set[str]): + """Set of loaded components. + + This set contains both top level components and platforms. + + Examples: + `light`, `switch`, `hue`, `mjpeg.camera`, `universal.media_player`, + `homeassistant.scene` + + The top level components set only contains the top level components. + + """ + + def __init__(self, top_level_components: set[str]) -> None: + """Initialize the component set.""" + self._top_level_components = top_level_components + + def add(self, component: str) -> None: + """Add a component to the store.""" + if "." not in component: + self._top_level_components.add(component) + return super().add(component) + + def remove(self, component: str) -> None: + """Remove a component from the store.""" + if "." in component: + raise ValueError("_ComponentSet does not support removing sub-components") + self._top_level_components.remove(component) + return super().remove(component) + + def discard(self, component: str) -> None: + """Remove a component from the store.""" + raise NotImplementedError("_ComponentSet does not support discard, use remove") + + class Config: """Configuration settings for Home Assistant.""" @@ -2702,8 +2737,13 @@ class Config: # List of packages to skip when installing requirements on startup self.skip_pip_packages: list[str] = [] - # List of loaded components - self.components: set[str] = set() + # Set of loaded top level components + # This set is updated by _ComponentSet + # and should not be modified directly + self.top_level_components: set[str] = set() + + # Set of loaded components + self.components: _ComponentSet = _ComponentSet(self.top_level_components) # API (HTTP) server configuration self.api: ApiConfig | None = None diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index 973c93674b1..db90d38744a 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Iterable from functools import lru_cache import logging +import pathlib from typing import Any from homeassistant.core import HomeAssistant, callback @@ -20,23 +21,17 @@ _LOGGER = logging.getLogger(__name__) @callback -def _component_icons_path(component: str, integration: Integration) -> str | None: +def _component_icons_path(integration: Integration) -> pathlib.Path: """Return the icons json file location for a component. Ex: components/hue/icons.json - If component is just a single file, will return None. """ - domain = component.rpartition(".")[-1] - - # If it's a component that is just one file, we don't support icons - # Example custom_components/my_component.py - if integration.file_path.name != domain: - return None - - return str(integration.file_path / "icons.json") + return integration.file_path / "icons.json" -def _load_icons_files(icons_files: dict[str, str]) -> dict[str, dict[str, Any]]: +def _load_icons_files( + icons_files: dict[str, pathlib.Path], +) -> dict[str, dict[str, Any]]: """Load and parse icons.json files.""" return { component: load_json_object(icons_file) @@ -53,19 +48,15 @@ async def _async_get_component_icons( icons: dict[str, Any] = {} # Determine files to load - files_to_load = {} - for loaded in components: - domain = loaded.rpartition(".")[-1] - if (path := _component_icons_path(loaded, integrations[domain])) is None: - icons[loaded] = {} - else: - files_to_load[loaded] = path + files_to_load = { + comp: _component_icons_path(integrations[comp]) for comp in components + } # Load files - if files_to_load and ( - load_icons_job := hass.async_add_executor_job(_load_icons_files, files_to_load) - ): - icons |= await load_icons_job + if files_to_load: + icons.update( + await hass.async_add_executor_job(_load_icons_files, files_to_load) + ) return icons @@ -108,8 +99,7 @@ class _IconsCache: _LOGGER.debug("Cache miss for: %s", components) integrations: dict[str, Integration] = {} - domains = {loaded.rpartition(".")[-1] for loaded in components} - ints_or_excs = await async_get_integrations(self._hass, domains) + ints_or_excs = await async_get_integrations(self._hass, components) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): raise int_or_exc @@ -127,11 +117,9 @@ class _IconsCache: icons: dict[str, dict[str, Any]], ) -> None: """Extract resources into the cache.""" - categories: set[str] = set() - - for resource in icons.values(): - categories.update(resource) - + categories = { + category for component in icons.values() for category in component + } for category in categories: self._cache.setdefault(category, {}).update( build_resources(icons, components, category) @@ -151,9 +139,7 @@ async def async_get_icons( if integrations: components = set(integrations) else: - components = { - component for component in hass.config.components if "." not in component - } + components = hass.config.top_level_components if ICON_CACHE in hass.data: cache: _IconsCache = hass.data[ICON_CACHE] diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 70846156702..be525b384e0 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -174,7 +174,7 @@ async def async_process_integration_platforms( integration_platforms = hass.data[DATA_INTEGRATION_PLATFORMS] async_register_preload_platform(hass, platform_name) - top_level_components = {comp for comp in hass.config.components if "." not in comp} + top_level_components = hass.config.top_level_components.copy() process_job = HassJob( catch_log_exception( process_platform, diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 1fc2c3d075b..5ec3af2d382 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -212,8 +212,7 @@ class _TranslationCache: languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] integrations: dict[str, Integration] = {} - domains = {loaded.partition(".")[0] for loaded in components} - ints_or_excs = await async_get_integrations(self.hass, domains) + ints_or_excs = await async_get_integrations(self.hass, components) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): _LOGGER.warning( @@ -345,7 +344,7 @@ async def async_get_translations( elif integrations is not None: components = set(integrations) else: - components = {comp for comp in hass.config.components if "." not in comp} + components = hass.config.top_level_components return await _async_get_translations_cache(hass).async_fetch( language, category, components @@ -364,11 +363,7 @@ def async_get_cached_translations( If integration is specified, return translations for it. Otherwise, default to all loaded integrations. """ - if integration is not None: - components = {integration} - else: - components = {comp for comp in hass.config.components if "." not in comp} - + components = {integration} if integration else hass.config.top_level_components return _async_get_translations_cache(hass).get_cached( language, category, components ) diff --git a/tests/helpers/test_icon.py b/tests/helpers/test_icon.py index e986a07d7d5..5ad5071266b 100644 --- a/tests/helpers/test_icon.py +++ b/tests/helpers/test_icon.py @@ -106,8 +106,8 @@ async def test_get_icons(hass: HomeAssistant) -> None: # Ensure icons file for platform isn't loaded, as that isn't supported icons = await icon.async_get_icons(hass, "entity") assert icons == {} - icons = await icon.async_get_icons(hass, "entity", ["test.switch"]) - assert icons == {} + with pytest.raises(ValueError, match="test.switch"): + await icon.async_get_icons(hass, "entity", ["test.switch"]) # Load up an custom integration hass.config.components.add("test_package") diff --git a/tests/test_core.py b/tests/test_core.py index 58738e3e52a..caed1433082 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3411,3 +3411,20 @@ async def test_async_listen_with_run_immediately_deprecated( f"Detected code that calls `{method}` with run_immediately, which is " "deprecated and will be removed in Home Assistant 2025.5." ) in caplog.text + + +async def test_top_level_components(hass: HomeAssistant) -> None: + """Test top level components are updated when components change.""" + hass.config.components.add("homeassistant") + assert hass.config.components == {"homeassistant"} + assert hass.config.top_level_components == {"homeassistant"} + hass.config.components.add("homeassistant.scene") + assert hass.config.components == {"homeassistant", "homeassistant.scene"} + assert hass.config.top_level_components == {"homeassistant"} + hass.config.components.remove("homeassistant") + assert hass.config.components == {"homeassistant.scene"} + assert hass.config.top_level_components == set() + with pytest.raises(ValueError): + hass.config.components.remove("homeassistant.scene") + with pytest.raises(NotImplementedError): + hass.config.components.discard("homeassistant") From 5c018f6ffceefd02f0f42779c2348e802a7e0541 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 14:10:26 +0200 Subject: [PATCH 051/107] Improve standard library violation check in hassfest (#115752) * Improve standard library violation check in hassfest * Improve prints * Improve error message --- script/hassfest/requirements.py | 45 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index aba7e5819d2..ee63bf07f90 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -28,16 +28,9 @@ PACKAGE_REGEX = re.compile( PIP_REGEX = re.compile(r"^(--.+\s)?([-_\.\w\d]+.*(?:==|>=|<=|~=|!=|<|>|===)?.*$)") PIP_VERSION_RANGE_SEPARATOR = re.compile(r"^(==|>=|<=|~=|!=|<|>|===)?(.*)$") -IGNORE_VIOLATIONS = { - # Still has standard library requirements. - "acmeda", - "blink", +IGNORE_STANDARD_LIBRARY_VIOLATIONS = { + # Integrations which have standard library requirements. "electrasmart", - "ezviz", - "hdmi_cec", - "juicenet", - "lupusec", - "rainbird", "slide", "suez_water", } @@ -113,10 +106,6 @@ def validate_requirements(integration: Integration) -> None: if not validate_requirements_format(integration): return - # Some integrations have not been fixed yet so are allowed to have violations. - if integration.domain in IGNORE_VIOLATIONS: - return - integration_requirements = set() integration_packages = set() for req in integration.requirements: @@ -150,12 +139,34 @@ def validate_requirements(integration: Integration) -> None: return # Check for requirements incompatible with standard library. + standard_library_violations = set() for req in all_integration_requirements: if req in sys.stdlib_module_names: - integration.add_error( - "requirements", - f"Package {req} is not compatible with the Python standard library", - ) + standard_library_violations.add(req) + + if ( + standard_library_violations + and integration.domain not in IGNORE_STANDARD_LIBRARY_VIOLATIONS + ): + integration.add_error( + "requirements", + ( + f"Package {req} has dependencies {standard_library_violations} which " + "are not compatible with the Python standard library" + ), + ) + elif ( + not standard_library_violations + and integration.domain in IGNORE_STANDARD_LIBRARY_VIOLATIONS + ): + integration.add_error( + "requirements", + ( + f"Integration {integration.domain} no longer has requirements which are" + " incompatible with the Python standard library, remove it from " + "IGNORE_STANDARD_LIBRARY_VIOLATIONS" + ), + ) @cache From 92aae4d368e7a5953206bbcf7837bc0c35d548b4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 17 Apr 2024 14:23:47 +0200 Subject: [PATCH 052/107] Bump renault-api to 0.2.2 (#115738) --- 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 98e1c8b1e7c..9891c838950 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.2.1"] + "requirements": ["renault-api==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5c1df9b1844..2f8d3e43780 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2427,7 +2427,7 @@ refoss-ha==1.2.0 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.1 +renault-api==0.2.2 # homeassistant.components.renson renson-endura-delta==1.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0342a10d69c..c70de76d37e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ refoss-ha==1.2.0 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.1 +renault-api==0.2.2 # homeassistant.components.renson renson-endura-delta==1.7.1 From 864c80fa55d2ad4f3e3c82bc85009ff5ae3958a7 Mon Sep 17 00:00:00 2001 From: Tomasz Date: Wed, 17 Apr 2024 14:24:34 +0200 Subject: [PATCH 053/107] Add Sanix integration (#106785) * Add Sanix integration * Add Sanix integration * Add sanix pypi package * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Fix ruff * Fix * Fix --------- Co-authored-by: Joostlek --- CODEOWNERS | 2 + homeassistant/components/sanix/__init__.py | 37 ++++++ homeassistant/components/sanix/config_flow.py | 60 +++++++++ homeassistant/components/sanix/const.py | 8 ++ homeassistant/components/sanix/coordinator.py | 36 +++++ homeassistant/components/sanix/icons.json | 9 ++ homeassistant/components/sanix/manifest.json | 9 ++ homeassistant/components/sanix/sensor.py | 125 ++++++++++++++++++ homeassistant/components/sanix/strings.json | 36 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/sanix/__init__.py | 13 ++ tests/components/sanix/conftest.py | 52 ++++++++ .../sanix/fixtures/get_measurements.json | 10 ++ tests/components/sanix/test_config_flow.py | 112 ++++++++++++++++ tests/components/sanix/test_init.py | 27 ++++ 18 files changed, 549 insertions(+) create mode 100644 homeassistant/components/sanix/__init__.py create mode 100644 homeassistant/components/sanix/config_flow.py create mode 100644 homeassistant/components/sanix/const.py create mode 100644 homeassistant/components/sanix/coordinator.py create mode 100644 homeassistant/components/sanix/icons.json create mode 100644 homeassistant/components/sanix/manifest.json create mode 100644 homeassistant/components/sanix/sensor.py create mode 100644 homeassistant/components/sanix/strings.json create mode 100644 tests/components/sanix/__init__.py create mode 100644 tests/components/sanix/conftest.py create mode 100644 tests/components/sanix/fixtures/get_measurements.json create mode 100644 tests/components/sanix/test_config_flow.py create mode 100644 tests/components/sanix/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 56d42e5a3f3..a4224025acc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1186,6 +1186,8 @@ build.json @home-assistant/supervisor /homeassistant/components/saj/ @fredericvl /homeassistant/components/samsungtv/ @chemelli74 @epenet /tests/components/samsungtv/ @chemelli74 @epenet +/homeassistant/components/sanix/ @tomaszsluszniak +/tests/components/sanix/ @tomaszsluszniak /homeassistant/components/scene/ @home-assistant/core /tests/components/scene/ @home-assistant/core /homeassistant/components/schedule/ @home-assistant/core diff --git a/homeassistant/components/sanix/__init__.py b/homeassistant/components/sanix/__init__.py new file mode 100644 index 00000000000..c8c5567eedc --- /dev/null +++ b/homeassistant/components/sanix/__init__.py @@ -0,0 +1,37 @@ +"""The Sanix integration.""" + +from sanix import Sanix + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_SERIAL_NUMBER, DOMAIN +from .coordinator import SanixCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sanix from a config entry.""" + + serial_no = entry.data[CONF_SERIAL_NUMBER] + token = entry.data[CONF_TOKEN] + + sanix_api = Sanix(serial_no, token) + coordinator = SanixCoordinator(hass, sanix_api) + + 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): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/sanix/config_flow.py b/homeassistant/components/sanix/config_flow.py new file mode 100644 index 00000000000..57aa5a5293a --- /dev/null +++ b/homeassistant/components/sanix/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for Sanix integration.""" + +import logging +from typing import Any + +from sanix import Sanix +from sanix.exceptions import SanixException, SanixInvalidAuthException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_TOKEN + +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_NUMBER): str, + vol.Required(CONF_TOKEN): str, + } +) + + +class SanixConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sanix.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input: + await self.async_set_unique_id(user_input[CONF_SERIAL_NUMBER]) + self._abort_if_unique_id_configured() + + sanix_api = Sanix(user_input[CONF_SERIAL_NUMBER], user_input[CONF_TOKEN]) + + try: + await self.hass.async_add_executor_job(sanix_api.fetch_data) + except SanixInvalidAuthException: + errors["base"] = "invalid_auth" + except SanixException: + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=MANUFACTURER, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + description_placeholders={"dashboard_url": "https://sanix.bitcomplex.pl/"}, + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/sanix/const.py b/homeassistant/components/sanix/const.py new file mode 100644 index 00000000000..22ab33823d6 --- /dev/null +++ b/homeassistant/components/sanix/const.py @@ -0,0 +1,8 @@ +"""Constants for the Sanix integration.""" + +CONF_SERIAL_NUMBER = "serial_number" + +DOMAIN = "sanix" +MANUFACTURER = "Sanix" + +SANIX_API_HOST = "https://sanix.bitcomplex.pl" diff --git a/homeassistant/components/sanix/coordinator.py b/homeassistant/components/sanix/coordinator.py new file mode 100644 index 00000000000..d6362337a38 --- /dev/null +++ b/homeassistant/components/sanix/coordinator.py @@ -0,0 +1,36 @@ +"""Sanix Coordinator.""" + +from datetime import timedelta +import logging + +from sanix import Sanix +from sanix.exceptions import SanixException +from sanix.models import Measurement + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +class SanixCoordinator(DataUpdateCoordinator[Measurement]): + """Sanix coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, sanix_api: Sanix) -> None: + """Initialize coordinator.""" + super().__init__( + hass, _LOGGER, name=MANUFACTURER, update_interval=timedelta(hours=1) + ) + self._sanix_api = sanix_api + + async def _async_update_data(self) -> Measurement: + """Fetch data from API endpoint.""" + try: + return await self.hass.async_add_executor_job(self._sanix_api.fetch_data) + except SanixException as err: + raise UpdateFailed("Error while communicating with the API") from err diff --git a/homeassistant/components/sanix/icons.json b/homeassistant/components/sanix/icons.json new file mode 100644 index 00000000000..2b49cf8ea20 --- /dev/null +++ b/homeassistant/components/sanix/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "fill_perc": { + "default": "mdi:water-percent" + } + } + } +} diff --git a/homeassistant/components/sanix/manifest.json b/homeassistant/components/sanix/manifest.json new file mode 100644 index 00000000000..4e1c6d56add --- /dev/null +++ b/homeassistant/components/sanix/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "sanix", + "name": "Sanix", + "codeowners": ["@tomaszsluszniak"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sanix", + "iot_class": "cloud_polling", + "requirements": ["sanix==1.0.5"] +} diff --git a/homeassistant/components/sanix/sensor.py b/homeassistant/components/sanix/sensor.py new file mode 100644 index 00000000000..e780c6f2df0 --- /dev/null +++ b/homeassistant/components/sanix/sensor.py @@ -0,0 +1,125 @@ +"""Platform for Sanix integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date, datetime + +from sanix.const import ( + ATTR_API_BATTERY, + ATTR_API_DEVICE_NO, + ATTR_API_DISTANCE, + ATTR_API_FILL_PERC, + ATTR_API_SERVICE_DATE, + ATTR_API_SSID, +) +from sanix.models import Measurement + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import SanixCoordinator + + +@dataclass(frozen=True, kw_only=True) +class SanixSensorEntityDescription(SensorEntityDescription): + """Class describing Sanix Sensor entities.""" + + native_value_fn: Callable[[Measurement], int | datetime | date | str] + + +SENSOR_TYPES: tuple[SanixSensorEntityDescription, ...] = ( + SanixSensorEntityDescription( + key=ATTR_API_BATTERY, + translation_key=ATTR_API_BATTERY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + native_value_fn=lambda data: data.battery, + ), + SanixSensorEntityDescription( + key=ATTR_API_DISTANCE, + translation_key=ATTR_API_DISTANCE, + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + native_value_fn=lambda data: data.distance, + ), + SanixSensorEntityDescription( + key=ATTR_API_SERVICE_DATE, + translation_key=ATTR_API_SERVICE_DATE, + device_class=SensorDeviceClass.DATE, + native_value_fn=lambda data: data.service_date, + ), + SanixSensorEntityDescription( + key=ATTR_API_FILL_PERC, + translation_key=ATTR_API_FILL_PERC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_value_fn=lambda data: data.fill_perc, + ), + SanixSensorEntityDescription( + key=ATTR_API_SSID, + translation_key=ATTR_API_SSID, + entity_registry_enabled_default=False, + native_value_fn=lambda data: data.ssid, + ), + SanixSensorEntityDescription( + key=ATTR_API_DEVICE_NO, + translation_key=ATTR_API_DEVICE_NO, + entity_registry_enabled_default=False, + native_value_fn=lambda data: data.device_no, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sanix Sensor entities based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + SanixSensorEntity(coordinator, description) for description in SENSOR_TYPES + ) + + +class SanixSensorEntity(CoordinatorEntity[SanixCoordinator], SensorEntity): + """Sanix Sensor entity.""" + + _attr_has_entity_name = True + entity_description: SanixSensorEntityDescription + + def __init__( + self, + coordinator: SanixCoordinator, + description: SanixSensorEntityDescription, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + serial_no = str(coordinator.config_entry.unique_id) + + self._attr_unique_id = f"{serial_no}-{description.key}" + self.entity_description = description + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_no)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + serial_number=serial_no, + ) + + @property + def native_value(self) -> int | datetime | date | str: + """Return the state of the sensor.""" + return self.entity_description.native_value_fn(self.coordinator.data) diff --git a/homeassistant/components/sanix/strings.json b/homeassistant/components/sanix/strings.json new file mode 100644 index 00000000000..6bff11e36af --- /dev/null +++ b/homeassistant/components/sanix/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "description": "To get the Serial number and the Token you just have to sign in to the [Sanix Dashboard]({dashboard_url}) and open the Help -> System version page.", + "data": { + "serial_number": "Serial number", + "token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "service_date": { + "name": "Service date" + }, + "fill_perc": { + "name": "Filled" + }, + "device_no": { + "name": "Device number" + }, + "ssid": { + "name": "SSID" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 30d580ad1ea..fd87c965db5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -457,6 +457,7 @@ FLOWS = { "rympro", "sabnzbd", "samsungtv", + "sanix", "schlage", "scrape", "screenlogic", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fa2cec4d77a..d10cb3fdb80 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5180,6 +5180,12 @@ } } }, + "sanix": { + "name": "Sanix", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "satel_integra": { "name": "Satel Integra", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 2f8d3e43780..74b458920e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2492,6 +2492,9 @@ samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv samsungtvws[async,encrypted]==2.6.0 +# homeassistant.components.sanix +sanix==1.0.5 + # homeassistant.components.satel_integra satel-integra==0.3.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c70de76d37e..ea115d4b29d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,6 +1929,9 @@ samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv samsungtvws[async,encrypted]==2.6.0 +# homeassistant.components.sanix +sanix==1.0.5 + # homeassistant.components.screenlogic screenlogicpy==0.10.0 diff --git a/tests/components/sanix/__init__.py b/tests/components/sanix/__init__.py new file mode 100644 index 00000000000..ef1a9c63fbe --- /dev/null +++ b/tests/components/sanix/__init__.py @@ -0,0 +1,13 @@ +"""Tests for Sanix.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/sanix/conftest.py b/tests/components/sanix/conftest.py new file mode 100644 index 00000000000..297416a6290 --- /dev/null +++ b/tests/components/sanix/conftest.py @@ -0,0 +1,52 @@ +"""Sanix tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sanix.models import Measurement + +from homeassistant.components.sanix.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_TOKEN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_sanix(): + """Build a fixture for the Sanix API that connects successfully and returns measurements.""" + fixture = load_json_object_fixture("sanix/get_measurements.json") + mock_sanix_api = MagicMock() + with ( + patch( + "homeassistant.components.sanix.config_flow.Sanix", + return_value=mock_sanix_api, + ) as mock_sanix_api, + patch( + "homeassistant.components.sanix.Sanix", + return_value=mock_sanix_api, + ), + ): + mock_sanix_api.return_value.fetch_data.return_value = Measurement(**fixture) + yield mock_sanix_api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Sanix", + unique_id="1810088", + data={CONF_SERIAL_NUMBER: "1234", CONF_TOKEN: "abcd"}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sanix.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sanix/fixtures/get_measurements.json b/tests/components/sanix/fixtures/get_measurements.json new file mode 100644 index 00000000000..de6f4c41311 --- /dev/null +++ b/tests/components/sanix/fixtures/get_measurements.json @@ -0,0 +1,10 @@ +{ + "device_no": "SANIX-1810088", + "status": "1", + "time": "30.12.2023 03:10:21", + "ssid": "Wifi", + "battery": "100", + "distance": "109", + "fill_perc": 32, + "service_date": "15.06.2024" +} diff --git a/tests/components/sanix/test_config_flow.py b/tests/components/sanix/test_config_flow.py new file mode 100644 index 00000000000..abd91ee306c --- /dev/null +++ b/tests/components/sanix/test_config_flow.py @@ -0,0 +1,112 @@ +"""Define tests for the Sanix config flow.""" + +from unittest.mock import MagicMock + +import pytest +from sanix.exceptions import SanixException, SanixInvalidAuthException + +from homeassistant.components.sanix.const import ( + CONF_SERIAL_NUMBER, + DOMAIN, + MANUFACTURER, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +CONFIG = {CONF_SERIAL_NUMBER: "1810088", CONF_TOKEN: "75868dcf8ea4c64e2063f6c4e70132d2"} + + +async def test_create_entry( + hass: HomeAssistant, mock_sanix: MagicMock, mock_setup_entry +) -> None: + """Test that the user step works.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MANUFACTURER + assert result["data"] == { + CONF_SERIAL_NUMBER: "1810088", + CONF_TOKEN: "75868dcf8ea4c64e2063f6c4e70132d2", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SanixInvalidAuthException("Invalid auth"), "invalid_auth"), + (SanixException("Something went wrong"), "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_sanix: MagicMock, + mock_setup_entry, +) -> None: + """Test Form exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_sanix.return_value.fetch_data.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + + mock_sanix.return_value.fetch_data.side_effect = None + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sanix" + assert result["data"] == { + CONF_SERIAL_NUMBER: "1810088", + CONF_TOKEN: "75868dcf8ea4c64e2063f6c4e70132d2", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_error( + hass: HomeAssistant, mock_sanix: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that errors are shown when duplicates are added.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/sanix/test_init.py b/tests/components/sanix/test_init.py new file mode 100644 index 00000000000..57e4920da11 --- /dev/null +++ b/tests/components/sanix/test_init.py @@ -0,0 +1,27 @@ +"""Test the Home Assistant analytics init module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.sanix import setup_integration + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_sanix: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From 3f62267a482fa800c5f556b2b8dbcb53a96ef591 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 14:29:49 +0200 Subject: [PATCH 054/107] Fix flaky qld_bushfire test (#115757) --- tests/components/qld_bushfire/test_geo_location.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index 522c5fabe90..20659182726 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -169,7 +169,7 @@ async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> Non [mock_entry_1, mock_entry_4, mock_entry_3], ) async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) all_states = hass.states.async_all() assert len(all_states) == 3 From e45583b83b9d434370a23a830e1ba72a4997e26d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 15:00:10 +0200 Subject: [PATCH 055/107] Fix homeworks import flow (#115761) --- .../components/homeworks/config_flow.py | 10 +----- .../components/homeworks/test_config_flow.py | 32 +------------------ 2 files changed, 2 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py index b2fe4e0e022..e54bbc61141 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -565,15 +565,7 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN): CONF_KEYPADS: [ { CONF_ADDR: keypad[CONF_ADDR], - CONF_BUTTONS: [ - { - CONF_LED: button[CONF_LED], - CONF_NAME: button[CONF_NAME], - CONF_NUMBER: button[CONF_NUMBER], - CONF_RELEASE_DELAY: button[CONF_RELEASE_DELAY], - } - for button in keypad[CONF_BUTTONS] - ], + CONF_BUTTONS: [], CONF_NAME: keypad[CONF_NAME], } for keypad in config[CONF_KEYPADS] diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index 53128c4cd65..6a5ae68e6ab 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -9,7 +9,6 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.homeworks.const import ( CONF_ADDR, - CONF_BUTTONS, CONF_DIMMERS, CONF_INDEX, CONF_KEYPADS, @@ -161,26 +160,6 @@ async def test_import_flow( { CONF_ADDR: "[02:08:02:01]", CONF_NAME: "Foyer Keypad", - CONF_BUTTONS: [ - { - CONF_NAME: "Morning", - CONF_NUMBER: 1, - CONF_LED: True, - CONF_RELEASE_DELAY: None, - }, - { - CONF_NAME: "Relax", - CONF_NUMBER: 2, - CONF_LED: True, - CONF_RELEASE_DELAY: None, - }, - { - CONF_NAME: "Dim up", - CONF_NUMBER: 3, - CONF_LED: False, - CONF_RELEASE_DELAY: 0.2, - }, - ], } ], }, @@ -207,16 +186,7 @@ async def test_import_flow( "keypads": [ { "addr": "[02:08:02:01]", - "buttons": [ - { - "led": True, - "name": "Morning", - "number": 1, - "release_delay": None, - }, - {"led": True, "name": "Relax", "number": 2, "release_delay": None}, - {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, - ], + "buttons": [], "name": "Foyer Keypad", } ], From 764a0f29cc3f557d1365da08f6e6c24ffbd65b91 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 15:04:37 +0200 Subject: [PATCH 056/107] Allow [##:##:##] type keypad address in homeworks (#115762) Allow [##:##:##] type keypad address --- homeassistant/components/homeworks/config_flow.py | 2 +- tests/components/homeworks/test_config_flow.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py index e54bbc61141..b9515c306d6 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -93,7 +93,7 @@ BUTTON_EDIT = { } -validate_addr = cv.matches_regex(r"\[\d\d:\d\d:\d\d:\d\d\]") +validate_addr = cv.matches_regex(r"\[(?:\d\d:)?\d\d:\d\d:\d\d\]") async def validate_add_controller( diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index 6a5ae68e6ab..d00b5a13150 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -544,8 +544,12 @@ async def test_options_add_remove_light_flow( ) +@pytest.mark.parametrize("keypad_address", ["[02:08:03:01]", "[02:08:03]"]) async def test_options_add_remove_keypad_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homeworks: MagicMock, + keypad_address: str, ) -> None: """Test options flow to add and remove a keypad.""" mock_config_entry.add_to_hass(hass) @@ -566,7 +570,7 @@ async def test_options_add_remove_keypad_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - CONF_ADDR: "[02:08:03:01]", + CONF_ADDR: keypad_address, CONF_NAME: "Hall Keypad", }, ) @@ -592,7 +596,7 @@ async def test_options_add_remove_keypad_flow( ], "name": "Foyer Keypad", }, - {"addr": "[02:08:03:01]", "buttons": [], "name": "Hall Keypad"}, + {"addr": keypad_address, "buttons": [], "name": "Hall Keypad"}, ], "port": 1234, } @@ -612,7 +616,7 @@ async def test_options_add_remove_keypad_flow( assert result["step_id"] == "remove_keypad" assert result["data_schema"].schema["index"].options == { "0": "Foyer Keypad ([02:08:02:01])", - "1": "Hall Keypad ([02:08:03:01])", + "1": f"Hall Keypad ({keypad_address})", } result = await hass.config_entries.options.async_configure( @@ -625,7 +629,7 @@ async def test_options_add_remove_keypad_flow( {"addr": "[02:08:01:01]", "name": "Foyer Sconces", "rate": 1.0}, ], "host": "192.168.0.1", - "keypads": [{"addr": "[02:08:03:01]", "buttons": [], "name": "Hall Keypad"}], + "keypads": [{"addr": keypad_address, "buttons": [], "name": "Hall Keypad"}], "port": 1234, } await hass.async_block_till_done() From be0926b7b8cb08415832d81e6559a43f062c85a7 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 17 Apr 2024 15:21:54 +0200 Subject: [PATCH 057/107] Add config flow to enigma2 (#106348) * add config flow to enigma2 * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * fix suggested change * use parametrize for config flow tests * Restore PLATFORM_SCHEMA and add create_issue to async_setup_platform * fix docstring * remove name, refactor config flow * bump dependency * remove name, add verify_ssl, use async_create_clientsession * use translation key, change integration type to device * Bump openwebifpy to 4.2.1 * cleanup, remove CONF_NAME from entity, add async_set_unique_id * clear unneeded constants, fix tests * fix tests * move _attr_translation_key out of init * update test requirement * Address review comments * address review comments * clear strings.json * Review coments --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 1 + homeassistant/components/enigma2/__init__.py | 47 ++++++ .../components/enigma2/config_flow.py | 158 ++++++++++++++++++ homeassistant/components/enigma2/const.py | 1 + .../components/enigma2/manifest.json | 2 + .../components/enigma2/media_player.py | 80 ++++----- homeassistant/components/enigma2/strings.json | 30 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- requirements_test_all.txt | 3 + tests/components/enigma2/__init__.py | 1 + tests/components/enigma2/conftest.py | 90 ++++++++++ tests/components/enigma2/test_config_flow.py | 149 +++++++++++++++++ tests/components/enigma2/test_init.py | 38 +++++ 14 files changed, 565 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/enigma2/config_flow.py create mode 100644 homeassistant/components/enigma2/strings.json create mode 100644 tests/components/enigma2/__init__.py create mode 100644 tests/components/enigma2/conftest.py create mode 100644 tests/components/enigma2/test_config_flow.py create mode 100644 tests/components/enigma2/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index a4224025acc..b2de3031cf8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -389,6 +389,7 @@ build.json @home-assistant/supervisor /homeassistant/components/energyzero/ @klaasnicolaas /tests/components/energyzero/ @klaasnicolaas /homeassistant/components/enigma2/ @autinerd +/tests/components/enigma2/ @autinerd /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py index 11cd4d9a804..241ca7444fb 100644 --- a/homeassistant/components/enigma2/__init__.py +++ b/homeassistant/components/enigma2/__init__.py @@ -1 +1,48 @@ """Support for Enigma2 devices.""" + +from openwebif.api import OpenWebIfDevice +from yarl import URL + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import DOMAIN + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Enigma2 from a config entry.""" + base_url = URL.build( + scheme="http" if not entry.data[CONF_SSL] else "https", + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + user=entry.data.get(CONF_USERNAME), + password=entry.data.get(CONF_PASSWORD), + ) + + session = async_create_clientsession( + hass, verify_ssl=entry.data[CONF_VERIFY_SSL], base_url=base_url + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = OpenWebIfDevice(session) + 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): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py new file mode 100644 index 00000000000..c144f2b7dae --- /dev/null +++ b/homeassistant/components/enigma2/config_flow.py @@ -0,0 +1,158 @@ +"""Config flow for Enigma2.""" + +from typing import Any + +from aiohttp.client_exceptions import ClientError +from openwebif.api import OpenWebIfDevice +from openwebif.error import InvalidAuthError +import voluptuous as vol +from yarl import URL + +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + CONF_DEEP_STANDBY, + CONF_SOURCE_BOUQUET, + CONF_USE_CHANNEL_ICON, + DEFAULT_PORT, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + DOMAIN, +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): selector.TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=65535, mode=selector.NumberSelectorMode.BOX + ) + ), + vol.Coerce(int), + ), + vol.Optional(CONF_USERNAME): selector.TextSelector(), + vol.Optional(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required(CONF_SSL, default=DEFAULT_SSL): selector.BooleanSelector(), + vol.Required( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL + ): selector.BooleanSelector(), + } +) + + +class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Enigma2.""" + + DATA_KEYS = ( + CONF_HOST, + CONF_PORT, + CONF_USERNAME, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, + ) + OPTIONS_KEYS = (CONF_DEEP_STANDBY, CONF_SOURCE_BOUQUET, CONF_USE_CHANNEL_ICON) + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.errors: dict[str, str] = {} + self._data: dict[str, Any] = {} + self._options: dict[str, Any] = {} + + async def validate_user_input(self, user_input: dict[str, Any]) -> dict[str, Any]: + """Validate user input.""" + + self.errors = {} + + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + base_url = URL.build( + scheme="http" if not user_input[CONF_SSL] else "https", + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + user=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + ) + + session = async_create_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL], base_url=base_url + ) + + try: + about = await OpenWebIfDevice(session).get_about() + except InvalidAuthError: + self.errors["base"] = "invalid_auth" + except ClientError: + self.errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + self.errors["base"] = "unknown" + else: + await self.async_set_unique_id(about["info"]["ifaces"][0]["mac"]) + self._abort_if_unique_id_configured() + + return user_input + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + if user_input is None: + return self.async_show_form(step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA) + + data = await self.validate_user_input(user_input) + if "base" in self.errors: + return self.async_show_form( + step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA, errors=self.errors + ) + return self.async_create_entry( + data=data, title=data[CONF_HOST], options=self._options + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Validate import.""" + if CONF_PORT not in user_input: + user_input[CONF_PORT] = DEFAULT_PORT + if CONF_SSL not in user_input: + user_input[CONF_SSL] = DEFAULT_SSL + user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.11.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Enigma2", + }, + ) + + self._data = { + key: user_input[key] for key in user_input if key in self.DATA_KEYS + } + self._options = { + key: user_input[key] for key in user_input if key in self.OPTIONS_KEYS + } + + return await self.async_step_user(self._data) diff --git a/homeassistant/components/enigma2/const.py b/homeassistant/components/enigma2/const.py index 277efad50eb..d7508fee64e 100644 --- a/homeassistant/components/enigma2/const.py +++ b/homeassistant/components/enigma2/const.py @@ -16,3 +16,4 @@ DEFAULT_PASSWORD = "dreambox" DEFAULT_DEEP_STANDBY = False DEFAULT_SOURCE_BOUQUET = "" DEFAULT_MAC_ADDRESS = "" +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 0de4adc13b8..ef08314e541 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -2,7 +2,9 @@ "domain": "enigma2", "name": "Enigma2 (OpenWebif)", "codeowners": ["@autinerd"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enigma2", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["openwebif"], "requirements": ["openwebifpy==4.2.4"] diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index afe8a426c72..037d82cd6c0 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -9,7 +9,6 @@ from aiohttp.client_exceptions import ClientConnectorError, ServerDisconnectedEr from openwebif.api import OpenWebIfDevice from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption import voluptuous as vol -from yarl import URL from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -17,6 +16,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -26,10 +26,9 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -47,6 +46,7 @@ from .const import ( DEFAULT_SSL, DEFAULT_USE_CHANNEL_ICON, DEFAULT_USERNAME, + DOMAIN, ) ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" @@ -81,49 +81,44 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up of an enigma2 media player.""" - if discovery_info: - # Discovery gives us the streaming service port (8001) - # which is not useful as OpenWebif never runs on that port. - # So use the default port instead. - config[CONF_PORT] = DEFAULT_PORT - config[CONF_NAME] = discovery_info["hostname"] - config[CONF_HOST] = discovery_info["host"] - config[CONF_USERNAME] = DEFAULT_USERNAME - config[CONF_PASSWORD] = DEFAULT_PASSWORD - config[CONF_SSL] = DEFAULT_SSL - config[CONF_USE_CHANNEL_ICON] = DEFAULT_USE_CHANNEL_ICON - config[CONF_MAC_ADDRESS] = DEFAULT_MAC_ADDRESS - config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY - config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET - base_url = URL.build( - scheme="https" if config[CONF_SSL] else "http", - host=config[CONF_HOST], - port=config.get(CONF_PORT), - user=config.get(CONF_USERNAME), - password=config.get(CONF_PASSWORD), + entry_data = { + CONF_HOST: config[CONF_HOST], + CONF_PORT: config[CONF_PORT], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_SSL: config[CONF_SSL], + CONF_USE_CHANNEL_ICON: config[CONF_USE_CHANNEL_ICON], + CONF_DEEP_STANDBY: config[CONF_DEEP_STANDBY], + CONF_SOURCE_BOUQUET: config[CONF_SOURCE_BOUQUET], + } + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_data + ) ) - session = async_create_clientsession(hass, verify_ssl=False, base_url=base_url) - device = OpenWebIfDevice( - host=session, - turn_off_to_deep=config.get(CONF_DEEP_STANDBY, False), - source_bouquet=config.get(CONF_SOURCE_BOUQUET), - ) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Enigma2 media player platform.""" - try: - about = await device.get_about() - except ClientConnectorError as err: - raise PlatformNotReady from err - - async_add_entities([Enigma2Device(config[CONF_NAME], device, about)]) + device: OpenWebIfDevice = hass.data[DOMAIN][entry.entry_id] + about = await device.get_about() + device.mac_address = about["info"]["ifaces"][0]["mac"] + entity = Enigma2Device(entry, device, about) + async_add_entities([entity]) class Enigma2Device(MediaPlayerEntity): """Representation of an Enigma2 box.""" _attr_has_entity_name = True + _attr_name = None _attr_media_content_type = MediaType.TVSHOW _attr_supported_features = ( @@ -139,14 +134,23 @@ class Enigma2Device(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None: + def __init__( + self, entry: ConfigEntry, device: OpenWebIfDevice, about: dict + ) -> None: """Initialize the Enigma2 device.""" self._device: OpenWebIfDevice = device - self._device.mac_address = about["info"]["ifaces"][0]["mac"] + self._entry = entry - self._attr_name = name self._attr_unique_id = device.mac_address + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.mac_address)}, + manufacturer=about["info"]["brand"], + model=about["info"]["model"], + configuration_url=device.base, + name=entry.data[CONF_HOST], + ) + async def async_turn_off(self) -> None: """Turn off media player.""" if self._device.turn_off_to_deep: diff --git a/homeassistant/components/enigma2/strings.json b/homeassistant/components/enigma2/strings.json new file mode 100644 index 00000000000..888c6d59387 --- /dev/null +++ b/homeassistant/components/enigma2/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Please enter the connection details of your device.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "name": "[%key:common::config_flow::data::name%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fd87c965db5..c02d8a2987e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -148,6 +148,7 @@ FLOWS = { "emulated_roku", "energenie_power_sockets", "energyzero", + "enigma2", "enocean", "enphase_envoy", "environment_canada", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d10cb3fdb80..2b1e5b4fb91 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1604,8 +1604,8 @@ }, "enigma2": { "name": "Enigma2 (OpenWebif)", - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_polling" }, "enmax": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea115d4b29d..19aae180e4f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1173,6 +1173,9 @@ openerz-api==0.3.0 # homeassistant.components.openhome openhomedevice==2.2.0 +# homeassistant.components.enigma2 +openwebifpy==4.2.4 + # homeassistant.components.opower opower==0.4.3 diff --git a/tests/components/enigma2/__init__.py b/tests/components/enigma2/__init__.py new file mode 100644 index 00000000000..15580d55b17 --- /dev/null +++ b/tests/components/enigma2/__init__.py @@ -0,0 +1 @@ +"""Tests for the Enigma2 integration.""" diff --git a/tests/components/enigma2/conftest.py b/tests/components/enigma2/conftest.py new file mode 100644 index 00000000000..9bbbda895bd --- /dev/null +++ b/tests/components/enigma2/conftest.py @@ -0,0 +1,90 @@ +"""Test the Enigma2 config flow.""" + +from homeassistant.components.enigma2.const import ( + CONF_DEEP_STANDBY, + CONF_MAC_ADDRESS, + CONF_SOURCE_BOUQUET, + CONF_USE_CHANNEL_ICON, + DEFAULT_DEEP_STANDBY, + DEFAULT_PORT, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +MAC_ADDRESS = "12:34:56:78:90:ab" + +TEST_REQUIRED = { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, +} + +TEST_FULL = { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: DEFAULT_SSL, + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, +} + +TEST_IMPORT_FULL = { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: DEFAULT_SSL, + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_NAME: "My Player", + CONF_DEEP_STANDBY: DEFAULT_DEEP_STANDBY, + CONF_SOURCE_BOUQUET: "Favourites", + CONF_MAC_ADDRESS: MAC_ADDRESS, + CONF_USE_CHANNEL_ICON: False, +} + +TEST_IMPORT_REQUIRED = {CONF_HOST: "1.1.1.1"} + +EXPECTED_OPTIONS = { + CONF_DEEP_STANDBY: DEFAULT_DEEP_STANDBY, + CONF_SOURCE_BOUQUET: "Favourites", + CONF_USE_CHANNEL_ICON: False, +} + + +class MockDevice: + """A mock Enigma2 device.""" + + mac_address: str | None = "12:34:56:78:90:ab" + _base = "http://1.1.1.1" + + async def _call_api(self, url: str) -> dict: + if url.endswith("/api/about"): + return { + "info": { + "ifaces": [ + { + "mac": self.mac_address, + } + ] + } + } + + def get_version(self): + """Return the version.""" + return None + + async def get_about(self) -> dict: + """Get mock about endpoint.""" + return await self._call_api("/api/about") + + async def close(self): + """Mock close.""" diff --git a/tests/components/enigma2/test_config_flow.py b/tests/components/enigma2/test_config_flow.py new file mode 100644 index 00000000000..dcd249ad943 --- /dev/null +++ b/tests/components/enigma2/test_config_flow.py @@ -0,0 +1,149 @@ +"""Test the Enigma2 config flow.""" + +from typing import Any +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +from openwebif.error import InvalidAuthError +import pytest + +from homeassistant import config_entries +from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import ( + EXPECTED_OPTIONS, + TEST_FULL, + TEST_IMPORT_FULL, + TEST_IMPORT_REQUIRED, + TEST_REQUIRED, + MockDevice, +) + + +@pytest.fixture +async def user_flow(hass: HomeAssistant) -> str: + """Return a user-initiated flow after filling in host info.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + return result["flow_id"] + + +@pytest.mark.parametrize( + ("test_config"), + [(TEST_FULL), (TEST_REQUIRED)], +) +async def test_form_user( + hass: HomeAssistant, user_flow: str, test_config: dict[str, Any] +): + """Test a successful user initiated flow.""" + with ( + patch( + "openwebif.api.OpenWebIfDevice.__new__", + return_value=MockDevice(), + ), + patch( + "homeassistant.components.enigma2.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure(user_flow, test_config) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == test_config[CONF_HOST] + assert result["data"] == test_config + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_type"), + [ + (InvalidAuthError, "invalid_auth"), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_user_errors( + hass: HomeAssistant, user_flow, exception: Exception, error_type: str +) -> None: + """Test we handle errors.""" + with patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure(user_flow, TEST_FULL) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + assert result["errors"] == {"base": error_type} + + +@pytest.mark.parametrize( + ("test_config", "expected_data", "expected_options"), + [ + (TEST_IMPORT_FULL, TEST_FULL, EXPECTED_OPTIONS), + (TEST_IMPORT_REQUIRED, TEST_REQUIRED, {}), + ], +) +async def test_form_import( + hass: HomeAssistant, + test_config: dict[str, Any], + expected_data: dict[str, Any], + expected_options: dict[str, Any], +) -> None: + """Test we get the form with import source.""" + with ( + patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", + return_value=MockDevice(), + ), + patch( + "homeassistant.components.enigma2.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=test_config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == test_config[CONF_HOST] + assert result["data"] == expected_data + assert result["options"] == expected_options + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_type"), + [ + (InvalidAuthError, "invalid_auth"), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_import_errors( + hass: HomeAssistant, exception: Exception, error_type: str +) -> None: + """Test we handle errors on import.""" + with patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=TEST_IMPORT_FULL, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error_type} diff --git a/tests/components/enigma2/test_init.py b/tests/components/enigma2/test_init.py new file mode 100644 index 00000000000..93a130eef54 --- /dev/null +++ b/tests/components/enigma2/test_init.py @@ -0,0 +1,38 @@ +"""Test the Enigma2 integration init.""" + +from unittest.mock import patch + +from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import TEST_REQUIRED, MockDevice + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test successful unload of entry.""" + with ( + patch( + "homeassistant.components.enigma2.OpenWebIfDevice.__new__", + return_value=MockDevice(), + ), + patch( + "homeassistant.components.enigma2.media_player.async_setup_entry", + return_value=True, + ), + ): + entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) From 9ca24ab2a209d419ef2c8577aea22dc98086e014 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 08:45:40 -0500 Subject: [PATCH 058/107] Avoid linear search to remove labels and floors from area registry (#115675) * Avoid linear search to remove labels and floors from area registry * simplify --- homeassistant/helpers/area_registry.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 2734ab5e2e5..b39fee9c185 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -385,9 +385,8 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None: """Update areas that are associated with a floor that has been removed.""" floor_id = event.data["floor_id"] - for area_id, area in self.areas.items(): - if floor_id == area.floor_id: - self.async_update(area_id, floor_id=None) + for area in self.areas.get_areas_for_floor(floor_id): + self.async_update(area.id, floor_id=None) self.hass.bus.async_listen( event_type=fr.EVENT_FLOOR_REGISTRY_UPDATED, @@ -399,11 +398,8 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None: """Update areas that have a label that has been removed.""" label_id = event.data["label_id"] - for area_id, area in self.areas.items(): - if label_id in area.labels: - labels = area.labels.copy() - labels.remove(label_id) - self.async_update(area_id, labels=labels) + for area in self.areas.get_areas_for_label(label_id): + self.async_update(area.id, labels=area.labels - {label_id}) self.hass.bus.async_listen( event_type=lr.EVENT_LABEL_REGISTRY_UPDATED, From 8cf14c92681d92fe658d45a09278de4bbb0ad210 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 08:46:59 -0500 Subject: [PATCH 059/107] Avoid linear search to clear labels and areas in the device registry (#115676) --- homeassistant/helpers/device_registry.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 0270c8dc456..4cc9a29d46a 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1053,18 +1053,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): @callback def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" - for dev_id, device in self.devices.items(): - if area_id == device.area_id: - self.async_update_device(dev_id, area_id=None) + for device in self.devices.get_devices_for_area_id(area_id): + self.async_update_device(device.id, area_id=None) @callback def async_clear_label_id(self, label_id: str) -> None: """Clear label from registry entries.""" - for device_id, entry in self.devices.items(): - if label_id in entry.labels: - labels = entry.labels.copy() - labels.remove(label_id) - self.async_update_device(device_id, labels=labels) + for device in self.devices.get_devices_for_label(label_id): + self.async_update_device(device.id, labels=device.labels - {label_id}) @callback From 45f025480e2e5b237f77b61510a6048a8ece8ca1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 08:47:56 -0500 Subject: [PATCH 060/107] Avoid linear search to remove a label from the entity registry (#115674) * Avoid linear search to remove a label from the entity registry * simplify --- homeassistant/helpers/entity_registry.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3a26505c7da..4e77df49ea6 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1329,11 +1329,8 @@ class EntityRegistry(BaseRegistry): @callback def async_clear_label_id(self, label_id: str) -> None: """Clear label from registry entries.""" - for entity_id, entry in self.entities.items(): - if label_id in entry.labels: - labels = entry.labels.copy() - labels.remove(label_id) - self.async_update_entity(entity_id, labels=labels) + for entry in self.entities.get_entries_for_label(label_id): + self.async_update_entity(entry.entity_id, labels=entry.labels - {label_id}) @callback def async_clear_config_entry(self, config_entry_id: str) -> None: From bd2efffb4a95fa1b99675a24246c489edb5e91e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 08:50:39 -0500 Subject: [PATCH 061/107] Reduce duplicate code in the device registry (#115677) --- homeassistant/helpers/device_registry.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 4cc9a29d46a..3a9d047810b 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1235,21 +1235,21 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: return True - if hass.is_running: + def _async_listen_for_cleanup() -> None: + """Listen for entity registry changes.""" hass.bus.async_listen( entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, _async_entity_registry_changed, event_filter=entity_registry_changed_filter, ) + + if hass.is_running: + _async_listen_for_cleanup() return async def startup_clean(event: Event) -> None: """Clean up on startup.""" - hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, - _async_entity_registry_changed, - event_filter=entity_registry_changed_filter, - ) + _async_listen_for_cleanup() await debounced_cleanup.async_call() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean) From f1ebe9d20a9135c0cbf08fd7e557c4c641ef20fd Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 17 Apr 2024 09:51:29 -0400 Subject: [PATCH 062/107] Add repairs to hassio manifest (#115486) * Add repairs to hassio manifest * Remove unnecessary fixture --- homeassistant/components/hassio/manifest.json | 2 +- tests/components/hassio/test_repairs.py | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 70fc024c005..b32e5ebcd53 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -2,7 +2,7 @@ "domain": "hassio", "name": "Home Assistant Supervisor", "codeowners": ["@home-assistant/supervisor"], - "dependencies": ["http"], + "dependencies": ["http", "repairs"], "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal" diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 2dffba74fef..33d266eb24b 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest -from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component @@ -18,12 +17,6 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -@pytest.fixture(autouse=True) -async def setup_repairs(hass: HomeAssistant): - """Set up the repairs integration.""" - assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) - - @pytest.fixture(autouse=True) async def fixture_supervisor_environ(): """Mock os environ for supervisor.""" From dfec91d2741253dab5221cbfca4f9f03b280ce20 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 17 Apr 2024 16:11:42 +0200 Subject: [PATCH 063/107] Remove obsolete translation keys in Sanix (#115764) --- homeassistant/components/sanix/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/sanix/sensor.py b/homeassistant/components/sanix/sensor.py index e780c6f2df0..39a1c593433 100644 --- a/homeassistant/components/sanix/sensor.py +++ b/homeassistant/components/sanix/sensor.py @@ -41,7 +41,6 @@ class SanixSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[SanixSensorEntityDescription, ...] = ( SanixSensorEntityDescription( key=ATTR_API_BATTERY, - translation_key=ATTR_API_BATTERY, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, @@ -49,7 +48,6 @@ SENSOR_TYPES: tuple[SanixSensorEntityDescription, ...] = ( ), SanixSensorEntityDescription( key=ATTR_API_DISTANCE, - translation_key=ATTR_API_DISTANCE, native_unit_of_measurement=UnitOfLength.CENTIMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, From 08b565701cad8d1aaf62bb1b29657603f7330c45 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 16:37:49 +0200 Subject: [PATCH 064/107] Include hash of requirements.txt in venv cache key (#115759) --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c96c6b5e5f2..a5bafa0c52d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -95,6 +95,7 @@ jobs: run: >- echo "key=venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt', 'requirements_test_pre_commit.txt') }}-${{ + hashFiles('requirements.txt') }}-${{ hashFiles('requirements_all.txt') }}-${{ hashFiles('homeassistant/package_constraints.txt') }}" >> $GITHUB_OUTPUT - name: Generate partial pre-commit restore key From 7a673043012f30a2ce695fcf43a9bb54743184e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 09:41:42 -0500 Subject: [PATCH 065/107] Bump habluetooth to 2.6.0 (#115724) --- homeassistant/components/bluetooth/__init__.py | 10 ++++------ homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 3273080d88b..35fbeb2f3b3 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -315,16 +315,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] scanner = HaScanner(mode, adapter, address) + scanner.async_setup() try: - scanner.async_setup() - except RuntimeError as err: + await scanner.async_start() + except (RuntimeError, ScannerStartError) as err: raise ConfigEntryNotReady( f"{adapter_human_name(adapter, address)}: {err}" ) from err - try: - await scanner.async_start() - except ScannerStartError as err: - raise ConfigEntryNotReady from err adapters = await manager.async_get_bluetooth_adapters() details = adapters[adapter] slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS @@ -332,6 +329,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_update_device(hass, entry, adapter, details) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner entry.async_on_unload(entry.add_update_listener(async_update_listener)) + entry.async_on_unload(scanner.async_stop) return True diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 5939a03cefc..471e327ee9d 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.4.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.5.2" + "habluetooth==2.6.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 318738558e8..dca7c82a885 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.2.0 -habluetooth==2.5.2 +habluetooth==2.6.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 diff --git a/requirements_all.txt b/requirements_all.txt index 74b458920e8..ab9f24284e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1029,7 +1029,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.5.2 +habluetooth==2.6.0 # homeassistant.components.cloud hass-nabucasa==0.78.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19aae180e4f..ab9b1e94b88 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -843,7 +843,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.5.2 +habluetooth==2.6.0 # homeassistant.components.cloud hass-nabucasa==0.78.0 From f62a3a7176af5cab107a56aa0736ec1e584ad13c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 09:42:23 -0500 Subject: [PATCH 066/107] Simplify config_entries entity registry filter (#115678) --- homeassistant/config_entries.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a3f31ff8715..bf576b517d3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2609,14 +2609,12 @@ def _handle_entry_updated_filter( Only handle changes to "disabled_by". If "disabled_by" was CONFIG_ENTRY, reload is not needed. """ - if ( + return not ( event_data["action"] != "update" or "disabled_by" not in event_data["changes"] or event_data["changes"]["disabled_by"] is entity_registry.RegistryEntryDisabler.CONFIG_ENTRY - ): - return False - return True + ) async def support_entry_unload(hass: HomeAssistant, domain: str) -> bool: From 9bae6d694d4672a2ce061d21755afc98796c4694 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:08:23 +0200 Subject: [PATCH 067/107] Add secondary temperature sensor for DHW in ViCare (#106612) * add temp2 sensor * Update strings.json --- homeassistant/components/vicare/number.py | 41 +++++++++++++++++--- homeassistant/components/vicare/strings.json | 3 ++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index f92241ceace..c0564170274 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -49,6 +49,23 @@ class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysM stepping_getter: Callable[[PyViCareDevice], float | None] | None = None +DEVICE_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( + ViCareNumberEntityDescription( + key="dhw_secondary_temperature", + translation_key="dhw_secondary_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterConfiguredTemperature2(), + value_setter=lambda api, value: api.setDomesticHotWaterTemperature2(value), + # no getters for min, max, stepping exposed yet, using static values + native_min_value=10, + native_max_value=60, + native_step=1, + ), +) + + CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( ViCareNumberEntityDescription( key="heating curve shift", @@ -216,18 +233,32 @@ def _build_entities( ) -> list[ViCareNumber]: """Create ViCare number entities for a device.""" - return [ + entities: list[ViCareNumber] = [ ViCareNumber( - circuit, + device.api, device.config, description, ) for device in device_list - for circuit in get_circuits(device.api) - for description in CIRCUIT_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, circuit) + for description in DEVICE_ENTITY_DESCRIPTIONS + if is_supported(description.key, description, device.api) ] + entities.extend( + [ + ViCareNumber( + circuit, + device.config, + description, + ) + for device in device_list + for circuit in get_circuits(device.api) + for description in CIRCUIT_ENTITY_DESCRIPTIONS + if is_supported(description.key, description, circuit) + ] + ) + return entities + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 5a69cae4d29..f81d01b71cf 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -89,6 +89,9 @@ }, "comfort_heating_temperature": { "name": "[%key:component::vicare::entity::number::comfort_temperature::name%]" + }, + "dhw_secondary_temperature": { + "name": "DHW secondary temperature" } }, "sensor": { From 7e9b5b112817ad4b4b1220c2cec97a2896d6e6dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 17 Apr 2024 17:11:29 +0200 Subject: [PATCH 068/107] Allow selecting Air Quality mode for Airzone Cloud (#106769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone_cloud: add Air Quality mode select Signed-off-by: Álvaro Fernández Rojas * trigger CI * trigger CI * Update select.py Co-authored-by: Erik Montnemery * airzone_cloud: select: remove AirzoneSelectDescriptionMixin usage * airzone_cloud: select: rename AIR_QUALITY_DICT --------- Signed-off-by: Álvaro Fernández Rojas Co-authored-by: Erik Montnemery --- .../components/airzone_cloud/__init__.py | 1 + .../components/airzone_cloud/select.py | 124 ++++++++++++++++++ .../components/airzone_cloud/strings.json | 10 ++ tests/components/airzone_cloud/test_select.py | 61 +++++++++ 4 files changed, 196 insertions(+) create mode 100644 homeassistant/components/airzone_cloud/select.py create mode 100644 tests/components/airzone_cloud/test_select.py diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index c6908b191d7..e53c01e0f81 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -16,6 +16,7 @@ from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.SELECT, Platform.SENSOR, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/airzone_cloud/select.py b/homeassistant/components/airzone_cloud/select.py new file mode 100644 index 00000000000..c5c9f664503 --- /dev/null +++ b/homeassistant/components/airzone_cloud/select.py @@ -0,0 +1,124 @@ +"""Support for the Airzone Cloud select.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Final + +from aioairzone_cloud.common import AirQualityMode +from aioairzone_cloud.const import ( + API_AQ_MODE_CONF, + API_VALUE, + AZD_AQ_MODE_CONF, + AZD_ZONES, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneEntity, AirzoneZoneEntity + + +@dataclass(frozen=True, kw_only=True) +class AirzoneSelectDescription(SelectEntityDescription): + """Class to describe an Airzone select entity.""" + + api_param: str + options_dict: dict[str, str] + + +AIR_QUALITY_MAP: Final[dict[str, str]] = { + "off": AirQualityMode.OFF, + "on": AirQualityMode.ON, + "auto": AirQualityMode.AUTO, +} + + +ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( + AirzoneSelectDescription( + api_param=API_AQ_MODE_CONF, + entity_category=EntityCategory.CONFIG, + key=AZD_AQ_MODE_CONF, + options=list(AIR_QUALITY_MAP), + options_dict=AIR_QUALITY_MAP, + translation_key="air_quality", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone Cloud select from a config_entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + # Zones + async_add_entities( + AirzoneZoneSelect( + coordinator, + description, + zone_id, + zone_data, + ) + for description in ZONE_SELECT_TYPES + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items() + if description.key in zone_data + ) + + +class AirzoneBaseSelect(AirzoneEntity, SelectEntity): + """Define an Airzone Cloud select.""" + + entity_description: AirzoneSelectDescription + values_dict: dict[str, str] + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + def _get_current_option(self) -> str | None: + """Get current selected option.""" + value = self.get_airzone_value(self.entity_description.key) + return self.values_dict.get(value) + + @callback + def _async_update_attrs(self) -> None: + """Update select attributes.""" + self._attr_current_option = self._get_current_option() + + +class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect): + """Define an Airzone Cloud Zone select.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: AirzoneSelectDescription, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, zone_id, zone_data) + + self._attr_unique_id = f"{zone_id}_{description.key}" + self.entity_description = description + self.values_dict = {v: k for k, v in description.options_dict.items()} + + self._async_update_attrs() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + param = self.entity_description.api_param + value = self.entity_description.options_dict[option] + params: dict[str, Any] = {} + params[param] = { + API_VALUE: value, + } + await self._async_update_params(params) diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json index fe7c38c8374..fe9455aa69e 100644 --- a/homeassistant/components/airzone_cloud/strings.json +++ b/homeassistant/components/airzone_cloud/strings.json @@ -21,6 +21,16 @@ "air_quality_active": { "name": "Air Quality active" } + }, + "select": { + "air_quality": { + "name": "Air Quality mode", + "state": { + "off": "Off", + "on": "On", + "auto": "Auto" + } + } } } } diff --git a/tests/components/airzone_cloud/test_select.py b/tests/components/airzone_cloud/test_select.py new file mode 100644 index 00000000000..1375b052050 --- /dev/null +++ b/tests/components/airzone_cloud/test_select.py @@ -0,0 +1,61 @@ +"""The select tests for the Airzone Cloud platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .util import async_init_integration + + +async def test_airzone_create_selects( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test creation of selects.""" + + await async_init_integration(hass) + + # Zones + state = hass.states.get("select.dormitorio_air_quality_mode") + assert state.state == "auto" + + state = hass.states.get("select.salon_air_quality_mode") + assert state.state == "auto" + + +async def test_airzone_select_air_quality_mode(hass: HomeAssistant) -> None: + """Test select Air Quality mode.""" + + await async_init_integration(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.dormitorio_air_quality_mode", + ATTR_OPTION: "Invalid", + }, + blocking=True, + ) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.dormitorio_air_quality_mode", + ATTR_OPTION: "off", + }, + blocking=True, + ) + + state = hass.states.get("select.dormitorio_air_quality_mode") + assert state.state == "off" From e4280b2c0013cf0098bbf493f57c9052247442c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Apr 2024 17:57:34 +0200 Subject: [PATCH 069/107] Use aiohttp-zlib-ng[isal] (#115767) --- .github/workflows/builder.yml | 9 --------- .github/workflows/wheels.yml | 5 ----- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 5 files changed, 3 insertions(+), 17 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 217093793d1..f02a8bacce8 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -174,15 +174,6 @@ jobs: sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - - name: Adjustments for 64-bit - if: matrix.arch == 'amd64' || matrix.arch == 'aarch64' - run: | - # Some speedups are only available on 64-bit, and since - # we build 32bit images on 64bit hosts, we only enable - # the speed ups on 64bit since the wheels for 32bit - # are not available. - sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" requirements_all.txt - - name: Download translations uses: actions/download-artifact@v4.1.4 with: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3636906c305..7102df0ae4d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -161,11 +161,6 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file} fi - # Some speedups are only for 64-bit - if [ "${{ matrix.arch }}" = "amd64" ] || [ "${{ matrix.arch }}" = "aarch64" ]; then - sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" ${requirement_file} - fi - done - name: Split requirements all diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dca7c82a885..6dce47b734d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.0 aiodiscover==2.0.0 aiodns==3.2.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.3.1 +aiohttp-zlib-ng[isal]==0.3.1 aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 diff --git a/pyproject.toml b/pyproject.toml index 2216295d00d..90466aa7290 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "aiohttp_cors==0.7.0", "aiohttp_session==2.12.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.3.1", + "aiohttp-zlib-ng[isal]==0.3.1", "astral==2.2", "async-interrupt==1.1.1", "attrs==23.2.0", diff --git a/requirements.txt b/requirements.txt index 650a9bf7554..980bf84eb26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.3.1 +aiohttp-zlib-ng[isal]==0.3.1 astral==2.2 async-interrupt==1.1.1 attrs==23.2.0 From dcd61ac0867c6bb485a7596035378b41b1f5f48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Wed, 17 Apr 2024 18:47:29 +0200 Subject: [PATCH 070/107] Fix unrecoverable error when fetching airthings_ble data (#115699) --- homeassistant/components/airthings_ble/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index e8a2d492ae2..39617a8a019 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -44,10 +44,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_method() -> AirthingsDevice: """Get data from Airthings BLE.""" - ble_device = bluetooth.async_ble_device_from_address(hass, address) - try: - data = await airthings.update_device(ble_device) # type: ignore[arg-type] + data = await airthings.update_device(ble_device) except Exception as err: raise UpdateFailed(f"Unable to fetch data: {err}") from err From 3202743b6c8495365be182f628e4c5a7b8e4a9c6 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 17 Apr 2024 19:10:09 +0200 Subject: [PATCH 071/107] Cleanup modbus test mocks (#115412) --- tests/components/modbus/conftest.py | 119 +++++++----------- tests/components/modbus/test_binary_sensor.py | 4 +- tests/components/modbus/test_climate.py | 28 ++--- tests/components/modbus/test_cover.py | 18 +-- tests/components/modbus/test_fan.py | 5 +- tests/components/modbus/test_init.py | 33 ++--- tests/components/modbus/test_light.py | 11 +- tests/components/modbus/test_sensor.py | 6 +- tests/components/modbus/test_switch.py | 9 +- 9 files changed, 96 insertions(+), 137 deletions(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 62cf12958d3..1253a856bbf 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -47,12 +47,36 @@ class ReadResult: return False +@pytest.fixture(name="check_config_loaded") +def check_config_loaded_fixture(): + """Set default for check_config_loaded.""" + return True + + +@pytest.fixture(name="register_words") +def register_words_fixture(): + """Set default for register_words.""" + return [0x00, 0x00] + + +@pytest.fixture(name="config_addon") +def config_addon_fixture(): + """Add extra configuration items.""" + return None + + +@pytest.fixture(name="do_exception") +def do_exception_fixture(): + """Remove side_effect to pymodbus calls.""" + return False + + @pytest.fixture(name="mock_pymodbus") -def mock_pymodbus_fixture(): +def mock_pymodbus_fixture(do_exception, register_words): """Mock pymodbus.""" mock_pb = mock.AsyncMock() mock_pb.close = mock.MagicMock() - read_result = ReadResult([]) + read_result = ReadResult(register_words if register_words else []) mock_pb.read_coils.return_value = read_result mock_pb.read_discrete_inputs.return_value = read_result mock_pb.read_input_registers.return_value = read_result @@ -61,6 +85,16 @@ def mock_pymodbus_fixture(): mock_pb.write_registers.return_value = read_result mock_pb.write_coil.return_value = read_result mock_pb.write_coils.return_value = read_result + if do_exception: + exc = ModbusException("mocked pymodbus exception") + mock_pb.read_coils.side_effect = exc + mock_pb.read_discrete_inputs.side_effect = exc + mock_pb.read_input_registers.side_effect = exc + mock_pb.read_holding_registers.side_effect = exc + mock_pb.write_register.side_effect = exc + mock_pb.write_registers.side_effect = exc + mock_pb.write_coil.side_effect = exc + mock_pb.write_coils.side_effect = exc with ( mock.patch( "homeassistant.components.modbus.modbus.AsyncModbusTcpClient", @@ -81,33 +115,9 @@ def mock_pymodbus_fixture(): yield mock_pb -@pytest.fixture(name="check_config_loaded") -def check_config_loaded_fixture(): - """Set default for check_config_loaded.""" - return True - - -@pytest.fixture(name="register_words") -def register_words_fixture(): - """Set default for register_words.""" - return [0x00, 0x00] - - -@pytest.fixture(name="config_addon") -def config_addon_fixture(): - """Add entra configuration items.""" - return None - - -@pytest.fixture(name="do_exception") -def do_exception_fixture(): - """Remove side_effect to pymodbus calls.""" - return False - - @pytest.fixture(name="mock_modbus") async def mock_modbus_fixture( - hass, caplog, register_words, check_config_loaded, config_addon, do_config + hass, caplog, check_config_loaded, config_addon, do_config, mock_pymodbus ): """Load integration modbus using mocked pymodbus.""" conf = copy.deepcopy(do_config) @@ -132,57 +142,23 @@ async def mock_modbus_fixture( } ] } - mock_pb = mock.AsyncMock() - mock_pb.close = mock.MagicMock() + now = dt_util.utcnow() with mock.patch( - "homeassistant.components.modbus.modbus.AsyncModbusTcpClient", - return_value=mock_pb, + "homeassistant.helpers.event.dt_util.utcnow", + return_value=now, autospec=True, ): - now = dt_util.utcnow() - with mock.patch( - "homeassistant.helpers.event.dt_util.utcnow", - return_value=now, - autospec=True, - ): - result = await async_setup_component(hass, DOMAIN, config) - assert result or not check_config_loaded - await hass.async_block_till_done() - yield mock_pb - - -@pytest.fixture(name="mock_pymodbus_exception") -async def mock_pymodbus_exception_fixture(hass, do_exception, mock_modbus): - """Trigger update call with time_changed event.""" - if do_exception: - exc = ModbusException("fail read_coils") - mock_modbus.read_coils.side_effect = exc - mock_modbus.read_discrete_inputs.side_effect = exc - mock_modbus.read_input_registers.side_effect = exc - mock_modbus.read_holding_registers.side_effect = exc - - -@pytest.fixture(name="mock_pymodbus_return") -async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): - """Trigger update call with time_changed event.""" - read_result = ReadResult(register_words if register_words else []) - mock_modbus.read_coils.return_value = read_result - mock_modbus.read_discrete_inputs.return_value = read_result - mock_modbus.read_input_registers.return_value = read_result - mock_modbus.read_holding_registers.return_value = read_result - mock_modbus.write_register.return_value = read_result - mock_modbus.write_registers.return_value = read_result - mock_modbus.write_coil.return_value = read_result - mock_modbus.write_coils.return_value = read_result - return mock_modbus + result = await async_setup_component(hass, DOMAIN, config) + assert result or not check_config_loaded + await hass.async_block_till_done() + return mock_pymodbus @pytest.fixture(name="mock_do_cycle") async def mock_do_cycle_fixture( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - mock_pymodbus_exception, - mock_pymodbus_return, + mock_modbus, ) -> FrozenDateTimeFactory: """Trigger update call with time_changed event.""" freezer.tick(timedelta(seconds=1)) @@ -207,11 +183,12 @@ async def mock_test_state_fixture(hass, request): return request.param -@pytest.fixture(name="mock_ha") -async def mock_ha_fixture(hass, mock_pymodbus_return): +@pytest.fixture(name="mock_modbus_ha") +async def mock_modbus_ha_fixture(hass, mock_modbus): """Load homeassistant to allow service calls.""" assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() + return mock_modbus @pytest.fixture(name="caplog_setup_text") diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 567618de3c6..7ae933998cf 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -207,7 +207,7 @@ async def test_all_binary_sensor(hass: HomeAssistant, expected, mock_do_cycle) - ], ) async def test_service_binary_sensor_update( - hass: HomeAssistant, mock_modbus, mock_ha + hass: HomeAssistant, mock_modbus_ha ) -> None: """Run test for service homeassistant.update_entity.""" @@ -217,7 +217,7 @@ async def test_service_binary_sensor_update( await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_modbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus_ha.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 093dee67895..94778cdcbd2 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -496,10 +496,10 @@ async def test_temperature_error(hass: HomeAssistant, expected, mock_do_cycle) - ], ) async def test_service_climate_update( - hass: HomeAssistant, mock_modbus, mock_ha, result, register_words + hass: HomeAssistant, mock_modbus_ha, result, register_words ) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -611,10 +611,10 @@ async def test_service_climate_update( ], ) async def test_service_climate_fan_update( - hass: HomeAssistant, mock_modbus, mock_ha, result, register_words + hass: HomeAssistant, mock_modbus_ha, result, register_words ) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -751,10 +751,10 @@ async def test_service_climate_fan_update( ], ) async def test_service_climate_swing_update( - hass: HomeAssistant, mock_modbus, mock_ha, result, register_words + hass: HomeAssistant, mock_modbus_ha, result, register_words ) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -844,10 +844,10 @@ async def test_service_climate_swing_update( ], ) async def test_service_climate_set_temperature( - hass: HomeAssistant, temperature, result, mock_modbus, mock_ha + hass: HomeAssistant, temperature, result, mock_modbus_ha ) -> None: """Test set_temperature.""" - mock_modbus.read_holding_registers.return_value = ReadResult(result) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, "set_temperature", @@ -954,10 +954,10 @@ async def test_service_climate_set_temperature( ], ) async def test_service_set_hvac_mode( - hass: HomeAssistant, hvac_mode, result, mock_modbus, mock_ha + hass: HomeAssistant, hvac_mode, result, mock_modbus_ha ) -> None: """Test set HVAC mode.""" - mock_modbus.read_holding_registers.return_value = ReadResult(result) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, @@ -1018,10 +1018,10 @@ async def test_service_set_hvac_mode( ], ) async def test_service_set_fan_mode( - hass: HomeAssistant, fan_mode, result, mock_modbus, mock_ha + hass: HomeAssistant, fan_mode, result, mock_modbus_ha ) -> None: """Test set Fan mode.""" - mock_modbus.read_holding_registers.return_value = ReadResult(result) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, "set_fan_mode", @@ -1081,10 +1081,10 @@ async def test_service_set_fan_mode( ], ) async def test_service_set_swing_mode( - hass: HomeAssistant, swing_mode, result, mock_modbus, mock_ha + hass: HomeAssistant, swing_mode, result, mock_modbus_ha ) -> None: """Test set Swing mode.""" - mock_modbus.read_holding_registers.return_value = ReadResult(result) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, "set_swing_mode", diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index fa9e617d96d..0860b3136ba 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -182,13 +182,13 @@ async def test_register_cover(hass: HomeAssistant, expected, mock_do_cycle) -> N }, ], ) -async def test_service_cover_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_cover_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_CLOSED - mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) + mock_modbus_ha.read_holding_registers.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -255,30 +255,30 @@ async def test_restore_state_cover( }, ], ) -async def test_service_cover_move(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_cover_move(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) + mock_modbus_ha.read_holding_registers.return_value = ReadResult([0x01]) await hass.services.async_call( "cover", "open_cover", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OPEN - mock_modbus.read_holding_registers.return_value = ReadResult([0x00]) + mock_modbus_ha.read_holding_registers.return_value = ReadResult([0x00]) await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_CLOSED - await mock_modbus.reset() - mock_modbus.read_holding_registers.side_effect = ModbusException("fail write_") + await mock_modbus_ha.reset() + mock_modbus_ha.read_holding_registers.side_effect = ModbusException("fail write_") await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID}, blocking=True ) - assert mock_modbus.read_holding_registers.called + assert mock_modbus_ha.read_holding_registers.called assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - mock_modbus.read_coils.side_effect = ModbusException("fail write_") + mock_modbus_ha.read_coils.side_effect = ModbusException("fail write_") await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID2}, blocking=True ) diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 9719de3601b..d52b9dc309a 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -262,7 +262,6 @@ async def test_fan_service_turn( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus, - mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" @@ -323,13 +322,13 @@ async def test_fan_service_turn( }, ], ) -async def test_service_fan_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_fan_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_modbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus_ha.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 1219a04fb0c..82c65576f02 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1366,7 +1366,6 @@ async def mock_modbus_read_pymodbus_fixture( do_type, do_scan_interval, do_return, - do_exception, caplog, mock_pymodbus, freezer: FrozenDateTimeFactory, @@ -1374,10 +1373,6 @@ async def mock_modbus_read_pymodbus_fixture( """Load integration modbus using mocked pymodbus.""" caplog.clear() caplog.set_level(logging.ERROR) - mock_pymodbus.read_coils.side_effect = do_exception - mock_pymodbus.read_discrete_inputs.side_effect = do_exception - mock_pymodbus.read_input_registers.side_effect = do_exception - mock_pymodbus.read_holding_registers.side_effect = do_exception mock_pymodbus.read_coils.return_value = do_return mock_pymodbus.read_discrete_inputs.return_value = do_return mock_pymodbus.read_input_registers.return_value = do_return @@ -1646,7 +1641,7 @@ async def test_shutdown( ], ) async def test_stop_restart( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus ) -> None: """Run test for service stop.""" @@ -1657,7 +1652,7 @@ async def test_stop_restart( await hass.async_block_till_done() assert hass.states.get(entity_id).state == "17" - mock_pymodbus_return.reset_mock() + mock_modbus.reset_mock() caplog.clear() data = { ATTR_HUB: TEST_MODBUS_NAME, @@ -1665,23 +1660,23 @@ async def test_stop_restart( await hass.services.async_call(DOMAIN, SERVICE_STOP, data, blocking=True) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - assert mock_pymodbus_return.close.called + assert mock_modbus.close.called assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text - mock_pymodbus_return.reset_mock() + mock_modbus.reset_mock() caplog.clear() await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) await hass.async_block_till_done() - assert not mock_pymodbus_return.close.called - assert mock_pymodbus_return.connect.called + assert not mock_modbus.close.called + assert mock_modbus.connect.called assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text - mock_pymodbus_return.reset_mock() + mock_modbus.reset_mock() caplog.clear() await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) await hass.async_block_till_done() - assert mock_pymodbus_return.close.called - assert mock_pymodbus_return.connect.called + assert mock_modbus.close.called + assert mock_modbus.connect.called assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text @@ -1711,7 +1706,7 @@ async def test_write_no_client(hass: HomeAssistant, mock_modbus) -> None: async def test_integration_reload( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mock_pymodbus_return, + mock_modbus, freezer: FrozenDateTimeFactory, ) -> None: """Run test for integration reload.""" @@ -1732,7 +1727,7 @@ async def test_integration_reload( @pytest.mark.parametrize("do_config", [{}]) async def test_integration_reload_failed( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus ) -> None: """Run test for integration connect failure on reload.""" caplog.set_level(logging.INFO) @@ -1741,9 +1736,7 @@ async def test_integration_reload_failed( yaml_path = get_fixture_path("configuration.yaml", "modbus") with ( mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), - mock.patch.object( - mock_pymodbus_return, "connect", side_effect=ModbusException("error") - ), + mock.patch.object(mock_modbus, "connect", side_effect=ModbusException("error")), ): await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) await hass.async_block_till_done() @@ -1754,7 +1747,7 @@ async def test_integration_reload_failed( @pytest.mark.parametrize("do_config", [{}]) async def test_integration_setup_failed( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus ) -> None: """Run test for integration setup on reload.""" with mock.patch.object( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index e5e1b56d77b..e74da085180 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -262,7 +262,6 @@ async def test_light_service_turn( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus, - mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" @@ -300,12 +299,6 @@ async def test_light_service_turn( ) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID2).state == STATE_UNAVAILABLE - mock_modbus.write_coil.side_effect = ModbusException("fail write_") - await hass.services.async_call( - "light", "turn_off", service_data={"entity_id": ENTITY_ID} - ) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -323,13 +316,13 @@ async def test_light_service_turn( }, ], ) -async def test_service_light_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_light_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_modbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus_ha.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 524acc0dabb..71cb64cc1b6 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1391,14 +1391,14 @@ async def test_restore_state_sensor( }, ], ) -async def test_service_sensor_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_sensor_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_input_registers.return_value = ReadResult([27]) + mock_modbus_ha.read_input_registers.return_value = ReadResult([27]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == "27" - mock_modbus.read_input_registers.return_value = ReadResult([32]) + mock_modbus_ha.read_input_registers.return_value = ReadResult([32]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 4eb0a5b3a18..bdb95c667c7 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -277,7 +277,6 @@ async def test_switch_service_turn( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus, - mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" assert MODBUS_DOMAIN in hass.config.components @@ -337,13 +336,13 @@ async def test_switch_service_turn( }, ], ) -async def test_service_switch_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_switch_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_modbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus_ha.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -368,9 +367,7 @@ async def test_service_switch_update(hass: HomeAssistant, mock_modbus, mock_ha) }, ], ) -async def test_delay_switch( - hass: HomeAssistant, mock_modbus, mock_pymodbus_return -) -> None: +async def test_delay_switch(hass: HomeAssistant, mock_modbus) -> None: """Run test for switch verify delay.""" mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) now = dt_util.utcnow() From d4b62adfdcbe5bda75d0c8eb3d59487ed92a7ef5 Mon Sep 17 00:00:00 2001 From: Xander Date: Wed, 17 Apr 2024 18:38:12 +0100 Subject: [PATCH 072/107] Guard negative values for IPP states (#107446) * Guard negative values for IPP states * ruff format * Update sensor.py --------- Co-authored-by: Chris Talkington Co-authored-by: Erik Montnemery --- homeassistant/components/ipp/sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 1aad6ae6b21..8d3b97d0ca5 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -120,7 +120,10 @@ async def async_setup_entry( ATTR_MARKER_TYPE: marker.marker_type, }, ), - value_fn=_get_marker_value_fn(index, lambda marker: marker.level), + value_fn=_get_marker_value_fn( + index, + lambda marker: marker.level if marker.level >= 0 else None, + ), ), ) ) From 0a78e9d4aa6c3082afa2136549073643e44c8102 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 12:46:15 -0500 Subject: [PATCH 073/107] Replace aiohttp-zlib-ng[isal] with aiohttp-isal (#115777) * Replace aiohttp-zlib-ng[isal] with aiohttp-isal The extra was causing wheel builds to fail Since isal works on all of our supported platforms we can always use it and drop the need for zlib-ng https://github.com/home-assistant/core/actions/runs/8725019072 https://github.com/bdraco/aiohttp-isal * typo --- homeassistant/components/http/__init__.py | 4 ++-- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3e5f7333cbc..f9532b90ce6 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -21,7 +21,7 @@ from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection from aiohttp.web_protocol import RequestHandler from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher -from aiohttp_zlib_ng import enable_zlib_ng +from aiohttp_isal import enable_isal from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -202,7 +202,7 @@ class ApiConfig: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" - enable_zlib_ng() + enable_isal() conf: ConfData | None = config.get(DOMAIN) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6dce47b734d..bd16f3c6147 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.0 aiodiscover==2.0.0 aiodns==3.2.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng[isal]==0.3.1 +aiohttp-isal==0.2.0 aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 diff --git a/pyproject.toml b/pyproject.toml index 90466aa7290..4b3b15f7bde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "aiohttp_cors==0.7.0", "aiohttp_session==2.12.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng[isal]==0.3.1", + "aiohttp-isal==0.2.0", "astral==2.2", "async-interrupt==1.1.1", "attrs==23.2.0", diff --git a/requirements.txt b/requirements.txt index 980bf84eb26..34ee8237921 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng[isal]==0.3.1 +aiohttp-isal==0.2.0 astral==2.2 async-interrupt==1.1.1 attrs==23.2.0 From 8275512130d6a62faf35e61fea3f1bd84b9f8b33 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 17 Apr 2024 20:07:11 +0200 Subject: [PATCH 074/107] Add mqtt notify platform (#115653) * Add mqtt notify platform * Stale docstring --- .../components/mqtt/config_integration.py | 1 + homeassistant/components/mqtt/const.py | 1 + homeassistant/components/mqtt/discovery.py | 1 + homeassistant/components/mqtt/notify.py | 95 ++++ tests/components/mqtt/test_notify.py | 474 ++++++++++++++++++ 5 files changed, 572 insertions(+) create mode 100644 homeassistant/components/mqtt/notify.py create mode 100644 tests/components/mqtt/test_notify.py diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 2500923ca9b..7244a41e975 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -45,6 +45,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.LAWN_MOWER.value: vol.All(cv.ensure_list, [dict]), Platform.LIGHT.value: vol.All(cv.ensure_list, [dict]), Platform.LOCK.value: vol.All(cv.ensure_list, [dict]), + Platform.NOTIFY.value: vol.All(cv.ensure_list, [dict]), Platform.NUMBER.value: vol.All(cv.ensure_list, [dict]), Platform.SCENE.value: vol.All(cv.ensure_list, [dict]), Platform.SELECT.value: vol.All(cv.ensure_list, [dict]), diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 82320cd2f11..7eca266edfa 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -157,6 +157,7 @@ RELOADABLE_PLATFORMS = [ Platform.LIGHT, Platform.LAWN_MOWER, Platform.LOCK, + Platform.NOTIFY, Platform.NUMBER, Platform.SCENE, Platform.SELECT, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 43f4f8cfd46..e330cd9b44b 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -66,6 +66,7 @@ SUPPORTED_COMPONENTS = { "lawn_mower", "light", "lock", + "notify", "number", "scene", "siren", diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py new file mode 100644 index 00000000000..b7a17f07f7f --- /dev/null +++ b/homeassistant/components/mqtt/notify.py @@ -0,0 +1,95 @@ +"""Support for MQTT notify.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components import notify +from homeassistant.components.notify import NotifyEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType + +from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_QOS, + CONF_RETAIN, +) +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) +from .models import MqttCommandTemplate +from .util import valid_publish_topic + +DEFAULT_NAME = "MQTT notify" + +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT notify through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttNotify, + notify.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttNotify(MqttEntity, NotifyEntity): + """Representation of a notification entity service that can send messages using MQTT.""" + + _default_name = DEFAULT_NAME + _entity_id_format = notify.ENTITY_ID_FORMAT + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), entity=self + ).async_render + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + async def async_send_message(self, message: str) -> None: + """Send a message.""" + payload = self._command_template(message) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) diff --git a/tests/components/mqtt/test_notify.py b/tests/components/mqtt/test_notify.py new file mode 100644 index 00000000000..bc833b79eb0 --- /dev/null +++ b/tests/components/mqtt/test_notify.py @@ -0,0 +1,474 @@ +"""The tests for the MQTT notify platform.""" + +import copy +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, notify +from homeassistant.components.notify import ATTR_MESSAGE +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_publishing_with_custom_encoding, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: {notify.DOMAIN: {"name": "test", "command_topic": "test-topic"}} +} + + +@pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + notify.DOMAIN: { + "command_topic": "command-topic", + "name": "test", + "object_id": "test_notify", + "qos": "2", + } + } + } + ], +) +async def test_sending_mqtt_commands( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("notify.test_notify") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test" + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "Beer message", ATTR_ENTITY_ID: "notify.test_notify"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "Beer message", 2, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("notify.test_notify") + assert state.state == "2021-11-08T13:31:44+00:00" + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + notify.DOMAIN: { + "command_topic": "command-topic", + "command_template": '{ "{{ entity_id }}": "{{ value }}" }', + "name": "test", + } + } + } + ], +) +async def test_command_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending of MQTT commands through a command template.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("notify.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test" + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "Beer message", ATTR_ENTITY_ID: "notify.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", '{ "notify.test": "Beer message" }', 0, False + ) + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, notify.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG, True + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, + mqtt_mock_entry, + notify.DOMAIN, + DEFAULT_CONFIG, + True, + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + notify.DOMAIN: [ + { + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one notify entity per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, notify.DOMAIN) + + +async def test_discovery_removal_notify( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered notify.""" + data = '{ "name": "test", "command_topic": "test_topic" }' + await help_test_discovery_removal( + hass, mqtt_mock_entry, caplog, notify.DOMAIN, data + ) + + +async def test_discovery_update_notify( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered notify.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][notify.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][notify.DOMAIN]) + config1["name"] = "Beer" + config2["name"] = "Milk" + + await help_test_discovery_update( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + config1, + config2, + ) + + +async def test_discovery_update_unchanged_notify( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered notify.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.notify.MqttNotify.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, notify.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT notify device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT notify device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + notify.DOMAIN, + DEFAULT_CONFIG, + notify.SERVICE_SEND_MESSAGE, + command_topic="test-topic", + command_payload="Milk", + state_topic=None, + service_parameters={"message": "Milk"}, + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + notify.SERVICE_SEND_MESSAGE, + "command_topic", + {"message": "Beer test"}, + "Beer test", + "command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, +) -> None: + """Test publishing MQTT payload with different encoding.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = notify.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) From cdc49328be0520258ddd314f10e63d0ef69a8d9c Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 17 Apr 2024 21:08:10 +0200 Subject: [PATCH 075/107] Address late reviews for the enigma2 config flow (#115768) * Address late reviews for the enigma2 config flow * fix tests * review comments * test for issues * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/enigma2/config_flow.py | 67 ++++++++++--------- homeassistant/components/enigma2/strings.json | 15 ++++- tests/components/enigma2/test_config_flow.py | 25 +++++-- 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py index c144f2b7dae..ac57bd9d0fa 100644 --- a/homeassistant/components/enigma2/config_flow.py +++ b/homeassistant/components/enigma2/config_flow.py @@ -8,7 +8,6 @@ from openwebif.error import InvalidAuthError import voluptuous as vol from yarl import URL -from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -18,6 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -68,17 +68,12 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) OPTIONS_KEYS = (CONF_DEEP_STANDBY, CONF_SOURCE_BOUQUET, CONF_USE_CHANNEL_ICON) - def __init__(self) -> None: - """Initialize the config flow.""" - super().__init__() - self.errors: dict[str, str] = {} - self._data: dict[str, Any] = {} - self._options: dict[str, Any] = {} - - async def validate_user_input(self, user_input: dict[str, Any]) -> dict[str, Any]: + async def validate_user_input( + self, user_input: dict[str, Any] + ) -> dict[str, str] | None: """Validate user input.""" - self.errors = {} + errors = None self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) @@ -97,16 +92,16 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): try: about = await OpenWebIfDevice(session).get_about() except InvalidAuthError: - self.errors["base"] = "invalid_auth" + errors = {"base": "invalid_auth"} except ClientError: - self.errors["base"] = "cannot_connect" + errors = {"base": "cannot_connect"} except Exception: # pylint: disable=broad-except - self.errors["base"] = "unknown" + errors = {"base": "unknown"} else: await self.async_set_unique_id(about["info"]["ifaces"][0]["mac"]) self._abort_if_unique_id_configured() - return user_input + return errors async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -115,23 +110,41 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA) - data = await self.validate_user_input(user_input) - if "base" in self.errors: + if errors := await self.validate_user_input(user_input): return self.async_show_form( - step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA, errors=self.errors + step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA, errors=errors ) - return self.async_create_entry( - data=data, title=data[CONF_HOST], options=self._options - ) + return self.async_create_entry(data=user_input, title=user_input[CONF_HOST]) async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Validate import.""" + """Handle the import step.""" if CONF_PORT not in user_input: user_input[CONF_PORT] = DEFAULT_PORT if CONF_SSL not in user_input: user_input[CONF_SSL] = DEFAULT_SSL user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL + data = {key: user_input[key] for key in user_input if key in self.DATA_KEYS} + options = { + key: user_input[key] for key in user_input if key in self.OPTIONS_KEYS + } + + if errors := await self.validate_user_input(user_input): + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_{DOMAIN}_import_issue_{errors["base"]}", + breaks_in_ha_version="2024.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{errors["base"]}", + translation_placeholders={ + "url": "/config/integrations/dashboard/add?domain=enigma2" + }, + ) + return self.async_abort(reason=errors["base"]) + async_create_issue( self.hass, HOMEASSISTANT_DOMAIN, @@ -147,12 +160,6 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): "integration_title": "Enigma2", }, ) - - self._data = { - key: user_input[key] for key in user_input if key in self.DATA_KEYS - } - self._options = { - key: user_input[key] for key in user_input if key in self.OPTIONS_KEYS - } - - return await self.async_step_user(self._data) + return self.async_create_entry( + data=data, title=data[CONF_HOST], options=options + ) diff --git a/homeassistant/components/enigma2/strings.json b/homeassistant/components/enigma2/strings.json index 888c6d59387..ddeb59ea6d5 100644 --- a/homeassistant/components/enigma2/strings.json +++ b/homeassistant/components/enigma2/strings.json @@ -10,7 +10,6 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "name": "[%key:common::config_flow::data::name%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } @@ -26,5 +25,19 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "issues": { + "deprecated_yaml_import_issue_unknown": { + "title": "The Enigma2 YAML configuration import failed", + "description": "Configuring Enigma2 using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure connection to the device works, the authentication details are correct and restart Home Assistant to try again or remove the Enigma2 YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The Enigma2 YAML configuration import failed", + "description": "Configuring Enigma2 using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure the authentication details are correct and restart Home Assistant to try again or remove the Enigma2 YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Enigma2 YAML configuration import failed", + "description": "Configuring Enigma2 using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure connection to the device works and restart Home Assistant to try again or remove the Enigma2 YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } } } diff --git a/tests/components/enigma2/test_config_flow.py b/tests/components/enigma2/test_config_flow.py index dcd249ad943..dfca569276d 100644 --- a/tests/components/enigma2/test_config_flow.py +++ b/tests/components/enigma2/test_config_flow.py @@ -10,8 +10,9 @@ import pytest from homeassistant import config_entries from homeassistant.components.enigma2.const import DOMAIN from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.issue_registry import IssueRegistry from .conftest import ( EXPECTED_OPTIONS, @@ -96,6 +97,7 @@ async def test_form_import( test_config: dict[str, Any], expected_data: dict[str, Any], expected_options: dict[str, Any], + issue_registry: IssueRegistry, ) -> None: """Test we get the form with import source.""" with ( @@ -115,6 +117,12 @@ async def test_form_import( ) await hass.async_block_till_done() + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + + assert issue + assert issue.issue_domain == DOMAIN assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == test_config[CONF_HOST] assert result["data"] == expected_data @@ -132,7 +140,10 @@ async def test_form_import( ], ) async def test_form_import_errors( - hass: HomeAssistant, exception: Exception, error_type: str + hass: HomeAssistant, + exception: Exception, + error_type: str, + issue_registry: IssueRegistry, ) -> None: """Test we handle errors on import.""" with patch( @@ -145,5 +156,11 @@ async def test_form_import_errors( data=TEST_IMPORT_FULL, ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": error_type} + issue = issue_registry.async_get_issue( + DOMAIN, f"deprecated_yaml_{DOMAIN}_import_issue_{error_type}" + ) + + assert issue + assert issue.issue_domain == DOMAIN + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error_type From 07a46f17d0e70ed963e823031fbd5dc8b436c911 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 17 Apr 2024 21:20:12 +0200 Subject: [PATCH 076/107] Add sanix sensor tests (#115763) Add sanix tests --- tests/components/sanix/conftest.py | 36 ++- .../sanix/snapshots/test_sensor.ambr | 292 ++++++++++++++++++ tests/components/sanix/test_init.py | 2 +- tests/components/sanix/test_sensor.py | 39 +++ 4 files changed, 362 insertions(+), 7 deletions(-) create mode 100644 tests/components/sanix/snapshots/test_sensor.ambr create mode 100644 tests/components/sanix/test_sensor.py diff --git a/tests/components/sanix/conftest.py b/tests/components/sanix/conftest.py index 297416a6290..d1f4424b166 100644 --- a/tests/components/sanix/conftest.py +++ b/tests/components/sanix/conftest.py @@ -1,9 +1,21 @@ """Sanix tests configuration.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime +from unittest.mock import AsyncMock, patch +from zoneinfo import ZoneInfo import pytest +from sanix import ( + ATTR_API_BATTERY, + ATTR_API_DEVICE_NO, + ATTR_API_DISTANCE, + ATTR_API_FILL_PERC, + ATTR_API_SERVICE_DATE, + ATTR_API_SSID, + ATTR_API_STATUS, + ATTR_API_TIME, +) from sanix.models import Measurement from homeassistant.components.sanix.const import CONF_SERIAL_NUMBER, DOMAIN @@ -15,19 +27,31 @@ from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture def mock_sanix(): """Build a fixture for the Sanix API that connects successfully and returns measurements.""" - fixture = load_json_object_fixture("sanix/get_measurements.json") - mock_sanix_api = MagicMock() + fixture = load_json_object_fixture("get_measurements.json", DOMAIN) with ( patch( "homeassistant.components.sanix.config_flow.Sanix", - return_value=mock_sanix_api, + autospec=True, ) as mock_sanix_api, patch( "homeassistant.components.sanix.Sanix", - return_value=mock_sanix_api, + new=mock_sanix_api, ), ): - mock_sanix_api.return_value.fetch_data.return_value = Measurement(**fixture) + mock_sanix_api.return_value.fetch_data.return_value = Measurement( + battery=fixture[ATTR_API_BATTERY], + device_no=fixture[ATTR_API_DEVICE_NO], + distance=fixture[ATTR_API_DISTANCE], + fill_perc=fixture[ATTR_API_FILL_PERC], + service_date=datetime.strptime( + fixture[ATTR_API_SERVICE_DATE], "%d.%m.%Y" + ).date(), + ssid=fixture[ATTR_API_SSID], + status=fixture[ATTR_API_STATUS], + time=datetime.strptime(fixture[ATTR_API_TIME], "%d.%m.%Y %H:%M:%S").replace( + tzinfo=ZoneInfo("Europe/Warsaw") + ), + ) yield mock_sanix_api diff --git a/tests/components/sanix/snapshots/test_sensor.ambr b/tests/components/sanix/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..792d2a3be64 --- /dev/null +++ b/tests/components/sanix/snapshots/test_sensor.ambr @@ -0,0 +1,292 @@ +# serializer version: 1 +# name: test_all_entities[sensor.sanix_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '1810088-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sanix_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Sanix Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sanix_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.sanix_device_number-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_device_number', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Device number', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_no', + 'unique_id': '1810088-device_no', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sanix_device_number-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sanix Device number', + }), + 'context': , + 'entity_id': 'sensor.sanix_device_number', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'SANIX-1810088', + }) +# --- +# name: test_all_entities[sensor.sanix_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'distance', + 'unique_id': '1810088-distance', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sanix_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Sanix Distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sanix_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '109', + }) +# --- +# name: test_all_entities[sensor.sanix_filled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_filled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filled', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fill_perc', + 'unique_id': '1810088-fill_perc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sanix_filled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sanix Filled', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sanix_filled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_all_entities[sensor.sanix_service_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_service_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Service date', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_date', + 'unique_id': '1810088-service_date', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sanix_service_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Sanix Service date', + }), + 'context': , + 'entity_id': 'sensor.sanix_service_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-15', + }) +# --- +# name: test_all_entities[sensor.sanix_ssid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SSID', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ssid', + 'unique_id': '1810088-ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sanix_ssid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sanix SSID', + }), + 'context': , + 'entity_id': 'sensor.sanix_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Wifi', + }) +# --- diff --git a/tests/components/sanix/test_init.py b/tests/components/sanix/test_init.py index 57e4920da11..467737628fe 100644 --- a/tests/components/sanix/test_init.py +++ b/tests/components/sanix/test_init.py @@ -1,4 +1,4 @@ -"""Test the Home Assistant analytics init module.""" +"""Test the Sanix init module.""" from __future__ import annotations diff --git a/tests/components/sanix/test_sensor.py b/tests/components/sanix/test_sensor.py new file mode 100644 index 00000000000..d9729ca3c25 --- /dev/null +++ b/tests/components/sanix/test_sensor.py @@ -0,0 +1,39 @@ +"""Test the Sanix sensor module.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_sanix: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.sanix.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") From 0aa7946208ee665a52aff814032f50ef32a33307 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 17 Apr 2024 12:23:58 -0700 Subject: [PATCH 077/107] Bump google-nest-sdm to 3.0.4 (#115731) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 89244642207..354066e2d87 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==3.0.3"] + "requirements": ["google-nest-sdm==3.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab9f24284e3..a9bc97d9158 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -962,7 +962,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.3.1 # homeassistant.components.nest -google-nest-sdm==3.0.3 +google-nest-sdm==3.0.4 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab9b1e94b88..6c40bd21e84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -788,7 +788,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.3.1 # homeassistant.components.nest -google-nest-sdm==3.0.3 +google-nest-sdm==3.0.4 # homeassistant.components.google_travel_time googlemaps==2.5.1 From 11931cdb5669c0e1b59c0541c92847358427663c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 14:28:09 -0500 Subject: [PATCH 078/107] Simplify labels and areas template calls (#115673) The labels and areas are already exposed on the object --- homeassistant/helpers/template.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d344a473494..1f0742e896d 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1453,8 +1453,7 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: def areas(hass: HomeAssistant) -> Iterable[str | None]: """Return all areas.""" - area_reg = area_registry.async_get(hass) - return [area.id for area in area_reg.async_list_areas()] + return list(area_registry.async_get(hass).areas) def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: @@ -1580,7 +1579,7 @@ def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None """Return all labels, or those from a area ID, device ID, or entity ID.""" label_reg = label_registry.async_get(hass) if lookup_value is None: - return [label.label_id for label in label_reg.async_list_labels()] + return list(label_reg.labels) ent_reg = entity_registry.async_get(hass) From 7188d62340ea790f898ba7612c10e89f217312d1 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel <41558831+felipediel@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:37:38 -0300 Subject: [PATCH 079/107] Bump Broadlink to 0.19.0 (#115742) Co-authored-by: J. Nick Koston --- homeassistant/components/broadlink/const.py | 2 ++ homeassistant/components/broadlink/manifest.json | 2 +- homeassistant/components/broadlink/remote.py | 2 +- homeassistant/components/broadlink/switch.py | 2 +- homeassistant/components/broadlink/updater.py | 11 +++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index 91d4358a077..41c4964c2b3 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -9,6 +9,7 @@ DOMAINS_AND_TYPES = { Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, Platform.SENSOR: { "A1", + "MP1S", "RM4MINI", "RM4PRO", "RMPRO", @@ -20,6 +21,7 @@ DOMAINS_AND_TYPES = { Platform.SWITCH: { "BG1", "MP1", + "MP1S", "RM4MINI", "RM4PRO", "RMMINI", diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index 7fd925a2ff4..bf5dfb16584 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -38,5 +38,5 @@ "documentation": "https://www.home-assistant.io/integrations/broadlink", "iot_class": "local_polling", "loggers": ["broadlink"], - "requirements": ["broadlink==0.18.3"] + "requirements": ["broadlink==0.19.0"] } diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index f8d903c51eb..55368e5ff59 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -373,7 +373,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): start_time = dt_util.utcnow() while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) - found = await device.async_request(device.api.check_frequency) + found = await device.async_request(device.api.check_frequency)[0] if found: break else: diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index f61e726b1d5..9cf7e3391fa 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -129,7 +129,7 @@ async def async_setup_entry( elif device.api.type == "BG1": switches.extend(BroadlinkBG1Slot(device, slot) for slot in range(1, 3)) - elif device.api.type == "MP1": + elif device.api.type in {"MP1", "MP1S"}: switches.extend(BroadlinkMP1Slot(device, slot) for slot in range(1, 5)) async_add_entities(switches) diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 20b241b0d89..f678af0105f 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -21,6 +21,7 @@ def get_update_manager(device): "LB1": BroadlinkLB1UpdateManager, "LB2": BroadlinkLB1UpdateManager, "MP1": BroadlinkMP1UpdateManager, + "MP1S": BroadlinkMP1SUpdateManager, "RM4MINI": BroadlinkRMUpdateManager, "RM4PRO": BroadlinkRMUpdateManager, "RMMINI": BroadlinkRMUpdateManager, @@ -112,6 +113,16 @@ class BroadlinkMP1UpdateManager(BroadlinkUpdateManager): return await self.device.async_request(self.device.api.check_power) +class BroadlinkMP1SUpdateManager(BroadlinkUpdateManager): + """Manages updates for Broadlink MP1 devices.""" + + async def async_fetch_data(self): + """Fetch data from the device.""" + power = await self.device.async_request(self.device.api.check_power) + sensors = await self.device.async_request(self.device.api.get_state) + return {**power, **sensors} + + class BroadlinkRMUpdateManager(BroadlinkUpdateManager): """Manages updates for Broadlink remotes.""" diff --git a/requirements_all.txt b/requirements_all.txt index a9bc97d9158..66c36c7ac18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -601,7 +601,7 @@ boto3==1.34.51 bring-api==0.5.7 # homeassistant.components.broadlink -broadlink==0.18.3 +broadlink==0.19.0 # homeassistant.components.brother brother==4.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c40bd21e84..5bac0527854 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -512,7 +512,7 @@ boschshcpy==0.2.91 bring-api==0.5.7 # homeassistant.components.broadlink -broadlink==0.18.3 +broadlink==0.19.0 # homeassistant.components.brother brother==4.1.0 From b829f1030bd67ca9f83d2b6a464e11cc53a1d95f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 14:59:16 -0500 Subject: [PATCH 080/107] Migrate snooze config flow to use eager_start (#115658) --- homeassistant/components/snooz/config_flow.py | 2 +- tests/components/snooz/test_config_flow.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py index 9992a68ef69..3962a44d8b9 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -132,7 +132,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): """Wait for device to enter pairing mode.""" if not self._pairing_task: self._pairing_task = self.hass.async_create_task( - self._async_wait_for_pairing_mode(), eager_start=False + self._async_wait_for_pairing_mode() ) if not self._pairing_task.done(): diff --git a/tests/components/snooz/test_config_flow.py b/tests/components/snooz/test_config_flow.py index 209bd50512a..4ed4d6184a7 100644 --- a/tests/components/snooz/test_config_flow.py +++ b/tests/components/snooz/test_config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Event +from asyncio import Event, sleep from unittest.mock import patch from homeassistant import config_entries @@ -298,9 +298,16 @@ async def _test_pairs( async def _test_pairs_timeout( hass: HomeAssistant, flow_id: str, user_input: dict | None = None ) -> str: + async def _async_process_advertisements( + _hass, _callback, _matcher, _mode, _timeout + ): + """Simulate a timeout waiting for pairing mode.""" + await sleep(0) + raise TimeoutError + with patch( "homeassistant.components.snooz.config_flow.async_process_advertisements", - side_effect=TimeoutError(), + _async_process_advertisements, ): result = await hass.config_entries.flow.async_configure( flow_id, user_input=user_input or {} From 98ed6e7fe573189e628cf0c35719185541eba60e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 17:47:40 -0500 Subject: [PATCH 081/107] Bump habluetooth to 2.7.0 (#115783) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/bluetooth/test_diagnostics.py | 32 +++++++++++++++++++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 471e327ee9d..d8dca1da607 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.4.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.6.0" + "habluetooth==2.7.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bd16f3c6147..6fe0d55ae6d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.2.0 -habluetooth==2.6.0 +habluetooth==2.7.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 diff --git a/requirements_all.txt b/requirements_all.txt index 66c36c7ac18..7801db05dff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1029,7 +1029,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.6.0 +habluetooth==2.7.0 # homeassistant.components.cloud hass-nabucasa==0.78.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5bac0527854..5e52c069edb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -843,7 +843,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.6.0 +habluetooth==2.7.0 # homeassistant.components.cloud hass-nabucasa==0.78.0 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 3d29080d56c..c67bd583b1e 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -176,6 +176,14 @@ async def test_diagnostics( "source": "00:00:00:00:00:01", "start_time": ANY, "type": "HaScanner", + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, }, { "adapter": "hci1", @@ -203,6 +211,14 @@ async def test_diagnostics( "source": "00:00:00:00:00:02", "start_time": ANY, "type": "FakeHaScanner", + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, }, ], "slot_manager": { @@ -376,6 +392,14 @@ async def test_diagnostics_macos( "source": "Core Bluetooth", "start_time": ANY, "type": "FakeHaScanner", + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, } ], "slot_manager": { @@ -543,6 +567,14 @@ async def test_diagnostics_remote_adapter( "source": "00:00:00:00:00:01", "start_time": ANY, "type": "HaScanner", + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, }, { "connectable": True, From 8fb551430d68979fca872498cd952f7c28fb417f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Apr 2024 20:38:17 -0500 Subject: [PATCH 082/107] Bump bluetooth-auto-recovery to 1.4.1 (#115792) --- 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 d8dca1da607..f7d27e84a17 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -17,7 +17,7 @@ "bleak==0.21.1", "bleak-retry-connector==3.5.0", "bluetooth-adapters==0.18.0", - "bluetooth-auto-recovery==1.4.0", + "bluetooth-auto-recovery==1.4.1", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", "habluetooth==2.7.0" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6fe0d55ae6d..7782fba1713 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -18,7 +18,7 @@ bcrypt==4.1.2 bleak-retry-connector==3.5.0 bleak==0.21.1 bluetooth-adapters==0.18.0 -bluetooth-auto-recovery==1.4.0 +bluetooth-auto-recovery==1.4.1 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 certifi>=2021.5.30 diff --git a/requirements_all.txt b/requirements_all.txt index 7801db05dff..2ac66b2b34c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -579,7 +579,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.18.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.0 +bluetooth-auto-recovery==1.4.1 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e52c069edb..b7a4a59a18e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -494,7 +494,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.18.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.0 +bluetooth-auto-recovery==1.4.1 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble From 14515b77bb79b245c177ea66a76dfa7f2020995e Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 17 Apr 2024 20:47:15 -0500 Subject: [PATCH 083/107] Add valve entity support for ESPHome (#115341) Co-authored-by: J. Nick Koston Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/esphome/entry_data.py | 2 + homeassistant/components/esphome/valve.py | 103 +++++++++ tests/components/esphome/test_valve.py | 196 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 homeassistant/components/esphome/valve.py create mode 100644 tests/components/esphome/test_valve.py diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 005963db872..52dc1f17ad6 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -36,6 +36,7 @@ from aioesphomeapi import ( TextSensorInfo, TimeInfo, UserService, + ValveInfo, build_unique_id, ) from aioesphomeapi.model import ButtonInfo @@ -78,6 +79,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { TextInfo: Platform.TEXT, TextSensorInfo: Platform.SENSOR, TimeInfo: Platform.TIME, + ValveInfo: Platform.VALVE, } diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py new file mode 100644 index 00000000000..5798d38803f --- /dev/null +++ b/homeassistant/components/esphome/valve.py @@ -0,0 +1,103 @@ +"""Support for ESPHome valves.""" + +from __future__ import annotations + +from typing import Any + +from aioesphomeapi import EntityInfo, ValveInfo, ValveOperation, ValveState + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.enum import try_parse_enum + +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up ESPHome valves based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + info_type=ValveInfo, + entity_type=EsphomeValve, + state_type=ValveState, + ) + + +class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): + """A valve implementation for ESPHome.""" + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + flags = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + if static_info.supports_stop: + flags |= ValveEntityFeature.STOP + if static_info.supports_position: + flags |= ValveEntityFeature.SET_POSITION + self._attr_supported_features = flags + self._attr_device_class = try_parse_enum( + ValveDeviceClass, static_info.device_class + ) + self._attr_assumed_state = static_info.assumed_state + self._attr_reports_position = static_info.supports_position + + @property + @esphome_state_property + def is_closed(self) -> bool: + """Return if the valve is closed or not.""" + return self._state.position == 0.0 + + @property + @esphome_state_property + def is_opening(self) -> bool: + """Return if the valve is opening or not.""" + return self._state.current_operation is ValveOperation.IS_OPENING + + @property + @esphome_state_property + def is_closing(self) -> bool: + """Return if the valve is closing or not.""" + return self._state.current_operation is ValveOperation.IS_CLOSING + + @property + @esphome_state_property + def current_valve_position(self) -> int | None: + """Return current position of valve. 0 is closed, 100 is open.""" + return round(self._state.position * 100.0) + + @convert_api_error_ha_error + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + self._client.valve_command(key=self._key, position=1.0) + + @convert_api_error_ha_error + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + self._client.valve_command(key=self._key, position=0.0) + + @convert_api_error_ha_error + async def async_stop_valve(self, **kwargs: Any) -> None: + """Stop the valve.""" + self._client.valve_command(key=self._key, stop=True) + + @convert_api_error_ha_error + async def async_set_valve_position(self, position: float) -> None: + """Move the valve to a specific position.""" + self._client.valve_command(key=self._key, position=position / 100) diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py new file mode 100644 index 00000000000..5ba7bcbe187 --- /dev/null +++ b/tests/components/esphome/test_valve.py @@ -0,0 +1,196 @@ +"""Test ESPHome valves.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import call + +from aioesphomeapi import ( + APIClient, + EntityInfo, + EntityState, + UserService, + ValveInfo, + ValveOperation, + ValveState, +) + +from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, + SERVICE_STOP_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .conftest import MockESPHomeDevice + + +async def test_valve_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic valve entity.""" + entity_info = [ + ValveInfo( + object_id="myvalve", + key=1, + name="my valve", + unique_id="my_valve", + supports_position=True, + supports_stop=True, + ) + ] + states = [ + ValveState( + key=1, + position=0.5, + current_operation=ValveOperation.IS_OPENING, + ) + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_OPENING + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test_myvalve", ATTR_POSITION: 50}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.5)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_STOP_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, stop=True)]) + mock_client.valve_command.reset_mock() + + mock_device.set_state( + ValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_CLOSED + + mock_device.set_state( + ValveState(key=1, position=0.5, current_operation=ValveOperation.IS_CLOSING) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_CLOSING + + mock_device.set_state( + ValveState(key=1, position=1.0, current_operation=ValveOperation.IDLE) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_OPEN + + +async def test_valve_entity_without_position( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic valve entity without position or stop.""" + entity_info = [ + ValveInfo( + object_id="myvalve", + key=1, + name="my valve", + unique_id="my_valve", + supports_position=False, + supports_stop=False, + ) + ] + states = [ + ValveState( + key=1, + position=0.5, + current_operation=ValveOperation.IS_OPENING, + ) + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_OPENING + assert ATTR_CURRENT_POSITION not in state.attributes + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.reset_mock() + + mock_device.set_state( + ValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_CLOSED From 0398a481c38cc1b19b1bff0148e8f9bef39dd493 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Apr 2024 02:25:03 -0500 Subject: [PATCH 084/107] Fix failing sanix tests (#115793) Fixing failing sanix tests Regenerate snapshot to match what actually happens. There is no translation keys for these two --- tests/components/sanix/snapshots/test_sensor.ambr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/sanix/snapshots/test_sensor.ambr b/tests/components/sanix/snapshots/test_sensor.ambr index 792d2a3be64..84c97ce68b1 100644 --- a/tests/components/sanix/snapshots/test_sensor.ambr +++ b/tests/components/sanix/snapshots/test_sensor.ambr @@ -29,7 +29,7 @@ 'platform': 'sanix', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'battery', + 'translation_key': None, 'unique_id': '1810088-battery', 'unit_of_measurement': '%', }) @@ -126,7 +126,7 @@ 'platform': 'sanix', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'distance', + 'translation_key': None, 'unique_id': '1810088-distance', 'unit_of_measurement': , }) From 4374ec767d5b5f3581901db812bba525cd35f28f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Apr 2024 10:14:51 +0200 Subject: [PATCH 085/107] Bump github/codeql-action from 3.25.0 to 3.25.1 (#115796) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9dba09557e3..2b9a2af127f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.1.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.25.0 + uses: github/codeql-action/init@v3.25.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.25.0 + uses: github/codeql-action/analyze@v3.25.1 with: category: "/language:python" From a47c76fc40d2207e54767a03575acfbf0c7ad67c Mon Sep 17 00:00:00 2001 From: Krzysztof Kwitt <120908425+krzysztof-kwitt@users.noreply.github.com> Date: Thu, 18 Apr 2024 13:18:14 +0200 Subject: [PATCH 086/107] Bump connect-box to 0.3.1 (#107852) --- homeassistant/components/upc_connect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/upc_connect/manifest.json b/homeassistant/components/upc_connect/manifest.json index 79ed768282a..02b852ec3a6 100644 --- a/homeassistant/components/upc_connect/manifest.json +++ b/homeassistant/components/upc_connect/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/upc_connect", "iot_class": "local_polling", "loggers": ["connect_box"], - "requirements": ["connect-box==0.2.8"] + "requirements": ["connect-box==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2ac66b2b34c..edb4c8919d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -658,7 +658,7 @@ colorthief==0.2.1 concord232==0.15 # homeassistant.components.upc_connect -connect-box==0.2.8 +connect-box==0.3.1 # homeassistant.components.xiaomi_miio construct==2.10.68 From aee620be9f54fe70e8327bbe6f48e70dbd775e96 Mon Sep 17 00:00:00 2001 From: William Easton Date: Thu, 18 Apr 2024 06:38:52 -0500 Subject: [PATCH 087/107] Ambient Weather: Check for key existence before checking value (#115776) --- homeassistant/components/ambient_station/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ambient_station/entity.py b/homeassistant/components/ambient_station/entity.py index a1a81d97c3f..24dfab438d8 100644 --- a/homeassistant/components/ambient_station/entity.py +++ b/homeassistant/components/ambient_station/entity.py @@ -49,7 +49,7 @@ class AmbientWeatherEntity(Entity): last_data = self._ambient.stations[self._mac_address][ATTR_LAST_DATA] key = self.entity_description.key available_key = TYPE_SOLARRADIATION if key == TYPE_SOLARRADIATION_LX else key - self._attr_available = last_data[available_key] is not None + self._attr_available = last_data.get(available_key) is not None self.update_from_latest_data() self.async_write_ha_state() From 47f0d5ed1f39b7dfb742496d362a20b2a4ce1e95 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 18 Apr 2024 13:41:34 +0200 Subject: [PATCH 088/107] Add script to compare alexa locales with upstream (#114247) * Add script to compare alexa locales with upstream * Use a function in script * Add test base * Complete output assertion * Add type annotation * Add note to docstring * Update script call example Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- .../components/alexa/capabilities.py | 4 + script/alexa_locales.py | 67 ++ .../non_packaged_scripts/alexa_locales.txt | 650 ++++++++++++++++++ tests/non_packaged_scripts/__init__.py | 1 + .../snapshots/test_alexa_locales.ambr | 62 ++ .../test_alexa_locales.py | 29 + 6 files changed, 813 insertions(+) create mode 100644 script/alexa_locales.py create mode 100644 tests/fixtures/non_packaged_scripts/alexa_locales.txt create mode 100644 tests/non_packaged_scripts/__init__.py create mode 100644 tests/non_packaged_scripts/snapshots/test_alexa_locales.ambr create mode 100644 tests/non_packaged_scripts/test_alexa_locales.py diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index bc9b482109f..df32220895d 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -300,6 +300,10 @@ class Alexa(AlexaCapability): The API suggests you should explicitly include this interface. https://developer.amazon.com/docs/device-apis/alexa-interface.html + + To compare current supported locales in Home Assistant + with Alexa supported locales, run the following script: + python -m script.alexa_locales """ supported_locales = { diff --git a/script/alexa_locales.py b/script/alexa_locales.py new file mode 100644 index 00000000000..84bdac4133a --- /dev/null +++ b/script/alexa_locales.py @@ -0,0 +1,67 @@ +"""Check if upstream Alexa locales are subset of the core Alexa supported locales.""" + +from pprint import pprint +import re + +from bs4 import BeautifulSoup +import requests + +from homeassistant.components.alexa import capabilities + +SITE = ( + "https://developer.amazon.com/en-GB/docs/alexa/device-apis/list-of-interfaces.html" +) + + +def run_script() -> None: + """Run the script.""" + response = requests.get(SITE, timeout=10) + soup = BeautifulSoup(response.text, "html.parser") + + table = soup.find("table") + table_body = table.find_all("tbody")[-1] + rows = table_body.find_all("tr") + data = [[ele.text.strip() for ele in row.find_all("td") if ele] for row in rows] + upstream_locales_raw = {row[0]: row[3] for row in data} + language_pattern = re.compile(r"^[a-z]{2}-[A-Z]{2}$") + upstream_locales = { + upstream_interface: { + name + for word in upstream_locale.split(" ") + if (name := word.strip(",")) and language_pattern.match(name) is not None + } + for upstream_interface, upstream_locale in upstream_locales_raw.items() + if upstream_interface.count(".") == 1 # Skip sub-interfaces + } + + interfaces_missing = {} + interfaces_nok = {} + interfaces_ok = {} + + for upstream_interface, upstream_locale in upstream_locales.items(): + core_interface_name = upstream_interface.replace(".", "") + core_interface = getattr(capabilities, core_interface_name, None) + + if core_interface is None: + interfaces_missing[upstream_interface] = upstream_locale + continue + + core_locale = core_interface.supported_locales + + if not upstream_locale.issubset(core_locale): + interfaces_nok[core_interface_name] = core_locale + else: + interfaces_ok[core_interface_name] = core_locale + + print("Missing interfaces:") + pprint(list(interfaces_missing)) + print("\n") + print("Interfaces where upstream locales are not subsets of the core locales:") + pprint(list(interfaces_nok)) + print("\n") + print("Interfaces checked ok:") + pprint(list(interfaces_ok)) + + +if __name__ == "__main__": + run_script() diff --git a/tests/fixtures/non_packaged_scripts/alexa_locales.txt b/tests/fixtures/non_packaged_scripts/alexa_locales.txt new file mode 100644 index 00000000000..beb9c8dbc7e --- /dev/null +++ b/tests/fixtures/non_packaged_scripts/alexa_locales.txt @@ -0,0 +1,650 @@ +

List of Alexa Interfaces and Supported Languages

+ + +
+ + + + + +

Implement the Alexa interfaces to build automotive skills, music, radio, and podcast skills, smart home skills, and video skills. Alexa interfaces use the pre-built voice interaction model.

+ +

You can use these interfaces with Alexa Voice Service (AVS) Built-in and Alexa Connect Kit (ACK) enabled devices, also. For more details, see Smart Home Development Options.

+ +

Alexa interfaces

+ +

The following table shows the interfaces that you can implement in your Alexa skills. Follow the link to each interface for full details, including the supported capabilities and example customer utterances.

+ + + + +
Interface + Version + Primary skill type + Supported languages + +
+

Alexa.ApplicationStateReporter

+
+

1.0

+
+

AVS

+
+

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

+ +
+

Alexa.Audio.PlayQueue

+
+

1.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.AuthorizationController

+
+

1.0

+
+

Automotive

+
+

en-CA, en-US, es-MX, es-US, fr-CA

+ +
+

Alexa.AutomationManagement

+
+

1.0

+
+

Smart Home Energy

+
+

en-US

+ +
+

Alexa.Automotive.VehicleData

+
+

1.0

+
+

Automotive

+
+

en-CA, en-US, es-MX, es-US, fr-CA

+ +
+

Alexa.BrightnessController

+
+

3

+
+

Smart Home

+
+

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

+ +
+

Alexa.Camera.LiveViewController

+
+

1.7

+
+

AVS

+
+

en-US

+ +
+

Alexa.CameraStreamController

+
+

3

+
+

Smart Home Security

+
+

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

+ +
+

Alexa.ChannelController

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

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

+ +
+

Alexa.ColorController

+
+

3

+
+

Smart Home

+
+

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

+ +
+

Alexa.ColorTemperatureController

+
+

3

+
+

Smart Home

+
+

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

+ +
+

Alexa.Commissionable

+
+

1.0

+
+

Smart Home

+
+

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

+ +
+

Alexa.ConsentManagement.ConsentRequiredReporter

+
+

1.0

+
+

Smart Home

+
+

ja-JP

+ +
+

Alexa.ContactSensor

+
+

3

+
+

Smart Home Security

+
+

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

+ +
+

Alexa.Cooking

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.FoodTemperatureController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.FoodTemperatureSensor

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.PresetController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.TemperatureController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.TemperatureSensor

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.TimeController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.DataController

+
+

1.0

+
+

Smart Home

+
+

en-US

+ +
+

Alexa.DeviceUsage.Estimation

+
+

1.0

+
+

Smart Home Energy

+
+

en-US

+ +
+

Alexa.DeviceUsage.Meter

+
+

1.0

+
+

Smart Home Energy

+
+

en-US

+ +
+

Alexa.DoorbellEventSource

+
+

3

+
+

Smart Home Security

+
+

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

+ +
+

Alexa.EndpointHealth

+
+

3.1

+
+

Smart Home

+
+

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

+ +
+

Alexa.EqualizerController

+
+

3

+
+

Smart Home Entertainment

+
+

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

+ +
+

Alexa.InputController

+
+

3

+
+

Smart Home Entertainment,
+Video, AVS

+
+

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

+ +
+

Alexa.InventoryLevelSensor

+
+

3

+
+

Smart Home

+
+

de-DE, en-CA, en-GB, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT, ja-JP

+ +
+

Alexa.InventoryLevelUsageSensor

+
+

3

+
+

Smart Home

+
+

de-DE, en-CA, en-GB, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT, ja-JP

+ +
+

Alexa.InventoryUsageSensor

+
+

3

+
+

Smart Home

+
+

de-DE, en-CA, en-GB, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT, ja-JP

+ +
+

Alexa.KeypadController

+
+

3

+
+

Video

+
+

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

+ +
+

Alexa.Launcher

+
+

3

+
+

Video

+
+

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

+ +
+

Alexa.LockController

+
+

3

+
+

Smart Home Security

+
+

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

+ +
+

Alexa.Media.Playback

+
+

1.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.Media.PlayQueue

+
+

1.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.Media.Search

+
+

1.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.ModeController

+
+

3

+
+

Smart Home, AVS

+
+

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

+ +
+

Alexa.MotionSensor

+
+

3

+
+

Smart Home Security

+
+

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

+ +
+

Alexa.PercentageController

+
+

3

+
+

Smart Home

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.PlaybackController

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

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

+ +
+

Alexa.PlaybackStateReporter

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

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

+ +
+

Alexa.PowerController

+
+

3

+
+

Smart Home,
+Video, AVS

+
+

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

+ +
+

Alexa.PowerLevelController

+
+

3

+
+

Smart Home

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, fr-CA, fr-FR, it-IT, ja-JP

+ +
+

Alexa.ProactiveNotificationSource

+
+

3.0

+
+

Smart Home

+
+

Notifications for device state: de-DE, en-CA, en-GB, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT
+Notifications for cooking: en-US

+ +
+

Alexa.RangeController

+
+

3

+
+

Smart Home, AVS

+
+

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

+ +
+

Alexa.RecordController

+
+

3

+
+

Video

+
+

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

+ +
+

Alexa.RemoteVideoPlayer

+
+

3.1

+
+

Video

+
+

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

+ +
+

Alexa.RTCSessionController

+
+

3

+
+

Smart Home Security

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-US, es-ES, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.SceneController

+
+

3

+
+

Smart Home

+
+

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

+ +
+

Alexa.SecurityPanelController

+
+

3

+
+

Smart Home Security

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT, ja-JP, pt-BR

+ +
+

Alexa.SecurityPanelController.Alert

+
+

1.1

+
+

Smart Home Security

+
+

de-DE, en-CA, en-GB, en-US, es-US, fr-CA, fr-FR

+ +
+

Alexa.SeekController

+
+

3

+
+

Video

+
+

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

+ +
+

Alexa.SimpleEventSource

+
+

1.0

+
+

Smart Home

+
+

en-US

+ +
+

Alexa.SmartVision.ObjectDetectionSensor

+
+

1.0

+
+

Smart Home Security

+
+

en-US

+ +
+

Alexa.Speaker

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, it-IT, ja-JP

+ +
+

Alexa.StepSpeaker

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, it-IT

+ +
+

Alexa.TemperatureSensor

+
+

3

+
+

Smart Home

+
+

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

+ +
+

Alexa.ThermostatController

+
+

3.1

+
+

Smart Home

+
+

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

+ +
+

Alexa.ThermostatController.Configuration

+
+

3

+
+

Smart Home

+
+

en-US

+ +
+

Alexa.ThermostatController.HVAC.Components

+
+

1.0

+
+

Smart Home Energy

+
+

en-US

+ +
+

Alexa.ThermostatController.Schedule

+
+

3.2

+
+

Smart Home

+
+

en-US

+ +
+

Alexa.TimeHoldController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.ToggleController

+
+

3

+
+

Smart Home, AVS

+
+

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

+ +
+

Alexa.UIController

+
+

3.0

+
+

Video

+
+

en-US

+ +
+

Alexa.UserPreference

+
+

2.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.VideoRecorder

+
+

3

+
+

Video

+
+

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

+ +
+

Alexa.WakeOnLANController

+
+

3

+
+

Smart Home Entertainment

+
+

de-DE, en-AU, en-IN, en-US, es-ES, it-IT, ja-JP

+ +
+ + + + diff --git a/tests/non_packaged_scripts/__init__.py b/tests/non_packaged_scripts/__init__.py new file mode 100644 index 00000000000..852c52a8293 --- /dev/null +++ b/tests/non_packaged_scripts/__init__.py @@ -0,0 +1 @@ +"""Tests for the non-packaged scripts in the script directory.""" diff --git a/tests/non_packaged_scripts/snapshots/test_alexa_locales.ambr b/tests/non_packaged_scripts/snapshots/test_alexa_locales.ambr new file mode 100644 index 00000000000..bad47eedf53 --- /dev/null +++ b/tests/non_packaged_scripts/snapshots/test_alexa_locales.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_alexa_locales + ''' + Missing interfaces: + ['Alexa.ApplicationStateReporter', + 'Alexa.AuthorizationController', + 'Alexa.AutomationManagement', + 'Alexa.Commissionable', + 'Alexa.Cooking', + 'Alexa.DataController', + 'Alexa.InventoryLevelSensor', + 'Alexa.InventoryLevelUsageSensor', + 'Alexa.InventoryUsageSensor', + 'Alexa.KeypadController', + 'Alexa.Launcher', + 'Alexa.PercentageController', + 'Alexa.ProactiveNotificationSource', + 'Alexa.RecordController', + 'Alexa.RemoteVideoPlayer', + 'Alexa.RTCSessionController', + 'Alexa.SimpleEventSource', + 'Alexa.UIController', + 'Alexa.UserPreference', + 'Alexa.VideoRecorder', + 'Alexa.WakeOnLANController'] + + + Interfaces where upstream locales are not subsets of the core locales: + [] + + + Interfaces checked ok: + ['AlexaBrightnessController', + 'AlexaCameraStreamController', + 'AlexaChannelController', + 'AlexaColorController', + 'AlexaColorTemperatureController', + 'AlexaContactSensor', + 'AlexaDoorbellEventSource', + 'AlexaEndpointHealth', + 'AlexaEqualizerController', + 'AlexaInputController', + 'AlexaLockController', + 'AlexaModeController', + 'AlexaMotionSensor', + 'AlexaPlaybackController', + 'AlexaPlaybackStateReporter', + 'AlexaPowerController', + 'AlexaPowerLevelController', + 'AlexaRangeController', + 'AlexaSceneController', + 'AlexaSecurityPanelController', + 'AlexaSeekController', + 'AlexaSpeaker', + 'AlexaStepSpeaker', + 'AlexaTemperatureSensor', + 'AlexaThermostatController', + 'AlexaTimeHoldController', + 'AlexaToggleController'] + + ''' +# --- diff --git a/tests/non_packaged_scripts/test_alexa_locales.py b/tests/non_packaged_scripts/test_alexa_locales.py new file mode 100644 index 00000000000..ea139f7de8e --- /dev/null +++ b/tests/non_packaged_scripts/test_alexa_locales.py @@ -0,0 +1,29 @@ +"""Test the alexa_locales script.""" + +from pathlib import Path + +import pytest +import requests_mock +from syrupy import SnapshotAssertion + +from script.alexa_locales import SITE, run_script + + +def test_alexa_locales( + capsys: pytest.CaptureFixture[str], + requests_mock: requests_mock.Mocker, + snapshot: SnapshotAssertion, +) -> None: + """Test alexa_locales script.""" + fixture_file = ( + Path(__file__).parent.parent / "fixtures/non_packaged_scripts/alexa_locales.txt" + ) + requests_mock.get( + SITE, + text=fixture_file.read_text(encoding="utf-8"), + ) + + run_script() + + captured = capsys.readouterr() + assert captured.out == snapshot From 28da10ad0d03a633c4b0aec60f31cda6b8382340 Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 18 Apr 2024 07:53:32 -0400 Subject: [PATCH 089/107] Handle connection error in honeywell (#108168) * Handle connection error * Catch connection error * Add tests * Add translation strings * Clean up overlapping exceptions * ServiceValidationError * HomeAssistant Error translations --------- Co-authored-by: Erik Montnemery --- homeassistant/components/honeywell/climate.py | 77 ++++++++++++++----- .../components/honeywell/strings.json | 33 ++++++++ tests/components/honeywell/test_climate.py | 61 +++++++++++---- 3 files changed, 140 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index bd32ee0a23d..ff63d66230d 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -34,7 +34,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -361,15 +361,18 @@ class HoneywellUSThermostat(ClimateEntity): if mode in ["heat", "emheat"]: await self._device.set_setpoint_heat(temperature) - except UnexpectedResponse as err: + except (AscConnectionError, UnexpectedResponse) as err: raise HomeAssistantError( - "Honeywell set temperature failed: Invalid Response" + translation_domain=DOMAIN, + translation_key="temp_failed", ) from err except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) - raise ValueError( - f"Honeywell set temperature failed: invalid temperature {temperature}." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temp_failed_value", + translation_placeholders={"temp": temperature}, ) from err async def async_set_temperature(self, **kwargs: Any) -> None: @@ -382,30 +385,41 @@ class HoneywellUSThermostat(ClimateEntity): if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): await self._device.set_setpoint_heat(temperature) - except UnexpectedResponse as err: + except (AscConnectionError, UnexpectedResponse) as err: raise HomeAssistantError( - "Honeywell set temperature failed: Invalid Response" + translation_domain=DOMAIN, + translation_key="temp_failed", ) from err except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) - raise ValueError( - f"Honeywell set temperature failed: invalid temperature: {temperature}." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temp_failed_value", + translation_placeholders={"temp": str(temperature)}, ) from err async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" try: await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) + except SomeComfortError as err: - raise HomeAssistantError("Honeywell could not set fan mode.") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="fan_mode_failed", + ) from err async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" try: await self._device.set_system_mode(self._hvac_mode_map[hvac_mode]) + except SomeComfortError as err: - raise HomeAssistantError("Honeywell could not set system mode.") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sys_mode_failed", + ) from err async def _turn_away_mode_on(self) -> None: """Turn away on. @@ -425,6 +439,12 @@ class HoneywellUSThermostat(ClimateEntity): if mode in HEATING_MODES: await self._device.set_hold_heat(True, self._heat_away_temp) + except (AscConnectionError, UnexpectedResponse) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="away_mode_failed", + ) from err + except SomeComfortError as err: _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", @@ -432,8 +452,14 @@ class HoneywellUSThermostat(ClimateEntity): self._heat_away_temp, self._cool_away_temp, ) - raise ValueError( - f"Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {self._heat_away_temp}, Cool Temperature: {self._cool_away_temp}." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temp_failed_range", + translation_placeholders={ + "heat": str(self._heat_away_temp), + "cool": str(self._cool_away_temp), + "mode": mode, + }, ) from err async def _turn_hold_mode_on(self) -> None: @@ -452,11 +478,16 @@ class HoneywellUSThermostat(ClimateEntity): except SomeComfortError as err: _LOGGER.error("Couldn't set permanent hold") raise HomeAssistantError( - "Honeywell couldn't set permanent hold." + translation_domain=DOMAIN, + translation_key="set_hold_failed", ) from err else: _LOGGER.error("Invalid system mode returned: %s", mode) - raise HomeAssistantError(f"Honeywell invalid system mode returned {mode}.") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_mode_failed", + translation_placeholders={"mode": mode}, + ) async def _turn_away_mode_off(self) -> None: """Turn away/hold off.""" @@ -465,9 +496,13 @@ class HoneywellUSThermostat(ClimateEntity): # Disabling all hold modes await self._device.set_hold_cool(False) await self._device.set_hold_heat(False) + except SomeComfortError as err: _LOGGER.error("Can not stop hold mode") - raise HomeAssistantError("Honeywell could not stop hold mode") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="stop_hold_failed", + ) from err async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -493,9 +528,11 @@ class HoneywellUSThermostat(ClimateEntity): ) try: await self._device.set_system_mode("emheat") + except SomeComfortError as err: raise HomeAssistantError( - "Honeywell could not set system mode to aux heat." + translation_domain=DOMAIN, + translation_key="set_aux_failed", ) from err async def async_turn_aux_heat_off(self) -> None: @@ -517,8 +554,12 @@ class HoneywellUSThermostat(ClimateEntity): await self.async_set_hvac_mode(HVACMode.HEAT) else: await self.async_set_hvac_mode(HVACMode.OFF) + except HomeAssistantError as err: - raise HomeAssistantError("Honeywell could turn off aux heat mode.") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="disable_aux_failed", + ) from err async def async_update(self) -> None: """Get the latest state from the service.""" diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 7506a7fda7c..d3bc1924e28 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -61,6 +61,39 @@ } }, "exceptions": { + "temp_failed": { + "message": "Honeywell set temperature failed" + }, + "sys_mode_failed": { + "message": "Honeywell could not set system mode" + }, + "fan_mode_failed": { + "message": "Honeywell could not set fan mode" + }, + "away_mode_failed": { + "message": "Honeywell set away mode failed" + }, + "temp_failed_value": { + "message": "Honeywell set temperature failed: invalid temperature {temperature}" + }, + "temp_failed_range": { + "message": "Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {heat}, Cool Temperature: {cool}" + }, + "set_hold_failed": { + "message": "Honeywell could not set permanent hold" + }, + "set_mode_failed": { + "message": "Honeywell invalid system mode returned {mode}" + }, + "stop_hold_failed": { + "message": "Honeywell could not stop hold mode" + }, + "set_aux_failed": { + "message": "Honeywell could not set system mode to aux heat" + }, + "disable_aux_failed": { + "message": "Honeywell could turn off aux heat mode" + }, "switch_failed_off": { "message": "Honeywell could turn off emergency heat mode." }, diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 751ba8aa288..d09444808d8 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -38,7 +38,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow @@ -205,6 +205,16 @@ async def test_mode_service_calls( ) device.set_system_mode.assert_called_once_with("auto") + device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.UnexpectedResponse + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + async def test_auxheat_service_calls( hass: HomeAssistant, device: MagicMock, config_entry: MagicMock @@ -300,6 +310,15 @@ async def test_fan_modes_service_calls( blocking=True, ) + device.set_fan_mode.side_effect = aiosomecomfort.UnexpectedResponse + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_DIFFUSE}, + blocking=True, + ) + async def test_service_calls_off_mode( hass: HomeAssistant, @@ -344,7 +363,7 @@ async def test_service_calls_off_mode( device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -431,6 +450,12 @@ async def test_service_calls_off_mode( device.set_hold_heat.assert_called_once_with(False) device.set_hold_cool.assert_called_once_with(False) + device.set_hold_heat.reset_mock() + device.set_hold_cool.reset_mock() + + device.set_setpoint_cool.reset_mock() + device.set_setpoint_heat.reset_mock() + reset_mock(device) device.raw_ui_data["StatusHeat"] = 2 @@ -506,7 +531,7 @@ async def test_service_calls_cool_mode( device.set_setpoint_cool.reset_mock() device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -538,7 +563,7 @@ async def test_service_calls_cool_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -570,7 +595,7 @@ async def test_service_calls_cool_mode( device.hold_heat = True device.hold_cool = True - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -709,7 +734,7 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -747,7 +772,7 @@ async def test_service_calls_heat_mode( device.set_setpoint_heat.reset_mock() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -780,7 +805,7 @@ async def test_service_calls_heat_mode( device.hold_heat = True device.hold_cool = True - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -811,7 +836,7 @@ async def test_service_calls_heat_mode( reset_mock(device) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -828,7 +853,7 @@ async def test_service_calls_heat_mode( device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -841,6 +866,16 @@ async def test_service_calls_heat_mode( device.set_setpoint_cool.assert_not_called() assert "Temperature out of range" in caplog.text + device.set_hold_heat.side_effect = aiosomecomfort.UnexpectedResponse + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + reset_mock(device) caplog.clear() with pytest.raises(HomeAssistantError): @@ -951,7 +986,7 @@ async def test_service_calls_auto_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -966,7 +1001,7 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -1021,7 +1056,7 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = None device.set_setpoint_cool.side_effect = None - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, From 844ff30a606866eb28093ae6a0da901824f3729f Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Thu, 18 Apr 2024 14:06:51 +0200 Subject: [PATCH 090/107] Add state class to mobile_app restore entity (#115798) add state class --- homeassistant/components/mobile_app/entity.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 1cac62ce964..f1f7b592621 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -17,6 +17,7 @@ from .const import ( ATTR_SENSOR_ENTITY_CATEGORY, ATTR_SENSOR_ICON, ATTR_SENSOR_STATE, + ATTR_SENSOR_STATE_CLASS, SIGNAL_SENSOR_UPDATE, ) from .helpers import device_info @@ -44,6 +45,7 @@ class MobileAppEntity(RestoreEntity): """Update the entity from the config.""" config = self._config self._attr_device_class = config.get(ATTR_SENSOR_DEVICE_CLASS) + self._attr_state_class = config.get(ATTR_SENSOR_STATE_CLASS) self._attr_extra_state_attributes = config[ATTR_SENSOR_ATTRIBUTES] self._attr_icon = config[ATTR_SENSOR_ICON] self._attr_entity_category = config.get(ATTR_SENSOR_ENTITY_CATEGORY) From d5e5a1630361e7d4e6faddb6bce111c4620721ad Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 18 Apr 2024 14:08:23 +0200 Subject: [PATCH 091/107] Add diagnostics platform to DSMR Reader (#115805) * Add diagnostics platform * Feedback --------- Co-authored-by: Joost Lekkerkerker --- .../components/dsmr_reader/diagnostics.py | 27 +++++++++++++ .../snapshots/test_diagnostics.ambr | 23 +++++++++++ .../dsmr_reader/test_diagnostics.py | 39 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 homeassistant/components/dsmr_reader/diagnostics.py create mode 100644 tests/components/dsmr_reader/snapshots/test_diagnostics.ambr create mode 100644 tests/components/dsmr_reader/test_diagnostics.py diff --git a/homeassistant/components/dsmr_reader/diagnostics.py b/homeassistant/components/dsmr_reader/diagnostics.py new file mode 100644 index 00000000000..554d90cc5dd --- /dev/null +++ b/homeassistant/components/dsmr_reader/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics support for DSMR Reader.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the config entry.""" + ent_reg = er.async_get(hass) + entities = [ + entity.entity_id + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ] + + entity_states = {entity: hass.states.get(entity) for entity in entities} + + return { + "entry": entry.as_dict(), + "entities": entity_states, + } diff --git a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c6bc616ffd3 --- /dev/null +++ b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr @@ -0,0 +1,23 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'entities': dict({ + }), + 'entry': dict({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'dsmr_reader', + 'entry_id': 'TEST_ENTRY_ID', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'dsmr_reader', + 'unique_id': 'UNIQUE_TEST_ID', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/dsmr_reader/test_diagnostics.py b/tests/components/dsmr_reader/test_diagnostics.py new file mode 100644 index 00000000000..553efd0b38b --- /dev/null +++ b/tests/components/dsmr_reader/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Test the DSMR Reader component diagnostics.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.dsmr_reader.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DOMAIN, + options={}, + entry_id="TEST_ENTRY_ID", + unique_id="UNIQUE_TEST_ID", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dsmr_reader.async_setup_entry", return_value=True + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert diagnostics == snapshot From 3299bc5ddc9132efed74606126672f4f4a67a2df Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 18 Apr 2024 14:36:03 +0200 Subject: [PATCH 092/107] Translate service validation errors (#115024) * Move service validation error message to translation cache * Fix test * Revert unrelated change * Address review comments * Improve error message --------- Co-authored-by: J. Nick Koston --- .../components/homeassistant/strings.json | 12 ++++++++ homeassistant/core.py | 30 ++++++++++++++----- homeassistant/exceptions.py | 2 +- .../components/websocket_api/test_commands.py | 2 +- tests/test_core.py | 16 +++++++--- 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 37604c0e18e..d46a2e50bfd 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -191,6 +191,18 @@ }, "service_not_found": { "message": "Service {domain}.{service} not found." + }, + "service_does_not_supports_reponse": { + "message": "A service which does not return responses can't be called with {return_response}." + }, + "service_lacks_response_request": { + "message": "The service call requires responses and must be called with {return_response}." + }, + "service_reponse_invalid": { + "message": "Failed to process the returned service response data, expected a dictionary, but got {response_data_type}." + }, + "service_should_be_blocking": { + "message": "A non blocking service call with argument {non_blocking_argument} can't be used together with argument {return_response}." } } } diff --git a/homeassistant/core.py b/homeassistant/core.py index 69227f793a1..01536f8ffdb 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -86,6 +86,7 @@ from .exceptions import ( InvalidStateError, MaxLengthExceeded, ServiceNotFound, + ServiceValidationError, Unauthorized, ) from .helpers.deprecation import ( @@ -2571,16 +2572,27 @@ class ServiceRegistry: if return_response: if not blocking: - raise ValueError( - "Invalid argument return_response=True when blocking=False" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_should_be_blocking", + translation_placeholders={ + "return_response": "return_response=True", + "non_blocking_argument": "blocking=False", + }, ) if handler.supports_response is SupportsResponse.NONE: - raise ValueError( - "Invalid argument return_response=True when handler does not support responses" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_does_not_supports_reponse", + translation_placeholders={ + "return_response": "return_response=True" + }, ) elif handler.supports_response is SupportsResponse.ONLY: - raise ValueError( - "Service call requires responses but caller did not ask for responses" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_lacks_response_request", + translation_placeholders={"return_response": "return_response=True"}, ) if target: @@ -2628,7 +2640,11 @@ class ServiceRegistry: return None if not isinstance(response_data, dict): raise HomeAssistantError( - f"Service response data expected a dictionary, was {type(response_data)}" + translation_domain=DOMAIN, + translation_key="service_reponse_invalid", + translation_placeholders={ + "response_data_type": str(type(response_data)) + }, ) return response_data diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 1eb964d82b1..044a41aab7a 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -255,7 +255,7 @@ class UnknownUser(Unauthorized): """When call is made with user ID that doesn't exist.""" -class ServiceNotFound(HomeAssistantError): +class ServiceNotFound(ServiceValidationError): """Raised when a service is not found.""" def __init__(self, domain: str, service: str) -> None: diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 2bd76accfdd..655d8adf1ea 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -204,7 +204,7 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N assert msg["id"] == 8 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] - assert msg["error"]["code"] == "unknown_error" + assert msg["error"]["code"] == "service_validation_error" @pytest.mark.parametrize("command", ["call_service", "call_service_action"]) diff --git a/tests/test_core.py b/tests/test_core.py index caed1433082..5d687d89833 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -55,6 +55,7 @@ from homeassistant.exceptions import ( InvalidStateError, MaxLengthExceeded, ServiceNotFound, + ServiceValidationError, ) from homeassistant.helpers.json import json_dumps from homeassistant.setup import async_setup_component @@ -1791,8 +1792,9 @@ async def test_services_call_return_response_requires_blocking( hass: HomeAssistant, ) -> None: """Test that non-blocking service calls cannot ask for response data.""" + await async_setup_component(hass, "homeassistant", {}) async_mock_service(hass, "test_domain", "test_service") - with pytest.raises(ValueError, match="when blocking=False"): + with pytest.raises(ServiceValidationError, match="blocking=False") as exc: await hass.services.async_call( "test_domain", "test_service", @@ -1800,6 +1802,10 @@ async def test_services_call_return_response_requires_blocking( blocking=False, return_response=True, ) + assert ( + str(exc.value) + == "A non blocking service call with argument blocking=False can't be used together with argument return_response=True" + ) @pytest.mark.parametrize( @@ -1816,6 +1822,7 @@ async def test_serviceregistry_return_response_invalid( hass: HomeAssistant, response_data: Any, expected_error: str ) -> None: """Test service call response data must be json serializable objects.""" + await async_setup_component(hass, "homeassistant", {}) def service_handler(call: ServiceCall) -> ServiceResponse: """Service handler coroutine.""" @@ -1842,8 +1849,8 @@ async def test_serviceregistry_return_response_invalid( @pytest.mark.parametrize( ("supports_response", "return_response", "expected_error"), [ - (SupportsResponse.NONE, True, "not support responses"), - (SupportsResponse.ONLY, False, "caller did not ask for responses"), + (SupportsResponse.NONE, True, "does not return responses"), + (SupportsResponse.ONLY, False, "call requires responses"), ], ) async def test_serviceregistry_return_response_arguments( @@ -1853,6 +1860,7 @@ async def test_serviceregistry_return_response_arguments( expected_error: str, ) -> None: """Test service call response data invalid arguments.""" + await async_setup_component(hass, "homeassistant", {}) hass.services.async_register( "test_domain", @@ -1861,7 +1869,7 @@ async def test_serviceregistry_return_response_arguments( supports_response=supports_response, ) - with pytest.raises(ValueError, match=expected_error): + with pytest.raises(ServiceValidationError, match=expected_error): await hass.services.async_call( "test_domain", "test_service", From 8ba1340c2e287fca28861a41a14c6f45ad198a9a Mon Sep 17 00:00:00 2001 From: vexofp Date: Thu, 18 Apr 2024 08:58:16 -0400 Subject: [PATCH 093/107] Clarify cover toggle logic; prevent opening when already open (#107920) Co-authored-by: Erik Montnemery --- homeassistant/components/cover/__init__.py | 21 ++++++++++++++++++--- tests/components/cover/test_init.py | 9 +++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 71e89797c05..5c7139d6290 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -480,15 +480,30 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _get_toggle_function( self, fns: dict[str, Callable[_P, _R]] ) -> Callable[_P, _R]: + # If we are opening or closing and we support stopping, then we should stop if self.supported_features & CoverEntityFeature.STOP and ( self.is_closing or self.is_opening ): return fns["stop"] - if self.is_closed: + + # If we are fully closed or in the process of closing, then we should open + if self.is_closed or self.is_closing: return fns["open"] - if self._cover_is_last_toggle_direction_open: + + # If we are fully open or in the process of opening, then we should close + if self.current_cover_position == 100 or self.is_opening: return fns["close"] - return fns["open"] + + # We are any of: + # * fully open but do not report `current_cover_position` + # * stopped partially open + # * either opening or closing, but do not report them + # If we previously reported opening/closing, we should move in the opposite direction. + # Otherwise, we must assume we are (partially) open and should always close. + # Note: _cover_is_last_toggle_direction_open will always remain True if we never report opening/closing. + return ( + fns["close"] if self._cover_is_last_toggle_direction_open else fns["open"] + ) # These can be removed if no deprecated constant are in this module anymore diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 0052093298e..5ccd948cc6b 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -108,6 +108,15 @@ async def test_services( await call_service(hass, SERVICE_TOGGLE, ent6) assert is_opening(hass, ent6) + # After the unusual state transition: closing -> fully open, toggle should close + set_state(ent5, STATE_OPEN) + await call_service(hass, SERVICE_TOGGLE, ent5) # Start closing + assert is_closing(hass, ent5) + set_state(ent5, STATE_OPEN) # Unusual state transition from closing -> fully open + set_cover_position(ent5, 100) + await call_service(hass, SERVICE_TOGGLE, ent5) # Should close, not open + assert is_closing(hass, ent5) + def call_service(hass, service, ent): """Call any service on entity.""" From ceaf8f240249baf9d1d4c842998850581da8c2af Mon Sep 17 00:00:00 2001 From: Lukasz Szmit <2490317+ptashek@users.noreply.github.com> Date: Thu, 18 Apr 2024 14:22:58 +0100 Subject: [PATCH 094/107] Add support for payload_template in rest component (#107464) * Add support for payload_template in rest component * Update homeassistant/components/rest/schema.py * Update homeassistant/components/rest/data.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/rest/__init__.py | 23 +++++++-- homeassistant/components/rest/const.py | 2 + homeassistant/components/rest/data.py | 4 ++ homeassistant/components/rest/schema.py | 4 +- tests/components/rest/test_init.py | 59 +++++++++++++++++++++++ 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 1c33b4592df..b7cdee2e039 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -45,6 +45,7 @@ from homeassistant.util.async_ import create_eager_task from .const import ( CONF_ENCODING, + CONF_PAYLOAD_TEMPLATE, CONF_SSL_CIPHER_LIST, COORDINATOR, DEFAULT_SSL_CIPHER_LIST, @@ -108,8 +109,11 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> bool for rest_idx, conf in enumerate(rest_config): scan_interval: timedelta = conf.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) resource_template: template.Template | None = conf.get(CONF_RESOURCE_TEMPLATE) + payload_template: template.Template | None = conf.get(CONF_PAYLOAD_TEMPLATE) rest = create_rest_data_from_config(hass, conf) - coordinator = _rest_coordinator(hass, rest, resource_template, scan_interval) + coordinator = _rest_coordinator( + hass, rest, resource_template, payload_template, scan_interval + ) refresh_coroutines.append(coordinator.async_refresh()) hass.data[DOMAIN][REST_DATA].append({REST: rest, COORDINATOR: coordinator}) @@ -156,16 +160,20 @@ def _rest_coordinator( hass: HomeAssistant, rest: RestData, resource_template: template.Template | None, + payload_template: template.Template | None, update_interval: timedelta, ) -> DataUpdateCoordinator[None]: """Wrap a DataUpdateCoordinator around the rest object.""" - if resource_template: + if resource_template or payload_template: - async def _async_refresh_with_resource_template() -> None: - rest.set_url(resource_template.async_render(parse_result=False)) + async def _async_refresh_with_templates() -> None: + if resource_template: + rest.set_url(resource_template.async_render(parse_result=False)) + if payload_template: + rest.set_payload(payload_template.async_render(parse_result=False)) await rest.async_update() - update_method = _async_refresh_with_resource_template + update_method = _async_refresh_with_templates else: update_method = rest.async_update @@ -184,6 +192,7 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res resource_template: template.Template | None = config.get(CONF_RESOURCE_TEMPLATE) method: str = config[CONF_METHOD] payload: str | None = config.get(CONF_PAYLOAD) + payload_template: template.Template | None = config.get(CONF_PAYLOAD_TEMPLATE) verify_ssl: bool = config[CONF_VERIFY_SSL] ssl_cipher_list: str = config.get(CONF_SSL_CIPHER_LIST, DEFAULT_SSL_CIPHER_LIST) username: str | None = config.get(CONF_USERNAME) @@ -196,6 +205,10 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res resource_template.hass = hass resource = resource_template.async_render(parse_result=False) + if payload_template is not None: + payload_template.hass = hass + payload = payload_template.async_render(parse_result=False) + if not resource: raise HomeAssistantError("Resource not set for RestData") diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index 8fb08f766fa..d10b3f3f74e 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -33,3 +33,5 @@ XML_MIME_TYPES = ( "application/xml", "text/xml", ) + +CONF_PAYLOAD_TEMPLATE = "payload_template" diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 06be7a4f6ff..4c9667e7651 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -56,6 +56,10 @@ class RestData: self.last_exception: Exception | None = None self.headers: httpx.Headers | None = None + def set_payload(self, payload: str) -> None: + """Set request data.""" + self._request_data = payload + @property def url(self) -> str: """Get url.""" diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index d6011a43efd..f7fd8a36113 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -38,6 +38,7 @@ from .const import ( CONF_ENCODING, CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, + CONF_PAYLOAD_TEMPLATE, CONF_SSL_CIPHER_LIST, DEFAULT_ENCODING, DEFAULT_FORCE_UPDATE, @@ -60,7 +61,8 @@ RESOURCE_SCHEMA = { vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PAYLOAD): cv.string, + vol.Exclusive(CONF_PAYLOAD, CONF_PAYLOAD): cv.string, + vol.Exclusive(CONF_PAYLOAD_TEMPLATE, CONF_PAYLOAD): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional( CONF_SSL_CIPHER_LIST, diff --git a/tests/components/rest/test_init.py b/tests/components/rest/test_init.py index 38a1661a831..0fda89cc329 100644 --- a/tests/components/rest/test_init.py +++ b/tests/components/rest/test_init.py @@ -475,3 +475,62 @@ async def test_config_schema_via_packages(hass: HomeAssistant) -> None: assert len(config["rest"]) == 2 assert config["rest"][0]["resource"] == "http://url1" assert config["rest"][1]["resource"] == "http://url2" + + +@respx.mock +async def test_setup_minimum_payload_template(hass: HomeAssistant) -> None: + """Test setup with minimum configuration (payload_template).""" + + respx.post("http://localhost", json={"data": "value"}).respond( + status_code=HTTPStatus.OK, + json={ + "sensor1": "1", + "sensor2": "2", + "binary_sensor1": "on", + "binary_sensor2": "off", + }, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "payload_template": '{% set payload = {"data": "value"} %}{{ payload | to_json }}', + "method": "POST", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "unit_of_measurement": UnitOfInformation.MEGABYTES, + "name": "sensor1", + "value_template": "{{ value_json.sensor1 }}", + }, + { + "unit_of_measurement": UnitOfInformation.MEGABYTES, + "name": "sensor2", + "value_template": "{{ value_json.sensor2 }}", + }, + ], + "binary_sensor": [ + { + "name": "binary_sensor1", + "value_template": "{{ value_json.binary_sensor1 }}", + }, + { + "name": "binary_sensor2", + "value_template": "{{ value_json.binary_sensor2 }}", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 4 + + assert hass.states.get("sensor.sensor1").state == "1" + assert hass.states.get("sensor.sensor2").state == "2" + assert hass.states.get("binary_sensor.binary_sensor1").state == "on" + assert hass.states.get("binary_sensor.binary_sensor2").state == "off" From 90575bc496fa82d7bf6a52c8b31041b6b5caa14a Mon Sep 17 00:00:00 2001 From: Marcin Wielgoszewski Date: Thu, 18 Apr 2024 09:37:11 -0400 Subject: [PATCH 095/107] Add hvac_action attribute to iAqualink Thermostat climate entities (#107803) * Update climate.py * Reorder if/else statements per @dcmeglio's suggestion * Don't infer state, actually read it from the underlying device * HVACAction has a HEATING state, not ON * Update homeassistant/components/iaqualink/climate.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/iaqualink/climate.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 29576e9fc10..868b5a32c67 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -6,11 +6,13 @@ import logging from typing import Any from iaqualink.device import AqualinkThermostat +from iaqualink.systems.iaqua.device import AqualinkState from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry @@ -82,6 +84,16 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): else: _LOGGER.warning("Unknown operation mode: %s", hvac_mode) + @property + def hvac_action(self) -> HVACAction: + """Return the current HVAC action.""" + state = AqualinkState(self.dev._heater.state) + if state == AqualinkState.ON: + return HVACAction.HEATING + if state == AqualinkState.ENABLED: + return HVACAction.IDLE + return HVACAction.OFF + @property def target_temperature(self) -> float: """Return the current target temperature.""" From 74afed3b6d4267f92445029ca6ebde17c77793e6 Mon Sep 17 00:00:00 2001 From: Arjan van Balken Date: Thu, 18 Apr 2024 15:52:03 +0200 Subject: [PATCH 096/107] Bump arris-tg2492lg to 2.2.0 (#107905) Bumps arris-tg2492lg from 1.2.1 to 2.2.0 --- .../arris_tg2492lg/device_tracker.py | 29 +++++++++++++------ .../components/arris_tg2492lg/manifest.json | 4 ++- requirements_all.txt | 2 +- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index 4f674a13c0e..3975109e07a 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -2,6 +2,7 @@ from __future__ import annotations +from aiohttp.client_exceptions import ClientResponseError from arris_tg2492lg import ConnectBox, Device import voluptuous as vol @@ -12,6 +13,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -25,12 +27,21 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ) -def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArrisDeviceScanner: - """Return the Arris device scanner.""" +async def async_get_scanner( + hass: HomeAssistant, config: ConfigType +) -> ArrisDeviceScanner | None: + """Return the Arris device scanner if successful.""" conf = config[DOMAIN] url = f"http://{conf[CONF_HOST]}" - connect_box = ConnectBox(url, conf[CONF_PASSWORD]) - return ArrisDeviceScanner(connect_box) + websession = async_get_clientsession(hass) + connect_box = ConnectBox(websession, url, conf[CONF_PASSWORD]) + + try: + await connect_box.async_login() + + return ArrisDeviceScanner(connect_box) + except ClientResponseError: + return None class ArrisDeviceScanner(DeviceScanner): @@ -41,22 +52,22 @@ class ArrisDeviceScanner(DeviceScanner): self.connect_box = connect_box self.last_results: list[Device] = [] - def scan_devices(self) -> list[str]: + async def async_scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" - self._update_info() + await self._async_update_info() return [device.mac for device in self.last_results if device.mac] - def get_device_name(self, device: str) -> str | None: + async def async_get_device_name(self, device: str) -> str | None: """Return the name of the given device or None if we don't know.""" return next( (result.hostname for result in self.last_results if result.mac == device), None, ) - def _update_info(self) -> None: + async def _async_update_info(self) -> None: """Ensure the information from the Arris TG2492LG router is up to date.""" - result = self.connect_box.get_connected_devices() + result = await self.connect_box.async_get_connected_devices() last_results: list[Device] = [] mac_addresses: set[str | None] = set() diff --git a/homeassistant/components/arris_tg2492lg/manifest.json b/homeassistant/components/arris_tg2492lg/manifest.json index 0134ea9077d..fa7673b4276 100644 --- a/homeassistant/components/arris_tg2492lg/manifest.json +++ b/homeassistant/components/arris_tg2492lg/manifest.json @@ -2,8 +2,10 @@ "domain": "arris_tg2492lg", "name": "Arris TG2492LG", "codeowners": ["@vanbalken"], + "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/arris_tg2492lg", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["arris_tg2492lg"], - "requirements": ["arris-tg2492lg==1.2.1"] + "requirements": ["arris-tg2492lg==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index edb4c8919d9..0e11345c278 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -464,7 +464,7 @@ aranet4==2.3.3 arcam-fmj==1.4.0 # homeassistant.components.arris_tg2492lg -arris-tg2492lg==1.2.1 +arris-tg2492lg==2.2.0 # homeassistant.components.ampio asmog==0.0.6 From fbdef7f5cdf7aead17df24a34b0dde1f8fb7e54e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Apr 2024 09:37:20 -0500 Subject: [PATCH 097/107] Bump habluetooth to 2.8.0 (#115789) * Bump habluetooth to 2.8.0 Adds support for recovering some adapters that fail to initialize due to kernel races * bump lib * tweak --- homeassistant/components/bluetooth/__init__.py | 13 ++----------- homeassistant/components/bluetooth/diagnostics.py | 2 +- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 7 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 35fbeb2f3b3..35d4b625942 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -53,7 +53,6 @@ from homeassistant.loader import async_get_bluetooth from . import models, passive_update_processor from .api import ( - _get_manager, async_address_present, async_ble_device_from_address, async_discovered_service_info, @@ -130,13 +129,6 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -async def _async_get_adapter_from_address( - hass: HomeAssistant, address: str -) -> str | None: - """Get an adapter by the address.""" - return await _get_manager(hass).async_get_adapter_from_address(address) - - async def _async_start_adapter_discovery( hass: HomeAssistant, manager: HomeAssistantBluetoothManager, @@ -303,17 +295,16 @@ async def async_update_device( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for a bluetooth scanner.""" + manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] address = entry.unique_id assert address is not None - adapter = await _async_get_adapter_from_address(hass, address) + adapter = await manager.async_get_adapter_from_address_or_recover(address) if adapter is None: raise ConfigEntryNotReady( f"Bluetooth adapter {adapter} with address {address} not found" ) - passive = entry.options.get(CONF_PASSIVE) mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE - manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] scanner = HaScanner(mode, adapter, address) scanner.async_setup() try: diff --git a/homeassistant/components/bluetooth/diagnostics.py b/homeassistant/components/bluetooth/diagnostics.py index a45500265cf..1c9c9a56b2e 100644 --- a/homeassistant/components/bluetooth/diagnostics.py +++ b/homeassistant/components/bluetooth/diagnostics.py @@ -10,7 +10,7 @@ from bluetooth_adapters import get_dbus_managed_objects from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import _get_manager +from .api import _get_manager async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f7d27e84a17..b41c344bdf2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.4.1", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.7.0" + "habluetooth==2.8.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7782fba1713..7f134b1a93d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.2.0 -habluetooth==2.7.0 +habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0e11345c278..18c4d6a0076 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1029,7 +1029,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.7.0 +habluetooth==2.8.0 # homeassistant.components.cloud hass-nabucasa==0.78.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b7a4a59a18e..aeb38c28aa1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -843,7 +843,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.7.0 +habluetooth==2.8.0 # homeassistant.components.cloud hass-nabucasa==0.78.0 From 53c48537d720e048e300dad7a04ee17dbb3028e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Apr 2024 09:39:32 -0500 Subject: [PATCH 098/107] Add bluetooth adapter model and manufacturer to config flow (#115780) * Show bluetooth adapter model and manufacturer in config flow If there are multiple adapters, it could be a bit difficult to figure out which one is which * Show bluetooth adapter model and manufacturer in config flow If there are multiple adapters, it could be a bit difficult to figure out which one is which * reorder * reorder * names * remove * fix incomplete mocking * more missing mocks --- .../components/airthings_ble/strings.json | 2 +- homeassistant/components/aranet/strings.json | 2 +- .../components/bluemaestro/strings.json | 2 +- .../components/bluetooth/config_flow.py | 52 +++++++++++++++---- .../components/bluetooth/strings.json | 4 +- homeassistant/components/bthome/strings.json | 2 +- .../components/dormakaba_dkey/strings.json | 2 +- .../components/eufylife_ble/strings.json | 2 +- .../components/govee_ble/strings.json | 2 +- .../components/improv_ble/strings.json | 2 +- homeassistant/components/inkbird/strings.json | 2 +- homeassistant/components/kegtron/strings.json | 2 +- homeassistant/components/leaone/strings.json | 2 +- .../components/medcom_ble/strings.json | 2 +- homeassistant/components/moat/strings.json | 2 +- homeassistant/components/mopeka/strings.json | 2 +- homeassistant/components/oralb/strings.json | 2 +- .../private_ble_device/strings.json | 2 +- .../components/qingping/strings.json | 2 +- .../components/rapt_ble/strings.json | 2 +- .../components/ruuvitag_ble/strings.json | 2 +- .../components/sensirion_ble/strings.json | 2 +- .../components/sensorpro/strings.json | 2 +- .../components/sensorpush/strings.json | 2 +- homeassistant/components/snooz/strings.json | 2 +- .../components/thermobeacon/strings.json | 2 +- .../components/thermopro/strings.json | 2 +- .../components/tilt_ble/strings.json | 2 +- .../components/xiaomi_ble/strings.json | 2 +- .../components/bluetooth/test_config_flow.py | 34 +++++++++--- tests/components/bluetooth/test_init.py | 4 ++ 31 files changed, 102 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index 6f17b9a317e..4b38923384a 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json index 918cfc1d384..ac8d1907770 100644 --- a/homeassistant/components/aranet/strings.json +++ b/homeassistant/components/aranet/strings.json @@ -11,7 +11,7 @@ "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" } }, - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "error": { "unknown": "[%key:common::config_flow::error::unknown%]" }, diff --git a/homeassistant/components/bluemaestro/strings.json b/homeassistant/components/bluemaestro/strings.json index 9dc500980a6..8f84456d3a7 100644 --- a/homeassistant/components/bluemaestro/strings.json +++ b/homeassistant/components/bluemaestro/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 2b5980fbcd6..6802bdc37c0 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -6,8 +6,10 @@ from typing import Any, cast from bluetooth_adapters import ( ADAPTER_ADDRESS, + ADAPTER_MANUFACTURER, AdapterDetails, adapter_human_name, + adapter_model, adapter_unique_name, get_adapters, ) @@ -35,6 +37,22 @@ OPTIONS_FLOW = { } +def adapter_display_info(adapter: str, details: AdapterDetails) -> str: + """Return the adapter display info.""" + name = adapter_human_name(adapter, details[ADAPTER_ADDRESS]) + model = adapter_model(details) + manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown" + return f"{name} {manufacturer} {model}" + + +def adapter_title(adapter: str, details: AdapterDetails) -> str: + """Return the adapter title.""" + unique_name = adapter_unique_name(adapter, details[ADAPTER_ADDRESS]) + model = adapter_model(details) + manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown" + return f"{manufacturer} {model} ({unique_name})" + + class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for Bluetooth.""" @@ -45,6 +63,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): self._adapter: str | None = None self._details: AdapterDetails | None = None self._adapters: dict[str, AdapterDetails] = {} + self._placeholders: dict[str, str] = {} async def async_step_integration_discovery( self, discovery_info: DiscoveryInfoType @@ -54,11 +73,23 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): self._details = cast(AdapterDetails, discovery_info[CONF_DETAILS]) await self.async_set_unique_id(self._details[ADAPTER_ADDRESS]) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = { - "name": adapter_human_name(self._adapter, self._details[ADAPTER_ADDRESS]) - } + details = self._details + self._async_set_adapter_info(self._adapter, details) return await self.async_step_single_adapter() + @callback + def _async_set_adapter_info(self, adapter: str, details: AdapterDetails) -> None: + """Set the adapter info.""" + name = adapter_human_name(adapter, details[ADAPTER_ADDRESS]) + model = adapter_model(details) + manufacturer = details[ADAPTER_MANUFACTURER] + self._placeholders = { + "name": name, + "model": model, + "manufacturer": manufacturer or "Unknown", + } + self.context["title_placeholders"] = self._placeholders + async def async_step_single_adapter( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -67,6 +98,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): details = self._details assert adapter is not None assert details is not None + assert self._placeholders is not None address = details[ADAPTER_ADDRESS] @@ -74,12 +106,12 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry( - title=adapter_unique_name(adapter, address), data={} + title=adapter_title(adapter, details), data={} ) return self.async_show_form( step_id="single_adapter", - description_placeholders={"name": adapter_human_name(adapter, address)}, + description_placeholders=self._placeholders, ) async def async_step_multiple_adapters( @@ -89,11 +121,12 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: assert self._adapters is not None adapter = user_input[CONF_ADAPTER] - address = self._adapters[adapter][ADAPTER_ADDRESS] + details = self._adapters[adapter] + address = details[ADAPTER_ADDRESS] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry( - title=adapter_unique_name(adapter, address), data={} + title=adapter_title(adapter, details), data={} ) configured_addresses = self._async_current_ids() @@ -116,6 +149,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): if len(unconfigured_adapters) == 1: self._adapter = list(self._adapters)[0] self._details = self._adapters[self._adapter] + self._async_set_adapter_info(self._adapter, self._details) return await self.async_step_single_adapter() return self.async_show_form( @@ -124,8 +158,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Required(CONF_ADAPTER): vol.In( { - adapter: adapter_human_name( - adapter, self._adapters[adapter][ADAPTER_ADDRESS] + adapter: adapter_display_info( + adapter, self._adapters[adapter] ) for adapter in sorted(unconfigured_adapters) } diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 4b168126251..c28bd3cc65e 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{name}", + "flow_title": "{name} {manufacturer} {model}", "step": { "user": { "description": "Choose a device to set up", @@ -18,7 +18,7 @@ } }, "single_adapter": { - "description": "Do you want to set up the Bluetooth adapter {name}?" + "description": "Do you want to set up the Bluetooth adapter {name} {manufacturer} {model}?" } }, "abort": { diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index 50c5c7bada6..c64028229b3 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index 480f021b126..1fdc7cb359f 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/eufylife_ble/strings.json b/homeassistant/components/eufylife_ble/strings.json index aaeeeb85f67..72f0e7b5973 100644 --- a/homeassistant/components/eufylife_ble/strings.json +++ b/homeassistant/components/eufylife_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/govee_ble/strings.json b/homeassistant/components/govee_ble/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/govee_ble/strings.json +++ b/homeassistant/components/govee_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/improv_ble/strings.json b/homeassistant/components/improv_ble/strings.json index b5713910134..be157b8070d 100644 --- a/homeassistant/components/improv_ble/strings.json +++ b/homeassistant/components/improv_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/kegtron/strings.json b/homeassistant/components/kegtron/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/kegtron/strings.json +++ b/homeassistant/components/kegtron/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/leaone/strings.json b/homeassistant/components/leaone/strings.json index 6391c754dec..bb684941147 100644 --- a/homeassistant/components/leaone/strings.json +++ b/homeassistant/components/leaone/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/medcom_ble/strings.json b/homeassistant/components/medcom_ble/strings.json index 56cfb5a1dd7..4f2b29b7269 100644 --- a/homeassistant/components/medcom_ble/strings.json +++ b/homeassistant/components/medcom_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/moat/strings.json b/homeassistant/components/moat/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/moat/strings.json +++ b/homeassistant/components/moat/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/mopeka/strings.json b/homeassistant/components/mopeka/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/mopeka/strings.json +++ b/homeassistant/components/mopeka/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index f60fd56a9a4..775bbedac74 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index 9e20a9476ec..c35775a4843 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "What is the IRK (Identity Resolving Key) of the BLE device you want to track?", diff --git a/homeassistant/components/qingping/strings.json b/homeassistant/components/qingping/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/qingping/strings.json +++ b/homeassistant/components/qingping/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/rapt_ble/strings.json b/homeassistant/components/rapt_ble/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/rapt_ble/strings.json +++ b/homeassistant/components/rapt_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/ruuvitag_ble/strings.json b/homeassistant/components/ruuvitag_ble/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/ruuvitag_ble/strings.json +++ b/homeassistant/components/ruuvitag_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/sensirion_ble/strings.json b/homeassistant/components/sensirion_ble/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/sensirion_ble/strings.json +++ b/homeassistant/components/sensirion_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/sensorpro/strings.json b/homeassistant/components/sensorpro/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/sensorpro/strings.json +++ b/homeassistant/components/sensorpro/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/sensorpush/strings.json b/homeassistant/components/sensorpush/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/sensorpush/strings.json +++ b/homeassistant/components/sensorpush/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index b38e105260c..5a31cea6cac 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/thermobeacon/strings.json b/homeassistant/components/thermobeacon/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/thermobeacon/strings.json +++ b/homeassistant/components/thermobeacon/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/tilt_ble/strings.json b/homeassistant/components/tilt_ble/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/tilt_ble/strings.json +++ b/homeassistant/components/tilt_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 2f2b705ff60..8ee8bac3fea 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 89243223129..f9bbbcd2d0e 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -65,7 +65,7 @@ async def test_async_step_user_macos(hass: HomeAssistant, macos_adapter: None) - result["flow_id"], user_input={} ) assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Core Bluetooth" + assert result2["title"] == "Apple Unknown MacOS Model (Core Bluetooth)" assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -81,6 +81,11 @@ async def test_async_step_user_linux_one_adapter( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "single_adapter" + assert result["description_placeholders"] == { + "name": "hci0 (00:00:00:00:00:01)", + "model": "Bluetooth Adapter 5.0 (cc01:aa01)", + "manufacturer": "ACME", + } with ( patch("homeassistant.components.bluetooth.async_setup", return_value=True), patch( @@ -91,7 +96,9 @@ async def test_async_step_user_linux_one_adapter( result["flow_id"], user_input={} ) assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "00:00:00:00:00:01" + assert ( + result2["title"] == "ACME Bluetooth Adapter 5.0 (cc01:aa01) (00:00:00:00:00:01)" + ) assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -107,6 +114,10 @@ async def test_async_step_user_linux_two_adapters( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "multiple_adapters" + assert result["data_schema"].schema["adapter"].container == { + "hci0": "hci0 (00:00:00:00:00:01) ACME Bluetooth Adapter 5.0 (cc01:aa01)", + "hci1": "hci1 (00:00:00:00:00:02) ACME Bluetooth Adapter 5.0 (cc01:aa01)", + } with ( patch("homeassistant.components.bluetooth.async_setup", return_value=True), patch( @@ -117,7 +128,9 @@ async def test_async_step_user_linux_two_adapters( result["flow_id"], user_input={CONF_ADAPTER: "hci1"} ) assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "00:00:00:00:00:02" + assert ( + result2["title"] == "ACME Bluetooth Adapter 5.0 (cc01:aa01) (00:00:00:00:00:02)" + ) assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -153,6 +166,11 @@ async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: data={CONF_ADAPTER: "hci0", CONF_DETAILS: details}, ) assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == { + "name": "hci0 (00:00:00:00:00:01)", + "model": "Unknown", + "manufacturer": "ACME", + } assert result["step_id"] == "single_adapter" with ( patch("homeassistant.components.bluetooth.async_setup", return_value=True), @@ -164,7 +182,7 @@ async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: result["flow_id"], user_input={} ) assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "00:00:00:00:00:01" + assert result2["title"] == "ACME Unknown (00:00:00:00:00:01)" assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -196,7 +214,7 @@ async def test_async_step_integration_discovery_during_onboarding_one_adapter( data={CONF_ADAPTER: "hci0", CONF_DETAILS: details}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "00:00:00:00:00:01" + assert result["title"] == "ACME Unknown (00:00:00:00:00:01)" assert result["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 @@ -240,11 +258,11 @@ async def test_async_step_integration_discovery_during_onboarding_two_adapters( data={CONF_ADAPTER: "hci1", CONF_DETAILS: details2}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "00:00:00:00:00:01" + assert result["title"] == "ACME Unknown (00:00:00:00:00:01)" assert result["data"] == {} assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "00:00:00:00:00:02" + assert result2["title"] == "ACME Unknown (00:00:00:00:00:02)" assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 2 @@ -278,7 +296,7 @@ async def test_async_step_integration_discovery_during_onboarding( data={CONF_ADAPTER: "Core Bluetooth", CONF_DETAILS: details}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Core Bluetooth" + assert result["title"] == "ACME Unknown (Core Bluetooth)" assert result["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 65962ac8f21..e68ccc94d19 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -3015,12 +3015,14 @@ async def test_discover_new_usb_adapters( "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", + "manufacturer": "ACME", }, "hci1": { "address": "00:00:00:00:00:02", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", + "manufacturer": "ACME", }, }, ), @@ -3088,12 +3090,14 @@ async def test_discover_new_usb_adapters_with_firmware_fallback_delay( "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", + "manufacturer": "ACME", }, "hci1": { "address": "00:00:00:00:00:02", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", + "manufacturer": "ACME", }, }, ), From ea8d4d0dcae8b042544341645170ba7ab940d431 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Apr 2024 09:39:58 -0500 Subject: [PATCH 099/107] Add reauth support to oncue (#115667) * Add reauth support to oncue * review comments * reauth on update failure * coverage --- homeassistant/components/oncue/__init__.py | 18 ++-- homeassistant/components/oncue/config_flow.py | 82 +++++++++++++++---- homeassistant/components/oncue/strings.json | 9 +- tests/components/oncue/__init__.py | 20 ++++- tests/components/oncue/test_config_flow.py | 56 ++++++++++++- tests/components/oncue/test_init.py | 29 ++++++- 6 files changed, 185 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/oncue/__init__.py b/homeassistant/components/oncue/__init__.py index b3d59f50321..f960b1a8b81 100644 --- a/homeassistant/components/oncue/__init__.py +++ b/homeassistant/components/oncue/__init__.py @@ -5,12 +5,12 @@ from __future__ import annotations from datetime import timedelta import logging -from aiooncue import LoginFailedException, Oncue +from aiooncue import LoginFailedException, Oncue, OncueDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -29,17 +29,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await client.async_login() except CONNECTION_EXCEPTIONS as ex: - raise ConfigEntryNotReady(ex) from ex + raise ConfigEntryNotReady from ex except LoginFailedException as ex: - _LOGGER.error("Failed to login to oncue service: %s", ex) - return False + raise ConfigEntryAuthFailed from ex + + async def _async_update() -> dict[str, OncueDevice]: + """Fetch data from Oncue.""" + try: + return await client.async_fetch_all() + except LoginFailedException as ex: + raise ConfigEntryAuthFailed from ex coordinator = DataUpdateCoordinator( hass, _LOGGER, name=f"Oncue {entry.data[CONF_USERNAME]}", update_interval=timedelta(minutes=10), - update_method=client.async_fetch_all, + update_method=_async_update, always_update=False, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/oncue/config_flow.py b/homeassistant/components/oncue/config_flow.py index ba672dcc588..e423ba08105 100644 --- a/homeassistant/components/oncue/config_flow.py +++ b/homeassistant/components/oncue/config_flow.py @@ -2,13 +2,14 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from aiooncue import LoginFailedException, Oncue import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -22,30 +23,26 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the oncue config flow.""" + self.reauth_entry: ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - try: - await Oncue( - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - async_get_clientsession(self.hass), - ).async_login() - except CONNECTION_EXCEPTIONS: - errors["base"] = "cannot_connect" - except LoginFailedException: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not (errors := await self._async_validate_or_error(user_input)): normalized_username = user_input[CONF_USERNAME].lower() await self.async_set_unique_id(normalized_username) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured( + updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) return self.async_create_entry( title=normalized_username, data=user_input ) @@ -60,3 +57,54 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def _async_validate_or_error(self, config: dict[str, Any]) -> dict[str, str]: + """Validate the user input.""" + errors: dict[str, str] = {} + try: + await Oncue( + config[CONF_USERNAME], + config[CONF_PASSWORD], + async_get_clientsession(self.hass), + ).async_login() + except CONNECTION_EXCEPTIONS: + errors["base"] = "cannot_connect" + except LoginFailedException: + errors[CONF_PASSWORD] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + entry_id = self.context["entry_id"] + self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth input.""" + errors: dict[str, str] = {} + existing_entry = self.reauth_entry + assert existing_entry + existing_data = existing_entry.data + description_placeholders: dict[str, str] = { + CONF_USERNAME: existing_data[CONF_USERNAME] + } + if user_input is not None: + new_config = {**existing_data, CONF_PASSWORD: user_input[CONF_PASSWORD]} + if not (errors := await self._async_validate_or_error(new_config)): + return self.async_update_reload_and_abort( + existing_entry, data=new_config + ) + + return self.async_show_form( + description_placeholders=description_placeholders, + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, + ) diff --git a/homeassistant/components/oncue/strings.json b/homeassistant/components/oncue/strings.json index f7a539fe0e6..ce7561962a2 100644 --- a/homeassistant/components/oncue/strings.json +++ b/homeassistant/components/oncue/strings.json @@ -6,6 +6,12 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "description": "Re-authenticate Oncue account {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -14,7 +20,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index df1452b176e..d88774307c0 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from unittest.mock import patch -from aiooncue import OncueDevice, OncueSensor +from aiooncue import LoginFailedException, OncueDevice, OncueSensor MOCK_ASYNC_FETCH_ALL = { "123456": OncueDevice( @@ -861,3 +861,21 @@ def _patch_login_and_data_unavailable_device(): yield return _patcher() + + +def _patch_login_and_data_auth_failure(): + @contextmanager + def _patcher(): + with ( + patch( + "homeassistant.components.oncue.Oncue.async_login", + side_effect=LoginFailedException, + ), + patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + side_effect=LoginFailedException, + ), + ): + yield + + return _patcher() diff --git a/tests/components/oncue/test_config_flow.py b/tests/components/oncue/test_config_flow.py index 2f327dec052..3907242e26c 100644 --- a/tests/components/oncue/test_config_flow.py +++ b/tests/components/oncue/test_config_flow.py @@ -6,6 +6,7 @@ from aiooncue import LoginFailedException from homeassistant import config_entries from homeassistant.components.oncue.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -42,7 +43,7 @@ async def test_form(hass: HomeAssistant) -> None: "username": "TEST-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 + assert mock_setup_entry.call_count == 1 async def test_form_invalid_auth(hass: HomeAssistant) -> None: @@ -64,7 +65,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -138,3 +139,54 @@ async def test_already_configured(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "any", + CONF_PASSWORD: "old", + }, + ) + config_entry.add_to_hass(hass) + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + flow = flows[0] + + with patch( + "homeassistant.components.oncue.config_flow.Oncue.async_login", + side_effect=LoginFailedException, + ): + result2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"password": "invalid_auth"} + + with ( + patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), + patch( + "homeassistant.components.oncue.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_PASSWORD] == "test-password" + assert mock_setup_entry.call_count == 1 diff --git a/tests/components/oncue/test_init.py b/tests/components/oncue/test_init.py index 2da3e04e4c3..cf93b51dee1 100644 --- a/tests/components/oncue/test_init.py +++ b/tests/components/oncue/test_init.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import patch from aiooncue import LoginFailedException @@ -12,10 +13,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from . import _patch_login_and_data +from . import _patch_login_and_data, _patch_login_and_data_auth_failure -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_config_entry_reload(hass: HomeAssistant) -> None: @@ -67,3 +69,26 @@ async def test_config_entry_retry_later(hass: HomeAssistant) -> None: await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_late_auth_failure(hass: HomeAssistant) -> None: + """Test auth fails after already setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + with _patch_login_and_data_auth_failure(): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + flow = flows[0] + assert flow["context"]["source"] == "reauth" From 588c260dc5d3b532064eb63edb685f2b6f564620 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Apr 2024 09:41:08 -0500 Subject: [PATCH 100/107] Skip processing websocket_api schema if it has no arguments (#115618) * Skip processing websocket_api schema if has no arguments About 40% of the websocket commands on first connection have no arguments. We can skip processing the schema for these cases * cover * fixes * allow extra * Revert "allow extra" This reverts commit 85d9ec36b30aa2aedecd8571c7ed734d0b0a9b05. * match behavior --- .../components/websocket_api/connection.py | 16 +++-- .../components/websocket_api/decorators.py | 10 ++- .../websocket_api/test_decorators.py | 68 +++++++++++++++++++ 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 63b4418a19d..3c0743601dd 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Hashable from contextvars import ContextVar -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from aiohttp import web import voluptuous as vol @@ -65,9 +65,9 @@ class ActiveConnection: self.last_id = 0 self.can_coalesce = False self.supported_features: dict[str, float] = {} - self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ - const.DOMAIN - ] + self.handlers: dict[str, tuple[MessageHandler, vol.Schema | Literal[False]]] = ( + self.hass.data[const.DOMAIN] + ) self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) @@ -185,6 +185,7 @@ class ActiveConnection: or ( not (cur_id := msg.get("id")) or type(cur_id) is not int # noqa: E721 + or cur_id < 0 or not (type_ := msg.get("type")) or type(type_) is not str # noqa: E721 ) @@ -220,7 +221,12 @@ class ActiveConnection: handler, schema = handler_schema try: - handler(self.hass, self, schema(msg)) + if schema is False: + if len(msg) > 2: + raise vol.Invalid("extra keys not allowed") + handler(self.hass, self, msg) + else: + handler(self.hass, self, schema(msg)) except Exception as err: # pylint: disable=broad-except self.async_handle_exception(msg, err) diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index 51643752a0f..0ed8be30139 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from functools import wraps -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -137,7 +137,7 @@ def websocket_command( The schema must be either a dictionary where the keys are voluptuous markers, or a voluptuous.All schema where the first item is a voluptuous Mapping schema. """ - if isinstance(schema, dict): + if is_dict := isinstance(schema, dict): command = schema["type"] else: command = schema.validators[0].schema["type"] @@ -145,9 +145,13 @@ def websocket_command( def decorate(func: const.WebSocketCommandHandler) -> const.WebSocketCommandHandler: """Decorate ws command function.""" # pylint: disable=protected-access - if isinstance(schema, dict): + if is_dict and len(schema) == 1: # type only empty schema + func._ws_schema = False # type: ignore[attr-defined] + elif is_dict: func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) # type: ignore[attr-defined] else: + if TYPE_CHECKING: + assert not isinstance(schema, dict) extended_schema = vol.All( schema.validators[0].extend( messages.BASE_COMMAND_MESSAGE_SCHEMA.schema diff --git a/tests/components/websocket_api/test_decorators.py b/tests/components/websocket_api/test_decorators.py index 3e9c13a8b15..0ade5329190 100644 --- a/tests/components/websocket_api/test_decorators.py +++ b/tests/components/websocket_api/test_decorators.py @@ -1,5 +1,7 @@ """Test decorators.""" +import voluptuous as vol + from homeassistant.components import http, websocket_api from homeassistant.core import HomeAssistant @@ -31,9 +33,16 @@ async def test_async_response_request_context( def get_request(hass, connection, msg): handle_request(http.current_request.get(), connection, msg) + @websocket_api.websocket_command( + {"type": "test-get-request-with-arg", vol.Required("arg"): str} + ) + def get_with_arg_request(hass, connection, msg): + handle_request(http.current_request.get(), connection, msg) + websocket_api.async_register_command(hass, executor_get_request) websocket_api.async_register_command(hass, async_get_request) websocket_api.async_register_command(hass, get_request) + websocket_api.async_register_command(hass, get_with_arg_request) await websocket_client.send_json( { @@ -71,6 +80,65 @@ async def test_async_response_request_context( assert not msg["success"] assert msg["error"]["code"] == "not_found" + await websocket_client.send_json( + { + "id": 8, + "type": "test-get-request-with-arg", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 8 + assert not msg["success"] + assert msg["error"]["code"] == "invalid_format" + assert ( + msg["error"]["message"] == "required key not provided @ data['arg']. Got None" + ) + + await websocket_client.send_json( + { + "id": 9, + "type": "test-get-request-with-arg", + "arg": "dog", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 9 + assert msg["success"] + assert msg["result"] == "/api/websocket" + + await websocket_client.send_json( + { + "id": -1, + "type": "test-get-request-with-arg", + "arg": "dog", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == -1 + assert not msg["success"] + assert msg["error"]["code"] == "invalid_format" + assert msg["error"]["message"] == "Message incorrectly formatted." + + await websocket_client.send_json( + { + "id": 10, + "type": "test-get-request", + "not_valid": "dog", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 10 + assert not msg["success"] + assert msg["error"]["code"] == "invalid_format" + assert msg["error"]["message"] == ( + "extra keys not allowed. " + "Got {'id': 10, 'type': 'test-get-request', 'not_valid': 'dog'}" + ) + async def test_supervisor_only(hass: HomeAssistant, websocket_client) -> None: """Test that only the Supervisor can make requests.""" From 80d6cdad676c88f88c9cde260c3deb92273d4781 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Apr 2024 09:42:44 -0500 Subject: [PATCH 101/107] Small cleanups to translation loading (#115583) - Add missing typing - Convert a update loop to a set comp - Save some indent --- homeassistant/helpers/translation.py | 37 ++++++++++++++-------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 5ec3af2d382..377826b7edb 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -30,9 +30,11 @@ TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache" LOCALE_EN = "en" -def recursive_flatten(prefix: Any, data: dict[str, Any]) -> dict[str, Any]: +def recursive_flatten( + prefix: str, data: dict[str, dict[str, Any] | str] +) -> dict[str, str]: """Return a flattened representation of dict data.""" - output = {} + output: dict[str, str] = {} for key, value in data.items(): if isinstance(value, dict): output.update(recursive_flatten(f"{prefix}{key}.", value)) @@ -250,9 +252,9 @@ class _TranslationCache: def _validate_placeholders( self, language: str, - updated_resources: dict[str, Any], - cached_resources: dict[str, Any] | None = None, - ) -> dict[str, Any]: + updated_resources: dict[str, str], + cached_resources: dict[str, str] | None = None, + ) -> dict[str, str]: """Validate if updated resources have same placeholders as cached resources.""" if cached_resources is None: return updated_resources @@ -301,9 +303,11 @@ class _TranslationCache: """Extract resources into the cache.""" resource: dict[str, Any] | str cached = self.cache.setdefault(language, {}) - categories: set[str] = set() - for resource in translation_strings.values(): - categories.update(resource) + categories = { + category + for component in translation_strings.values() + for category in component + } for category in categories: new_resources = build_resources(translation_strings, components, category) @@ -312,17 +316,14 @@ class _TranslationCache: for component, resource in new_resources.items(): component_cache = category_cache.setdefault(component, {}) - if isinstance(resource, dict): - resources_flatten = recursive_flatten( - f"component.{component}.{category}.", - resource, - ) - resources_flatten = self._validate_placeholders( - language, resources_flatten, component_cache - ) - component_cache.update(resources_flatten) - else: + if not isinstance(resource, dict): component_cache[f"component.{component}.{category}"] = resource + continue + + prefix = f"component.{component}.{category}." + flat = recursive_flatten(prefix, resource) + flat = self._validate_placeholders(language, flat, component_cache) + component_cache.update(flat) @bind_hass From b18f1ac265460c83da1c394d519143aefbfea12d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Apr 2024 09:45:06 -0500 Subject: [PATCH 102/107] Migrate device_sun_light_trigger to use async_track_state_change_event (#115555) * Migrate device_sun_light_trigger to use async_track_state_change_event async_track_state_change is legacy and will eventually be deprecated after all core usage is removed. There are only two places left * coverage --- .../device_sun_light_trigger/__init__.py | 31 ++++++++++++------- .../device_sun_light_trigger/test_init.py | 17 ++++++++-- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index 861a634eda7..6781b9afaf7 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -1,6 +1,7 @@ """Support to turn on lights based on the states.""" from datetime import timedelta +from functools import partial import logging import voluptuous as vol @@ -27,11 +28,11 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( async_track_point_in_utc_time, - async_track_state_change, + async_track_state_change_event, ) from homeassistant.helpers.sun import get_astral_event_next, is_up from homeassistant.helpers.typing import ConfigType @@ -195,8 +196,20 @@ async def activate_automation( # noqa: C901 schedule_light_turn_on(None) @callback - def check_light_on_dev_state_change(entity, old_state, new_state): + def check_light_on_dev_state_change( + from_state: str, to_state: str, event: Event[EventStateChangedData] + ) -> None: """Handle tracked device state changes.""" + event_data = event.data + if ( + (old_state := event_data["old_state"]) is None + or (new_state := event_data["new_state"]) is None + or old_state.state != from_state + or new_state.state != to_state + ): + return + + entity = event_data["entity_id"] lights_are_on = any_light_on() light_needed = not (lights_are_on or is_up(hass)) @@ -237,12 +250,10 @@ async def activate_automation( # noqa: C901 # will all the following then, break. break - async_track_state_change( + async_track_state_change_event( hass, device_entity_ids, - check_light_on_dev_state_change, - STATE_NOT_HOME, - STATE_HOME, + partial(check_light_on_dev_state_change, STATE_NOT_HOME, STATE_HOME), ) if disable_turn_off: @@ -266,12 +277,10 @@ async def activate_automation( # noqa: C901 ) ) - async_track_state_change( + async_track_state_change_event( hass, device_entity_ids, - turn_off_lights_when_all_leave, - STATE_HOME, - STATE_NOT_HOME, + partial(turn_off_lights_when_all_leave, STATE_HOME, STATE_NOT_HOME), ) return diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index b373bd4401f..5f44593aabe 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -22,6 +22,7 @@ from homeassistant.const import ( STATE_NOT_HOME, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component @@ -150,10 +151,22 @@ async def test_lights_turn_on_when_coming_home_after_sun_set( hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} ) - hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) - + hass.states.async_set(f"{DOMAIN}.device_2", STATE_UNKNOWN) await hass.async_block_till_done() + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + hass.states.async_set(f"{DOMAIN}.device_2", STATE_NOT_HOME) + await hass.async_block_till_done() + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + + hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) + await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == light.STATE_ON for ent_id in hass.states.async_entity_ids("light") From d48bd9b016584ba1eeb17e0e81fae6114acf121e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Apr 2024 10:45:14 -0500 Subject: [PATCH 103/107] Deprecate async_track_state_change in favor of async_track_state_change_event (#115558) --- homeassistant/helpers/event.py | 9 +++++++++ tests/helpers/test_event.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 648a118f175..7fae0976686 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -38,6 +38,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.event_type import EventType +from . import frame from .device_registry import ( EVENT_DEVICE_REGISTRY_UPDATED, EventDeviceRegistryUpdatedData, @@ -203,8 +204,16 @@ def async_track_state_change( being None, async_track_state_change_event should be used instead as it is slightly faster. + This function is deprecated and will be removed in Home Assistant 2025.5. + Must be run within the event loop. """ + frame.report( + "calls `async_track_state_change` instead of `async_track_state_change_event`" + " which is deprecated and will be removed in Home Assistant 2025.5", + error_if_core=False, + ) + if from_state is not None: match_from_state = process_state_match(from_state) if to_state is not None: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a4235d1ee2c..07228abcc2c 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4804,3 +4804,18 @@ async def test_async_track_device_registry_updated_event_with_a_callback_that_th unsub2() assert event_data[0] == {"action": "create", "device_id": device_id} + + +async def test_track_state_change_deprecated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test track_state_change is deprecated.""" + async_track_state_change( + hass, "light.Bowl", lambda entity_id, old_state, new_state: None, "on", "off" + ) + + assert ( + "Detected code that calls `async_track_state_change` instead " + "of `async_track_state_change_event` which is deprecated and " + "will be removed in Home Assistant 2025.5. Please report this issue." + ) in caplog.text From 3a461c32ac9f9e4466af48d43e98bfc44d454d3a Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Thu, 18 Apr 2024 14:26:09 -0400 Subject: [PATCH 104/107] Add battery binary sensor to Rachio hose timer (#115810) Co-authored-by: J. Nick Koston --- .../components/rachio/binary_sensor.py | 34 ++++++++++++++++++- homeassistant/components/rachio/const.py | 1 + homeassistant/components/rachio/entity.py | 1 + homeassistant/components/rachio/switch.py | 2 -- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index eb7a84867ab..e6248b2c93b 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -2,6 +2,7 @@ from abc import abstractmethod import logging +from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -15,16 +16,21 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DOMAIN as DOMAIN_RACHIO, + KEY_BATTERY_STATUS, KEY_DEVICE_ID, + KEY_LOW, KEY_RAIN_SENSOR_TRIPPED, + KEY_REPORTED_STATE, + KEY_STATE, KEY_STATUS, KEY_SUBTYPE, SIGNAL_RACHIO_CONTROLLER_UPDATE, SIGNAL_RACHIO_RAIN_SENSOR_UPDATE, STATUS_ONLINE, ) +from .coordinator import RachioUpdateCoordinator from .device import RachioPerson -from .entity import RachioDevice +from .entity import RachioDevice, RachioHoseTimerEntity from .webhooks import ( SUBTYPE_COLD_REBOOT, SUBTYPE_OFFLINE, @@ -52,6 +58,11 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent for controller in person.controllers: entities.append(RachioControllerOnlineBinarySensor(controller)) entities.append(RachioRainSensor(controller)) + entities.extend( + RachioHoseTimerBattery(valve, base_station.coordinator) + for base_station in person.base_stations + for valve in base_station.coordinator.data.values() + ) return entities @@ -140,3 +151,24 @@ class RachioRainSensor(RachioControllerBinarySensor): self._async_handle_any_update, ) ) + + +class RachioHoseTimerBattery(RachioHoseTimerEntity, BinarySensorEntity): + """Represents a battery sensor for a smart hose timer.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY + + def __init__( + self, data: dict[str, Any], coordinator: RachioUpdateCoordinator + ) -> None: + """Initialize a smart hose timer battery sensor.""" + super().__init__(data, coordinator) + self._attr_unique_id = f"{self.id}-battery" + + @callback + def _update_attr(self) -> None: + """Handle updated coordinator data.""" + data = self.coordinator.data[self.id] + + self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] + self._attr_is_on = self._static_attrs[KEY_BATTERY_STATUS] == KEY_LOW diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 22c92be2b74..b9b16c0cd87 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -57,6 +57,7 @@ KEY_CONNECTED = "connected" KEY_CURRENT_STATUS = "lastWateringAction" KEY_DETECT_FLOW = "detectFlow" KEY_BATTERY_STATUS = "batteryStatus" +KEY_LOW = "LOW" KEY_REASON = "reason" KEY_DEFAULT_RUNTIME = "defaultRuntimeSeconds" KEY_DURATION_SECONDS = "durationSeconds" diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index 27564f1caca..056abe9145b 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -70,6 +70,7 @@ class RachioHoseTimerEntity(CoordinatorEntity[RachioUpdateCoordinator]): manufacturer=DEFAULT_NAME, configuration_url="https://app.rach.io", ) + self._update_attr() @property def available(self) -> bool: diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 0f696baad3a..1a8dbe42904 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -548,8 +548,6 @@ class RachioValve(RachioHoseTimerEntity, SwitchEntity): self._person = person self._base = base self._attr_unique_id = f"{self.id}-valve" - self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] - self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs def turn_on(self, **kwargs: Any) -> None: """Turn on this valve.""" From 5702ab30596c6b43ae460d39bd626fedd3f4f006 Mon Sep 17 00:00:00 2001 From: Or Evron <20145882+orevron@users.noreply.github.com> Date: Thu, 18 Apr 2024 21:48:37 +0300 Subject: [PATCH 105/107] Add zhimi.fan.za3 to xiaomi_miio workaround unable to discover device (#108310) * add zhimi.fan.za3 to workaround fix unable to discover issue * Update __init__.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/xiaomi_miio/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 35ee017286f..bea8d9b402f 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -299,6 +299,7 @@ async def async_create_miio_device_and_coordinator( # List of models requiring specific lazy_discover setting LAZY_DISCOVER_FOR_MODEL = { + "zhimi.fan.za3": True, "zhimi.fan.za5": True, "zhimi.airpurifier.za1": True, } From 05c37648c416a451eee6f07ecd138dee4d2984b4 Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Thu, 18 Apr 2024 13:50:11 -0500 Subject: [PATCH 106/107] Add support for room sensor accessories assigned to a Honeywell (Lyric) Thermostat (#104343) * Add support for room sensor accessories. - Update coordinator to refresh and grab information about room sensor accessories assigned to a thermostat - Add sensor entities for room humidity and room temperature - Add devices to the registry for each room accessory - "via_device" these entities through the assigned thermostat. * fixed pre-commit issues. * PR suggestions - update docstring to reflect ownership by thermostat - fixed potential issue where a sensor would not be added if its temperature value was 0 * fix bad github merge * asyicio.gather futures for updating theromstat room stats --- homeassistant/components/lyric/__init__.py | 47 +++++++++++- homeassistant/components/lyric/sensor.py | 80 ++++++++++++++++++++- homeassistant/components/lyric/strings.json | 6 ++ 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 84ef3a2b7db..349e4f871a3 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -12,6 +12,7 @@ from aiolyric import Lyric from aiolyric.exceptions import LyricAuthenticationException, LyricException from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricAccessories, LyricRoom from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -77,6 +78,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(60): await lyric.get_locations() + await asyncio.gather( + *( + lyric.get_thermostat_rooms(location.locationID, device.deviceID) + for location in lyric.locations + for device in location.devices + if device.deviceClass == "Thermostat" + ) + ) + except LyricAuthenticationException as exception: # Attempt to refresh the token before failing. # Honeywell appear to have issues keeping tokens saved. @@ -159,8 +169,43 @@ class LyricDeviceEntity(LyricEntity): def device_info(self) -> DeviceInfo: """Return device information about this Honeywell Lyric instance.""" return DeviceInfo( + identifiers={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, connections={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, manufacturer="Honeywell", model=self.device.deviceModel, - name=self.device.name, + name=f"{self.device.name} Thermostat", + ) + + +class LyricAccessoryEntity(LyricDeviceEntity): + """Defines a Honeywell Lyric accessory entity, a sub-device of a thermostat.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[Lyric], + location: LyricLocation, + device: LyricDevice, + room: LyricRoom, + accessory: LyricAccessories, + key: str, + ) -> None: + """Initialize the Honeywell Lyric accessory entity.""" + super().__init__(coordinator, location, device, key) + self._room = room + self._accessory = accessory + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Honeywell Lyric instance.""" + return DeviceInfo( + identifiers={ + ( + f"{dr.CONNECTION_NETWORK_MAC}_room_accessory", + f"{self._mac_id}_room{self._room.id}_accessory{self._accessory.id}", + ) + }, + manufacturer="Honeywell", + model="RCHTSENSOR", + name=f"{self._room.roomName} Sensor", + via_device=(dr.CONNECTION_NETWORK_MAC, self._mac_id), ) diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 276336e02cc..64f60fa6611 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from aiolyric import Lyric from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricAccessories, LyricRoom from homeassistant.components.sensor import ( SensorDeviceClass, @@ -24,7 +25,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import LyricDeviceEntity +from . import LyricAccessoryEntity, LyricDeviceEntity from .const import ( DOMAIN, PRESET_HOLD_UNTIL, @@ -50,6 +51,14 @@ class LyricSensorEntityDescription(SensorEntityDescription): suitable_fn: Callable[[LyricDevice], bool] +@dataclass(frozen=True, kw_only=True) +class LyricSensorAccessoryEntityDescription(SensorEntityDescription): + """Class describing Honeywell Lyric room sensor entities.""" + + value_fn: Callable[[LyricRoom, LyricAccessories], StateType | datetime] + suitable_fn: Callable[[LyricRoom, LyricAccessories], bool] + + DEVICE_SENSORS: list[LyricSensorEntityDescription] = [ LyricSensorEntityDescription( key="indoor_temperature", @@ -109,6 +118,26 @@ DEVICE_SENSORS: list[LyricSensorEntityDescription] = [ ), ] +ACCESSORY_SENSORS: list[LyricSensorAccessoryEntityDescription] = [ + LyricSensorAccessoryEntityDescription( + key="room_temperature", + translation_key="room_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda _, accessory: accessory.temperature, + suitable_fn=lambda _, accessory: accessory.type == "IndoorAirSensor", + ), + LyricSensorAccessoryEntityDescription( + key="room_humidity", + translation_key="room_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda room, _: room.roomAvgHumidity, + suitable_fn=lambda _, accessory: accessory.type == "IndoorAirSensor", + ), +] + def get_setpoint_status(status: str, time: str) -> str | None: """Get status of the setpoint.""" @@ -147,6 +176,18 @@ async def async_setup_entry( if device_sensor.suitable_fn(device) ) + async_add_entities( + LyricAccessorySensor( + coordinator, accessory_sensor, location, device, room, accessory + ) + for location in coordinator.data.locations + for device in location.devices + for room in coordinator.data.rooms_dict.get(device.macID, {}).values() + for accessory in room.accessories + for accessory_sensor in ACCESSORY_SENSORS + if accessory_sensor.suitable_fn(room, accessory) + ) + class LyricSensor(LyricDeviceEntity, SensorEntity): """Define a Honeywell Lyric sensor.""" @@ -178,3 +219,40 @@ class LyricSensor(LyricDeviceEntity, SensorEntity): def native_value(self) -> StateType | datetime: """Return the state.""" return self.entity_description.value_fn(self.device) + + +class LyricAccessorySensor(LyricAccessoryEntity, SensorEntity): + """Define a Honeywell Lyric sensor.""" + + entity_description: LyricSensorAccessoryEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[Lyric], + description: LyricSensorAccessoryEntityDescription, + location: LyricLocation, + parentDevice: LyricDevice, + room: LyricRoom, + accessory: LyricAccessories, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + location, + parentDevice, + room, + accessory, + f"{parentDevice.macID}_room{room.id}_acc{accessory.id}_{description.key}", + ) + self.room = room + self.entity_description = description + if description.device_class == SensorDeviceClass.TEMPERATURE: + if parentDevice.units == "Fahrenheit": + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + else: + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + @property + def native_value(self) -> StateType | datetime: + """Return the state.""" + return self.entity_description.value_fn(self._room, self._accessory) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 68bb6292f9e..739ad7fad68 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -41,6 +41,12 @@ }, "setpoint_status": { "name": "Setpoint status" + }, + "room_temperature": { + "name": "Room temperature" + }, + "room_humidity": { + "name": "Room humidity" } } }, From 1d6ae01baab69957e1bf7f26fc00564a4de9df9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Apr 2024 15:06:53 -0500 Subject: [PATCH 107/107] Handle Bluetooth adapters in a crashed state (#115790) * Skip bluetooth discovery for Bluetooth adapters in a crashed state * fixes * fixes * adjust * coverage * coverage * fix race --- .../components/bluetooth/__init__.py | 19 ++++++++- .../components/bluetooth/config_flow.py | 7 ++++ tests/components/bluetooth/conftest.py | 39 +++++++++++++++++++ .../components/bluetooth/test_config_flow.py | 16 ++++++++ tests/components/bluetooth/test_init.py | 23 +++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 35d4b625942..560fb0663a8 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -196,6 +196,17 @@ async def _async_start_adapter_discovery( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the bluetooth integration.""" + if platform.system() == "Linux": + # Remove any config entries that are using the default address + # that were created from discovering adapters in a crashed state + # + # DEFAULT_ADDRESS is perfectly valid on MacOS but on + # Linux it means the adapter is not yet configured + # or crashed + for entry in list(hass.config_entries.async_entries(DOMAIN)): + if entry.unique_id == DEFAULT_ADDRESS: + await hass.config_entries.async_remove(entry.entry_id) + bluetooth_adapters = get_adapters() bluetooth_storage = BluetoothStorage(hass) slot_manager = BleakSlotManager() @@ -257,13 +268,19 @@ async def async_discover_adapters( adapters: dict[str, AdapterDetails], ) -> None: """Discover adapters and start flows.""" - if platform.system() == "Windows": + system = platform.system() + if system == "Windows": # We currently do not have a good way to detect if a bluetooth device is # available on Windows. We will just assume that it is not unless they # actively add it. return for adapter, details in adapters.items(): + if system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS: + # DEFAULT_ADDRESS is perfectly valid on MacOS but on + # Linux it means the adapter is not yet configured + # or crashed so we should not try to start a flow for it. + continue discovery_flow.async_create_flow( hass, DOMAIN, diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 6802bdc37c0..87038d48151 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -2,11 +2,13 @@ from __future__ import annotations +import platform from typing import Any, cast from bluetooth_adapters import ( ADAPTER_ADDRESS, ADAPTER_MANUFACTURER, + DEFAULT_ADDRESS, AdapterDetails, adapter_human_name, adapter_model, @@ -133,10 +135,15 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): bluetooth_adapters = get_adapters() await bluetooth_adapters.refresh() self._adapters = bluetooth_adapters.adapters + system = platform.system() unconfigured_adapters = [ adapter for adapter, details in self._adapters.items() if details[ADAPTER_ADDRESS] not in configured_addresses + # DEFAULT_ADDRESS is perfectly valid on MacOS but on + # Linux it means the adapter is not yet configured + # or crashed + and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS) ] if not unconfigured_adapters: ignored_adapters = len( diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index c1e040ccd49..d4056c1e38e 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -219,6 +219,45 @@ def two_adapters_fixture(): yield +@pytest.fixture(name="crashed_adapter") +def crashed_adapter_fixture(): + """Fixture that mocks one crashed adapter on Linux.""" + with ( + patch( + "homeassistant.components.bluetooth.platform.system", + return_value="Linux", + ), + patch( + "habluetooth.scanner.platform.system", + return_value="Linux", + ), + patch( + "bluetooth_adapters.systems.platform.system", + return_value="Linux", + ), + patch("habluetooth.scanner.SYSTEM", "Linux"), + patch( + "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", + ), + patch( + "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", + { + "hci0": { + "address": "00:00:00:00:00:00", + "hw_version": "usb:v1D6Bp0246d053F", + "passive_scan": True, + "sw_version": "homeassistant", + "manufacturer": None, + "product": None, + "product_id": None, + "vendor_id": None, + }, + }, + ), + ): + yield + + @pytest.fixture(name="one_adapter_old_bluez") def one_adapter_old_bluez(): """Fixture that mocks two adapters on Linux.""" diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index f9bbbcd2d0e..d044be76e6d 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -32,6 +32,9 @@ async def test_options_flow_disabled_not_setup( domain=DOMAIN, data={}, options={}, unique_id=DEFAULT_ADDRESS ) entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) await ws_client.send_json( @@ -103,6 +106,19 @@ async def test_async_step_user_linux_one_adapter( assert len(mock_setup_entry.mock_calls) == 1 +async def test_async_step_user_linux_crashed_adapter( + hass: HomeAssistant, crashed_adapter: None +) -> None: + """Test setting up manually with one crashed adapter on Linux.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_adapters" + + async def test_async_step_user_linux_two_adapters( hass: HomeAssistant, two_adapters: None ) -> None: diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index e68ccc94d19..82fa0341966 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -2807,6 +2807,19 @@ async def test_can_unsetup_bluetooth_single_adapter_macos( await hass.async_block_till_done() +async def test_default_address_config_entries_removed_linux( + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + one_adapter: None, +) -> None: + """Test default address entries are removed on linux.""" + entry = MockConfigEntry(domain=bluetooth.DOMAIN, data={}, unique_id=DEFAULT_ADDRESS) + entry.add_to_hass(hass) + await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(bluetooth.DOMAIN) + + async def test_can_unsetup_bluetooth_single_adapter_linux( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, @@ -2889,6 +2902,16 @@ async def test_auto_detect_bluetooth_adapters_linux_multiple( assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 2 +async def test_auto_detect_bluetooth_adapters_skips_crashed( + hass: HomeAssistant, crashed_adapter: None +) -> None: + """Test we skip crashed adapters on linux.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(bluetooth.DOMAIN) + assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 0 + + async def test_auto_detect_bluetooth_adapters_linux_none_found( hass: HomeAssistant, ) -> None: